memlnaut-nisps/playground/c15/worklet-processor.js
w1n5t0n 6f6a334529 feat(playground): add C15 synth mode with NISPS-controlled parameters and arpeggiator
Adds a secondary Synth mode (toggled via collapsible left side panel)
where the NISPS ML engine's 20 outputs control curated C15 synthesizer
parameters (oscillator PM, shapers, filters, reverb, echo, flanger,
output mixer) through a WASM AudioWorklet bridge.

Includes a chord progression arpeggiator with controls for tempo (BPM),
octave range, octave offset, and 4 selectable progressions. The C15
engine runs in an AudioWorklet with lock-free SharedArrayBuffer ring
buffer communication.

New files: c15/ (WASM assets), js/synth/ (bridge, arpeggiator, param map),
serve-coop.py (COOP/COEP headers for SharedArrayBuffer support).
2026-03-21 22:26:37 +02:00

592 lines
21 KiB
JavaScript

/**
* C15 Audio Engine - AudioWorklet Processor
*
* This processor loads the C15 WASM module and calls render() each audio frame.
* It runs in AudioWorkletGlobalScope and connects to the Web Audio graph with
* 0 inputs and 2 outputs (stereo).
*
* WASM API:
* - engineInit(sampleRate, polyphony) -> int
* - render(numFrames) -> float* (interleaved stereo)
* - noteOn(note, velocity)
* - noteOff(note, velocity)
* - setParameter(paramId, value)
* - reset()
*
* Ring Buffer Protocol (SharedArrayBuffer):
* - Lock-free SPSC ring buffer for main thread -> worklet communication
* - Message types: 0=parameter, 1=noteOn, 2=noteOff
* - Message format: [type, id/note, value/velocity, reserved]
*
* @file worklet-processor.js
*/
// WASM module state (shared across all processor instances)
let wasmInstance = null;
let wasmMemory = null;
let wasmReady = false;
// Ring buffer state
let ringBufferReader = null;
// Message type constants (must match ring-buffer.js)
const MessageType = {
PARAMETER: 0,
NOTE_ON: 1,
NOTE_OFF: 2
};
/**
* Ring buffer layout constants
*/
const HEADER_SIZE = 3;
const MESSAGE_SIZE = 4;
const RING_CAPACITY = 512;
/**
* RingBufferReader - Reads messages from SharedArrayBuffer ring buffer
*
* This is the consumer side of the SPSC ring buffer, designed for use
* in the AudioWorklet's process() callback.
*
* @class RingBufferReader
*/
class RingBufferReader {
constructor(sharedBuffer) {
this._buffer = new Float32Array(sharedBuffer);
this._capacity = RING_CAPACITY;
this._messageSize = MESSAGE_SIZE;
this._headerSize = HEADER_SIZE;
}
/**
* Get current write index (main thread updates this)
* @private
*/
_getWriteIndex() {
return Atomics.load(new Int32Array(this._buffer.buffer), 0);
}
/**
* Get current read index
* @private
*/
_getReadIndex() {
return Atomics.load(new Int32Array(this._buffer.buffer), 1);
}
/**
* Advance read index with atomic store
* @private
*/
_advanceReadIndex(currentIdx) {
const nextIdx = (currentIdx + 1) % this._capacity;
Atomics.store(new Int32Array(this._buffer.buffer), 1, nextIdx);
return nextIdx;
}
/**
* Read and process messages with callbacks
*
* This is the preferred method for use in the AudioWorklet process() call.
* It avoids creating arrays and directly calls the appropriate callback.
*
* @param {Object} callbacks - Callback handlers
* @param {Function} callbacks.onParameter - Called for parameter updates (paramId, value)
* @param {Function} callbacks.onNoteOn - Called for note on events (note, velocity)
* @param {Function} callbacks.onNoteOff - Called for note off events (note, velocity)
* @param {number} maxMessages - Maximum messages to process per call
* @returns {number} Number of messages processed
*/
processMessages(callbacks, maxMessages = 32) {
const writeIdx = this._getWriteIndex();
let readIdx = this._getReadIndex();
let count = 0;
while (readIdx !== writeIdx && count < maxMessages) {
// Read message from buffer
const msgOffset = this._headerSize + (readIdx * this._messageSize);
const type = this._buffer[msgOffset + 0];
const id = this._buffer[msgOffset + 1];
const value = this._buffer[msgOffset + 2];
// Dispatch to callback based on type
switch (type) {
case MessageType.PARAMETER:
if (callbacks.onParameter) {
callbacks.onParameter(id, value);
}
break;
case MessageType.NOTE_ON:
if (callbacks.onNoteOn) {
callbacks.onNoteOn(id, value);
}
break;
case MessageType.NOTE_OFF:
if (callbacks.onNoteOff) {
callbacks.onNoteOff(id, value);
}
break;
default:
console.warn('[RingBufferReader] Unknown message type:', type);
}
// Advance read index
readIdx = this._advanceReadIndex(readIdx);
count++;
}
return count;
}
/**
* Get number of available messages (for debugging/monitoring)
*/
getAvailableCount() {
const writeIdx = this._getWriteIndex();
const readIdx = this._getReadIndex();
if (writeIdx >= readIdx) {
return writeIdx - readIdx;
} else {
return this._capacity - readIdx + writeIdx;
}
}
}
/**
* Initialize WASM module from compiled WebAssembly.Module and memory
* This is called when the main thread sends the 'init-wasm' message
*/
function initWasmFromModule(wasmModule, memory) {
return WebAssembly.instantiate(wasmModule, {
// Import object - Emscripten typically uses 'a' for the main import namespace
a: {
// Memory import if needed
d: () => { throw new Error('abort'); },
b: () => 1, // nowIsMonotonic
a: () => performance.now(), // _emscripten_get_now
c: (size) => { // _emscripten_resize_heap - not typically needed with fixed memory
return 0;
}
}
}).then(instance => {
wasmInstance = instance;
wasmMemory = memory;
wasmReady = true;
// Call the constructors
if (instance.exports.f) {
instance.exports.f();
}
return true;
});
}
/**
* C15Processor - AudioWorklet processor for the C15 synth engine
*
* Fulfills validation assertions:
* - VAL-M2-001: WASM module loads in AudioWorkletGlobalScope
* - VAL-M2-002: registerProcessor() succeeds with 0 inputs, 2 outputs
* - VAL-M2-003: AudioWorkletNode connects to AudioContext destination
* - VAL-M2-004: WASM render produces valid stereo float32 output
* - VAL-M2-005: Ring buffer enables lock-free parameter updates without corruption
* - VAL-M2-006: Note on/off via ring buffer triggers audio start/release
*
* NOTE: WebAssembly.Module cannot be sent via MessagePort.postMessage() in Chrome.
* The WASM module must be passed via processorOptions in the AudioWorkletNode constructor.
* See: https://issues.chromium.org/issues/40855462
*/
class C15Processor extends AudioWorkletProcessor {
constructor(options) {
super(options);
// Processor configuration
this._initialized = false;
this._sampleRate = 48000;
this._polyphony = 24;
this._bufferSize = 128;
// Default configuration
const processorOptions = options.processorOptions || {};
this._sampleRate = processorOptions.sampleRate || 48000;
this._polyphony = processorOptions.polyphony || 24;
// Listen for messages from main thread
this.port.onmessage = this._handleMessage.bind(this);
// Debug: verify message handler is bound
this.port.postMessage({ type: 'status', status: 'handler-bound', test: true });
// Log that processor was created
this._postStatus('created', {
sampleRate: this._sampleRate,
polyphony: this._polyphony
});
// Initialize WASM from processorOptions if provided
// Chrome requires this approach - WebAssembly.Module cannot be sent via postMessage
// See: https://issues.chromium.org/issues/40855462
if (processorOptions.wasmModule instanceof WebAssembly.Module) {
this._postStatus('wasm-module-received-via-options', {
hasModule: true,
moduleType: 'WebAssembly.Module'
});
// Initialize WASM asynchronously
this._initFromWasmModule(processorOptions.wasmModule, null);
} else if (processorOptions.wasmModule) {
this._postError(new Error('wasmModule in processorOptions is not a WebAssembly.Module'), 'constructor');
} else {
this._postStatus('no-wasm-in-options', {
hint: 'WASM module should be passed via processorOptions.wasmModule'
});
}
// Initialize ring buffer if provided
if (processorOptions.ringBuffer) {
ringBufferReader = new RingBufferReader(processorOptions.ringBuffer);
this._postStatus('ring-buffer-ready', {
hasRingBuffer: true,
capacity: RING_CAPACITY
});
}
}
/**
* Post status message to main thread
* @private
*/
_postStatus(status, data = {}) {
this.port.postMessage({
type: 'status',
status: status,
...data
});
}
/**
* Post error message to main thread
* @private
*/
_postError(error, context = '') {
this.port.postMessage({
type: 'error',
error: error.toString(),
context: context
});
}
/**
* Handle messages from main thread
* @private
*/
_handleMessage(event) {
const data = event.data; // event.data contains the message
// Debug log all messages (except high-frequency ones)
if (data.type !== 'setParameter' && data.type !== 'tick') {
this._postStatus('message-received', { msgType: data.type });
}
switch (data.type) {
case 'test':
this._postStatus('test-received', { value: data.value });
break;
case 'init-wasm':
// Initialize WASM from compiled module sent by main thread
// NOTE: This may not work in Chrome due to cross-origin issues
// See: https://issues.chromium.org/issues/40855462
// The WASM module should be passed via processorOptions instead
if (this._initialized) {
this._postStatus('wasm-already-initialized', {
hint: 'WASM was already initialized via processorOptions'
});
return;
}
// Check if wasmModule is valid
if (!data.wasmModule) {
this._postError(new Error('No wasmModule in init-wasm message'), 'init-wasm');
return;
}
if (!(data.wasmModule instanceof WebAssembly.Module)) {
this._postError(new Error('wasmModule is not a WebAssembly.Module: ' + typeof data.wasmModule), 'init-wasm');
return;
}
this._postStatus('starting-wasm-init', {
hasModule: true,
moduleType: 'WebAssembly.Module',
note: 'Using postMessage (may fail in Chrome)'
});
this._initFromWasmModule(data.wasmModule, data.memory);
break;
case 'init-ring-buffer':
// Initialize ring buffer from SharedArrayBuffer sent by main thread
if (this._initialized && ringBufferReader) {
this._postStatus('ring-buffer-already-initialized', {
hint: 'Ring buffer was already initialized via processorOptions'
});
return;
}
if (data.ringBuffer) {
ringBufferReader = new RingBufferReader(data.ringBuffer);
this._postStatus('ring-buffer-ready', {
hasRingBuffer: true,
capacity: RING_CAPACITY
});
} else {
this._postError(new Error('No SharedArrayBuffer provided'), 'ring buffer init');
}
break;
case 'noteOn':
if (this._initialized && wasmInstance) {
wasmInstance.exports.o(data.note, data.velocity); // _noteOn
}
break;
case 'noteOff':
if (this._initialized && wasmInstance) {
wasmInstance.exports.p(data.note, data.velocity); // _noteOff
}
break;
case 'setParameter':
if (this._initialized && wasmInstance) {
wasmInstance.exports.q(data.paramId, data.value); // _setParameter
}
break;
case 'reset':
if (this._initialized && wasmInstance) {
wasmInstance.exports.t(); // _reset
}
break;
case 'getConfig':
this._postStatus('config', {
sampleRate: this._sampleRate,
polyphony: this._polyphony,
initialized: this._initialized
});
break;
default:
console.warn('[C15Processor] Unknown message type:', data.type);
}
}
/**
* Initialize from pre-compiled WASM module
* @private
*/
async _initFromWasmModule(wasmModule, memory) {
try {
this._postStatus('loading');
// Lazy reference to WASM memory — needed by _clock_time_get before we can
// assign wasmMemory (which only exists after WebAssembly.instantiate returns).
const memRef = [null];
// Define the WASM imports matching Emscripten's expected structure.
// Mapping (namespace 'a') as of the current build:
// a -> _emscripten_get_now
// b -> _proc_exit
// c -> __emscripten_runtime_keepalive_clear
// d -> __setitimer_js
// e -> _clock_time_get (WASI clock; writes i64 nanoseconds into WASM heap)
// f -> _emscripten_resize_heap
// g -> __abort_js
const imports = {
a: {
a: () => performance.now(),
b: (code) => { throw new Error('WASM exit: ' + code); },
c: () => {},
d: (_which, _timeout_ms) => 0,
e: (clk_id, _ignored_precision, ptime) => {
const now = clk_id === 0 ? Date.now() : performance.now();
const nsec = BigInt(Math.round(now * 1e6));
if (memRef[0]) {
new BigInt64Array(memRef[0].buffer)[ptime >> 3] = nsec;
}
return 0;
},
f: (requestedSize) => {
console.warn('[C15Processor] Heap resize requested but not supported');
return 0;
},
g: () => { throw new Error('WASM abort called'); }
}
};
// Instantiate the compiled module
const instance = await WebAssembly.instantiate(wasmModule, imports);
wasmInstance = instance;
// Get memory from exports (export 'h' is memory)
wasmMemory = instance.exports.h;
memRef[0] = wasmMemory;
// Call runtime init (export 'i' is initRuntime/__wasm_call_ctors)
if (instance.exports.i) {
instance.exports.i();
}
// Initialize the engine (export 'j' is _engineInit)
const initResult = instance.exports.j(this._sampleRate, this._polyphony);
if (initResult !== 1) {
throw new Error('engineInit failed with result: ' + initResult);
}
this._initialized = true;
wasmReady = true;
// Get the default frames per render call (export 'v' is _getDefaultFrames)
this._bufferSize = instance.exports.v();
this._postStatus('ready', {
sampleRate: this._sampleRate,
polyphony: this._polyphony,
bufferSize: this._bufferSize
});
} catch (error) {
this._postError(error, 'WASM load/init');
console.error('[C15Processor] Failed to init WASM:', error);
}
}
/**
* Process audio frames
*
* Called by the browser for each audio render quantum (typically 128 frames).
*
* VAL-M2-005: Processes ring buffer messages lock-free from main thread
* VAL-M2-006: Note on/off via ring buffer triggers audio
*
* @param {Float32Array[][]} inputs - Input audio buffers (unused)
* @param {Float32Array[][]} outputs - Output audio buffers (stereo)
* @param {Object} parameters - Automatable parameters (unused)
* @returns {boolean} - True to keep processor alive
*/
process(inputs, outputs, parameters) {
const output = outputs[0];
if (!output || output.length < 2) {
return true; // Keep alive but no output
}
const leftChannel = output[0];
const rightChannel = output[1];
const numFrames = leftChannel.length;
// If not initialized, output silence
if (!this._initialized || !wasmInstance) {
for (let i = 0; i < numFrames; i++) {
leftChannel[i] = 0;
rightChannel[i] = 0;
}
return true;
}
// Process ring buffer messages first (VAL-M2-005, VAL-M2-006)
if (ringBufferReader) {
ringBufferReader.processMessages({
onParameter: (paramId, value) => {
wasmInstance.exports.q(paramId, value); // _setParameter
},
onNoteOn: (note, velocity) => {
this._dbgNoteOns = (this._dbgNoteOns || 0) + 1;
wasmInstance.exports.o(note, velocity); // _noteOn
},
onNoteOff: (note, velocity) => {
wasmInstance.exports.p(note, velocity); // _noteOff
}
}, 64); // Process up to 64 messages per audio frame
}
// Cache HEAPF32 view (recreate if memory grew)
if (!this._heapF32 || this._heapF32.buffer !== wasmMemory.buffer) {
this._heapF32 = new Float32Array(wasmMemory.buffer);
}
try {
// Render audio using WASM
// export 'm' is _render - returns pointer to interleaved stereo buffer
const bufferPtr = wasmInstance.exports.m(numFrames);
if (!bufferPtr) {
// Render failed, output silence
for (let i = 0; i < numFrames; i++) {
leftChannel[i] = 0;
rightChannel[i] = 0;
}
return true;
}
const heapF32 = this._heapF32;
const bufferOffset = bufferPtr >> 2; // Convert byte offset to float32 index
// Deinterleave stereo data from WASM buffer to output channels
let frameMax = 0;
for (let i = 0; i < numFrames; i++) {
const sampleIndex = bufferOffset + (i * 2);
const l = heapF32[sampleIndex];
const r = heapF32[sampleIndex + 1];
leftChannel[i] = l;
rightChannel[i] = r;
const v = Math.abs(l) > Math.abs(r) ? Math.abs(l) : Math.abs(r);
if (v > frameMax) frameMax = v;
}
// Periodic diagnostics (every ~2 seconds = 750 frames at 128 frames/quantum)
this._dbgFrameCount = (this._dbgFrameCount || 0) + 1;
this._dbgMaxSample = Math.max(this._dbgMaxSample || 0, frameMax);
if (this._dbgFrameCount % 750 === 0) {
this.port.postMessage({
type: 'diag',
frames: this._dbgFrameCount,
maxSample: this._dbgMaxSample,
noteOns: this._dbgNoteOns || 0
});
this._dbgMaxSample = 0;
}
} catch (error) {
// On error, output silence
console.error('[C15Processor] Render error:', error);
for (let i = 0; i < numFrames; i++) {
leftChannel[i] = 0;
rightChannel[i] = 0;
}
}
return true; // Keep processor alive
}
/**
* Static getter for parameter descriptors (for automatable parameters)
* Currently not used but required for proper AudioWorklet interface.
*/
static get parameterDescriptors() {
return [];
}
}
// Register the processor with the AudioWorkletGlobalScope
// VAL-M2-002: This registration must succeed for the processor to be usable
registerProcessor('c15-processor', C15Processor);