import { useCallback, useEffect, useRef, useState } from 'react' import { clampView, type View } from '../hooks/useZoomPan' import { dbColor, formatSeconds } from '../plot' import type { RenderResult, Source } from '../types' import { ImagePlot } from './ImagePlot' import { WaveformPlot } from './WaveformPlot' type Props = { source: Source result: RenderResult stale: boolean playing: string | null play: (label: string, which: ('A' | 'B')[]) => void stop: () => void } const COLOR_A = '#1f5fa8' const COLOR_B = '#d9641e' const DB_RANGE = 70 const specColor = dbColor(DB_RANGE) /** Play A and B, and compare their waveforms and spectrograms. */ export function PlaybackPanel({ source, result, stale, playing, play, stop }: Props) { const fs = source.fs const duration = source.samples.length / fs const minSpan = 16 / fs const [view, setView] = useState({ start: 0, span: duration }) // A new clip resets the view; the same clip re-rendered with a new map keeps it. useEffect(() => { setView({ start: 0, span: source.samples.length / source.fs }) }, [source]) const onView = useCallback((v: View) => setView(v), []) const zoomBy = (factor: number) => { const c = view.start + view.span / 2 const span = view.span * factor setView(clampView({ start: c - span / 2, span }, duration, minSpan)) } // 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. const scrollRef = useRef(null) useEffect(() => { const el = scrollRef.current if (!el) return const target = (view.start / duration) * el.scrollWidth if (Math.abs(el.scrollLeft - target) > 1) el.scrollLeft = target }, [view, duration]) const onScroll = () => { const el = scrollRef.current if (!el || el.scrollWidth <= el.clientWidth) return const start = (el.scrollLeft / el.scrollWidth) * duration if (Math.abs(start - view.start) <= duration / el.scrollWidth) return // our own programmatic scroll setView(clampView({ start, span: view.span }, duration, minSpan)) } let yMax = 0 for (let i = 0; i < source.samples.length; i++) yMax = Math.max(yMax, Math.abs(source.samples[i])) for (let i = 0; i < result.y.length; i++) yMax = Math.max(yMax, Math.abs(result.y[i])) yMax = Number(yMax.toPrecision(2)) || 1 const busy = playing !== null const zoomed = view.span < duration const highlight: [number, number] | undefined = zoomed ? [view.start, view.start + view.span] : undefined return (

3. Listen: can you hear a difference?

A is the original and B is the scrambled version, played at the same level{stale ? ' (B is being re-rendered)' : ''}. If they sound the same, phase is inaudible for this sound and these settings. The waveforms differ by {Math.round(100 * result.relDiff)}% of A's RMS; the spectrograms, which show how strong each frequency is over time, should look the same {result.timeVarying ? ' as long as the scramble changes slowly compared with the time window' : ''}.

{playing ? `Playing ${playing}…` : ''}
Drag across a waveform to zoom into that range. The scroll wheel also zooms; the bar below the waveforms pans. {zoomed ? `${formatSeconds(view.span)} from ${formatSeconds(view.start)}` : `full clip, ${formatSeconds(duration)}`}
Spectrograms at the analysis resolution, {DB_RANGE} dB range, shared scale{zoomed ? '; the box marks the waveform view' : ''}
) }