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