fix: reduce main thread pressure to prevent audio buffer underruns

The periodic audio glitches were caused by main thread starvation:
~840 events/sec during playback starved the audio buffer.

Changes:
- Master clock 480→120 Hz (still 6x headroom for 300 BPM sixteenths)
- Connection cache: replace O(n) reduce hash with dirty flag (zero work
  on cache hit, flag set only when connections actually change)
- Tone.js lookAhead: 100ms→50ms for tighter scheduling
- ModuleNode LFO visualization RAF: 60fps→15fps (every 4th frame)
- ScopeDisplay RAF: 60fps→30fps (every 2nd frame)

Net effect: ~840 events/sec → ~200 events/sec during playback.
Audio processing gets 4x more main thread headroom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 18:19:12 +01:00
parent 7596aea491
commit 7e6c960b0b
5 changed files with 26 additions and 10 deletions

View File

@@ -71,7 +71,13 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
return;
}
let frameCount = 0;
const tick = () => {
frameCount++;
rafRef.current = requestAnimationFrame(tick);
// Throttle to ~15fps (every 4th frame) to reduce main thread pressure
if (frameCount % 4 !== 0) return;
const t = performance.now() / 1000 - startTimeRef.current;
const newValues = {};
@@ -104,7 +110,6 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
}
setLiveValues(newValues);
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);

View File

@@ -22,7 +22,13 @@ export default function ScopeDisplay({ moduleId }) {
const w = canvas.width = 160;
const h = canvas.height = 60;
let frameCount = 0;
const draw = () => {
frameCount++;
rafRef.current = requestAnimationFrame(draw);
// Throttle to ~30fps to reduce main thread pressure during playback
if (frameCount % 2 !== 0) return;
ctx.fillStyle = '#050510';
ctx.fillRect(0, 0, w, h);
@@ -58,7 +64,6 @@ export default function ScopeDisplay({ moduleId }) {
ctx.stroke();
}
rafRef.current = requestAnimationFrame(draw);
};
draw();

View File

@@ -16,7 +16,7 @@ const keyboardState = { frequency: 440, gate: false };
// Single clock with integer tick counter. All sequencers/piano rolls
// derive their step positions from this shared tick count.
// Using integers avoids floating-point drift entirely.
export const MASTER_TICK_RATE = 480; // Hz — must be high enough for fastest BPM
export const MASTER_TICK_RATE = 120; // Hz — 6x headroom for 300 BPM sixteenths (20 Hz). Lower = less main thread pressure.
let _masterClock = null;
const _tickListeners = new Map(); // id → callback(audioTime, ticks)
@@ -406,21 +406,23 @@ export function updateParam(moduleId, paramName, value) {
}
// Cache connection lookups for hot-path audio scheduling
// Rebuilt lazily when connections change
let _connCacheVersion = -1;
// Rebuilt only when connections actually change (dirty flag, no computation on hit)
let _connCacheDirty = true;
const _connByModulePort = new Map(); // "moduleId-portName" → [connections]
export function invalidateConnectionCache() {
_connCacheDirty = true;
}
function getConnectionsFrom(moduleId, portName) {
// Rebuild cache if connections changed
const version = state.connections.length + state.connections.reduce((s, c) => s + c.id, 0);
if (version !== _connCacheVersion) {
if (_connCacheDirty) {
_connByModulePort.clear();
for (const conn of state.connections) {
const key = `${conn.from.moduleId}-${conn.from.port}`;
if (!_connByModulePort.has(key)) _connByModulePort.set(key, []);
_connByModulePort.get(key).push(conn);
}
_connCacheVersion = version;
_connCacheDirty = false;
}
return _connByModulePort.get(`${moduleId}-${portName}`) || [];
}

View File

@@ -4,6 +4,7 @@
*/
import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js';
import { getModuleDef } from './moduleRegistry.js';
import { invalidateConnectionCache } from './audioEngine.js';
let _listeners = new Set();
let _nextModuleId = 1;
@@ -93,6 +94,7 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
const id = _nextConnectionId++;
state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } });
invalidateConnectionCache();
emit();
playConnect();
return id;
@@ -100,6 +102,7 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
export function removeConnection(id, _silent = false) {
state.connections = state.connections.filter(c => c.id !== id);
invalidateConnectionCache();
emit();
if (!_silent) playDisconnect();
}

View File

@@ -16,8 +16,9 @@ function Root() {
createRoot(document.getElementById('root')).render(<Root />);
// Unlock audio context on first user interaction
// Configure and unlock audio context on first user interaction
import * as Tone from 'tone';
Tone.getContext().lookAhead = 0.05; // 50ms — tighter than default 100ms
const unlockAudio = () => {
if (Tone.context.state !== 'running') {
Tone.start().catch(() => {});