memlnaut-nisps/playground/js/synth/c15-bridge.js

211 lines
6 KiB
JavaScript
Raw Normal View History

// C15 WASM synth engine bridge
// Handles WASM loading, AudioWorklet setup, ring buffer communication
const CONFIG = {
WASM_PATH: 'c15/c15_engine.wasm',
WORKLET_PATH: 'c15/worklet-processor.js',
PARAMS_PATH: 'c15/parameters.json',
SAMPLE_RATE: 48000,
POLYPHONY: 24,
};
const MESSAGE_TYPE = { PARAMETER: 0, NOTE_ON: 1, NOTE_OFF: 2 };
const HEADER_SIZE = 3;
const MESSAGE_SIZE = 4;
const RING_CAPACITY = 512;
class RingBufferWriter {
constructor(sharedBuffer) {
this._buffer = new Float32Array(sharedBuffer);
this._int32 = new Int32Array(sharedBuffer);
}
write(type, id, value) {
const writeIdx = Atomics.load(this._int32, 0);
const readIdx = Atomics.load(this._int32, 1);
const next = (writeIdx + 1) % RING_CAPACITY;
if (next === readIdx) return false;
const off = HEADER_SIZE + writeIdx * MESSAGE_SIZE;
this._buffer[off] = type;
this._buffer[off + 1] = id;
this._buffer[off + 2] = value;
this._buffer[off + 3] = 0;
Atomics.store(this._int32, 0, next);
Atomics.add(this._int32, 2, 1);
return true;
}
writeParameter(paramId, value) {
return this.write(MESSAGE_TYPE.PARAMETER, paramId, value);
}
writeNoteOn(note, velocity) {
return this.write(MESSAGE_TYPE.NOTE_ON, note, velocity);
}
writeNoteOff(note, velocity) {
return this.write(MESSAGE_TYPE.NOTE_OFF, note, velocity || 0);
}
}
export class C15Bridge {
constructor() {
this.audioContext = null;
this.workletNode = null;
this.masterGain = null;
this.ringWriter = null;
this.running = false;
this.ready = false;
this.allParams = null;
this.activeNotes = new Set();
this._onStatusChange = null;
}
set onStatusChange(fn) { this._onStatusChange = fn; }
_status(msg) {
console.log('[C15]', msg);
this._onStatusChange?.(msg);
}
async loadParams() {
if (this.allParams) return this.allParams;
try {
const res = await fetch(CONFIG.PARAMS_PATH);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
this.allParams = data.parameters;
} catch (err) {
console.warn('[C15] Failed to load parameters.json:', err.message);
this.allParams = [];
}
return this.allParams;
}
async start() {
if (this.running) return;
try {
if (typeof SharedArrayBuffer === 'undefined') {
throw new Error('SharedArrayBuffer not available — serve with COOP/COEP headers (use serve.sh)');
}
this._status('Compiling WASM...');
const resp = await fetch(CONFIG.WASM_PATH);
if (!resp.ok) throw new Error(`Failed to fetch WASM: ${resp.status}`);
const wasmBytes = await resp.arrayBuffer();
const wasmModule = await WebAssembly.compile(wasmBytes);
const AC = window.AudioContext || window.webkitAudioContext;
this.audioContext = new AC({
sampleRate: CONFIG.SAMPLE_RATE,
latencyHint: 'interactive',
});
// Ring buffer (passed via processorOptions — worklet reads it immediately)
const sabSize = (HEADER_SIZE + RING_CAPACITY * MESSAGE_SIZE) * 4;
const sab = new SharedArrayBuffer(sabSize);
new Float32Array(sab).fill(0);
this.ringWriter = new RingBufferWriter(sab);
// Load worklet
this._status('Loading AudioWorklet...');
await this.audioContext.audioWorklet.addModule(CONFIG.WORKLET_PATH);
this.workletNode = new AudioWorkletNode(this.audioContext, 'c15-processor', {
processorOptions: {
sampleRate: CONFIG.SAMPLE_RATE,
polyphony: CONFIG.POLYPHONY,
wasmModule,
ringBuffer: sab,
},
numberOfInputs: 0,
numberOfOutputs: 1,
outputChannelCount: [2],
});
this.workletNode.port.onmessage = (e) => this._handleWorkletMsg(e.data);
this.masterGain = this.audioContext.createGain();
this.masterGain.gain.value = 0.5;
this.workletNode.connect(this.masterGain);
this.masterGain.connect(this.audioContext.destination);
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume();
}
this.running = true;
this._status('Running');
} catch (err) {
this._status(`Error: ${err.message}`);
console.error('[C15] Start failed:', err);
}
}
async stop() {
if (!this.running) return;
this.panic();
if (this.audioContext) {
await this.audioContext.suspend();
}
this.running = false;
this.ready = false;
this._status('Stopped');
}
_handleWorkletMsg(data) {
if (data.type === 'status' && data.status === 'ready') {
this.ready = true;
this._status('WASM ready');
this._sendAllDefaults();
}
}
_sendAllDefaults() {
if (!this.allParams || !this.ringWriter) return;
for (const p of this.allParams) {
if (!p.valid) continue;
this.ringWriter.writeParameter(p.id, Math.max(0, Math.min(1, p.defaultValue)));
}
// Set some initial sound: Osc A output level up, envelope sustain
this.ringWriter.writeParameter(169, 0.75); // Out_Mix_A_Lvl
this.ringWriter.writeParameter(8, 0.4); // Env_A_Sus
this.ringWriter.writeParameter(0, 0.1); // Env_A_Att (quick)
this.ringWriter.writeParameter(10, 0.5); // Env_A_Rel
this.ringWriter.writeParameter(241, 0.15); // Reverb_Mix
this.ringWriter.writeParameter(233, 0.08); // Echo_Mix
}
setParameter(paramId, value) {
if (!this.ringWriter || !this.running) return;
this.ringWriter.writeParameter(paramId, Math.max(0, Math.min(1, value)));
}
noteOn(note, velocity = 0.7) {
if (!this.ringWriter || !this.running) return;
this.ringWriter.writeNoteOn(note, velocity);
this.activeNotes.add(note);
}
noteOff(note) {
if (!this.ringWriter || !this.running) return;
this.ringWriter.writeNoteOff(note, 0);
this.activeNotes.delete(note);
}
panic() {
for (const note of this.activeNotes) {
this.ringWriter?.writeNoteOff(note, 0);
}
this.activeNotes.clear();
}
setMasterVolume(value) {
if (this.masterGain) {
this.masterGain.gain.value = value;
}
}
}