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.
232 lines
8.2 KiB
JavaScript
232 lines
8.2 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|