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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}`) || [];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
Reference in New Issue
Block a user