From 40ac7256f3f5f75e9ffe1a4cc1efd3b973934e9a Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Fri, 3 Apr 2026 17:30:28 +0100 Subject: [PATCH] feat(playground): engine abstraction layer + C15 adapter (meml-byk) Introduces SynthEngine interface so additive/FM engines can be hot-swapped later. C15Bridge is wrapped by C15Adapter; arpeggiator decoupled from SAB. --- playground/js/synth/arpeggiator-worker.js | 59 ++------ playground/js/synth/arpeggiator.js | 49 +++--- playground/js/synth/c15-adapter.js | 173 ++++++++++++++++++++++ 3 files changed, 216 insertions(+), 65 deletions(-) create mode 100644 playground/js/synth/c15-adapter.js diff --git a/playground/js/synth/arpeggiator-worker.js b/playground/js/synth/arpeggiator-worker.js index e4ac49f..ca9f498 100644 --- a/playground/js/synth/arpeggiator-worker.js +++ b/playground/js/synth/arpeggiator-worker.js @@ -1,11 +1,8 @@ // Arpeggiator Worker — runs note scheduling on a dedicated thread // for reliable timing independent of main thread load. -// Writes noteOn/noteOff directly to the C15 SharedArrayBuffer ring buffer. - -const MESSAGE_TYPE = { PARAMETER: 0, NOTE_ON: 1, NOTE_OFF: 2 }; -const HEADER_SIZE = 3; -const MESSAGE_SIZE = 4; -const RING_CAPACITY = 512; +// Posts noteOn/noteOff messages back to the main thread, which forwards them +// through the active SynthEngine. This decouples the worker from C15's +// SharedArrayBuffer ring buffer so any engine can be used. // Cmin7 inversions: root [0,3,7,10], 1st [3,7,10,12], 2nd [7,10,12,15], 3rd [10,12,15,19] const CMIN7_INVERSIONS = [ @@ -23,31 +20,6 @@ const PROGRESSIONS = { 'I-V-vi-IV': [[0,4,7],[7,11,14],[9,12,16],[5,9,12]], }; -// Ring buffer writer (CAS-safe, matches c15-bridge.js) -let ringBuffer = null; -let ringF32 = null; -let ringI32 = null; - -function ringWrite(type, id, value) { - if (!ringI32) return false; - for (let attempt = 0; attempt < 4; attempt++) { - const writeIdx = Atomics.load(ringI32, 0); - const readIdx = Atomics.load(ringI32, 1); - const next = (writeIdx + 1) % RING_CAPACITY; - if (next === readIdx) return false; - if (Atomics.compareExchange(ringI32, 0, writeIdx, next) === writeIdx) { - const off = HEADER_SIZE + writeIdx * MESSAGE_SIZE; - ringF32[off] = type; - ringF32[off + 1] = id; - ringF32[off + 2] = value; - ringF32[off + 3] = 0; - Atomics.add(ringI32, 2, 1); - return true; - } - } - return false; -} - // Arpeggiator state let playing = false; let bpm = 120; @@ -135,7 +107,7 @@ function advanceIndex(notes) { function playNextNote() { // Release previous note if (lastNote >= 0) { - ringWrite(MESSAGE_TYPE.NOTE_OFF, lastNote, 0); + postMessage({ type: 'noteOff', data: { note: lastNote } }); } const notes = getChordNotes(); @@ -143,7 +115,7 @@ function playNextNote() { const note = notes[Math.min(noteIndex, notes.length - 1)]; const velocity = 0.6 + Math.random() * 0.2; - ringWrite(MESSAGE_TYPE.NOTE_ON, note, velocity); + postMessage({ type: 'noteOn', data: { note, velocity } }); lastNote = note; advanceIndex(notes); @@ -157,7 +129,7 @@ function start() { ascending = true; currentInversion = null; scheduleNext(); - postMessage({ type: 'state', playing: true }); + postMessage({ type: 'state', data: { playing: true } }); } function stop() { @@ -167,21 +139,16 @@ function stop() { timer = null; } if (lastNote >= 0) { - ringWrite(MESSAGE_TYPE.NOTE_OFF, lastNote, 0); + postMessage({ type: 'noteOff', data: { note: lastNote } }); lastNote = -1; } - postMessage({ type: 'state', playing: false }); + postMessage({ type: 'state', data: { playing: false } }); } // Handle messages from main thread self.onmessage = (e) => { const { type, data } = e.data; switch (type) { - case 'init': - ringBuffer = data.sharedBuffer; - ringF32 = new Float32Array(ringBuffer); - ringI32 = new Int32Array(ringBuffer); - break; case 'start': start(); break; @@ -189,11 +156,11 @@ self.onmessage = (e) => { stop(); break; case 'set': - if ('bpm' in data) bpm = data.bpm; - if ('octaves' in data) octaves = data.octaves; - if ('octaveOffset' in data) octaveOffset = data.octaveOffset; - if ('progression' in data) progression = data.progression; - if ('direction' in data) direction = data.direction; + if (data && 'bpm' in data) bpm = data.bpm; + if (data && 'octaves' in data) octaves = data.octaves; + if (data && 'octaveOffset' in data) octaveOffset = data.octaveOffset; + if (data && 'progression' in data) progression = data.progression; + if (data && 'direction' in data) direction = data.direction; break; } }; diff --git a/playground/js/synth/arpeggiator.js b/playground/js/synth/arpeggiator.js index c191bbf..c4bfb94 100644 --- a/playground/js/synth/arpeggiator.js +++ b/playground/js/synth/arpeggiator.js @@ -1,11 +1,15 @@ -// Arpeggiator — plays chord progressions through the C15 engine +// Arpeggiator — plays chord progressions through any SynthEngine. // Delegates to a dedicated Web Worker for reliable timing. -// The worker writes noteOn/noteOff directly to the SharedArrayBuffer -// ring buffer, bypassing main thread entirely. +// The worker posts noteOn/noteOff messages back to the main thread, which +// forwards them through the active SynthEngine. This keeps the arpeggiator +// decoupled from C15's SharedArrayBuffer ring buffer so any engine works. export class Arpeggiator { - constructor(bridge) { - this.bridge = bridge; + /** + * @param {import('./engine-interface.js').SynthEngine} engine + */ + constructor(engine) { + this.engine = engine; this._worker = null; this._playing = false; this._bpm = 120; @@ -64,18 +68,21 @@ export class Arpeggiator { ); this._worker.onmessage = (e) => { - if (e.data.type === 'state') { - this._playing = e.data.playing; + const { type, data } = e.data; + switch (type) { + case 'state': + this._playing = data.playing; + break; + case 'noteOn': + this.engine.noteOn(data.note, data.velocity); + break; + case 'noteOff': + this.engine.noteOff(data.note); + break; } }; - // Pass the SharedArrayBuffer so the worker can write notes directly - const sab = this.bridge.sharedBuffer; - if (sab) { - this._worker.postMessage({ type: 'init', data: { sharedBuffer: sab } }); - } - - // Sync current settings + // Sync current settings to worker this._send('set', { bpm: this._bpm, octaves: this._octaves, @@ -93,11 +100,6 @@ export class Arpeggiator { start() { this._ensureWorker(); - // If bridge wasn't ready when worker was created, send the buffer now - const sab = this.bridge.sharedBuffer; - if (sab) { - this._worker.postMessage({ type: 'init', data: { sharedBuffer: sab } }); - } this._send('start'); this._playing = true; // optimistic, worker confirms } @@ -106,4 +108,13 @@ export class Arpeggiator { this._send('stop'); this._playing = false; } + + /** + * Hot-swap the engine without restarting the worker. + * Called by setActiveEngine() in a-app.js. + * @param {import('./engine-interface.js').SynthEngine} engine + */ + setEngine(engine) { + this.engine = engine; + } } diff --git a/playground/js/synth/c15-adapter.js b/playground/js/synth/c15-adapter.js new file mode 100644 index 0000000..94f8fc0 --- /dev/null +++ b/playground/js/synth/c15-adapter.js @@ -0,0 +1,173 @@ +// C15Adapter — wraps C15Bridge to satisfy the SynthEngine interface. +// +// All C15-specific internals (SharedArrayBuffer, ring buffer, AudioWorklet) +// remain inside C15Bridge. This adapter is the only file outside the synth/ +// directory that needs to know about C15Bridge. + +import { SynthEngine } from './engine-interface.js'; +import { C15Bridge } from './c15-bridge.js'; +import { SYNTH_PARAM_MAP } from './param-map.js'; + +// --------------------------------------------------------------------------- +// Derive paramMeta from SYNTH_PARAM_MAP +// --------------------------------------------------------------------------- + +// Infer a human-readable group name from the parameter's machine name. +// e.g. 'Env_A_Att' → 'Envelope A', 'SV_Flt_Cut' → 'SVF', 'Cabinet_Drive' → 'Cabinet' +function _inferGroup(name) { + if (name.startsWith('Env_A')) return 'Envelope A'; + if (name.startsWith('Env_B')) return 'Envelope B'; + if (name.startsWith('Env_C')) return 'Envelope C'; + if (name.startsWith('Osc_A')) return 'Oscillator A'; + if (name.startsWith('Osc_B')) return 'Oscillator B'; + if (name.startsWith('Shp_A')) return 'Shaper A'; + if (name.startsWith('Shp_B')) return 'Shaper B'; + if (name.startsWith('Comb_Flt')) return 'Comb Filter'; + if (name.startsWith('SV_Flt')) return 'SVF'; + if (name.startsWith('Gap_Flt')) return 'Gap Filter'; + if (name.startsWith('FB_Mix')) return 'Feedback Mixer'; + if (name.startsWith('Out_Mix')) return 'Output Mixer'; + if (name.startsWith('Cabinet')) return 'Cabinet'; + if (name.startsWith('Flanger')) return 'Flanger'; + if (name.startsWith('Echo')) return 'Echo'; + if (name.startsWith('Reverb')) return 'Reverb'; + if (name.startsWith('Unison')) return 'Unison'; + if (name.startsWith('Mono')) return 'Mono'; + return 'Other'; +} + +// Build paramMeta once at module load time — no per-call allocation. +const C15_PARAM_META = SYNTH_PARAM_MAP.map(p => ({ + id: p.name, // stable machine ID (e.g. 'Env_A_Att') + name: p.label, // short display label (e.g. 'EnvA Att') + min: p.safeMin !== undefined ? p.safeMin : 0, + max: p.safeMax !== undefined ? p.safeMax : 1, + init: p.defaultValue, + curve: 0.5, // linear by default; presets can override + group: _inferGroup(p.name), +})); + +// --------------------------------------------------------------------------- +// C15Adapter +// --------------------------------------------------------------------------- + +export class C15Adapter extends SynthEngine { + constructor() { + super(); + this._bridge = new C15Bridge(); + this._running = false; + } + + // --- Identity --- + + get id() { return 'shaper-feedback'; } + get displayName() { return 'C15 Shaper-Feedback'; } + + // --- Parameter schema --- + + get paramMeta() { return C15_PARAM_META; } + // paramCount is derived from paramMeta.length via the base class + + // --- Lifecycle --- + + /** + * Initialise the C15 engine. + * The AudioContext is created internally by C15Bridge.start(), so the + * audioCtx parameter is accepted for interface compatibility but not used. + * + * @param {AudioContext|null} _audioCtx — ignored; C15Bridge creates its own + */ + async init(_audioCtx) { + // Forward status callbacks before starting so callers can observe progress + this._bridge.onStatusChange = (msg) => { + this._onStatusChange?.(msg); + // Mirror the running flag — C15Bridge sets this.running internally + this._running = this._bridge.running; + }; + + await this._bridge.loadParams(); + await this._bridge.start(); + this._running = this._bridge.running; + } + + /** Stop audio and release the AudioContext. */ + async stop() { + await this._bridge.stop(); + this._running = this._bridge.running; + } + + dispose() { + this._bridge.panic(); + // C15Bridge has no explicit destroy; AudioContext will be GC'd + this._running = false; + } + + // --- Status callback (optional, assigned by a-app.js) --- + + set onStatusChange(fn) { this._onStatusChange = fn; } + + // --- running passthrough --- + get running() { return this._running; } + + // --- Real-time control --- + + /** + * Set a parameter by MLP output index (0-based position in SYNTH_PARAM_MAP). + * Converts the index to the C15 hardware param ID before writing. + * + * @param {number} index — MLP output index [0, paramCount) + * @param {number} normalizedValue — [0, 1] + */ + setParam(index, normalizedValue) { + const entry = SYNTH_PARAM_MAP[index]; + if (!entry) return; + this._bridge.setParameter(entry.id, normalizedValue); + } + + /** + * Trigger a note. + * @param {number} note — MIDI note number 0–127 + * @param {number} velocity — [0, 1] + */ + noteOn(note, velocity = 0.7) { + this._bridge.noteOn(note, velocity); + } + + /** + * Release a note. + * @param {number} note — MIDI note number 0–127 + */ + noteOff(note) { + this._bridge.noteOff(note); + } + + // --- Audio graph --- + + /** + * Return the master gain node. Connect this to a compressor or destination. + * Only available after init() completes. + * + * @returns {GainNode} + */ + getOutputNode() { + return this._bridge.masterGain; + } + + // --- C15-specific passthrough (for MIDIInput and volume controls) --- + + /** Pass-through for MIDIInput which was constructed with the bridge */ + get bridge() { return this._bridge; } + + setMasterVolume(value) { this._bridge.setMasterVolume(value); } + panic() { this._bridge.panic(); } + + /** + * The SharedArrayBuffer from the ring buffer. Exposed so Arpeggiator can + * hand it off to its worker. After the engine-interface migration is complete + * the arpeggiator no longer needs direct SAB access — this getter exists only + * for backward compatibility during transition. + * + * @returns {SharedArrayBuffer|null} + */ + get sharedBuffer() { return this._bridge.sharedBuffer; } +}