1import { useCallback, useEffect, useRef, useState } from 'react'
2import { clampView, type View } from '../hooks/useZoomPan'
3import { dbColor, formatSeconds } from '../plot'
4import type { RenderResult, Source } from '../types'
5import { ImagePlot } from './ImagePlot'
6import { WaveformPlot } from './WaveformPlot'
8type Props = {
9 source: Source
10 result: RenderResult
11 stale: boolean
12 playing: string | null
13 play: (label: string, which: ('A' | 'B')[]) => void
14 stop: () => void
15}
17const COLOR_A = '#1f5fa8'
18const COLOR_B = '#d9641e'
19const DB_RANGE = 70
20const specColor = dbColor(DB_RANGE)
22/** Play A and B, and compare their waveforms and spectrograms. */
23export function PlaybackPanel({ source, result, stale, playing, play, stop }: Props) {
24 const fs = source.fs
25 const duration = source.samples.length / fs
26 const minSpan = 16 / fs
27 const [view, setView] = useState<View>({ start: 0, span: duration })
28 // A new clip resets the view; the same clip re-rendered with a new map keeps it.
29 useEffect(() => {
30 setView({ start: 0, span: source.samples.length / source.fs })
31 }, [source])
32 const onView = useCallback((v: View) => setView(v), [])
33 const zoomBy = (factor: number) => {
34 const c = view.start + view.span / 2
35 const span = view.span * factor
36 setView(clampView({ start: c - span / 2, span }, duration, minSpan))
37 }
39 // A native horizontal scrollbar pans the view: its content is as many times wider than the plot as the clip is longer than the view.
40 const scrollRef = useRef<HTMLDivElement>(null)
41 useEffect(() => {
42 const el = scrollRef.current
43 if (!el) return
44 const target = (view.start / duration) * el.scrollWidth
45 if (Math.abs(el.scrollLeft - target) > 1) el.scrollLeft = target
46 }, [view, duration])
47 const onScroll = () => {
48 const el = scrollRef.current
49 if (!el || el.scrollWidth <= el.clientWidth) return
50 const start = (el.scrollLeft / el.scrollWidth) * duration
51 if (Math.abs(start - view.start) <= duration / el.scrollWidth) return // our own programmatic scroll
52 setView(clampView({ start, span: view.span }, duration, minSpan))
53 }
55 let yMax = 0
56 for (let i = 0; i < source.samples.length; i++) yMax = Math.max(yMax, Math.abs(source.samples[i]))
57 for (let i = 0; i < result.y.length; i++) yMax = Math.max(yMax, Math.abs(result.y[i]))
58 yMax = Number(yMax.toPrecision(2)) || 1
59 const busy = playing !== null
60 const zoomed = view.span < duration
61 const highlight: [number, number] | undefined = zoomed ? [view.start, view.start + view.span] : undefined
63 return (
64 <section className="card">
65 <h2>3. Listen: can you hear a difference?</h2>
66 <p className="hint">
67 A is the original and B is the scrambled version, played at the same level{stale ? ' (B is being re-rendered)' : ''}. If they sound
68 the same, phase is inaudible for this sound and these settings. The waveforms differ by {Math.round(100 * result.relDiff)}% of A's
69 RMS; the spectrograms, which show how strong each frequency is over time, should look the same
70 {result.timeVarying ? ' as long as the scramble changes slowly compared with the time window' : ''}.
71 </p>
72 <div className="button-row">
73 <button className="primary" onClick={() => play('A', ['A'])} disabled={busy || stale}>
74 Play A
75 </button>
76 <button className="primary" onClick={() => play('B', ['B'])} disabled={busy || stale}>
77 Play B
78 </button>
79 <button className="primary" onClick={() => play('A then B', ['A', 'B'])} disabled={busy || stale}>
80 Play A then B
81 </button>
82 <button className="primary" onClick={() => play('B then A', ['B', 'A'])} disabled={busy || stale}>
83 Play B then A
84 </button>
85 <button onClick={stop} disabled={!busy}>
86 Stop
87 </button>
88 <span className="playing-indicator">{playing ? `Playing ${playing}…` : ''}</span>
89 </div>
91 <div className="view-controls">
92 <span className="info">Drag across a waveform to zoom into that range. The scroll wheel also zooms; the bar below the waveforms pans.</span>
93 <span className="spacer" />
94 <span className="info">{zoomed ? `${formatSeconds(view.span)} from ${formatSeconds(view.start)}` : `full clip, ${formatSeconds(duration)}`}</span>
95 <span className="button-group">
96 <button className="small" onClick={() => zoomBy(0.5)} disabled={view.span <= minSpan}>
97 Zoom in
98 </button>
99 <button className="small" onClick={() => zoomBy(2)} disabled={!zoomed}>
100 Zoom out
101 </button>
102 <button className="small" onClick={() => setView({ start: 0, span: duration })} disabled={!zoomed}>
103 Full clip
104 </button>
105 </span>
106 </div>
107 <WaveformPlot samples={source.samples} fs={fs} view={view} onView={onView} yMax={yMax} label="A" color={COLOR_A} />
108 <WaveformPlot samples={result.y} fs={fs} view={view} onView={onView} yMax={yMax} label="B" color={COLOR_B} />
109 <div className="scrollbar" ref={scrollRef} onScroll={onScroll} aria-label="pan">
110 <div style={{ width: `${(100 * duration) / view.span}%` }} />
111 </div>
113 <div className="plot-label">Spectrograms at the analysis resolution, {DB_RANGE} dB range, shared scale{zoomed ? '; the box marks the waveform view' : ''}</div>
114 <div className="spectrograms">
115 <ImagePlot image={result.specA} color={specColor} label="A" labelColor="#fff" highlight={highlight} />
116 <ImagePlot image={result.specB} color={specColor} label="B" labelColor="#fff" highlight={highlight} />
117 </div>
118 </section>
119 )
120}