fix(playground): move arpeggiator to Worker thread, throttle synth params

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.
This commit is contained in:
w1n5t0n 2026-03-24 01:42:37 +02:00
parent 4fd0b39d26
commit 2fb00f20a9
4 changed files with 270 additions and 123 deletions

View file

@ -1040,22 +1040,37 @@ function onJoystickMove() {
} }
// ---- Output routing ---- // ---- 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) { function routeOutputs(outputs) {
if (outputMode === 'synth') { if (outputMode === 'synth') {
// Synth mode: synth visualizer + C15
// Apply group overrides before visualization and C15
const overridden = new Array(outputs.length); const overridden = new Array(outputs.length);
for (let i = 0; i < outputs.length; i++) { for (let i = 0; i < outputs.length; i++) {
overridden[i] = applyGroupOverrides(outputs[i], i); overridden[i] = applyGroupOverrides(outputs[i], i);
} }
// Visualizer always gets every frame (it's local, no buffer)
synthVisualizer.setParams(overridden); synthVisualizer.setParams(overridden);
// C15 ring buffer: throttle + dead-zone filter
if (c15 && c15.running) { if (c15 && c15.running) {
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++) { for (let i = 0; i < overridden.length && i < SYNTH_PARAM_MAP.length; i++) {
c15.setParameter(SYNTH_PARAM_MAP[i].id, overridden[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 { } else {
// Visual mode: flow field uses first 20
visualizer.setParams(outputs.slice(0, N_VISUAL_OUTPUTS)); visualizer.setParams(outputs.slice(0, N_VISUAL_OUTPUTS));
} }
} }

View file

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

View file

@ -1,121 +1,97 @@
// Arpeggiator — plays chord progressions through the C15 engine // Arpeggiator — plays chord progressions through the C15 engine
// Supports tempo, octave range, octave offset, and multiple chord progressions // Delegates to a dedicated Web Worker for reliable timing.
// The worker writes noteOn/noteOff directly to the SharedArrayBuffer
// Chord progressions as arrays of arrays of intervals (semitones from root) // ring buffer, bypassing main thread entirely.
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 { export class Arpeggiator {
constructor(bridge) { constructor(bridge) {
this.bridge = bridge; this.bridge = bridge;
this.bpm = 120; this._worker = null;
this.octaves = 2; // how many octaves to span this._playing = false;
this.octaveOffset = 0; // base octave shift (-2 to +2) this._bpm = 120;
this.progression = 'I-vi-IV-V'; this._octaves = 2;
this.playing = false; this._octaveOffset = 0;
this._timer = null; this._progression = 'I-vi-IV-V';
this._chordIndex = 0;
this._noteIndex = 0;
this._currentNotes = [];
this._lastNote = -1;
} }
get progressionNames() { 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() { start() {
if (this.playing) return; this._ensureWorker();
this.playing = true; // If bridge wasn't ready when worker was created, send the buffer now
this._chordIndex = 0; const sab = this.bridge.sharedBuffer;
this._noteIndex = 0; if (sab) {
this._scheduleNext(); this._worker.postMessage({ type: 'init', data: { sharedBuffer: sab } });
}
this._send('start');
this._playing = true; // optimistic, worker confirms
} }
stop() { stop() {
this.playing = false; this._send('stop');
if (this._timer) { this._playing = false;
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

@ -14,28 +14,38 @@ const HEADER_SIZE = 3;
const MESSAGE_SIZE = 4; const MESSAGE_SIZE = 4;
const RING_CAPACITY = 512; 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 { class RingBufferWriter {
constructor(sharedBuffer) { constructor(sharedBuffer) {
this._sab = sharedBuffer;
this._buffer = new Float32Array(sharedBuffer); this._buffer = new Float32Array(sharedBuffer);
this._int32 = new Int32Array(sharedBuffer); this._int32 = new Int32Array(sharedBuffer);
} }
write(type, id, value) { write(type, id, value) {
// CAS loop: claim a slot atomically
for (let attempt = 0; attempt < 4; attempt++) {
const writeIdx = Atomics.load(this._int32, 0); const writeIdx = Atomics.load(this._int32, 0);
const readIdx = Atomics.load(this._int32, 1); const readIdx = Atomics.load(this._int32, 1);
const next = (writeIdx + 1) % RING_CAPACITY; const next = (writeIdx + 1) % RING_CAPACITY;
if (next === readIdx) return false; if (next === readIdx) return false; // full
// 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; const off = HEADER_SIZE + writeIdx * MESSAGE_SIZE;
this._buffer[off] = type; this._buffer[off] = type;
this._buffer[off + 1] = id; this._buffer[off + 1] = id;
this._buffer[off + 2] = value; this._buffer[off + 2] = value;
this._buffer[off + 3] = 0; this._buffer[off + 3] = 0;
Atomics.store(this._int32, 0, next);
Atomics.add(this._int32, 2, 1); Atomics.add(this._int32, 2, 1);
return true; return true;
} }
// Another writer took it — retry
}
return false; // contention too high
}
writeParameter(paramId, value) { writeParameter(paramId, value) {
return this.write(MESSAGE_TYPE.PARAMETER, paramId, value); return this.write(MESSAGE_TYPE.PARAMETER, paramId, value);
@ -46,6 +56,9 @@ class RingBufferWriter {
writeNoteOff(note, velocity) { writeNoteOff(note, velocity) {
return this.write(MESSAGE_TYPE.NOTE_OFF, note, velocity || 0); 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 { export class C15Bridge {
@ -58,11 +71,15 @@ export class C15Bridge {
this.ready = false; this.ready = false;
this.allParams = null; this.allParams = null;
this.activeNotes = new Set(); this.activeNotes = new Set();
this._sab = null; // SharedArrayBuffer, exposed after start()
this._onStatusChange = null; this._onStatusChange = null;
} }
set onStatusChange(fn) { this._onStatusChange = fn; } set onStatusChange(fn) { this._onStatusChange = fn; }
/** SharedArrayBuffer for the ring buffer — available after start() */
get sharedBuffer() { return this._sab; }
_status(msg) { _status(msg) {
console.log('[C15]', msg); console.log('[C15]', msg);
this._onStatusChange?.(msg); this._onStatusChange?.(msg);
@ -106,6 +123,7 @@ export class C15Bridge {
const sabSize = (HEADER_SIZE + RING_CAPACITY * MESSAGE_SIZE) * 4; const sabSize = (HEADER_SIZE + RING_CAPACITY * MESSAGE_SIZE) * 4;
const sab = new SharedArrayBuffer(sabSize); const sab = new SharedArrayBuffer(sabSize);
new Float32Array(sab).fill(0); new Float32Array(sab).fill(0);
this._sab = sab;
this.ringWriter = new RingBufferWriter(sab); this.ringWriter = new RingBufferWriter(sab);
// Load worklet // Load worklet