Merge pull request 'Add cyclestruct function' (#2107) from tzwaan/cyclestruct into main

Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2107
This commit is contained in:
froos
2026-10-11 13:44:26 +02:00
3 changed files with 151 additions and 0 deletions
+68
View File
@@ -2567,6 +2567,74 @@ export const bite = register(
false,
);
/**
* Similar to `struct`, but every hap in the structure pattern plays the next
* cycle of the main pattern.
* With a different number of cycles in the main pattern and haps in the
* structure pattern, this creates an isorhythm.
*
* Note: This function works on the assumption that the structure pattern
* repeats every cycle. If the structure pattern only repeats after multiple
* cycles, it may skip or repeat certain parts of the melody.
* @tags temporal
* @name isobite
* @synonyms cycleStruct, cyclestruct
* @memberof Pattern
* @param {Pattern} structure structure of the pattern
* @example
* "<0 1 2 3 4 5>".isobite("[x - x*2]*2").n().scale("c:major")
* // the same as "0 - [1 2] 3 - [4 5]".n().scale("c:major")
*
* @example
* "<0 1 2 3 4>".isobite("x - x").n().scale("c:major")
* // the same as "<[0 - 1] [2 - 3] [4 - 0] [1 - 2] [3 - 4]>".n().scale("c:major")
*
* @example
* // Here the structure pattern repeats after 2 cycles,
* // so certain haps from the melody are skipped.
* "<0 1 2 3>".isobite("x x*2".slow(2)).n().scale("c:major")
* // the same as "<[0] [2 3] [2] [2 3]>".n().scale("c:major")
*
* @example
* // This can be avoided by making the struct pattern 1 cycle,
* // and slowing the whole pattern down as follows:
* "<0 1 2 3>".isobite("x x*2").slow(2).n().scale("c:major")
* // the same as "<[0] [1 2] [3] [0 1] [2] [3 0] [1] [2 3]>".n().scale("c:major")
*/
export const { isobite, cyclestruct, cycleStruct } = register(
['isobite', 'cyclestruct', 'cycleStruct'],
(structure, pat) => {
const structPat = new Pattern((state) => {
const haps_cycle = structure
.withQuerySpan((span) => span.begin.wholeCycle())
.query(state)
.filter((hap) => hap.value != 0)
.sort((a, b) => a.whole.begin - b.whole.begin)
.map((hap, i) => hap.withValue((_) => i));
const haps = [];
for (const hap of haps_cycle) {
const new_part = state.span.intersection(hap.part);
if (new_part) {
haps.push(new Hap(hap.whole, new_part, hap.value, hap.context));
}
}
return haps;
}).splitQueries();
const stepsPattern = new Pattern((state) => {
const nrHaps = structure
.withQuerySpan((span) => span.begin.wholeCycle())
.query(state)
.filter((hap) => hap.value != 0).length;
return [new Hap(state.span.begin.wholeCycle(), state.span, nrHaps, {})];
}).splitQueries();
return pat.fast(stepsPattern).bite(stepsPattern, structPat);
},
false,
);
/**
* Selects the given fraction of the pattern and repeats that part to fill the remainder of the cycle.
* @tags temporal
+19
View File
@@ -75,6 +75,10 @@ const sameFirst = (a, b) => {
return expect(a.sortHapsByPart().firstCycle()).toStrictEqual(b.sortHapsByPart().firstCycle());
};
const sameFirstX = (x, a, b) => {
return expect(a.fast(x).sortHapsByPart().firstCycle()).toStrictEqual(b.fast(x).sortHapsByPart().firstCycle());
};
describe('TimeSpan', () => {
describe('equals()', () => {
it('Should be equal to the same value', () => {
@@ -1321,6 +1325,21 @@ describe('Pattern', () => {
);
});
});
describe('isobite', () => {
it('can play a melody with a structure', () => {
sameFirst(
slowcat(0, 1, 2, 3, 4, 5).isobite(fastcat(true, silence, [true, true]).fast(2)),
fastcat(0, silence, [1, 2], 3, silence, [4, 5]),
);
});
it('syncopates over multiple cycles if the number of haps in the patterns differ', () => {
sameFirstX(
4,
slowcat(0, 1, 2, 3).isobite(fastcat(true, [true, true])),
slowcat(0, [1, 2], 3, [0, 1], 2, [3, 0], 1, [2, 3]).fast(2),
);
});
});
describe('unjoin', () => {
it('destructures a pattern into subcycles', () => {
sameFirst(
+64
View File
@@ -6418,6 +6418,70 @@ exports[`runs examples > example "isaw" example index 1 1`] = `
]
`;
exports[`runs examples > example "isobite" example index 0 1`] = `
[
"[ 0/1 → 1/6 | note:C3 ]",
"[ 1/3 → 5/12 | note:D3 ]",
"[ 5/12 → 1/2 | note:E3 ]",
"[ 1/2 → 2/3 | note:F3 ]",
"[ 5/6 → 11/12 | note:G3 ]",
"[ 11/12 → 1/1 | note:A3 ]",
"[ 1/1 → 7/6 | note:C3 ]",
"[ 4/3 → 17/12 | note:D3 ]",
"[ 17/12 → 3/2 | note:E3 ]",
"[ 3/2 → 5/3 | note:F3 ]",
"[ 11/6 → 23/12 | note:G3 ]",
"[ 23/12 → 2/1 | note:A3 ]",
"[ 2/1 → 13/6 | note:C3 ]",
"[ 7/3 → 29/12 | note:D3 ]",
"[ 29/12 → 5/2 | note:E3 ]",
"[ 5/2 → 8/3 | note:F3 ]",
"[ 17/6 → 35/12 | note:G3 ]",
"[ 35/12 → 3/1 | note:A3 ]",
"[ 3/1 → 19/6 | note:C3 ]",
"[ 10/3 → 41/12 | note:D3 ]",
"[ 41/12 → 7/2 | note:E3 ]",
"[ 7/2 → 11/3 | note:F3 ]",
"[ 23/6 → 47/12 | note:G3 ]",
"[ 47/12 → 4/1 | note:A3 ]",
]
`;
exports[`runs examples > example "isobite" example index 1 1`] = `
[
"[ 0/1 → 1/3 | note:C3 ]",
"[ 2/3 → 1/1 | note:D3 ]",
"[ 1/1 → 4/3 | note:E3 ]",
"[ 5/3 → 2/1 | note:F3 ]",
"[ 2/1 → 7/3 | note:G3 ]",
"[ 8/3 → 3/1 | note:C3 ]",
"[ 3/1 → 10/3 | note:D3 ]",
"[ 11/3 → 4/1 | note:E3 ]",
]
`;
exports[`runs examples > example "isobite" example index 2 1`] = `
[
"[ 0/1 → 1/1 | note:C3 ]",
"[ 1/1 → 3/2 | note:E3 ]",
"[ 3/2 → 2/1 | note:F3 ]",
"[ 2/1 → 3/1 | note:E3 ]",
"[ 3/1 → 7/2 | note:E3 ]",
"[ 7/2 → 4/1 | note:F3 ]",
]
`;
exports[`runs examples > example "isobite" example index 3 1`] = `
[
"[ 0/1 → 1/1 | note:C3 ]",
"[ 1/1 → 3/2 | note:D3 ]",
"[ 3/2 → 2/1 | note:E3 ]",
"[ 2/1 → 3/1 | note:F3 ]",
"[ 3/1 → 7/2 | note:C3 ]",
"[ 7/2 → 4/1 | note:D3 ]",
]
`;
exports[`runs examples > example "iter" example index 0 1`] = `
[
"[ 0/1 → 1/4 | note:A3 ]",