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>
113 lines
3.8 KiB
JavaScript
113 lines
3.8 KiB
JavaScript
import React, { useRef, useEffect, useState } from 'react';
|
||
import { getAnalyserData } from '../engine/audioEngine.js';
|
||
|
||
// Zoom levels: how many samples to display (from a 2048-sample buffer)
|
||
// Fewer samples = zoomed in (more detail), more samples = zoomed out (more time visible)
|
||
const ZOOM_LEVELS = [64, 128, 256, 512, 1024, 2048];
|
||
const DEFAULT_ZOOM = 2; // index → 256 samples
|
||
|
||
export default function ScopeDisplay({ moduleId }) {
|
||
const canvasRef = useRef(null);
|
||
const rafRef = useRef(null);
|
||
const [zoomIdx, setZoomIdx] = useState(DEFAULT_ZOOM);
|
||
const zoomRef = useRef(ZOOM_LEVELS[DEFAULT_ZOOM]);
|
||
|
||
// Keep ref in sync so the draw loop picks it up without re-creating the effect
|
||
useEffect(() => { zoomRef.current = ZOOM_LEVELS[zoomIdx]; }, [zoomIdx]);
|
||
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
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);
|
||
|
||
// Grid lines
|
||
ctx.strokeStyle = '#151530';
|
||
ctx.lineWidth = 0.5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2);
|
||
ctx.moveTo(0, h / 4); ctx.lineTo(w, h / 4);
|
||
ctx.moveTo(0, h * 3 / 4); ctx.lineTo(w, h * 3 / 4);
|
||
for (let x = w / 4; x < w; x += w / 4) {
|
||
ctx.moveTo(x, 0); ctx.lineTo(x, h);
|
||
}
|
||
ctx.stroke();
|
||
|
||
const data = getAnalyserData(moduleId);
|
||
if (data && data.length > 0) {
|
||
const samplesToShow = zoomRef.current;
|
||
// Center the window in the buffer
|
||
const offset = Math.max(0, Math.floor((data.length - samplesToShow) / 2));
|
||
const end = Math.min(data.length, offset + samplesToShow);
|
||
|
||
ctx.strokeStyle = '#00e5ff';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
const count = end - offset;
|
||
const step = w / count;
|
||
for (let i = 0; i < count; i++) {
|
||
const y = h / 2 + data[offset + i] * h / 2 * -1;
|
||
if (i === 0) ctx.moveTo(0, y);
|
||
else ctx.lineTo(i * step, y);
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
|
||
};
|
||
draw();
|
||
|
||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
||
}, [moduleId]);
|
||
|
||
const canZoomIn = zoomIdx > 0;
|
||
const canZoomOut = zoomIdx < ZOOM_LEVELS.length - 1;
|
||
|
||
return (
|
||
<div style={{ position: 'relative' }}>
|
||
<canvas ref={canvasRef} className="scope-canvas" />
|
||
<div style={{
|
||
position: 'absolute', bottom: 2, right: 2,
|
||
display: 'flex', gap: 2,
|
||
}}>
|
||
<button
|
||
onClick={() => canZoomOut && setZoomIdx(i => i + 1)}
|
||
disabled={!canZoomOut}
|
||
title="Zoom out (más tiempo)"
|
||
style={{
|
||
width: 18, height: 18, padding: 0,
|
||
background: canZoomOut ? '#1a1a3a' : '#0a0a15',
|
||
border: '1px solid #333', borderRadius: 3,
|
||
color: canZoomOut ? '#00e5ff' : '#333',
|
||
cursor: canZoomOut ? 'pointer' : 'default',
|
||
fontSize: 12, lineHeight: '16px', fontWeight: 'bold',
|
||
}}
|
||
>−</button>
|
||
<button
|
||
onClick={() => canZoomIn && setZoomIdx(i => i - 1)}
|
||
disabled={!canZoomIn}
|
||
title="Zoom in (más detalle)"
|
||
style={{
|
||
width: 18, height: 18, padding: 0,
|
||
background: canZoomIn ? '#1a1a3a' : '#0a0a15',
|
||
border: '1px solid #333', borderRadius: 3,
|
||
color: canZoomIn ? '#00e5ff' : '#333',
|
||
cursor: canZoomIn ? 'pointer' : 'default',
|
||
fontSize: 12, lineHeight: '16px', fontWeight: 'bold',
|
||
}}
|
||
>+</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|