memlnaut-nisps/playground/c15/worklet-processor.js

593 lines
21 KiB
JavaScript
Raw Normal View History

/**
* 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);