feat(pattern): add midiNote field + trigger-aware merge
Replace the pitch*127 encoding hack with a dedicated midiNote field on pattern steps. IntervalLock now writes integer MIDI notes to step.midiNote while preserving the original [0,1] pitch for other consumers. The clock and sequencer bridge pass midiNote through the event pipeline. Also reworks mergePatterns to be trigger-aware: when only one generator fires at a step, use its values directly instead of averaging with the other generator's defaults.
This commit is contained in:
parent
3f4ffcd4f9
commit
aec58537db
6 changed files with 549 additions and 18 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {} };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
243
playground/js/shapeseq/tests/midi-note.test.js
Normal file
243
playground/js/shapeseq/tests/midi-note.test.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
229
playground/js/shapeseq/tests/pattern-merge.test.js
Normal file
229
playground/js/shapeseq/tests/pattern-merge.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue