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.
This commit is contained in:
parent
2d20a8c256
commit
40ac7256f3
3 changed files with 216 additions and 65 deletions
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
173
playground/js/synth/c15-adapter.js
Normal file
173
playground/js/synth/c15-adapter.js
Normal file
|
|
@ -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; }
|
||||
}
|
||||
Loading…
Reference in a new issue