feat(sequencer): mono/poly voice mode

Mono mode (default) kills the previous note before firing a new one —
classic monophonic synth behavior. Poly mode retriggers same-pitch
notes without killing others. All active notes are released on mode
switch.
This commit is contained in:
w1n5t0n 2026-04-06 23:59:04 +01:00
parent 4aedc92bf9
commit 4240b03c1c
2 changed files with 273 additions and 0 deletions

View file

@ -85,6 +85,14 @@ export class ShapeSeqEngine {
// Dirty-check: skip re-evaluation when inputs haven't changed // Dirty-check: skip re-evaluation when inputs haven't changed
/** @private */ this._lastInputs = [NaN, NaN]; /** @private */ this._lastInputs = [NaN, NaN];
// Voice mode: 'mono' (default) or 'poly'
/** @private @type {'mono'|'poly'} */
this._voiceMode = 'mono';
// Last note tracker for mono mode
/** @private @type {{note: number, velocity: number}|null} */
this._lastNote = null;
// Generation counter: bumped on config changes to force re-evaluation // Generation counter: bumped on config changes to force re-evaluation
/** @private */ this._generation = 0; /** @private */ this._generation = 0;
/** @private */ this._lastGeneration = -1; /** @private */ this._lastGeneration = -1;
@ -288,6 +296,25 @@ export class ShapeSeqEngine {
/** @returns {MLPModeManager} */ /** @returns {MLPModeManager} */
get mlpMode() { return this._mlpMode; } get mlpMode() { return this._mlpMode; }
/** @returns {'mono'|'poly'} */
get voiceMode() { return this._voiceMode; }
/**
* Set voice mode.
* @param {'mono'|'poly'} mode
*/
setVoiceMode(mode) {
if (mode !== 'mono' && mode !== 'poly') {
throw new RangeError(`Invalid voice mode: "${mode}". Must be 'mono' or 'poly'.`);
}
if (mode === this._voiceMode) return;
// Release all active notes when switching modes
this._releaseAllNotes();
this._lastNote = null;
this._voiceMode = mode;
}
// ── MLP mode switching ──────────────────────────────────────────── // ── MLP mode switching ────────────────────────────────────────────
/** /**
@ -530,6 +557,20 @@ export class ShapeSeqEngine {
const note = midiNote < 0 ? 0 : midiNote > 127 ? 127 : midiNote; const note = midiNote < 0 ? 0 : midiNote > 127 ? 127 : midiNote;
const vel = velocity < 0 ? 0 : velocity > 1 ? 1 : velocity; const vel = velocity < 0 ? 0 : velocity > 1 ? 1 : velocity;
if (this._voiceMode === 'mono') {
// Kill previous note (monophonic behavior)
if (this._lastNote) {
this._c15.noteOff(this._lastNote.note);
this._activeNotes.delete(this._lastNote.note);
}
this._lastNote = { note, velocity: vel };
} else {
// Poly: retrigger if same pitch already active
if (this._activeNotes.has(note)) {
this._c15.noteOff(note);
}
}
this._c15.noteOn(note, vel); this._c15.noteOn(note, vel);
this._activeNotes.add(note); this._activeNotes.add(note);
} }

View file

@ -0,0 +1,232 @@
/**
* Tests for configurable mono/poly voice mode in ShapeSeqEngine.
*
* Uses lightweight mocks for C15Bridge and EventBus since the full
* engine requires AudioContext and WASM which aren't available in Node.
*/
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
// We can't fully construct ShapeSeqEngine without AudioContext/WASM,
// so we test the voice mode logic by importing the class and exercising
// the parts we can reach via a minimal mock setup.
import { ShapeSeqEngine } from '../sequencer.js';
// ── Mock factories ─────────────────────────────────────────────────
function createMockC15() {
const calls = [];
return {
calls,
noteOn(note, vel) { calls.push({ type: 'noteOn', note, vel }); },
noteOff(note) { calls.push({ type: 'noteOff', note }); },
};
}
function createMockBus() {
const handlers = {};
return {
on(event, fn) {
if (!handlers[event]) handlers[event] = [];
handlers[event].push(fn);
},
off(event, fn) {
if (handlers[event]) {
handlers[event] = handlers[event].filter(h => h !== fn);
}
},
emit(event, data) {
if (handlers[event]) {
for (const fn of handlers[event]) fn(data);
}
},
handlers,
};
}
function createMockAudioContext() {
return { currentTime: 0 };
}
/**
* Build a ShapeSeqEngine with mocks injected but WITHOUT calling init()
* (which needs WASM). We can still test voice mode, noteOn/noteOff
* handling, and the getter/setter since those don't depend on init.
*/
function createEngine() {
const c15 = createMockC15();
const bus = createMockBus();
const audioCtx = createMockAudioContext();
const engine = new ShapeSeqEngine({ audioContext: audioCtx, eventBus: bus, c15Bridge: c15 });
return { engine, c15, bus };
}
// ── Voice mode getter / setter ─────────────────────────────────────
describe('voiceMode property', () => {
it('defaults to mono', () => {
const { engine } = createEngine();
assert.equal(engine.voiceMode, 'mono');
});
it('setVoiceMode("poly") changes mode', () => {
const { engine } = createEngine();
engine.setVoiceMode('poly');
assert.equal(engine.voiceMode, 'poly');
});
it('setVoiceMode("mono") sets back to mono', () => {
const { engine } = createEngine();
engine.setVoiceMode('poly');
engine.setVoiceMode('mono');
assert.equal(engine.voiceMode, 'mono');
});
it('rejects invalid values', () => {
const { engine } = createEngine();
assert.throws(() => engine.setVoiceMode('duophonic'), RangeError);
assert.throws(() => engine.setVoiceMode(''), RangeError);
assert.throws(() => engine.setVoiceMode(null), RangeError);
assert.throws(() => engine.setVoiceMode(undefined), RangeError);
});
it('no-ops if mode is already set', () => {
const { engine, c15 } = createEngine();
// Trigger a note so _activeNotes is non-empty, then set same mode
// — _releaseAllNotes should NOT be called (no noteOff emitted).
engine._handleNoteOn({ midiNote: 60, velocity: 0.8 });
c15.calls.length = 0; // clear
engine.setVoiceMode('mono'); // already mono
assert.equal(c15.calls.length, 0, 'no noteOff for same-mode set');
});
});
// ── Mono mode note handling ────────────────────────────────────────
describe('mono mode noteOn/noteOff', () => {
let engine, c15;
beforeEach(() => {
({ engine, c15 } = createEngine());
// default is mono
});
it('sends noteOff for previous note before new noteOn', () => {
engine._handleNoteOn({ midiNote: 60, velocity: 0.8 });
engine._handleNoteOn({ midiNote: 64, velocity: 0.7 });
// Expected: noteOn(60), noteOff(60), noteOn(64)
assert.equal(c15.calls.length, 3);
assert.deepEqual(c15.calls[0], { type: 'noteOn', note: 60, vel: 0.8 });
assert.deepEqual(c15.calls[1], { type: 'noteOff', note: 60 });
assert.deepEqual(c15.calls[2], { type: 'noteOn', note: 64, vel: 0.7 });
});
it('first note has no preceding noteOff', () => {
engine._handleNoteOn({ midiNote: 60, velocity: 0.5 });
assert.equal(c15.calls.length, 1);
assert.deepEqual(c15.calls[0], { type: 'noteOn', note: 60, vel: 0.5 });
});
it('_activeNotes contains only the latest note', () => {
engine._handleNoteOn({ midiNote: 60, velocity: 0.8 });
engine._handleNoteOn({ midiNote: 64, velocity: 0.7 });
// After second noteOn, only 64 should be active (60 was killed)
assert.equal(engine._activeNotes.size, 1);
assert.ok(engine._activeNotes.has(64));
});
});
// ── Poly mode note handling ────────────────────────────────────────
describe('poly mode noteOn/noteOff', () => {
let engine, c15;
beforeEach(() => {
({ engine, c15 } = createEngine());
engine.setVoiceMode('poly');
});
it('does NOT send noteOff before noteOn for different pitches', () => {
engine._handleNoteOn({ midiNote: 60, velocity: 0.8 });
engine._handleNoteOn({ midiNote: 64, velocity: 0.7 });
// Expected: noteOn(60), noteOn(64) — no noteOff
assert.equal(c15.calls.length, 2);
assert.deepEqual(c15.calls[0], { type: 'noteOn', note: 60, vel: 0.8 });
assert.deepEqual(c15.calls[1], { type: 'noteOn', note: 64, vel: 0.7 });
});
it('tracks multiple active notes', () => {
engine._handleNoteOn({ midiNote: 60, velocity: 0.8 });
engine._handleNoteOn({ midiNote: 64, velocity: 0.7 });
engine._handleNoteOn({ midiNote: 67, velocity: 0.6 });
assert.equal(engine._activeNotes.size, 3);
assert.ok(engine._activeNotes.has(60));
assert.ok(engine._activeNotes.has(64));
assert.ok(engine._activeNotes.has(67));
});
it('retrigger: sends noteOff then noteOn for same pitch', () => {
engine._handleNoteOn({ midiNote: 60, velocity: 0.8 });
engine._handleNoteOn({ midiNote: 60, velocity: 0.6 });
// Expected: noteOn(60, 0.8), noteOff(60), noteOn(60, 0.6)
assert.equal(c15.calls.length, 3);
assert.deepEqual(c15.calls[0], { type: 'noteOn', note: 60, vel: 0.8 });
assert.deepEqual(c15.calls[1], { type: 'noteOff', note: 60 });
assert.deepEqual(c15.calls[2], { type: 'noteOn', note: 60, vel: 0.6 });
});
it('noteOff removes specific note from _activeNotes', () => {
engine._handleNoteOn({ midiNote: 60, velocity: 0.8 });
engine._handleNoteOn({ midiNote: 64, velocity: 0.7 });
engine._handleNoteOff({ midiNote: 60, velocity: 0 });
assert.equal(engine._activeNotes.size, 1);
assert.ok(engine._activeNotes.has(64));
assert.ok(!engine._activeNotes.has(60));
});
});
// ── releaseAllNotes ────────────────────────────────────────────────
describe('releaseAllNotes', () => {
it('releases all notes in poly mode', () => {
const { engine, c15 } = createEngine();
engine.setVoiceMode('poly');
engine._handleNoteOn({ midiNote: 60, velocity: 0.8 });
engine._handleNoteOn({ midiNote: 64, velocity: 0.7 });
engine._handleNoteOn({ midiNote: 67, velocity: 0.6 });
c15.calls.length = 0;
engine._releaseAllNotes();
// Should have 3 noteOff calls
const noteOffs = c15.calls.filter(c => c.type === 'noteOff');
assert.equal(noteOffs.length, 3);
assert.equal(engine._activeNotes.size, 0);
});
});
// ── Mode switching cleans up ───────────────────────────────────────
describe('mode switching cleanup', () => {
it('releases active notes when switching from poly to mono', () => {
const { engine, c15 } = createEngine();
engine.setVoiceMode('poly');
engine._handleNoteOn({ midiNote: 60, velocity: 0.8 });
engine._handleNoteOn({ midiNote: 64, velocity: 0.7 });
c15.calls.length = 0;
engine.setVoiceMode('mono');
const noteOffs = c15.calls.filter(c => c.type === 'noteOff');
assert.equal(noteOffs.length, 2);
assert.equal(engine._activeNotes.size, 0);
});
});