From 2fb00f20a91b6b2118b7ca72910cd3e04b11b026 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Tue, 24 Mar 2026 01:42:37 +0200 Subject: [PATCH] fix(playground): move arpeggiator to Worker thread, throttle synth params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand tracking at 30fps was writing 126 params per frame to the C15 ring buffer (capacity 512), flooding it and starving arpeggiator noteOn/noteOff messages — causing stuck/dropped notes. Three-part fix: - Arpeggiator now runs in a dedicated Web Worker with direct SharedArrayBuffer access, bypassing the main thread entirely for note timing. Worker setInterval isn't subject to main thread jank. - RingBufferWriter uses CAS (Atomics.compareExchange) for multi-producer safety — both main thread (params) and worker (notes) write safely. - routeOutputs throttles synth parameter sends: dead-zone filter (0.2% change threshold) + rate cap (~20fps), reducing ring buffer pressure from ~3800 msg/s to ~50-100 msg/s of actual changes. --- playground/js/a-app.js | 25 ++- playground/js/synth/arpeggiator-worker.js | 138 ++++++++++++++++ playground/js/synth/arpeggiator.js | 186 ++++++++++------------ playground/js/synth/c15-bridge.js | 44 +++-- 4 files changed, 270 insertions(+), 123 deletions(-) create mode 100644 playground/js/synth/arpeggiator-worker.js diff --git a/playground/js/a-app.js b/playground/js/a-app.js index 1acec1f..5b3e67f 100644 --- a/playground/js/a-app.js +++ b/playground/js/a-app.js @@ -1040,22 +1040,37 @@ function onJoystickMove() { } // ---- Output routing ---- +// Synth param throttling: only send values that changed beyond a dead zone. +// Prevents ring buffer flooding (126 params × 30fps = 3780 msg/s > 512 capacity). +const _lastSentParams = new Float32Array(N_OUTPUTS); +const PARAM_DEAD_ZONE = 0.002; // ~0.2% change threshold +let _lastParamSendTime = 0; +const PARAM_SEND_INTERVAL = 50; // max ~20fps for synth param updates + function routeOutputs(outputs) { if (outputMode === 'synth') { - // Synth mode: synth visualizer + C15 - // Apply group overrides before visualization and C15 const overridden = new Array(outputs.length); for (let i = 0; i < outputs.length; i++) { overridden[i] = applyGroupOverrides(outputs[i], i); } + // Visualizer always gets every frame (it's local, no buffer) synthVisualizer.setParams(overridden); + + // C15 ring buffer: throttle + dead-zone filter if (c15 && c15.running) { - for (let i = 0; i < overridden.length && i < SYNTH_PARAM_MAP.length; i++) { - c15.setParameter(SYNTH_PARAM_MAP[i].id, overridden[i]); + const now = performance.now(); + if (now - _lastParamSendTime >= PARAM_SEND_INTERVAL) { + _lastParamSendTime = now; + for (let i = 0; i < overridden.length && i < SYNTH_PARAM_MAP.length; i++) { + const v = overridden[i]; + if (Math.abs(v - _lastSentParams[i]) > PARAM_DEAD_ZONE) { + c15.setParameter(SYNTH_PARAM_MAP[i].id, v); + _lastSentParams[i] = v; + } + } } } } else { - // Visual mode: flow field uses first 20 visualizer.setParams(outputs.slice(0, N_VISUAL_OUTPUTS)); } } diff --git a/playground/js/synth/arpeggiator-worker.js b/playground/js/synth/arpeggiator-worker.js new file mode 100644 index 0000000..1565179 --- /dev/null +++ b/playground/js/synth/arpeggiator-worker.js @@ -0,0 +1,138 @@ +// Arpeggiator Worker — runs note scheduling on a dedicated thread +// for reliable timing independent of main thread load. +// Writes noteOn/noteOff directly to the C15 SharedArrayBuffer ring buffer. + +const MESSAGE_TYPE = { PARAMETER: 0, NOTE_ON: 1, NOTE_OFF: 2 }; +const HEADER_SIZE = 3; +const MESSAGE_SIZE = 4; +const RING_CAPACITY = 512; + +const PROGRESSIONS = { + 'I-vi-IV-V': [[0,4,7],[9,12,16],[5,9,12],[7,11,14]], + 'I-IV-vi-V': [[0,4,7],[5,9,12],[9,12,16],[7,11,14]], + 'i-VI-III-VII': [[0,3,7],[8,12,15],[3,7,10],[10,14,17]], + 'I-V-vi-IV': [[0,4,7],[7,11,14],[9,12,16],[5,9,12]], +}; + +// Ring buffer writer (CAS-safe, matches c15-bridge.js) +let ringBuffer = null; +let ringF32 = null; +let ringI32 = null; + +function ringWrite(type, id, value) { + if (!ringI32) return false; + for (let attempt = 0; attempt < 4; attempt++) { + const writeIdx = Atomics.load(ringI32, 0); + const readIdx = Atomics.load(ringI32, 1); + const next = (writeIdx + 1) % RING_CAPACITY; + if (next === readIdx) return false; + if (Atomics.compareExchange(ringI32, 0, writeIdx, next) === writeIdx) { + const off = HEADER_SIZE + writeIdx * MESSAGE_SIZE; + ringF32[off] = type; + ringF32[off + 1] = id; + ringF32[off + 2] = value; + ringF32[off + 3] = 0; + Atomics.add(ringI32, 2, 1); + return true; + } + } + return false; +} + +// Arpeggiator state +let playing = false; +let bpm = 120; +let octaves = 2; +let octaveOffset = 0; +let progression = 'I-vi-IV-V'; +let chordIndex = 0; +let noteIndex = 0; +let lastNote = -1; +let timer = null; + +function scheduleNext() { + if (!playing) return; + const msPerBeat = 60000 / bpm; + const noteDuration = msPerBeat / 4; // 16th notes + playNextNote(); + timer = setTimeout(scheduleNext, noteDuration); +} + +function playNextNote() { + const chords = PROGRESSIONS[progression] || PROGRESSIONS['I-vi-IV-V']; + + // Release previous note + if (lastNote >= 0) { + ringWrite(MESSAGE_TYPE.NOTE_OFF, lastNote, 0); + } + + const chord = chords[chordIndex]; + const baseNote = 48 + (octaveOffset * 12); + + const notes = []; + for (let oct = 0; oct < 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; + + const note = notes[noteIndex % notes.length]; + const velocity = 0.6 + Math.random() * 0.2; + ringWrite(MESSAGE_TYPE.NOTE_ON, note, velocity); + lastNote = note; + + noteIndex++; + if (noteIndex >= notes.length) { + noteIndex = 0; + chordIndex = (chordIndex + 1) % chords.length; + } +} + +function start() { + if (playing) return; + playing = true; + chordIndex = 0; + noteIndex = 0; + scheduleNext(); + postMessage({ type: 'state', playing: true }); +} + +function stop() { + playing = false; + if (timer) { + clearTimeout(timer); + timer = null; + } + if (lastNote >= 0) { + ringWrite(MESSAGE_TYPE.NOTE_OFF, lastNote, 0); + lastNote = -1; + } + postMessage({ type: 'state', playing: false }); +} + +// Handle messages from main thread +self.onmessage = (e) => { + const { type, data } = e.data; + switch (type) { + case 'init': + ringBuffer = data.sharedBuffer; + ringF32 = new Float32Array(ringBuffer); + ringI32 = new Int32Array(ringBuffer); + break; + case 'start': + start(); + break; + case 'stop': + stop(); + break; + case 'set': + if ('bpm' in data) bpm = data.bpm; + if ('octaves' in data) octaves = data.octaves; + if ('octaveOffset' in data) octaveOffset = data.octaveOffset; + if ('progression' in data) progression = data.progression; + break; + } +}; diff --git a/playground/js/synth/arpeggiator.js b/playground/js/synth/arpeggiator.js index c555b16..a49f2b3 100644 --- a/playground/js/synth/arpeggiator.js +++ b/playground/js/synth/arpeggiator.js @@ -1,121 +1,97 @@ // 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], - ], -}; +// Delegates to a dedicated Web Worker for reliable timing. +// The worker writes noteOn/noteOff directly to the SharedArrayBuffer +// ring buffer, bypassing main thread entirely. 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; + this._worker = null; + this._playing = false; + this._bpm = 120; + this._octaves = 2; + this._octaveOffset = 0; + this._progression = 'I-vi-IV-V'; } get progressionNames() { - return Object.keys(PROGRESSIONS); + return ['I-vi-IV-V', 'I-IV-vi-V', 'i-VI-III-VII', 'I-V-vi-IV']; + } + + get playing() { return this._playing; } + + get bpm() { return this._bpm; } + set bpm(v) { + this._bpm = v; + this._send('set', { bpm: v }); + } + + get octaves() { return this._octaves; } + set octaves(v) { + this._octaves = v; + this._send('set', { octaves: v }); + } + + get octaveOffset() { return this._octaveOffset; } + set octaveOffset(v) { + this._octaveOffset = v; + this._send('set', { octaveOffset: v }); + } + + get progression() { return this._progression; } + set progression(v) { + this._progression = v; + this._send('set', { progression: v }); + } + + _ensureWorker() { + if (this._worker) return; + + this._worker = new Worker( + new URL('./arpeggiator-worker.js', import.meta.url), + { type: 'module' } + ); + + this._worker.onmessage = (e) => { + if (e.data.type === 'state') { + this._playing = e.data.playing; + } + }; + + // Pass the SharedArrayBuffer so the worker can write notes directly + const sab = this.bridge.sharedBuffer; + if (sab) { + this._worker.postMessage({ type: 'init', data: { sharedBuffer: sab } }); + } + + // Sync current settings + this._send('set', { + bpm: this._bpm, + octaves: this._octaves, + octaveOffset: this._octaveOffset, + progression: this._progression, + }); + } + + _send(type, data) { + if (this._worker) { + this._worker.postMessage({ type, data }); + } } start() { - if (this.playing) return; - this.playing = true; - this._chordIndex = 0; - this._noteIndex = 0; - this._scheduleNext(); + this._ensureWorker(); + // If bridge wasn't ready when worker was created, send the buffer now + const sab = this.bridge.sharedBuffer; + if (sab) { + this._worker.postMessage({ type: 'init', data: { sharedBuffer: sab } }); + } + this._send('start'); + this._playing = true; // optimistic, worker confirms } 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; - } + this._send('stop'); + this._playing = false; } } diff --git a/playground/js/synth/c15-bridge.js b/playground/js/synth/c15-bridge.js index 732ec54..f94d87d 100644 --- a/playground/js/synth/c15-bridge.js +++ b/playground/js/synth/c15-bridge.js @@ -14,27 +14,37 @@ const HEADER_SIZE = 3; const MESSAGE_SIZE = 4; const RING_CAPACITY = 512; +// Multi-producer-safe ring buffer writer using CAS (compare-and-swap). +// Safe for concurrent writes from main thread + arpeggiator Worker. class RingBufferWriter { constructor(sharedBuffer) { + this._sab = 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; + // CAS loop: claim a slot atomically + for (let attempt = 0; attempt < 4; attempt++) { + 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; // full - 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; + // Try to claim this slot + if (Atomics.compareExchange(this._int32, 0, writeIdx, next) === writeIdx) { + // Won the slot — write data + 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.add(this._int32, 2, 1); + return true; + } + // Another writer took it — retry + } + return false; // contention too high } writeParameter(paramId, value) { @@ -46,6 +56,9 @@ class RingBufferWriter { writeNoteOff(note, velocity) { return this.write(MESSAGE_TYPE.NOTE_OFF, note, velocity || 0); } + + /** Expose the SharedArrayBuffer for passing to Workers */ + get sharedBuffer() { return this._sab; } } export class C15Bridge { @@ -58,11 +71,15 @@ export class C15Bridge { this.ready = false; this.allParams = null; this.activeNotes = new Set(); + this._sab = null; // SharedArrayBuffer, exposed after start() this._onStatusChange = null; } set onStatusChange(fn) { this._onStatusChange = fn; } + /** SharedArrayBuffer for the ring buffer — available after start() */ + get sharedBuffer() { return this._sab; } + _status(msg) { console.log('[C15]', msg); this._onStatusChange?.(msg); @@ -106,6 +123,7 @@ export class C15Bridge { const sabSize = (HEADER_SIZE + RING_CAPACITY * MESSAGE_SIZE) * 4; const sab = new SharedArrayBuffer(sabSize); new Float32Array(sab).fill(0); + this._sab = sab; this.ringWriter = new RingBufferWriter(sab); // Load worklet