memlnaut-nisps/playground/js/synth/arpeggiator-worker.js
w1n5t0n 2fb00f20a9 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.
2026-03-24 01:42:37 +02:00

138 lines
3.5 KiB
JavaScript

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