diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 1b4ac6b26..e50c317c7 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -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 diff --git a/packages/core/test/pattern.test.mjs b/packages/core/test/pattern.test.mjs index d2b4df21a..0a688eae8 100644 --- a/packages/core/test/pattern.test.mjs +++ b/packages/core/test/pattern.test.mjs @@ -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( diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 4aeeea272..998223cc1 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -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 ]",