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:
parent
4aedc92bf9
commit
4240b03c1c
2 changed files with 273 additions and 0 deletions
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
232
playground/js/shapeseq/tests/voice-mode.test.js
Normal file
232
playground/js/shapeseq/tests/voice-mode.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue