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).
210 lines
6 KiB
JavaScript
210 lines
6 KiB
JavaScript
// 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;
|
|
}
|
|
}
|
|
}
|