diff --git a/playground/js/shapeseq/clock.js b/playground/js/shapeseq/clock.js index aa87892..0594907 100644 --- a/playground/js/shapeseq/clock.js +++ b/playground/js/shapeseq/clock.js @@ -96,6 +96,7 @@ export class ClockEngine { stepIndex: this._lastNote.stepIndex, time: this._ctx.currentTime, pitch: this._lastNote.pitch, + midiNote: this._lastNote.midiNote, velocity: 0, }); this._lastNote = null; @@ -192,16 +193,18 @@ export class ClockEngine { const subs = step.subdivisions; + const midiNote = step.midiNote != null ? step.midiNote : null; + if (subs <= 1) { // Single hit - this._scheduleNote(stepIndex, offsetTime, step.pitch, step.velocity, step.accent, false); + this._scheduleNote(stepIndex, offsetTime, step.pitch, step.velocity, step.accent, false, midiNote); } else { // Ratchet: evenly divide this step's duration const subDur = stepDuration / subs; for (let s = 0; s < subs; s++) { const t = offsetTime + s * subDur; const vel = s === 0 ? step.velocity : step.velocity * SUBDIVISION_VEL_SCALE; - this._scheduleNote(stepIndex, t, step.pitch, vel, step.accent, s > 0); + this._scheduleNote(stepIndex, t, step.pitch, vel, step.accent, s > 0, midiNote); } } } @@ -209,20 +212,21 @@ export class ClockEngine { /** * Schedule a single noteOn (with preceding noteOff for the previous note). */ - _scheduleNote(stepIndex, time, pitch, velocity, accent, isSubdivision) { + _scheduleNote(stepIndex, time, pitch, velocity, accent, isSubdivision, midiNote) { // NoteOff for previous note if (this._lastNote) { this._emit(SEQ.NOTE_OFF, { stepIndex: this._lastNote.stepIndex, time, pitch: this._lastNote.pitch, + midiNote: this._lastNote.midiNote, velocity: 0, }); } - const noteData = { stepIndex, time, pitch, velocity, accent, isSubdivision }; + const noteData = { stepIndex, time, pitch, midiNote: midiNote ?? null, velocity, accent, isSubdivision }; this._emit(SEQ.NOTE_ON, noteData); - this._lastNote = { stepIndex, time, pitch, velocity }; + this._lastNote = { stepIndex, time, pitch, midiNote: midiNote ?? null, velocity }; } /** diff --git a/playground/js/shapeseq/pattern.js b/playground/js/shapeseq/pattern.js index 48415b5..f667b30 100644 --- a/playground/js/shapeseq/pattern.js +++ b/playground/js/shapeseq/pattern.js @@ -32,6 +32,7 @@ export function createStep() { accent: DEFAULT_ACCENT, timeOffset: DEFAULT_TIME_OFFSET, subdivisions: DEFAULT_SUBDIVISIONS, + midiNote: null, }; } @@ -80,6 +81,7 @@ export function clonePattern(pattern) { accent: s.accent, timeOffset: s.timeOffset, subdivisions: s.subdivisions, + midiNote: s.midiNote, }; } @@ -133,14 +135,61 @@ export function mergePatterns(patternA, patternB, mode) { const b = stepsB[i]; const trigger = isAdditive ? (a.trigger || b.trigger) : (a.trigger && b.trigger); + const bothTriggered = a.trigger && b.trigger; + + let pitch, velocity, accent, timeOffset, subdivisions, midiNote; + + if (bothTriggered) { + // Both generators triggered: average continuous values, combine accent by mode + pitch = (a.pitch + b.pitch) * 0.5; + velocity = (a.velocity + b.velocity) * 0.5; + accent = isAdditive ? (a.accent || b.accent) : (a.accent && b.accent); + timeOffset = (a.timeOffset + b.timeOffset) * 0.5; + subdivisions = Math.max(a.subdivisions, b.subdivisions); + // midiNote: both set → average (rounded), one set → use it, neither → null + if (a.midiNote != null && b.midiNote != null) { + midiNote = Math.round((a.midiNote + b.midiNote) * 0.5); + } else if (a.midiNote != null) { + midiNote = a.midiNote; + } else if (b.midiNote != null) { + midiNote = b.midiNote; + } else { + midiNote = null; + } + } else if (a.trigger) { + // Only A triggered: use A's values directly + pitch = a.pitch; + velocity = a.velocity; + accent = a.accent; + timeOffset = a.timeOffset; + subdivisions = a.subdivisions; + midiNote = a.midiNote; + } else if (b.trigger) { + // Only B triggered: use B's values directly + pitch = b.pitch; + velocity = b.velocity; + accent = b.accent; + timeOffset = b.timeOffset; + subdivisions = b.subdivisions; + midiNote = b.midiNote; + } else { + // Neither triggered: defaults + pitch = DEFAULT_PITCH; + velocity = DEFAULT_VELOCITY; + accent = DEFAULT_ACCENT; + timeOffset = DEFAULT_TIME_OFFSET; + subdivisions = DEFAULT_SUBDIVISIONS; + midiNote = null; + } steps[i] = { trigger: trigger, - pitch: (a.pitch + b.pitch) * 0.5, - velocity: (a.velocity + b.velocity) * 0.5, - accent: isAdditive ? (a.accent || b.accent) : (a.accent && b.accent), - timeOffset: (a.timeOffset + b.timeOffset) * 0.5, - subdivisions: Math.max(a.subdivisions, b.subdivisions), + pitch: pitch, + velocity: velocity, + accent: accent, + timeOffset: timeOffset, + subdivisions: subdivisions, + midiNote: midiNote, }; } @@ -174,6 +223,7 @@ export function setStep(pattern, index, stepData) { if (stepData.accent !== undefined) step.accent = !!stepData.accent; if (stepData.timeOffset !== undefined) step.timeOffset = +stepData.timeOffset; if (stepData.subdivisions !== undefined) step.subdivisions = stepData.subdivisions | 0; + if (stepData.midiNote !== undefined) step.midiNote = stepData.midiNote === null ? null : stepData.midiNote | 0; } /** @@ -208,6 +258,12 @@ export function validatePattern(pattern) { if (typeof s.timeOffset !== 'number' || s.timeOffset < -0.5 || s.timeOffset > 0.5) return false; if (typeof s.subdivisions !== 'number' || (s.subdivisions | 0) < 1 || (s.subdivisions | 0) > 4) return false; if (s.subdivisions !== (s.subdivisions | 0)) return false; + // midiNote: null (pre-quantization) or integer 0-127 + if (s.midiNote !== null) { + if (typeof s.midiNote !== 'number') return false; + if (s.midiNote !== (s.midiNote | 0)) return false; + if (s.midiNote < 0 || s.midiNote > 127) return false; + } } return true; diff --git a/playground/js/shapeseq/primitives.js b/playground/js/shapeseq/primitives.js index 6c20d1a..517dd95 100644 --- a/playground/js/shapeseq/primitives.js +++ b/playground/js/shapeseq/primitives.js @@ -437,10 +437,9 @@ export class IntervalLock extends Primitive { // Quantize pitch [0,1] to nearest note in our scale const targetIdx = Math.round(step.pitch * (notes.length - 1)); const clampedIdx = targetIdx < 0 ? 0 : targetIdx >= notes.length ? notes.length - 1 : targetIdx; - // Store MIDI note directly — downstream (sequencer._handleNoteOn) - // reads this as a MIDI note number, not a [0,1] value. - // We store as note/127 to stay within the [0,1] pattern field range. - step.pitch = notes[clampedIdx] / 127; + // Store the integer MIDI note in midiNote; leave pitch as-is + // (pre-quantization [0,1] value) for other consumers. + step.midiNote = notes[clampedIdx]; } return { patternDesc: pattern, nextState: {} }; diff --git a/playground/js/shapeseq/sequencer.js b/playground/js/shapeseq/sequencer.js index 2eccde6..7fe39dc 100644 --- a/playground/js/shapeseq/sequencer.js +++ b/playground/js/shapeseq/sequencer.js @@ -276,9 +276,9 @@ export class ShapeSeqEngine { * @param {Object} data - { pitch, velocity, stepIndex, time, accent, isSubdivision } */ _handleNoteOn(data) { - // pitch is stored as midiNote/127 in the pattern (set by IntervalLock). - // Convert back to MIDI note number. - const midiNote = Math.round(data.pitch * 127) | 0; + // Use integer midiNote if set (post-IntervalLock), otherwise fall back + // to the old pitch*127 encoding for backward compatibility. + const midiNote = data.midiNote != null ? data.midiNote : (Math.round(data.pitch * 127) | 0); const velocity = data.velocity; // Clamp to valid MIDI range @@ -296,7 +296,7 @@ export class ShapeSeqEngine { * @param {Object} data - { pitch, velocity, stepIndex, time } */ _handleNoteOff(data) { - const midiNote = Math.round(data.pitch * 127) | 0; + const midiNote = data.midiNote != null ? data.midiNote : (Math.round(data.pitch * 127) | 0); const note = midiNote < 0 ? 0 : midiNote > 127 ? 127 : midiNote; this._c15.noteOff(note); diff --git a/playground/js/shapeseq/tests/midi-note.test.js b/playground/js/shapeseq/tests/midi-note.test.js new file mode 100644 index 0000000..5218a9c --- /dev/null +++ b/playground/js/shapeseq/tests/midi-note.test.js @@ -0,0 +1,243 @@ +/** + * Tests for the midiNote field on pattern step data structures. + * + * Covers: createStep, clonePattern, setStep, validatePattern, + * mergePatterns, and IntervalLock.process() behavior. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createStep, createPattern, clonePattern, + mergePatterns, setStep, validatePattern, +} from '../pattern.js'; + +import { IntervalLock } from '../primitives.js'; +import { createPRNG } from '../prng.js'; + +// ── createStep ────────────────────────────────────────────────────── + +describe('createStep – midiNote', () => { + it('has midiNote: null by default', () => { + const step = createStep(); + assert.strictEqual(step.midiNote, null); + }); +}); + +// ── clonePattern ──────────────────────────────────────────────────── + +describe('clonePattern – midiNote', () => { + it('preserves midiNote across clone', () => { + const p = createPattern(2); + p.steps[0].midiNote = 60; + p.steps[1].midiNote = null; + + const c = clonePattern(p); + assert.strictEqual(c.steps[0].midiNote, 60); + assert.strictEqual(c.steps[1].midiNote, null); + }); + + it('clone is independent (mutation does not propagate)', () => { + const p = createPattern(1); + p.steps[0].midiNote = 72; + const c = clonePattern(p); + c.steps[0].midiNote = 48; + assert.strictEqual(p.steps[0].midiNote, 72); + }); +}); + +// ── setStep ───────────────────────────────────────────────────────── + +describe('setStep – midiNote', () => { + it('can set midiNote to an integer', () => { + const p = createPattern(1); + setStep(p, 0, { midiNote: 64 }); + assert.strictEqual(p.steps[0].midiNote, 64); + }); + + it('can set midiNote to null', () => { + const p = createPattern(1); + p.steps[0].midiNote = 60; + setStep(p, 0, { midiNote: null }); + assert.strictEqual(p.steps[0].midiNote, null); + }); + + it('coerces midiNote to integer', () => { + const p = createPattern(1); + setStep(p, 0, { midiNote: 60.9 }); + assert.strictEqual(p.steps[0].midiNote, 60); + }); + + it('does not touch midiNote when not in stepData', () => { + const p = createPattern(1); + p.steps[0].midiNote = 72; + setStep(p, 0, { pitch: 0.3 }); + assert.strictEqual(p.steps[0].midiNote, 72); + }); +}); + +// ── validatePattern ───────────────────────────────────────────────── + +describe('validatePattern – midiNote', () => { + it('accepts midiNote: null', () => { + const p = createPattern(1); + assert.strictEqual(validatePattern(p), true); + }); + + it('accepts midiNote: 0', () => { + const p = createPattern(1); + p.steps[0].midiNote = 0; + assert.strictEqual(validatePattern(p), true); + }); + + it('accepts midiNote: 127', () => { + const p = createPattern(1); + p.steps[0].midiNote = 127; + assert.strictEqual(validatePattern(p), true); + }); + + it('accepts midiNote: 60 (middle C)', () => { + const p = createPattern(1); + p.steps[0].midiNote = 60; + assert.strictEqual(validatePattern(p), true); + }); + + it('rejects midiNote: float (60.5)', () => { + const p = createPattern(1); + p.steps[0].midiNote = 60.5; + assert.strictEqual(validatePattern(p), false); + }); + + it('rejects midiNote: -1', () => { + const p = createPattern(1); + p.steps[0].midiNote = -1; + assert.strictEqual(validatePattern(p), false); + }); + + it('rejects midiNote: 128', () => { + const p = createPattern(1); + p.steps[0].midiNote = 128; + assert.strictEqual(validatePattern(p), false); + }); + + it('rejects midiNote: string', () => { + const p = createPattern(1); + p.steps[0].midiNote = 'C4'; + assert.strictEqual(validatePattern(p), false); + }); +}); + +// ── mergePatterns ─────────────────────────────────────────────────── + +describe('mergePatterns – midiNote', () => { + it('both triggered, both have midiNote → average rounded', () => { + const a = createPattern(1); + setStep(a, 0, { trigger: true, midiNote: 60 }); + const b = createPattern(1); + setStep(b, 0, { trigger: true, midiNote: 65 }); + + const m = mergePatterns(a, b, 'additive'); + // (60 + 65) / 2 = 62.5 → 63 + assert.strictEqual(m.steps[0].midiNote, 63); + }); + + it('both triggered, only A has midiNote → use A', () => { + const a = createPattern(1); + setStep(a, 0, { trigger: true, midiNote: 72 }); + const b = createPattern(1); + setStep(b, 0, { trigger: true }); + + const m = mergePatterns(a, b, 'additive'); + assert.strictEqual(m.steps[0].midiNote, 72); + }); + + it('both triggered, only B has midiNote → use B', () => { + const a = createPattern(1); + setStep(a, 0, { trigger: true }); + const b = createPattern(1); + setStep(b, 0, { trigger: true, midiNote: 48 }); + + const m = mergePatterns(a, b, 'additive'); + assert.strictEqual(m.steps[0].midiNote, 48); + }); + + it('both triggered, neither has midiNote → null', () => { + const a = createPattern(1); + setStep(a, 0, { trigger: true }); + const b = createPattern(1); + setStep(b, 0, { trigger: true }); + + const m = mergePatterns(a, b, 'additive'); + assert.strictEqual(m.steps[0].midiNote, null); + }); + + it('only A triggered → uses A midiNote', () => { + const a = createPattern(1); + setStep(a, 0, { trigger: true, midiNote: 55 }); + const b = createPattern(1); + // b not triggered (default) + + const m = mergePatterns(a, b, 'additive'); + assert.strictEqual(m.steps[0].midiNote, 55); + }); + + it('only B triggered → uses B midiNote', () => { + const a = createPattern(1); + // a not triggered + const b = createPattern(1); + setStep(b, 0, { trigger: true, midiNote: 80 }); + + const m = mergePatterns(a, b, 'additive'); + assert.strictEqual(m.steps[0].midiNote, 80); + }); + + it('neither triggered → null', () => { + const a = createPattern(1); + const b = createPattern(1); + + const m = mergePatterns(a, b, 'additive'); + assert.strictEqual(m.steps[0].midiNote, null); + }); +}); + +// ── IntervalLock.process() ────────────────────────────────────────── + +describe('IntervalLock – midiNote field', () => { + it('sets midiNote as integer and leaves pitch as [0,1]', () => { + const lock = new IntervalLock(); + const rng = createPRNG(42); + + // Create a pattern with a triggered step at pitch 0.5 + const input = createPattern(1); + setStep(input, 0, { trigger: true, pitch: 0.5 }); + + // root=0 (C), mode=0.1 (major), octaveRange=0.25 (1 octave) + const result = lock.process([0.0, 0.1, 0.25], input, {}, rng); + const step = result.patternDesc.steps[0]; + + // midiNote should be an integer (a MIDI note from the C major scale) + assert.strictEqual(typeof step.midiNote, 'number'); + assert.strictEqual(step.midiNote, step.midiNote | 0, 'midiNote should be integer'); + assert.ok(step.midiNote >= 0 && step.midiNote <= 127, 'midiNote in MIDI range'); + + // pitch should remain a [0,1] float (the original pre-quantization value) + assert.strictEqual(step.pitch, 0.5, 'pitch should be unchanged from input'); + }); + + it('produces different midiNote values for different pitch inputs', () => { + const lock = new IntervalLock(); + const rng = createPRNG(42); + + const input = createPattern(2); + setStep(input, 0, { trigger: true, pitch: 0.0 }); + setStep(input, 1, { trigger: true, pitch: 1.0 }); + + const result = lock.process([0.0, 0.1, 0.5], input, {}, rng); + const note0 = result.patternDesc.steps[0].midiNote; + const note1 = result.patternDesc.steps[1].midiNote; + + assert.notStrictEqual(note0, note1, 'different pitches should produce different MIDI notes'); + assert.ok(note0 < note1, 'higher pitch should produce higher MIDI note'); + }); +}); diff --git a/playground/js/shapeseq/tests/pattern-merge.test.js b/playground/js/shapeseq/tests/pattern-merge.test.js new file mode 100644 index 0000000..e92e27d --- /dev/null +++ b/playground/js/shapeseq/tests/pattern-merge.test.js @@ -0,0 +1,229 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { createPattern, setStep, mergePatterns } from '../pattern.js'; + +/** + * Helper: build a 4-step pattern and configure specific steps. + * stepConfigs is an array of { index, ...stepData } objects. + */ +function buildPattern(stepConfigs) { + const p = createPattern(4); + for (const { index, ...data } of stepConfigs) { + setStep(p, index, data); + } + return p; +} + +// --------------------------------------------------------------------------- +// Additive mode +// --------------------------------------------------------------------------- + +describe('mergePatterns — additive mode', () => { + it('both trigger: averages pitch, velocity, timeOffset; ORs accent; max subdivisions', () => { + const a = buildPattern([ + { index: 0, trigger: true, pitch: 0.8, velocity: 0.6, accent: true, timeOffset: 0.1, subdivisions: 2 }, + ]); + const b = buildPattern([ + { index: 0, trigger: true, pitch: 0.4, velocity: 1.0, accent: false, timeOffset: -0.1, subdivisions: 3 }, + ]); + + const merged = mergePatterns(a, b, 'additive'); + const s = merged.steps[0]; + + assert.equal(s.trigger, true); + assert.ok(Math.abs(s.pitch - 0.6) < 1e-9, `pitch should be 0.6, got ${s.pitch}`); + assert.ok(Math.abs(s.velocity - 0.8) < 1e-9, `velocity should be 0.8, got ${s.velocity}`); + assert.equal(s.accent, true); // OR + assert.ok(Math.abs(s.timeOffset - 0.0) < 1e-9, `timeOffset should be 0.0, got ${s.timeOffset}`); + assert.equal(s.subdivisions, 3); // max + }); + + it('only A triggers: uses A values directly', () => { + const a = buildPattern([ + { index: 1, trigger: true, pitch: 0.9, velocity: 0.3, accent: true, timeOffset: 0.2, subdivisions: 2 }, + ]); + const b = buildPattern([]); // step 1 stays default (trigger: false) + + const merged = mergePatterns(a, b, 'additive'); + const s = merged.steps[1]; + + assert.equal(s.trigger, true); + assert.equal(s.pitch, 0.9); + assert.equal(s.velocity, 0.3); + assert.equal(s.accent, true); + assert.equal(s.timeOffset, 0.2); + assert.equal(s.subdivisions, 2); + }); + + it('only B triggers: uses B values directly', () => { + const a = buildPattern([]); // step 2 stays default (trigger: false) + const b = buildPattern([ + { index: 2, trigger: true, pitch: 0.1, velocity: 0.95, accent: false, timeOffset: -0.3, subdivisions: 4 }, + ]); + + const merged = mergePatterns(a, b, 'additive'); + const s = merged.steps[2]; + + assert.equal(s.trigger, true); + assert.equal(s.pitch, 0.1); + assert.equal(s.velocity, 0.95); + assert.equal(s.accent, false); + assert.equal(s.timeOffset, -0.3); + assert.equal(s.subdivisions, 4); + }); + + it('neither triggers: step stays untriggered with defaults', () => { + const a = buildPattern([]); + const b = buildPattern([]); + + const merged = mergePatterns(a, b, 'additive'); + const s = merged.steps[0]; + + assert.equal(s.trigger, false); + assert.equal(s.pitch, 0.5); // DEFAULT_PITCH + assert.equal(s.velocity, 0.7); // DEFAULT_VELOCITY + assert.equal(s.accent, false); // DEFAULT_ACCENT + assert.equal(s.timeOffset, 0.0); // DEFAULT_TIME_OFFSET + assert.equal(s.subdivisions, 1); // DEFAULT_SUBDIVISIONS + }); + + it('timeOffset averaged when both trigger', () => { + const a = buildPattern([{ index: 0, trigger: true, timeOffset: 0.4 }]); + const b = buildPattern([{ index: 0, trigger: true, timeOffset: -0.2 }]); + + const merged = mergePatterns(a, b, 'additive'); + assert.ok(Math.abs(merged.steps[0].timeOffset - 0.1) < 1e-9); + }); + + it('subdivisions: max when both trigger, sole value when one triggers', () => { + const a = buildPattern([ + { index: 0, trigger: true, subdivisions: 1 }, + { index: 1, trigger: true, subdivisions: 3 }, + ]); + const b = buildPattern([ + { index: 0, trigger: true, subdivisions: 4 }, + // step 1: not triggered + ]); + + const merged = mergePatterns(a, b, 'additive'); + assert.equal(merged.steps[0].subdivisions, 4); // max(1, 4) + assert.equal(merged.steps[1].subdivisions, 3); // only A triggered + }); +}); + +// --------------------------------------------------------------------------- +// Multiplicative mode +// --------------------------------------------------------------------------- + +describe('mergePatterns — multiplicative mode', () => { + it('both trigger: triggers, averages values, ANDs accent', () => { + const a = buildPattern([ + { index: 0, trigger: true, pitch: 0.8, velocity: 0.6, accent: true, timeOffset: 0.1, subdivisions: 2 }, + ]); + const b = buildPattern([ + { index: 0, trigger: true, pitch: 0.4, velocity: 1.0, accent: false, timeOffset: -0.1, subdivisions: 3 }, + ]); + + const merged = mergePatterns(a, b, 'multiplicative'); + const s = merged.steps[0]; + + assert.equal(s.trigger, true); + assert.ok(Math.abs(s.pitch - 0.6) < 1e-9); + assert.ok(Math.abs(s.velocity - 0.8) < 1e-9); + assert.equal(s.accent, false); // AND: true && false + assert.ok(Math.abs(s.timeOffset - 0.0) < 1e-9); + assert.equal(s.subdivisions, 3); + }); + + it('only A triggers: no trigger in multiplicative (AND)', () => { + const a = buildPattern([ + { index: 0, trigger: true, pitch: 0.9, velocity: 0.3, accent: true }, + ]); + const b = buildPattern([]); + + const merged = mergePatterns(a, b, 'multiplicative'); + const s = merged.steps[0]; + + // AND: true && false = false, so step not triggered + assert.equal(s.trigger, false); + // Since only A triggered (but result is untriggered due to AND), + // values come from the A-only branch + assert.equal(s.pitch, 0.9); + assert.equal(s.velocity, 0.3); + }); + + it('only B triggers: no trigger in multiplicative (AND)', () => { + const a = buildPattern([]); + const b = buildPattern([ + { index: 0, trigger: true, pitch: 0.1, velocity: 0.95, accent: false, timeOffset: -0.3, subdivisions: 4 }, + ]); + + const merged = mergePatterns(a, b, 'multiplicative'); + const s = merged.steps[0]; + + assert.equal(s.trigger, false); + // Values from the B-only branch + assert.equal(s.pitch, 0.1); + assert.equal(s.velocity, 0.95); + }); + + it('neither triggers: untriggered with defaults', () => { + const a = buildPattern([]); + const b = buildPattern([]); + + const merged = mergePatterns(a, b, 'multiplicative'); + const s = merged.steps[0]; + + assert.equal(s.trigger, false); + assert.equal(s.pitch, 0.5); + assert.equal(s.velocity, 0.7); + assert.equal(s.accent, false); + assert.equal(s.timeOffset, 0.0); + assert.equal(s.subdivisions, 1); + }); + + it('timeOffset from sole triggering generator in multiplicative', () => { + const a = buildPattern([{ index: 0, trigger: true, timeOffset: 0.4 }]); + const b = buildPattern([]); + + const merged = mergePatterns(a, b, 'multiplicative'); + assert.equal(merged.steps[0].timeOffset, 0.4); + }); + + it('subdivisions from sole triggering generator in multiplicative', () => { + const a = buildPattern([]); + const b = buildPattern([{ index: 0, trigger: true, subdivisions: 3 }]); + + const merged = mergePatterns(a, b, 'multiplicative'); + assert.equal(merged.steps[0].subdivisions, 3); + }); +}); + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- + +describe('mergePatterns — edge cases', () => { + it('tiles mismatched step counts to LCM instead of rejecting', () => { + const a = createPattern(4); + const b = createPattern(8); + const merged = mergePatterns(a, b, 'additive'); + assert.equal(merged.stepCount, 8); // lcm(4, 8) = 8 + }); + + it('rejects invalid mode', () => { + const a = createPattern(4); + const b = createPattern(4); + assert.throws(() => mergePatterns(a, b, 'xor'), TypeError); + }); + + it('does not mutate input patterns', () => { + const a = buildPattern([{ index: 0, trigger: true, pitch: 0.2 }]); + const b = buildPattern([{ index: 0, trigger: true, pitch: 0.8 }]); + + mergePatterns(a, b, 'additive'); + + assert.equal(a.steps[0].pitch, 0.2); + assert.equal(b.steps[0].pitch, 0.8); + }); +});