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:
w1n5t0n 2026-03-21 22:26:37 +02:00
parent d0c42d78b5
commit 6f6a334529
12 changed files with 6940 additions and 25 deletions

BIN
playground/c15/c15_engine.wasm Executable file

Binary file not shown.

File diff suppressed because it is too large Load diff

View 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);

View file

@ -11,6 +11,7 @@
--danger: #ff3366;
--good: #00ff88;
--bad: #ff6644;
--synth-accent: #ff6b35;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@ -35,6 +36,7 @@ html, body {
height: 100dvh;
max-width: 600px;
margin: 0 auto;
position: relative;
}
.header,
@ -62,6 +64,12 @@ html, body {
color: var(--accent);
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.header button {
background: none;
border: 1px solid #333;
@ -73,6 +81,235 @@ html, body {
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-container {
position: relative;
@ -160,6 +397,11 @@ html, body {
flex-shrink: 0;
}
.param-container.synth-mode .param-label {
width: 72px;
color: #886644;
}
.param-track {
flex: 1;
height: 8px;

View file

@ -11,16 +11,84 @@
<body>
<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">&times;</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">&#9776;</button>
<!-- Header -->
<div class="header">
<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>
<!-- Visual output -->
<div class="visual-container">
<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('rainbow-sweep')">Rainbow</button>
<button class="preset-pill" onclick="loadPreset('vortex')">Vortex</button>
@ -37,35 +105,43 @@
<div id="controls-container"></div>
</div>
<!-- Side panel backdrop -->
<div class="side-panel-backdrop hidden" id="side-panel-backdrop"></div>
<!-- Help overlay -->
<div class="help-overlay hidden" id="help-overlay">
<div class="help-content" onclick="event.stopPropagation()">
<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 (&#9776;) 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>
<ol>
<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>Repeat for different joystick positions</li>
<li>Press <strong>Train</strong> &mdash; the network learns to interpolate between your examples</li>
<li>Move the joystick &mdash; visuals respond through the learned mapping</li>
</ol>
<h3>RL Feedback Mode</h3>
<ol>
<li>Move the joystick around &mdash; the network produces different visual outputs</li>
<li>Move the joystick &mdash; the network produces outputs</li>
<li>See something you like? Press <strong>+</strong> (thumbs up)</li>
<li>Don't like it? Press <strong>&minus;</strong> (thumbs down) to explore more</li>
<li>Double-click the joystick to toggle <strong>Follow</strong> mode (no hold needed)</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>
<li>Double-click the joystick to toggle <strong>Follow</strong> mode</li>
</ol>
<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>
</div>
</div>

View file

@ -1,15 +1,22 @@
// 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 { FlowFieldVisualizer } from './ui/visualizer.js';
import { VirtualJoystick } from './ui/joystick.js';
import { Controls } from './ui/controls.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_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 ---
let iml;
let visualizer;
@ -17,6 +24,7 @@ let joystick;
let controls;
let paramDisplay;
let learningMode = 'examples'; // 'examples' | 'rl'
let outputMode = 'visual'; // 'visual' | 'synth'
let noiseLevel = 0.05;
let rlExplorationDecay = 0.97;
let animating = true;
@ -29,6 +37,10 @@ let visualExpanded = false;
let appRoot;
let expandVisualBtn;
// Synth state
let c15 = null;
let arpeggiator = null;
// --- Init ---
function init() {
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'));
}
// 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('gamepaddisconnected', () => refreshDashboard());
window.addEventListener('keydown', onKeyDown);
@ -106,6 +133,149 @@ function init() {
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 ---
function animate() {
if (!animating) return;
@ -121,7 +291,7 @@ function onJoystickMove(x, y) {
iml.process();
const outputs = iml.getOutputs();
visualizer.setParams(outputs);
routeOutputs(outputs);
// Only update param display from network in inference (not when user is dragging)
if (learningMode !== 'examples' || paramDisplay.activeBar < 0) {
@ -147,7 +317,7 @@ function onTrain() {
if (loss !== null) {
// After training, switch back to inference and update display
const outputs = iml.getOutputs();
visualizer.setParams(outputs);
routeOutputs(outputs);
paramDisplay.update(outputs);
controls.updateStatus(iml.exampleCount, loss, noiseLevel);
controls.updateLossPlot(iml.lossHistory);
@ -159,7 +329,7 @@ function onTrain() {
function onRandomize() {
iml.randomiseWeights();
const outputs = iml.getOutputs();
visualizer.setParams(outputs);
routeOutputs(outputs);
paramDisplay.update(outputs);
noiseLevel = 0.05; // reset noise
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
@ -206,7 +376,7 @@ function onThumbsDown() {
iml.moveWeights(noiseLevel);
const outputs = iml.getOutputs();
visualizer.setParams(outputs);
routeOutputs(outputs);
paramDisplay.update(outputs);
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
refreshDashboard();
@ -292,7 +462,7 @@ window.loadPreset = function(name) {
const loss = trainModel();
const outputs = iml.getOutputs();
visualizer.setParams(outputs);
routeOutputs(outputs);
paramDisplay.update(outputs);
controls.updateStatus(iml.exampleCount, loss, noiseLevel);
controls.updateLossPlot(iml.lossHistory);
@ -319,7 +489,7 @@ function loadState() {
}
trainModel();
const outputs = iml.getOutputs();
visualizer.setParams(outputs);
routeOutputs(outputs);
paramDisplay.update(outputs);
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
controls.updateLossPlot(iml.lossHistory);

View 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;
}
}
}

View 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;
}
}
}

View 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',
];

View file

@ -1,8 +1,8 @@
// Parameter bar display
// 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 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_NAMES = ['Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb', 'Attract', 'Radius', 'DispRate', 'DispAmt', 'Lifetime', 'Respawn', 'Advection', 'Inertia', 'Drag', 'Repulse', 'RepCnt', 'RepRate'];
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 {
constructor(container, numParams = 20) {
@ -12,6 +12,8 @@ export class ParamDisplay {
this.draggable = false;
this.onChange = null;
this.activeBar = -1;
this.paramNames = [...DEFAULT_PARAM_NAMES];
this.paramColors = [...DEFAULT_PARAM_COLORS];
this.build();
}
@ -25,7 +27,7 @@ export class ParamDisplay {
const label = document.createElement('span');
label.className = 'param-label';
label.textContent = PARAM_NAMES[i] || `p${i}`;
label.textContent = this.paramNames[i] || `p${i}`;
const track = document.createElement('div');
track.className = 'param-track';
@ -33,7 +35,7 @@ export class ParamDisplay {
const fill = document.createElement('div');
fill.className = 'param-fill';
fill.style.background = PARAM_COLORS[i] || '#888';
fill.style.background = this.paramColors[i] || '#888';
fill.style.width = '50%';
const val = document.createElement('span');
@ -106,4 +108,17 @@ export class ParamDisplay {
this.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
View 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()

View file

@ -1,5 +1,5 @@
#!/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}"
MAX_ATTEMPTS=20
@ -8,8 +8,8 @@ DIR="$(cd "$(dirname "$0")" && pwd)"
for ((i = 0; i < MAX_ATTEMPTS; i++)); do
candidate=$((PORT + i))
if ! ss -tlnp 2>/dev/null | grep -q ":${candidate} "; then
echo "Serving playground at http://localhost:${candidate}"
exec python3 -m http.server "$candidate" -d "$DIR"
echo "Serving playground at http://localhost:${candidate} (COOP/COEP enabled)"
exec python3 "$DIR/serve-coop.py" "$candidate"
fi
echo "Port ${candidate} in use, trying next..."
done