/ proof-of-concept / phase-perception-2
Sign in
proof-of-concept / phase-perception-2
phase-perception-2 / src / components / PlaybackPanel.tsx
119 lines · 5.4 KBBlameHistoryRaw
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
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</h2>
66 <p className="hint">
67 A is the source, B is the phase-rotated version; both use the same gain{stale ? ' (B is being re-rendered)' : ''}. RMS of B minus A
68 is {Math.round(100 * result.relDiff)}% of the RMS of A. The spectrograms are computed at the analysis resolution and should match
69 {result.timeVarying ? ' when the map changes slowly compared with the window' : ''}.
70 </p>
71 <div className="button-row">
72 <button className="primary" onClick={() => play('A', ['A'])} disabled={busy || stale}>
73 Play A
74 </button>
75 <button className="primary" onClick={() => play('B', ['B'])} disabled={busy || stale}>
76 Play B
77 </button>
78 <button className="primary" onClick={() => play('A then B', ['A', 'B'])} disabled={busy || stale}>
79 Play A then B
80 </button>
81 <button className="primary" onClick={() => play('B then A', ['B', 'A'])} disabled={busy || stale}>
82 Play B then A
83 </button>
84 <button onClick={stop} disabled={!busy}>
85 Stop
86 </button>
87 <span className="playing-indicator">{playing ? `Playing ${playing}…` : ''}</span>
88 </div>
90 <div className="view-controls">
91 <span className="info">Drag to zoom into a range; wheel zooms; the bar below pans. Both waveforms share the view.</span>
92 <span className="spacer" />
93 <span className="info">{zoomed ? `${formatSeconds(view.span)} from ${formatSeconds(view.start)}` : `full clip, ${formatSeconds(duration)}`}</span>
94 <span className="button-group">
95 <button className="small" onClick={() => zoomBy(0.5)} disabled={view.span <= minSpan}>
96 Zoom in
97 </button>
98 <button className="small" onClick={() => zoomBy(2)} disabled={!zoomed}>
99 Zoom out
100 </button>
101 <button className="small" onClick={() => setView({ start: 0, span: duration })} disabled={!zoomed}>
102 Full clip
103 </button>
104 </span>
105 </div>
106 <WaveformPlot samples={source.samples} fs={fs} view={view} onView={onView} yMax={yMax} label="A" color={COLOR_A} />
107 <WaveformPlot samples={result.y} fs={fs} view={view} onView={onView} yMax={yMax} label="B" color={COLOR_B} />
108 <div className="scrollbar" ref={scrollRef} onScroll={onScroll} aria-label="pan">
109 <div style={{ width: `${(100 * duration) / view.span}%` }} />
110 </div>
112 <div className="plot-label">Spectrograms at the analysis resolution, {DB_RANGE} dB range, shared scale{zoomed ? '; the box marks the waveform view' : ''}</div>
113 <div className="spectrograms">
114 <ImagePlot image={result.specA} color={specColor} label="A" labelColor="#fff" highlight={highlight} />
115 <ImagePlot image={result.specB} color={specColor} label="B" labelColor="#fff" highlight={highlight} />
116 </div>
117 </section>
118 )
↑↓ move↵openescclose