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).
This commit is contained in:
parent
d0c42d78b5
commit
6f6a334529
12 changed files with 6940 additions and 25 deletions
BIN
playground/c15/c15_engine.wasm
Executable file
BIN
playground/c15/c15_engine.wasm
Executable file
Binary file not shown.
5433
playground/c15/parameters.json
Normal file
5433
playground/c15/parameters.json
Normal file
File diff suppressed because it is too large
Load diff
592
playground/c15/worklet-processor.js
Normal file
592
playground/c15/worklet-processor.js
Normal file
|
|
@ -0,0 +1,592 @@
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
--danger: #ff3366;
|
--danger: #ff3366;
|
||||||
--good: #00ff88;
|
--good: #00ff88;
|
||||||
--bad: #ff6644;
|
--bad: #ff6644;
|
||||||
|
--synth-accent: #ff6b35;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
@ -35,6 +36,7 @@ html, body {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header,
|
.header,
|
||||||
|
|
@ -62,6 +64,12 @@ html, body {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.header button {
|
.header button {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid #333;
|
border: 1px solid #333;
|
||||||
|
|
@ -73,6 +81,235 @@ html, body {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(0, 255, 136, 0.12);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-badge.synth {
|
||||||
|
background: rgba(255, 107, 53, 0.12);
|
||||||
|
border-color: var(--synth-accent);
|
||||||
|
color: var(--synth-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Side Panel ==================== */
|
||||||
|
.side-panel-toggle {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 50;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-left: none;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 12px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel-toggle:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 260px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
z-index: 60;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px 10px;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 0;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-tab:first-child {
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-tab:last-child {
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-tab.active[data-mode="visual"] {
|
||||||
|
background: rgba(0, 255, 136, 0.12);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-tab.active[data-mode="synth"] {
|
||||||
|
background: rgba(255, 107, 53, 0.12);
|
||||||
|
border-color: var(--synth-accent);
|
||||||
|
color: var(--synth-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-hint {
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Synth controls */
|
||||||
|
.synth-controls {
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.synth-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.synth-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.synth-label span {
|
||||||
|
color: var(--synth-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.synth-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.synth-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--synth-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.synth-slider::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--synth-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.synth-select {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.synth-start-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.synth-arp-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.synth-arp-btn.playing {
|
||||||
|
background: rgba(255, 107, 53, 0.15);
|
||||||
|
border-color: var(--synth-accent);
|
||||||
|
color: var(--synth-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.synth-status {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
/* Visual canvas */
|
/* Visual canvas */
|
||||||
.visual-container {
|
.visual-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -160,6 +397,11 @@ html, body {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.param-container.synth-mode .param-label {
|
||||||
|
width: 72px;
|
||||||
|
color: #886644;
|
||||||
|
}
|
||||||
|
|
||||||
.param-track {
|
.param-track {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,84 @@
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
|
|
||||||
|
<!-- Side Panel (collapsible) -->
|
||||||
|
<div class="side-panel" id="side-panel">
|
||||||
|
<div class="side-panel-header">
|
||||||
|
<span class="side-panel-title">Mode</span>
|
||||||
|
<button class="side-panel-close" id="side-panel-close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side-panel-tabs">
|
||||||
|
<button class="sp-tab active" data-mode="visual">Visual</button>
|
||||||
|
<button class="sp-tab" data-mode="synth">Synth</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Synth controls (shown when synth mode active) -->
|
||||||
|
<div class="synth-controls hidden" id="synth-controls">
|
||||||
|
<div class="synth-section">
|
||||||
|
<label class="synth-label">Engine</label>
|
||||||
|
<button class="btn btn-primary synth-start-btn" id="synth-start">Start Audio</button>
|
||||||
|
<div class="synth-status" id="synth-status">Stopped</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="synth-section">
|
||||||
|
<label class="synth-label">Volume</label>
|
||||||
|
<input type="range" class="synth-slider" id="synth-volume" min="0" max="1" step="0.01" value="0.5">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="synth-section">
|
||||||
|
<label class="synth-label">Arpeggiator</label>
|
||||||
|
<button class="btn synth-arp-btn" id="arp-toggle">Play</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="synth-section">
|
||||||
|
<label class="synth-label">Progression</label>
|
||||||
|
<select class="synth-select" id="arp-progression">
|
||||||
|
<option value="I-vi-IV-V">I-vi-IV-V</option>
|
||||||
|
<option value="I-IV-vi-V">I-IV-vi-V</option>
|
||||||
|
<option value="i-VI-III-VII">i-VI-III-VII</option>
|
||||||
|
<option value="I-V-vi-IV">I-V-vi-IV</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="synth-section">
|
||||||
|
<label class="synth-label">Tempo <span id="tempo-val">120</span> BPM</label>
|
||||||
|
<input type="range" class="synth-slider" id="arp-tempo" min="40" max="240" step="1" value="120">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="synth-section">
|
||||||
|
<label class="synth-label">Octaves <span id="octaves-val">2</span></label>
|
||||||
|
<input type="range" class="synth-slider" id="arp-octaves" min="1" max="5" step="1" value="2">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="synth-section">
|
||||||
|
<label class="synth-label">Oct Offset <span id="offset-val">0</span></label>
|
||||||
|
<input type="range" class="synth-slider" id="arp-offset" min="-2" max="3" step="1" value="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visual mode info (shown when visual mode active) -->
|
||||||
|
<div class="visual-info" id="visual-info">
|
||||||
|
<p class="sp-hint">Move the joystick and train the neural network to map positions to visual parameters.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Side panel toggle tab (always visible) -->
|
||||||
|
<button class="side-panel-toggle" id="side-panel-toggle" title="Open mode panel">☰</button>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>NISPS Playground</h1>
|
<h1>NISPS Playground</h1>
|
||||||
<button id="help-btn" title="Help">?</button>
|
<div class="header-right">
|
||||||
|
<span class="mode-badge" id="mode-badge">Visual</span>
|
||||||
|
<button id="help-btn" title="Help">?</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Visual output -->
|
<!-- Visual output -->
|
||||||
<div class="visual-container">
|
<div class="visual-container">
|
||||||
<canvas id="visual-canvas"></canvas>
|
<canvas id="visual-canvas"></canvas>
|
||||||
<div class="presets">
|
<div class="presets" id="presets-visual">
|
||||||
<button class="preset-pill" onclick="loadPreset('calm-to-chaotic')">Calm/Chaos</button>
|
<button class="preset-pill" onclick="loadPreset('calm-to-chaotic')">Calm/Chaos</button>
|
||||||
<button class="preset-pill" onclick="loadPreset('rainbow-sweep')">Rainbow</button>
|
<button class="preset-pill" onclick="loadPreset('rainbow-sweep')">Rainbow</button>
|
||||||
<button class="preset-pill" onclick="loadPreset('vortex')">Vortex</button>
|
<button class="preset-pill" onclick="loadPreset('vortex')">Vortex</button>
|
||||||
|
|
@ -37,35 +105,43 @@
|
||||||
<div id="controls-container"></div>
|
<div id="controls-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Side panel backdrop -->
|
||||||
|
<div class="side-panel-backdrop hidden" id="side-panel-backdrop"></div>
|
||||||
|
|
||||||
<!-- Help overlay -->
|
<!-- Help overlay -->
|
||||||
<div class="help-overlay hidden" id="help-overlay">
|
<div class="help-overlay hidden" id="help-overlay">
|
||||||
<div class="help-content" onclick="event.stopPropagation()">
|
<div class="help-content" onclick="event.stopPropagation()">
|
||||||
<h2>NISPS Playground</h2>
|
<h2>NISPS Playground</h2>
|
||||||
<p>Train a neural network to map joystick positions to visual parameters using interactive machine learning.</p>
|
<p>Train a neural network to map joystick positions to output parameters using interactive machine learning.</p>
|
||||||
|
|
||||||
|
<h3>Modes</h3>
|
||||||
|
<p>Open the side panel (☰) to switch between <strong>Visual</strong> and <strong>Synth</strong> modes.</p>
|
||||||
|
|
||||||
|
<h3>Visual Mode</h3>
|
||||||
|
<p>20 outputs control a flow-field particle system.</p>
|
||||||
|
|
||||||
|
<h3>Synth Mode</h3>
|
||||||
|
<p>20 outputs control a C15 synthesizer engine. Use the arpeggiator for automatic note playback.</p>
|
||||||
|
|
||||||
<h3>Examples Mode</h3>
|
<h3>Examples Mode</h3>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Move the joystick to a position</li>
|
<li>Move the joystick to a position</li>
|
||||||
<li>Drag the parameter bars to set desired visual output</li>
|
<li>Drag the parameter bars to set desired output</li>
|
||||||
<li>Press <strong>Add Example</strong> to save this mapping</li>
|
<li>Press <strong>Add Example</strong> to save this mapping</li>
|
||||||
<li>Repeat for different joystick positions</li>
|
<li>Repeat for different joystick positions</li>
|
||||||
<li>Press <strong>Train</strong> — the network learns to interpolate between your examples</li>
|
<li>Press <strong>Train</strong> — the network learns to interpolate between your examples</li>
|
||||||
<li>Move the joystick — visuals respond through the learned mapping</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<h3>RL Feedback Mode</h3>
|
<h3>RL Feedback Mode</h3>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Move the joystick around — the network produces different visual outputs</li>
|
<li>Move the joystick — the network produces outputs</li>
|
||||||
<li>See something you like? Press <strong>+</strong> (thumbs up)</li>
|
<li>See something you like? Press <strong>+</strong> (thumbs up)</li>
|
||||||
<li>Don't like it? Press <strong>−</strong> (thumbs down) to explore more</li>
|
<li>Don't like it? Press <strong>−</strong> (thumbs down) to explore more</li>
|
||||||
<li>Double-click the joystick to toggle <strong>Follow</strong> mode (no hold needed)</li>
|
<li>Double-click the joystick to toggle <strong>Follow</strong> mode</li>
|
||||||
<li>In Follow mode: keyboard <strong>2</strong> = thumbs up, <strong>1</strong> = thumbs down</li>
|
|
||||||
<li>With a gamepad: <strong>RB</strong> = thumbs up, <strong>LB</strong> = thumbs down</li>
|
|
||||||
<li>The network learns from your preferences over time</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<h3>Tips</h3>
|
<h3>Tips</h3>
|
||||||
<p>Try the preset buttons above the visuals for instant demos. Your training data auto-saves to your browser.</p>
|
<p>Your training data auto-saves to your browser. Gamepad supported (LB/RB for RL feedback).</p>
|
||||||
<p style="margin-top: 16px; text-align: center; color: #444;">Tap anywhere outside to close</p>
|
<p style="margin-top: 16px; text-align: center; color: #444;">Tap anywhere outside to close</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,22 @@
|
||||||
// NISPS Playground - Main application
|
// NISPS Playground - Main application
|
||||||
// Wires IML engine to visual system with joystick input and dual learning modes
|
// Wires IML engine to visual system OR C15 synth with joystick input and dual learning modes
|
||||||
|
|
||||||
import { IML } from './nisps/iml.js';
|
import { IML } from './nisps/iml.js';
|
||||||
import { FlowFieldVisualizer } from './ui/visualizer.js';
|
import { FlowFieldVisualizer } from './ui/visualizer.js';
|
||||||
import { VirtualJoystick } from './ui/joystick.js';
|
import { VirtualJoystick } from './ui/joystick.js';
|
||||||
import { Controls } from './ui/controls.js';
|
import { Controls } from './ui/controls.js';
|
||||||
import { ParamDisplay } from './ui/param-display.js';
|
import { ParamDisplay } from './ui/param-display.js';
|
||||||
|
import { C15Bridge } from './synth/c15-bridge.js';
|
||||||
|
import { Arpeggiator } from './synth/arpeggiator.js';
|
||||||
|
import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS } from './synth/param-map.js';
|
||||||
|
|
||||||
const N_INPUTS = 2;
|
const N_INPUTS = 2;
|
||||||
const N_OUTPUTS = 20;
|
const N_OUTPUTS = 20;
|
||||||
|
|
||||||
|
// Visual mode param display config
|
||||||
|
const VISUAL_PARAM_NAMES = ['Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb', 'Attract', 'Radius', 'DispRate', 'DispAmt', 'Lifetime', 'Respawn', 'Advection', 'Inertia', 'Drag', 'Repulse', 'RepCnt', 'RepRate'];
|
||||||
|
const VISUAL_PARAM_COLORS = ['#00ff88', '#00ccff', '#ff6600', '#ff00cc', '#ffcc00', '#88ff00', '#0088ff', '#ff3366', '#9bff5f', '#59d3ff', '#ff8f3f', '#a0b7ff', '#f4ff7a', '#ffa8db', '#7dffc8', '#ffd166', '#8ad4ff', '#ff5f5f', '#ffc15f', '#ff8a3d'];
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
let iml;
|
let iml;
|
||||||
let visualizer;
|
let visualizer;
|
||||||
|
|
@ -17,6 +24,7 @@ let joystick;
|
||||||
let controls;
|
let controls;
|
||||||
let paramDisplay;
|
let paramDisplay;
|
||||||
let learningMode = 'examples'; // 'examples' | 'rl'
|
let learningMode = 'examples'; // 'examples' | 'rl'
|
||||||
|
let outputMode = 'visual'; // 'visual' | 'synth'
|
||||||
let noiseLevel = 0.05;
|
let noiseLevel = 0.05;
|
||||||
let rlExplorationDecay = 0.97;
|
let rlExplorationDecay = 0.97;
|
||||||
let animating = true;
|
let animating = true;
|
||||||
|
|
@ -29,6 +37,10 @@ let visualExpanded = false;
|
||||||
let appRoot;
|
let appRoot;
|
||||||
let expandVisualBtn;
|
let expandVisualBtn;
|
||||||
|
|
||||||
|
// Synth state
|
||||||
|
let c15 = null;
|
||||||
|
let arpeggiator = null;
|
||||||
|
|
||||||
// --- Init ---
|
// --- Init ---
|
||||||
function init() {
|
function init() {
|
||||||
iml = new IML(N_INPUTS, N_OUTPUTS, [10, 10, 14], 1000, 1.0, 0.00001);
|
iml = new IML(N_INPUTS, N_OUTPUTS, [10, 10, 14], 1000, 1.0, 0.00001);
|
||||||
|
|
@ -85,6 +97,21 @@ function init() {
|
||||||
helpOverlay.addEventListener('click', () => helpOverlay.classList.add('hidden'));
|
helpOverlay.addEventListener('click', () => helpOverlay.classList.add('hidden'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Side panel
|
||||||
|
initSidePanel();
|
||||||
|
|
||||||
|
// Init synth
|
||||||
|
c15 = new C15Bridge();
|
||||||
|
c15.onStatusChange = (msg) => {
|
||||||
|
const el = document.getElementById('synth-status');
|
||||||
|
if (el) el.textContent = msg;
|
||||||
|
};
|
||||||
|
c15.loadParams();
|
||||||
|
arpeggiator = new Arpeggiator(c15);
|
||||||
|
|
||||||
|
// Wire synth controls
|
||||||
|
initSynthControls();
|
||||||
|
|
||||||
window.addEventListener('gamepadconnected', () => refreshDashboard());
|
window.addEventListener('gamepadconnected', () => refreshDashboard());
|
||||||
window.addEventListener('gamepaddisconnected', () => refreshDashboard());
|
window.addEventListener('gamepaddisconnected', () => refreshDashboard());
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
|
@ -106,6 +133,149 @@ function init() {
|
||||||
loadState();
|
loadState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Side Panel ---
|
||||||
|
function initSidePanel() {
|
||||||
|
const panel = document.getElementById('side-panel');
|
||||||
|
const toggle = document.getElementById('side-panel-toggle');
|
||||||
|
const close = document.getElementById('side-panel-close');
|
||||||
|
const backdrop = document.getElementById('side-panel-backdrop');
|
||||||
|
|
||||||
|
const openPanel = () => {
|
||||||
|
panel.classList.add('open');
|
||||||
|
backdrop.classList.remove('hidden');
|
||||||
|
};
|
||||||
|
const closePanel = () => {
|
||||||
|
panel.classList.remove('open');
|
||||||
|
backdrop.classList.add('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle.addEventListener('click', openPanel);
|
||||||
|
close.addEventListener('click', closePanel);
|
||||||
|
backdrop.addEventListener('click', closePanel);
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
panel.querySelectorAll('.sp-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
const mode = tab.dataset.mode;
|
||||||
|
setOutputMode(mode);
|
||||||
|
|
||||||
|
panel.querySelectorAll('.sp-tab').forEach(t => t.classList.toggle('active', t === tab));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOutputMode(mode) {
|
||||||
|
outputMode = mode;
|
||||||
|
|
||||||
|
const synthControls = document.getElementById('synth-controls');
|
||||||
|
const visualInfo = document.getElementById('visual-info');
|
||||||
|
const presetsVisual = document.getElementById('presets-visual');
|
||||||
|
const modeBadge = document.getElementById('mode-badge');
|
||||||
|
const paramContainer = document.getElementById('param-display');
|
||||||
|
|
||||||
|
if (mode === 'synth') {
|
||||||
|
synthControls.classList.remove('hidden');
|
||||||
|
visualInfo.classList.add('hidden');
|
||||||
|
presetsVisual.classList.add('hidden');
|
||||||
|
modeBadge.textContent = 'Synth';
|
||||||
|
modeBadge.classList.add('synth');
|
||||||
|
paramContainer.classList.add('synth-mode');
|
||||||
|
|
||||||
|
// Rebuild param display with synth labels/colors
|
||||||
|
paramDisplay.setNamesAndColors(SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS);
|
||||||
|
} else {
|
||||||
|
synthControls.classList.add('hidden');
|
||||||
|
visualInfo.classList.remove('hidden');
|
||||||
|
presetsVisual.classList.remove('hidden');
|
||||||
|
modeBadge.textContent = 'Visual';
|
||||||
|
modeBadge.classList.remove('synth');
|
||||||
|
paramContainer.classList.remove('synth-mode');
|
||||||
|
|
||||||
|
// Restore visual param labels/colors
|
||||||
|
paramDisplay.setNamesAndColors(VISUAL_PARAM_NAMES, VISUAL_PARAM_COLORS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-run inference and route outputs
|
||||||
|
routeOutputs(iml.getOutputs());
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeOutputs(outputs) {
|
||||||
|
// Always update visualizer (it's always visible)
|
||||||
|
visualizer.setParams(outputs);
|
||||||
|
|
||||||
|
// If in synth mode, also send to C15
|
||||||
|
if (outputMode === 'synth' && c15 && c15.running) {
|
||||||
|
for (let i = 0; i < outputs.length && i < SYNTH_PARAM_MAP.length; i++) {
|
||||||
|
c15.setParameter(SYNTH_PARAM_MAP[i].id, outputs[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Synth Controls ---
|
||||||
|
function initSynthControls() {
|
||||||
|
const startBtn = document.getElementById('synth-start');
|
||||||
|
const volumeSlider = document.getElementById('synth-volume');
|
||||||
|
const arpToggle = document.getElementById('arp-toggle');
|
||||||
|
const arpProgression = document.getElementById('arp-progression');
|
||||||
|
const arpTempo = document.getElementById('arp-tempo');
|
||||||
|
const arpOctaves = document.getElementById('arp-octaves');
|
||||||
|
const arpOffset = document.getElementById('arp-offset');
|
||||||
|
|
||||||
|
startBtn.addEventListener('click', async () => {
|
||||||
|
if (c15.running) {
|
||||||
|
arpeggiator.stop();
|
||||||
|
arpToggle.textContent = 'Play';
|
||||||
|
arpToggle.classList.remove('playing');
|
||||||
|
await c15.stop();
|
||||||
|
startBtn.textContent = 'Start Audio';
|
||||||
|
} else {
|
||||||
|
await c15.start();
|
||||||
|
startBtn.textContent = 'Stop Audio';
|
||||||
|
// Send current NISPS outputs to synth
|
||||||
|
routeOutputs(iml.getOutputs());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
volumeSlider.addEventListener('input', (e) => {
|
||||||
|
c15.setMasterVolume(parseFloat(e.target.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
arpToggle.addEventListener('click', () => {
|
||||||
|
if (!c15.running) return;
|
||||||
|
if (arpeggiator.playing) {
|
||||||
|
arpeggiator.stop();
|
||||||
|
arpToggle.textContent = 'Play';
|
||||||
|
arpToggle.classList.remove('playing');
|
||||||
|
} else {
|
||||||
|
arpeggiator.start();
|
||||||
|
arpToggle.textContent = 'Stop';
|
||||||
|
arpToggle.classList.add('playing');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
arpProgression.addEventListener('change', (e) => {
|
||||||
|
arpeggiator.progression = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
arpTempo.addEventListener('input', (e) => {
|
||||||
|
const val = parseInt(e.target.value);
|
||||||
|
arpeggiator.bpm = val;
|
||||||
|
document.getElementById('tempo-val').textContent = val;
|
||||||
|
});
|
||||||
|
|
||||||
|
arpOctaves.addEventListener('input', (e) => {
|
||||||
|
const val = parseInt(e.target.value);
|
||||||
|
arpeggiator.octaves = val;
|
||||||
|
document.getElementById('octaves-val').textContent = val;
|
||||||
|
});
|
||||||
|
|
||||||
|
arpOffset.addEventListener('input', (e) => {
|
||||||
|
const val = parseInt(e.target.value);
|
||||||
|
arpeggiator.octaveOffset = val;
|
||||||
|
document.getElementById('offset-val').textContent = val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Animation loop ---
|
// --- Animation loop ---
|
||||||
function animate() {
|
function animate() {
|
||||||
if (!animating) return;
|
if (!animating) return;
|
||||||
|
|
@ -121,7 +291,7 @@ function onJoystickMove(x, y) {
|
||||||
iml.process();
|
iml.process();
|
||||||
|
|
||||||
const outputs = iml.getOutputs();
|
const outputs = iml.getOutputs();
|
||||||
visualizer.setParams(outputs);
|
routeOutputs(outputs);
|
||||||
|
|
||||||
// Only update param display from network in inference (not when user is dragging)
|
// Only update param display from network in inference (not when user is dragging)
|
||||||
if (learningMode !== 'examples' || paramDisplay.activeBar < 0) {
|
if (learningMode !== 'examples' || paramDisplay.activeBar < 0) {
|
||||||
|
|
@ -147,7 +317,7 @@ function onTrain() {
|
||||||
if (loss !== null) {
|
if (loss !== null) {
|
||||||
// After training, switch back to inference and update display
|
// After training, switch back to inference and update display
|
||||||
const outputs = iml.getOutputs();
|
const outputs = iml.getOutputs();
|
||||||
visualizer.setParams(outputs);
|
routeOutputs(outputs);
|
||||||
paramDisplay.update(outputs);
|
paramDisplay.update(outputs);
|
||||||
controls.updateStatus(iml.exampleCount, loss, noiseLevel);
|
controls.updateStatus(iml.exampleCount, loss, noiseLevel);
|
||||||
controls.updateLossPlot(iml.lossHistory);
|
controls.updateLossPlot(iml.lossHistory);
|
||||||
|
|
@ -159,7 +329,7 @@ function onTrain() {
|
||||||
function onRandomize() {
|
function onRandomize() {
|
||||||
iml.randomiseWeights();
|
iml.randomiseWeights();
|
||||||
const outputs = iml.getOutputs();
|
const outputs = iml.getOutputs();
|
||||||
visualizer.setParams(outputs);
|
routeOutputs(outputs);
|
||||||
paramDisplay.update(outputs);
|
paramDisplay.update(outputs);
|
||||||
noiseLevel = 0.05; // reset noise
|
noiseLevel = 0.05; // reset noise
|
||||||
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
|
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
|
||||||
|
|
@ -206,7 +376,7 @@ function onThumbsDown() {
|
||||||
iml.moveWeights(noiseLevel);
|
iml.moveWeights(noiseLevel);
|
||||||
|
|
||||||
const outputs = iml.getOutputs();
|
const outputs = iml.getOutputs();
|
||||||
visualizer.setParams(outputs);
|
routeOutputs(outputs);
|
||||||
paramDisplay.update(outputs);
|
paramDisplay.update(outputs);
|
||||||
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
|
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
|
||||||
refreshDashboard();
|
refreshDashboard();
|
||||||
|
|
@ -292,7 +462,7 @@ window.loadPreset = function(name) {
|
||||||
|
|
||||||
const loss = trainModel();
|
const loss = trainModel();
|
||||||
const outputs = iml.getOutputs();
|
const outputs = iml.getOutputs();
|
||||||
visualizer.setParams(outputs);
|
routeOutputs(outputs);
|
||||||
paramDisplay.update(outputs);
|
paramDisplay.update(outputs);
|
||||||
controls.updateStatus(iml.exampleCount, loss, noiseLevel);
|
controls.updateStatus(iml.exampleCount, loss, noiseLevel);
|
||||||
controls.updateLossPlot(iml.lossHistory);
|
controls.updateLossPlot(iml.lossHistory);
|
||||||
|
|
@ -319,7 +489,7 @@ function loadState() {
|
||||||
}
|
}
|
||||||
trainModel();
|
trainModel();
|
||||||
const outputs = iml.getOutputs();
|
const outputs = iml.getOutputs();
|
||||||
visualizer.setParams(outputs);
|
routeOutputs(outputs);
|
||||||
paramDisplay.update(outputs);
|
paramDisplay.update(outputs);
|
||||||
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
|
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
|
||||||
controls.updateLossPlot(iml.lossHistory);
|
controls.updateLossPlot(iml.lossHistory);
|
||||||
|
|
|
||||||
121
playground/js/synth/arpeggiator.js
Normal file
121
playground/js/synth/arpeggiator.js
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
// Arpeggiator — plays chord progressions through the C15 engine
|
||||||
|
// Supports tempo, octave range, octave offset, and multiple chord progressions
|
||||||
|
|
||||||
|
// Chord progressions as arrays of arrays of intervals (semitones from root)
|
||||||
|
const PROGRESSIONS = {
|
||||||
|
'I-vi-IV-V': [
|
||||||
|
[0, 4, 7], // C major
|
||||||
|
[9, 12, 16], // A minor
|
||||||
|
[5, 9, 12], // F major
|
||||||
|
[7, 11, 14], // G major
|
||||||
|
],
|
||||||
|
'I-IV-vi-V': [
|
||||||
|
[0, 4, 7],
|
||||||
|
[5, 9, 12],
|
||||||
|
[9, 12, 16],
|
||||||
|
[7, 11, 14],
|
||||||
|
],
|
||||||
|
'i-VI-III-VII': [
|
||||||
|
[0, 3, 7], // C minor
|
||||||
|
[8, 12, 15], // Ab major
|
||||||
|
[3, 7, 10], // Eb major
|
||||||
|
[10, 14, 17], // Bb major
|
||||||
|
],
|
||||||
|
'I-V-vi-IV': [
|
||||||
|
[0, 4, 7],
|
||||||
|
[7, 11, 14],
|
||||||
|
[9, 12, 16],
|
||||||
|
[5, 9, 12],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Arpeggiator {
|
||||||
|
constructor(bridge) {
|
||||||
|
this.bridge = bridge;
|
||||||
|
this.bpm = 120;
|
||||||
|
this.octaves = 2; // how many octaves to span
|
||||||
|
this.octaveOffset = 0; // base octave shift (-2 to +2)
|
||||||
|
this.progression = 'I-vi-IV-V';
|
||||||
|
this.playing = false;
|
||||||
|
this._timer = null;
|
||||||
|
this._chordIndex = 0;
|
||||||
|
this._noteIndex = 0;
|
||||||
|
this._currentNotes = [];
|
||||||
|
this._lastNote = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
get progressionNames() {
|
||||||
|
return Object.keys(PROGRESSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.playing) return;
|
||||||
|
this.playing = true;
|
||||||
|
this._chordIndex = 0;
|
||||||
|
this._noteIndex = 0;
|
||||||
|
this._scheduleNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.playing = false;
|
||||||
|
if (this._timer) {
|
||||||
|
clearTimeout(this._timer);
|
||||||
|
this._timer = null;
|
||||||
|
}
|
||||||
|
// Release current note
|
||||||
|
if (this._lastNote >= 0) {
|
||||||
|
this.bridge.noteOff(this._lastNote);
|
||||||
|
this._lastNote = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleNext() {
|
||||||
|
if (!this.playing) return;
|
||||||
|
|
||||||
|
const msPerBeat = 60000 / this.bpm;
|
||||||
|
// Each note gets a 16th-note duration, chord changes every bar (4 beats)
|
||||||
|
const noteDuration = msPerBeat / 4;
|
||||||
|
|
||||||
|
this._playNextNote();
|
||||||
|
|
||||||
|
this._timer = setTimeout(() => this._scheduleNext(), noteDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
_playNextNote() {
|
||||||
|
const chords = PROGRESSIONS[this.progression] || PROGRESSIONS['I-vi-IV-V'];
|
||||||
|
|
||||||
|
// Release previous note
|
||||||
|
if (this._lastNote >= 0) {
|
||||||
|
this.bridge.noteOff(this._lastNote);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build arpeggiated note sequence from current chord across octaves
|
||||||
|
const chord = chords[this._chordIndex];
|
||||||
|
const baseNote = 48 + (this.octaveOffset * 12); // C3 as base + offset
|
||||||
|
|
||||||
|
// Build notes across octave range
|
||||||
|
const notes = [];
|
||||||
|
for (let oct = 0; oct < this.octaves; oct++) {
|
||||||
|
for (const interval of chord) {
|
||||||
|
const note = baseNote + interval + (oct * 12);
|
||||||
|
if (note >= 0 && note <= 127) {
|
||||||
|
notes.push(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notes.length === 0) return;
|
||||||
|
|
||||||
|
// Play the next note in sequence
|
||||||
|
const note = notes[this._noteIndex % notes.length];
|
||||||
|
this.bridge.noteOn(note, 0.6 + Math.random() * 0.2);
|
||||||
|
this._lastNote = note;
|
||||||
|
|
||||||
|
// Advance
|
||||||
|
this._noteIndex++;
|
||||||
|
if (this._noteIndex >= notes.length) {
|
||||||
|
this._noteIndex = 0;
|
||||||
|
this._chordIndex = (this._chordIndex + 1) % chords.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
210
playground/js/synth/c15-bridge.js
Normal file
210
playground/js/synth/c15-bridge.js
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
playground/js/synth/param-map.js
Normal file
38
playground/js/synth/param-map.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
// Maps 20 NISPS outputs (0-1) to curated C15 synth parameters
|
||||||
|
// Each entry: { id, name, label, defaultValue, bipolar }
|
||||||
|
// Chosen for maximum sonic impact and exploration potential
|
||||||
|
|
||||||
|
export const SYNTH_PARAM_MAP = [
|
||||||
|
{ id: 60, name: 'Osc_A_PM_Self', label: 'OscA Self-PM', defaultValue: 0.00, bipolar: true },
|
||||||
|
{ id: 64, name: 'Osc_A_PM_B', label: 'OscA PM-B', defaultValue: 0.00, bipolar: true },
|
||||||
|
{ id: 90, name: 'Osc_B_PM_Self', label: 'OscB Self-PM', defaultValue: 0.00, bipolar: true },
|
||||||
|
{ id: 94, name: 'Osc_B_PM_A', label: 'OscB PM-A', defaultValue: 0.00, bipolar: true },
|
||||||
|
{ id: 71, name: 'Shp_A_Drive', label: 'ShpA Drive', defaultValue: 0.20, bipolar: false },
|
||||||
|
{ id: 74, name: 'Shp_A_Fold', label: 'ShpA Fold', defaultValue: 0.50, bipolar: false },
|
||||||
|
{ id: 101, name: 'Shp_B_Drive', label: 'ShpB Drive', defaultValue: 0.20, bipolar: false },
|
||||||
|
{ id: 140, name: 'SV_Flt_Cut', label: 'SVF Cutoff', defaultValue: 0.50, bipolar: false },
|
||||||
|
{ id: 144, name: 'SV_Flt_Res', label: 'SVF Reso', defaultValue: 0.50, bipolar: false },
|
||||||
|
{ id: 148, name: 'SV_Flt_Spread', label: 'SVF Spread', defaultValue: 0.20, bipolar: true },
|
||||||
|
{ id: 115, name: 'Comb_Flt_Pitch', label: 'Comb Pitch', defaultValue: 0.50, bipolar: false },
|
||||||
|
{ id: 119, name: 'Comb_Flt_Decay', label: 'Comb Decay', defaultValue: 0.00, bipolar: true },
|
||||||
|
{ id: 127, name: 'Comb_Flt_AP_Res', label: 'Comb AP Res', defaultValue: 0.50, bipolar: false },
|
||||||
|
{ id: 235, name: 'Reverb_Size', label: 'Reverb Size', defaultValue: 0.33, bipolar: false },
|
||||||
|
{ id: 238, name: 'Reverb_Color', label: 'Reverb Color', defaultValue: 0.50, bipolar: false },
|
||||||
|
{ id: 225, name: 'Echo_Time', label: 'Echo Time', defaultValue: 0.43, bipolar: false },
|
||||||
|
{ id: 229, name: 'Echo_Feedback', label: 'Echo FB', defaultValue: 0.50, bipolar: false },
|
||||||
|
{ id: 219, name: 'Flanger_Feedback', label: 'Flanger FB', defaultValue: 0.00, bipolar: true },
|
||||||
|
{ id: 169, name: 'Out_Mix_A_Lvl', label: 'Out A Level', defaultValue: 0.75, bipolar: true },
|
||||||
|
{ id: 172, name: 'Out_Mix_B_Lvl', label: 'Out B Level', defaultValue: 0.00, bipolar: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Parameter display names for the param bar UI
|
||||||
|
export const SYNTH_PARAM_NAMES = SYNTH_PARAM_MAP.map(p => p.label);
|
||||||
|
|
||||||
|
// Colors for synth mode param bars (warm/synth palette)
|
||||||
|
export const SYNTH_PARAM_COLORS = [
|
||||||
|
'#ff6b35', '#ff9a3c', '#e85d04', '#ffb703',
|
||||||
|
'#fb5607', '#ff006e', '#8338ec', '#3a86ff',
|
||||||
|
'#06d6a0', '#118ab2', '#073b4c', '#ef476f',
|
||||||
|
'#ffd166', '#06d6a0', '#118ab2', '#8ecae6',
|
||||||
|
'#219ebc', '#ffb4a2', '#e5989b', '#b5838d',
|
||||||
|
];
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
// Parameter bar display
|
// Parameter bar display
|
||||||
// Shows output parameters as horizontal bars, draggable in examples mode
|
// Shows output parameters as horizontal bars, draggable in examples mode
|
||||||
|
|
||||||
const PARAM_NAMES = ['Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb', 'Attract', 'Radius', 'DispRate', 'DispAmt', 'Lifetime', 'Respawn', 'Advection', 'Inertia', 'Drag', 'Repulse', 'RepCnt', 'RepRate'];
|
const DEFAULT_PARAM_NAMES = ['Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb', 'Attract', 'Radius', 'DispRate', 'DispAmt', 'Lifetime', 'Respawn', 'Advection', 'Inertia', 'Drag', 'Repulse', 'RepCnt', 'RepRate'];
|
||||||
const PARAM_COLORS = ['#00ff88', '#00ccff', '#ff6600', '#ff00cc', '#ffcc00', '#88ff00', '#0088ff', '#ff3366', '#9bff5f', '#59d3ff', '#ff8f3f', '#a0b7ff', '#f4ff7a', '#ffa8db', '#7dffc8', '#ffd166', '#8ad4ff', '#ff5f5f', '#ffc15f', '#ff8a3d'];
|
const DEFAULT_PARAM_COLORS = ['#00ff88', '#00ccff', '#ff6600', '#ff00cc', '#ffcc00', '#88ff00', '#0088ff', '#ff3366', '#9bff5f', '#59d3ff', '#ff8f3f', '#a0b7ff', '#f4ff7a', '#ffa8db', '#7dffc8', '#ffd166', '#8ad4ff', '#ff5f5f', '#ffc15f', '#ff8a3d'];
|
||||||
|
|
||||||
export class ParamDisplay {
|
export class ParamDisplay {
|
||||||
constructor(container, numParams = 20) {
|
constructor(container, numParams = 20) {
|
||||||
|
|
@ -12,6 +12,8 @@ export class ParamDisplay {
|
||||||
this.draggable = false;
|
this.draggable = false;
|
||||||
this.onChange = null;
|
this.onChange = null;
|
||||||
this.activeBar = -1;
|
this.activeBar = -1;
|
||||||
|
this.paramNames = [...DEFAULT_PARAM_NAMES];
|
||||||
|
this.paramColors = [...DEFAULT_PARAM_COLORS];
|
||||||
this.build();
|
this.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,7 +27,7 @@ export class ParamDisplay {
|
||||||
|
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.className = 'param-label';
|
label.className = 'param-label';
|
||||||
label.textContent = PARAM_NAMES[i] || `p${i}`;
|
label.textContent = this.paramNames[i] || `p${i}`;
|
||||||
|
|
||||||
const track = document.createElement('div');
|
const track = document.createElement('div');
|
||||||
track.className = 'param-track';
|
track.className = 'param-track';
|
||||||
|
|
@ -33,7 +35,7 @@ export class ParamDisplay {
|
||||||
|
|
||||||
const fill = document.createElement('div');
|
const fill = document.createElement('div');
|
||||||
fill.className = 'param-fill';
|
fill.className = 'param-fill';
|
||||||
fill.style.background = PARAM_COLORS[i] || '#888';
|
fill.style.background = this.paramColors[i] || '#888';
|
||||||
fill.style.width = '50%';
|
fill.style.width = '50%';
|
||||||
|
|
||||||
const val = document.createElement('span');
|
const val = document.createElement('span');
|
||||||
|
|
@ -106,4 +108,17 @@ export class ParamDisplay {
|
||||||
this.draggable = draggable;
|
this.draggable = draggable;
|
||||||
this.container.classList.toggle('draggable', draggable);
|
this.container.classList.toggle('draggable', draggable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setNamesAndColors(names, colors) {
|
||||||
|
this.paramNames = names || DEFAULT_PARAM_NAMES;
|
||||||
|
this.paramColors = colors || DEFAULT_PARAM_COLORS;
|
||||||
|
// Update existing bars in-place
|
||||||
|
for (let i = 0; i < this.numParams; i++) {
|
||||||
|
if (!this.bars[i]) continue;
|
||||||
|
const row = this.bars[i].fill.closest('.param-row');
|
||||||
|
const label = row?.querySelector('.param-label');
|
||||||
|
if (label) label.textContent = this.paramNames[i] || `p${i}`;
|
||||||
|
this.bars[i].fill.style.background = this.paramColors[i] || '#888';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
playground/serve-coop.py
Executable file
18
playground/serve-coop.py
Executable file
|
|
@ -0,0 +1,18 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""HTTP server with COOP/COEP headers for SharedArrayBuffer support."""
|
||||||
|
import http.server
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
|
||||||
|
|
||||||
|
class COOPHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
def end_headers(self):
|
||||||
|
self.send_header('Cross-Origin-Opener-Policy', 'same-origin')
|
||||||
|
self.send_header('Cross-Origin-Embedder-Policy', 'require-corp')
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
with http.server.HTTPServer(('', PORT), COOPHandler) as httpd:
|
||||||
|
print(f'Serving playground at http://localhost:{PORT} (COOP/COEP enabled)')
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Serve the NISPS playground, finding an open port if needed.
|
# Serve the NISPS playground with COOP/COEP headers (required for SharedArrayBuffer/synth mode).
|
||||||
|
|
||||||
PORT="${1:-8000}"
|
PORT="${1:-8000}"
|
||||||
MAX_ATTEMPTS=20
|
MAX_ATTEMPTS=20
|
||||||
|
|
@ -8,8 +8,8 @@ DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
for ((i = 0; i < MAX_ATTEMPTS; i++)); do
|
for ((i = 0; i < MAX_ATTEMPTS; i++)); do
|
||||||
candidate=$((PORT + i))
|
candidate=$((PORT + i))
|
||||||
if ! ss -tlnp 2>/dev/null | grep -q ":${candidate} "; then
|
if ! ss -tlnp 2>/dev/null | grep -q ":${candidate} "; then
|
||||||
echo "Serving playground at http://localhost:${candidate}"
|
echo "Serving playground at http://localhost:${candidate} (COOP/COEP enabled)"
|
||||||
exec python3 -m http.server "$candidate" -d "$DIR"
|
exec python3 "$DIR/serve-coop.py" "$candidate"
|
||||||
fi
|
fi
|
||||||
echo "Port ${candidate} in use, trying next..."
|
echo "Port ${candidate} in use, trying next..."
|
||||||
done
|
done
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue