From 7e6c960b0b514812a9097a94aaa0ecc3c65c76f2 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 18:19:12 +0100 Subject: [PATCH] fix: reduce main thread pressure to prevent audio buffer underruns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/ModuleNode.jsx | 7 ++++++- src/components/ScopeDisplay.jsx | 7 ++++++- src/engine/audioEngine.js | 16 +++++++++------- src/engine/state.js | 3 +++ src/main.jsx | 3 ++- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/components/ModuleNode.jsx b/src/components/ModuleNode.jsx index 400dcb7..6285969 100644 --- a/src/components/ModuleNode.jsx +++ b/src/components/ModuleNode.jsx @@ -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); diff --git a/src/components/ScopeDisplay.jsx b/src/components/ScopeDisplay.jsx index 7ac45cf..6ef7612 100644 --- a/src/components/ScopeDisplay.jsx +++ b/src/components/ScopeDisplay.jsx @@ -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(); diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index b38d97a..07b6f5e 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -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}`) || []; } diff --git a/src/engine/state.js b/src/engine/state.js index dbef959..cce06ec 100644 --- a/src/engine/state.js +++ b/src/engine/state.js @@ -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(); } diff --git a/src/main.jsx b/src/main.jsx index 2ca5599..5ad89a6 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -16,8 +16,9 @@ function Root() { createRoot(document.getElementById('root')).render(); -// 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(() => {});