memlnaut-nisps/playground/js/shapeseq/tests/midi-note.test.js
w1n5t0n aec58537db 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.
2026-04-06 23:28:01 +01:00

243 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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');
});
});