Waveforms: wheel zoom, drag pan, double-click reset, shared view marked on the spectrograms
5 changed files+167−58
src/components/PlaybackPanel.tsxmodified+25−35View file
@@ -1,4 +1,5 @@
1-import { useState } from 'react'
1+import { useCallback, useEffect, useState } from 'react'
2+import type { View } from '../hooks/useZoomPan'
23 import { formatSeconds } from '../plot'
34 import type { RenderResult, Source } from '../types'
45 import { SpectrogramPlot } from './SpectrogramPlot'
@@ -15,28 +16,26 @@ type Props = {
1516
1617 const COLOR_A = '#1f5fa8'
1718 const COLOR_B = '#d9641e'
18-const SPANS: { label: string; sec: number | null }[] = [
19- { label: 'full clip', sec: null },
20- { label: '500 ms', sec: 0.5 },
21- { label: '100 ms', sec: 0.1 },
22- { label: '20 ms', sec: 0.02 },
23- { label: '5 ms', sec: 0.005 },
24-]
2519 const DB_RANGE = 70
2620
2721 /** Play A and B, and compare their waveforms and spectrograms. */
2822 export function PlaybackPanel({ source, result, stale, playing, play, stop }: Props) {
29- const [spanIdx, setSpanIdx] = useState(0)
30- const [center, setCenter] = useState(0.5)
3123 const fs = source.fs
3224 const duration = source.samples.length / fs
33- const span = SPANS[spanIdx].sec ?? duration
34- const start = SPANS[spanIdx].sec ? Math.min(Math.max(0, center * duration - span / 2), Math.max(0, duration - span)) : 0
25+ const [view, setView] = useState<View>({ start: 0, span: duration })
26+ // A new clip resets the view; the same clip re-rendered with a new map keeps it.
27+ useEffect(() => {
28+ setView({ start: 0, span: source.samples.length / source.fs })
29+ }, [source])
30+ const onView = useCallback((v: View) => setView(v), [])
31+
3532 let yMax = 0
3633 for (let i = 0; i < source.samples.length; i++) yMax = Math.max(yMax, Math.abs(source.samples[i]))
3734 for (let i = 0; i < result.y.length; i++) yMax = Math.max(yMax, Math.abs(result.y[i]))
3835 yMax = Number(yMax.toPrecision(2)) || 1
3936 const busy = playing !== null
37+ const zoomed = view.span < duration
38+ const highlight: [number, number] | undefined = zoomed ? [view.start, view.start + view.span] : undefined
4039
4140 return (
4241 <section className="card">
@@ -65,32 +64,23 @@ export function PlaybackPanel({ source, result, stale, playing, play, stop }: Pr
6564 <span className="playing-indicator">{playing ? `Playing ${playing}…` : ''}</span>
6665 </div>
6766
68- <div className="zoom-controls">
69- <label>
70- <span>Waveform window</span>
71- <select value={spanIdx} onChange={(e) => setSpanIdx(Number(e.target.value))}>
72- {SPANS.map((s, i) => (
73- <option key={s.label} value={i}>
74- {s.label}
75- </option>
76- ))}
77- </select>
78- </label>
79- {SPANS[spanIdx].sec !== null && (
80- <label className="grow">
81- <span>Position</span>
82- <input type="range" min={0} max={1} step={0.0005} value={center} onChange={(e) => setCenter(Number(e.target.value))} />
83- <span className="slider-unit">{formatSeconds(start)}</span>
84- </label>
85- )}
67+ <div className="view-controls">
68+ <span className="info">Scroll to zoom, drag to pan, double-click to reset. Both waveforms share the view.</span>
69+ <span className="spacer" />
70+ <span className="info">
71+ {zoomed ? `showing ${formatSeconds(view.span)} from ${formatSeconds(view.start)}` : `full clip, ${formatSeconds(duration)}`}
72+ </span>
73+ <button className="small" onClick={() => setView({ start: 0, span: duration })} disabled={!zoomed}>
74+ Reset zoom
75+ </button>
8676 </div>
87- <WaveformPlot samples={source.samples} fs={fs} startSec={start} spanSec={span} yMax={yMax} label="A" color={COLOR_A} />
88- <WaveformPlot samples={result.y} fs={fs} startSec={start} spanSec={span} yMax={yMax} label="B" color={COLOR_B} />
77+ <WaveformPlot samples={source.samples} fs={fs} view={view} onView={onView} yMax={yMax} label="A" color={COLOR_A} />
78+ <WaveformPlot samples={result.y} fs={fs} view={view} onView={onView} yMax={yMax} label="B" color={COLOR_B} />
8979
90- <div className="plot-label">Spectrograms at the analysis resolution, {DB_RANGE} dB range, shared scale</div>
80+ <div className="plot-label">Spectrograms at the analysis resolution, {DB_RANGE} dB range, shared scale{zoomed ? '; the box marks the waveform view' : ''}</div>
9181 <div className="spectrograms">
92- <SpectrogramPlot spec={result.specA} dbRange={DB_RANGE} label="A" color="#fff" />
93- <SpectrogramPlot spec={result.specB} dbRange={DB_RANGE} label="B" color="#fff" />
82+ <SpectrogramPlot spec={result.specA} dbRange={DB_RANGE} label="A" color="#fff" highlight={highlight} />
83+ <SpectrogramPlot spec={result.specB} dbRange={DB_RANGE} label="B" color="#fff" highlight={highlight} />
9484 </div>
9585 </section>
9686 )
src/components/SpectrogramPlot.tsxmodified+12−2View file
@@ -9,12 +9,14 @@ type Props = {
99 dbRange: number
1010 label: string
1111 color: string
12+ /** time range to outline, e.g. the waveform view */
13+ highlight?: [number, number]
1214 }
1315
1416 const M = { l: 48, r: 12, t: 8, b: 26 }
1517
1618 /** Draws a dB spectrogram as an image with time and frequency axes. */
17-export function SpectrogramPlot({ spec, dbRange, label, color }: Props) {
19+export function SpectrogramPlot({ spec, dbRange, label, color, highlight }: Props) {
1820 // Rasterize once per spectrogram, then scale to the canvas on every draw.
1921 const image = useMemo(() => {
2022 const { rows, cols, db } = spec
@@ -53,6 +55,14 @@ export function SpectrogramPlot({ spec, dbRange, label, color }: Props) {
5355 ctx.strokeStyle = FRAME
5456 ctx.lineWidth = 1
5557 ctx.strokeRect(M.l + 0.5, M.t + 0.5, pw, ph)
58+ if (highlight) {
59+ const x0 = M.l + (Math.max(0, highlight[0]) / spec.durationSec) * pw
60+ const x1 = M.l + (Math.min(spec.durationSec, highlight[1]) / spec.durationSec) * pw
61+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)'
62+ ctx.setLineDash([4, 3])
63+ ctx.strokeRect(Math.round(x0) + 0.5, M.t + 0.5, Math.max(2, Math.round(x1 - x0)), ph)
64+ ctx.setLineDash([])
65+ }
5666
5767 ctx.fillStyle = AXIS
5868 ctx.textAlign = 'right'
@@ -84,7 +94,7 @@ export function SpectrogramPlot({ spec, dbRange, label, color }: Props) {
8494 ctx.textBaseline = 'top'
8595 ctx.fillText(label, M.l + 6, M.t + 5)
8696 },
87- [image, spec, label, color],
97+ [image, spec, label, color, highlight],
8898 )
8999 const ref = useCanvas(draw)
90100 return <canvas ref={ref} className="plot spectrogram-plot" />
src/components/WaveformPlot.tsxmodified+31−21View file
@@ -1,25 +1,30 @@
11 import { useCallback } from 'react'
22 import { useCanvas } from '../hooks/useCanvas'
3+import { useZoomPan, type View } from '../hooks/useZoomPan'
34 import { AXIS, FONT, FRAME, GRID, formatTick, niceTicks } from '../plot'
45
56 type Props = {
67 samples: Float32Array
78 fs: number
8- /** start of the visible window in seconds */
9- startSec: number
10- /** length of the visible window in seconds */
11- spanSec: number
9+ /** visible window in seconds; shared between plots so they stay synchronized */
10+ view: View
11+ onView: (v: View) => void
1212 yMax: number
1313 label: string
1414 color: string
15- /** highlight this region (the zoom window) when drawing the full clip */
16- highlight?: [number, number]
1715 }
1816
1917 const M = { l: 48, r: 12, t: 6, b: 22 }
18+const MARGINS = { left: M.l, right: M.r }
19+const MIN_SPAN_SAMPLES = 16
2020
21-/** One waveform. Draws a min/max envelope when there are many samples per pixel, a line otherwise. */
22-export function WaveformPlot({ samples, fs, startSec, spanSec, yMax, label, color, highlight }: Props) {
21+/**
22+ * One waveform with wheel zoom, drag pan, and double-click reset. Draws a
23+ * min/max envelope when there are many samples per pixel, a line otherwise.
24+ */
25+export function WaveformPlot({ samples, fs, view, onView, yMax, label, color }: Props) {
26+ const duration = samples.length / fs
27+ const { start: startSec, span: spanSec } = view
2328 const draw = useCallback(
2429 (ctx: CanvasRenderingContext2D, w: number, h: number) => {
2530 const pw = w - M.l - M.r
@@ -28,15 +33,10 @@ export function WaveformPlot({ samples, fs, startSec, spanSec, yMax, label, colo
2833 const y0 = M.t + ph / 2
2934 const toY = (v: number) => y0 - (v / ymax) * (ph / 2)
3035 const toX = (t: number) => M.l + ((t - startSec) / spanSec) * pw
36+ const ms = spanSec < 0.5
3137
3238 ctx.font = FONT
3339 ctx.lineWidth = 1
34- if (highlight) {
35- ctx.fillStyle = 'rgba(31, 95, 168, 0.10)'
36- const a = Math.max(M.l, toX(highlight[0]))
37- const b = Math.min(M.l + pw, toX(highlight[1]))
38- if (b > a) ctx.fillRect(a, M.t, b - a, ph)
39- }
4040 ctx.strokeStyle = GRID
4141 ctx.fillStyle = AXIS
4242 ctx.textAlign = 'center'
@@ -48,7 +48,7 @@ export function WaveformPlot({ samples, fs, startSec, spanSec, yMax, label, colo
4848 ctx.moveTo(x, M.t)
4949 ctx.lineTo(x, M.t + ph)
5050 ctx.stroke()
51- ctx.fillText(spanSec < 0.5 ? `${formatTick(v * 1000)}` : formatTick(v), x, M.t + ph + 4)
51+ ctx.fillText(formatTick(ms ? v * 1000 : v), x, M.t + ph + 4)
5252 }
5353 ctx.textAlign = 'right'
5454 ctx.textBaseline = 'middle'
@@ -65,11 +65,11 @@ export function WaveformPlot({ samples, fs, startSec, spanSec, yMax, label, colo
6565 ctx.fillStyle = AXIS
6666 ctx.textAlign = 'right'
6767 ctx.textBaseline = 'bottom'
68- ctx.fillText(spanSec < 0.5 ? 'ms' : 's', M.l + pw, h - 1)
68+ ctx.fillText(ms ? 'ms' : 's', M.l + pw, h - 1)
6969
7070 const i0 = Math.max(0, Math.floor(startSec * fs))
71- const i1 = Math.min(samples.length, Math.ceil((startSec + spanSec) * fs))
72- const perPx = (i1 - i0) / pw
71+ const i1 = Math.min(samples.length, Math.ceil((startSec + spanSec) * fs) + 1)
72+ const perPx = ((i1 - i0) * 1.0) / pw
7373 ctx.save()
7474 ctx.beginPath()
7575 ctx.rect(M.l, M.t, pw, ph)
@@ -80,9 +80,10 @@ export function WaveformPlot({ samples, fs, startSec, spanSec, yMax, label, colo
8080 ctx.lineJoin = 'round'
8181 if (perPx > 2) {
8282 // min/max envelope per pixel column
83+ const sampPerPx = (spanSec * fs) / pw
8384 for (let px = 0; px < pw; px++) {
84- const a = i0 + Math.floor(px * perPx)
85- const b = Math.min(i1, i0 + Math.floor((px + 1) * perPx))
85+ const a = Math.max(0, Math.floor(startSec * fs + px * sampPerPx))
86+ const b = Math.min(samples.length, Math.floor(startSec * fs + (px + 1) * sampPerPx))
8687 if (b <= a) continue
8788 let lo = Infinity
8889 let hi = -Infinity
@@ -104,6 +105,14 @@ export function WaveformPlot({ samples, fs, startSec, spanSec, yMax, label, colo
104105 else ctx.lineTo(px, py)
105106 }
106107 ctx.stroke()
108+ if (perPx < 0.25) {
109+ // individual samples are visible: mark them
110+ for (let i = i0; i < i1; i++) {
111+ ctx.beginPath()
112+ ctx.arc(toX(i / fs), toY(samples[i]), 2, 0, 2 * Math.PI)
113+ ctx.fill()
114+ }
115+ }
107116 }
108117 ctx.restore()
109118
@@ -113,8 +122,9 @@ export function WaveformPlot({ samples, fs, startSec, spanSec, yMax, label, colo
113122 ctx.textBaseline = 'top'
114123 ctx.fillText(label, M.l + 6, M.t + 4)
115124 },
116- [samples, fs, startSec, spanSec, yMax, label, color, highlight],
125+ [samples, fs, startSec, spanSec, yMax, label, color],
117126 )
118127 const ref = useCanvas(draw)
128+ useZoomPan(ref, view, duration, MIN_SPAN_SAMPLES / fs, MARGINS, onView)
119129 return <canvas ref={ref} className="plot waveform-plot" />
120130 }
src/hooks/useZoomPan.tsadded+82−0View file
@@ -0,0 +1,82 @@
1+import { useEffect, useRef, type RefObject } from 'react'
2+
3+export type View = { start: number; span: number }
4+
5+/** Keep the view inside [0, duration] with a span of at least minSpan. */
6+export function clampView(v: View, duration: number, minSpan: number): View {
7+ const span = Math.min(duration, Math.max(minSpan, v.span))
8+ const start = Math.min(Math.max(0, v.start), Math.max(0, duration - span))
9+ return { start, span }
10+}
11+
12+/**
13+ * Mouse-wheel zoom about the cursor, drag to pan, double-click to reset, on a
14+ * canvas whose plot area spans horizontally from `left` to `width - right`.
15+ * The listeners are registered once and read the latest view through a ref,
16+ * so a drag survives the re-renders it causes.
17+ */
18+export function useZoomPan(ref: RefObject<HTMLCanvasElement | null>, view: View, duration: number, minSpan: number, margins: { left: number; right: number }, onView: (v: View) => void): void {
19+ const viewRef = useRef(view)
20+ viewRef.current = view
21+ const onViewRef = useRef(onView)
22+ onViewRef.current = onView
23+ const drag = useRef<{ x: number; start: number } | null>(null)
24+
25+ useEffect(() => {
26+ const el = ref.current
27+ if (!el) return
28+ const plotWidth = () => el.clientWidth - margins.left - margins.right
29+ const timeAt = (clientX: number) => {
30+ const v = viewRef.current
31+ const rect = el.getBoundingClientRect()
32+ return v.start + ((clientX - rect.left - margins.left) / plotWidth()) * v.span
33+ }
34+ const emit = (v: View) => onViewRef.current(clampView(v, duration, minSpan))
35+
36+ const onWheel = (e: WheelEvent) => {
37+ e.preventDefault()
38+ const v = viewRef.current
39+ const factor = Math.exp(e.deltaY * 0.0015)
40+ const t = timeAt(e.clientX)
41+ const span = v.span * factor
42+ emit({ start: t - (t - v.start) * (span / v.span), span })
43+ }
44+ const onDown = (e: PointerEvent) => {
45+ if (e.button !== 0) return
46+ drag.current = { x: e.clientX, start: viewRef.current.start }
47+ el.setPointerCapture(e.pointerId)
48+ el.style.cursor = 'grabbing'
49+ }
50+ const onMove = (e: PointerEvent) => {
51+ const d = drag.current
52+ if (!d) return
53+ const v = viewRef.current
54+ const dt = ((e.clientX - d.x) / plotWidth()) * v.span
55+ emit({ start: d.start - dt, span: v.span })
56+ }
57+ const onUp = (e: PointerEvent) => {
58+ if (!drag.current) return
59+ drag.current = null
60+ if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
61+ el.style.cursor = 'grab'
62+ }
63+ const onDouble = () => emit({ start: 0, span: duration })
64+
65+ el.style.cursor = 'grab'
66+ el.style.touchAction = 'none'
67+ el.addEventListener('wheel', onWheel, { passive: false })
68+ el.addEventListener('pointerdown', onDown)
69+ el.addEventListener('pointermove', onMove)
70+ el.addEventListener('pointerup', onUp)
71+ el.addEventListener('pointercancel', onUp)
72+ el.addEventListener('dblclick', onDouble)
73+ return () => {
74+ el.removeEventListener('wheel', onWheel)
75+ el.removeEventListener('pointerdown', onDown)
76+ el.removeEventListener('pointermove', onMove)
77+ el.removeEventListener('pointerup', onUp)
78+ el.removeEventListener('pointercancel', onUp)
79+ el.removeEventListener('dblclick', onDouble)
80+ }
81+ }, [ref, duration, minSpan, margins])
82+}
src/index.cssmodified+17−0View file
@@ -158,6 +158,23 @@ button.selected {
158158 min-width: 8em;
159159 }
160160
161+.view-controls {
162+ display: flex;
163+ align-items: center;
164+ flex-wrap: wrap;
165+ gap: 8px 16px;
166+ margin: 10px 0 2px;
167+}
168+
169+.spacer {
170+ flex: 1;
171+}
172+
173+button.small {
174+ font-size: 0.8rem;
175+ padding: 4px 10px;
176+}
177+
161178 .global-controls,
162179 .zoom-controls {
163180 display: flex;