Spaces:
Running
Running
| /** | |
| * 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(); | |
| } | |
| } | |
| } | |