1import { useCallback, useEffect, useState } from 'react'
2import type { View } from '../hooks/useZoomPan'
3import { formatSeconds } from '../plot'
4import type { RenderResult, Source } from '../types'
5import { SpectrogramPlot } from './SpectrogramPlot'
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
21/** Play A and B, and compare their waveforms and spectrograms. */
22export function PlaybackPanel({ source, result, stale, playing, play, stop }: Props) {
23 const fs = source.fs
24 const duration = source.samples.length / fs
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), [])
32 let yMax = 0
33 for (let i = 0; i < source.samples.length; i++) yMax = Math.max(yMax, Math.abs(source.samples[i]))
34 for (let i = 0; i < result.y.length; i++) yMax = Math.max(yMax, Math.abs(result.y[i]))
35 yMax = Number(yMax.toPrecision(2)) || 1
36 const busy = playing !== null
37 const zoomed = view.span < duration
38 const highlight: [number, number] | undefined = zoomed ? [view.start, view.start + view.span] : undefined
40 return (
41 <section className="card">
42 <h2>Listen and compare</h2>
43 <p className="hint">
44 A is the source; B has the phase map applied. Both are played with the same gain{stale ? ' (B is being re-rendered)' : ''}. The
45 waveforms differ by {Math.round(100 * result.relDiff)}% of A's RMS, while the spectrograms below, computed at the analysis
46 resolution, should look the same.
47 </p>
48 <div className="button-row">
49 <button className="primary" onClick={() => play('A', ['A'])} disabled={busy || stale}>
50 Play A
51 </button>
52 <button className="primary" onClick={() => play('B', ['B'])} disabled={busy || stale}>
53 Play B
54 </button>
55 <button className="primary" onClick={() => play('A then B', ['A', 'B'])} disabled={busy || stale}>
56 Play A then B
57 </button>
58 <button className="primary" onClick={() => play('B then A', ['B', 'A'])} disabled={busy || stale}>
59 Play B then A
60 </button>
61 <button onClick={stop} disabled={!busy}>
62 Stop
63 </button>
64 <span className="playing-indicator">{playing ? `Playing ${playing}…` : ''}</span>
65 </div>
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>
76 </div>
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} />
80 <div className="plot-label">Spectrograms at the analysis resolution, {DB_RANGE} dB range, shared scale{zoomed ? '; the box marks the waveform view' : ''}</div>
81 <div className="spectrograms">
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} />
84 </div>
85 </section>
86 )
87}