/** * Canvas visualizer: a scrolling level meter with vertical markers at each * detected onset, plus a recent-waveform trace. Purely presentational; it * reads values pushed from the Mic level callback and onset events. */ export class Scope { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext("2d"); this.level = new Float32Array(600); // ring buffer of recent levels this.head = 0; this.onsets = []; // {age} markers scrolling left this._raf = null; this._peak = 0.02; } pushLevel(level) { this.level[this.head] = level; this.head = (this.head + 1) % this.level.length; if (level > this._peak) this._peak = level; else this._peak = this._peak * 0.999 + level * 0.001; } markOnset() { // a marker positioned at the current write head this.onsets.push({ pos: this.head }); if (this.onsets.length > 64) this.onsets.shift(); } start() { const draw = () => { this._draw(); this._raf = requestAnimationFrame(draw); }; draw(); } stop() { if (this._raf) cancelAnimationFrame(this._raf); this._raf = null; } _draw() { const { ctx, canvas } = this; const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth, h = canvas.clientHeight; if (canvas.width !== w * dpr || canvas.height !== h * dpr) { canvas.width = w * dpr; canvas.height = h * dpr; } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const css = getComputedStyle(canvas); ctx.clearRect(0, 0, w, h); const n = this.level.length; const scale = 1 / (this._peak * 1.4 + 1e-6); const mid = h / 2; // level fill ctx.beginPath(); ctx.moveTo(0, mid); for (let i = 0; i < n; i++) { const idx = (this.head + i) % n; const x = (i / n) * w; const v = Math.min(1, this.level[idx] * scale); ctx.lineTo(x, mid - v * (h * 0.45)); } for (let i = n - 1; i >= 0; i--) { const idx = (this.head + i) % n; const x = (i / n) * w; const v = Math.min(1, this.level[idx] * scale); ctx.lineTo(x, mid + v * (h * 0.45)); } ctx.closePath(); ctx.fillStyle = css.getPropertyValue("--scope-fill") || "rgba(120,160,255,0.5)"; ctx.fill(); // onset markers (their stored head pos maps to a position in the ring) ctx.strokeStyle = css.getPropertyValue("--scope-mark") || "rgba(255,90,120,0.9)"; ctx.lineWidth = 2; for (const o of this.onsets) { let rel = (o.pos - this.head + n) % n; // distance behind head const x = (rel / n) * w; ctx.beginPath(); ctx.moveTo(x, 4); ctx.lineTo(x, h - 4); ctx.stroke(); } } }