7d910acPhase perception 2: subband phase rotation of arbitrary sounds with filter bank, phase maps, plots, and playbackJeremy Magland 1import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2import { getSampleRate, playSequence, stopAll } from './audio'
3import { AnalysisPanel } from './components/AnalysisPanel'
4import { PlaybackPanel } from './components/PlaybackPanel'
5import { SourcePanel } from './components/SourcePanel'
6import { defaultPhaseMap, type PhaseMapSpec } from './phasemap'
7import { Renderer } from './renderer'
8import { makePreset, presets } from './sources'
9import type { RenderResult, Source } from './types'
11const RENDER_DEBOUNCE_MS = 120
12const MAX_COLS = 600
13const MAX_ROWS = 256
14const CURVE_POINTS = 1024
16export default function App() {
17 const fs = useMemo(() => getSampleRate(), [])
18 const fmax = Math.min(10000, fs / 2)
19 const [source, setSource] = useState<Source | null>(() => makePreset(presets[4], fs))
20 const [sigmaMs, setSigmaMs] = useState(5)
21 const [map, setMap] = useState<PhaseMapSpec>(defaultPhaseMap)
22 const [result, setResult] = useState<RenderResult | null>(null)
23 const [rendering, setRendering] = useState(false)
24 const [error, setError] = useState<string | null>(null)
25 const [playing, setPlaying] = useState<string | null>(null)
4a285b7Create the render worker lazily so StrictMode remounts do not leave it terminatedJeremy Magland 26 const rendererRef = useRef<Renderer | null>(null)
7d910acPhase perception 2: subband phase rotation of arbitrary sounds with filter bank, phase maps, plots, and playbackJeremy Magland 27 const resultSource = useRef<Source | null>(null)
4a285b7Create the render worker lazily so StrictMode remounts do not leave it terminatedJeremy Magland 29 // The worker is created on demand and terminated on unmount. React StrictMode unmounts and
30 // remounts in development, so a terminated worker must be replaced rather than kept.
31 useEffect(
32 () => () => {
33 rendererRef.current?.terminate()
34 rendererRef.current = null
35 },
36 [],
37 )
7d910acPhase perception 2: subband phase rotation of arbitrary sounds with filter bank, phase maps, plots, and playbackJeremy Magland 38
39 // Re-render B whenever the source, the window, or the map changes (debounced for slider drags).
40 useEffect(() => {
41 if (!source) return
42 setRendering(true)
43 const timer = setTimeout(() => {
4a285b7Create the render worker lazily so StrictMode remounts do not leave it terminatedJeremy Magland 44 const renderer = rendererRef.current ?? (rendererRef.current = new Renderer())
7d910acPhase perception 2: subband phase rotation of arbitrary sounds with filter bank, phase maps, plots, and playbackJeremy Magland 45 renderer
46 .request({ samples: source.samples, fs: source.fs, sigmaMs, map, fmax, maxCols: MAX_COLS, maxRows: MAX_ROWS, curvePoints: CURVE_POINTS })
47 .then((r) => {
48 if (!r) return // superseded
49 resultSource.current = source
50 setResult(r)
51 setError(null)
52 setRendering(false)
53 })
54 .catch((err: unknown) => {
55 setError(err instanceof Error ? err.message : String(err))
56 setRendering(false)
57 })
58 }, RENDER_DEBOUNCE_MS)
59 return () => clearTimeout(timer)
4a285b7Create the render worker lazily so StrictMode remounts do not leave it terminatedJeremy Magland 60 }, [source, sigmaMs, map, fmax])
7d910acPhase perception 2: subband phase rotation of arbitrary sounds with filter bank, phase maps, plots, and playbackJeremy Magland 61
62 const play = useCallback(
63 async (label: string, which: ('A' | 'B')[]) => {
64 const src = resultSource.current
65 if (!src || !result) return
66 const g = result.gain
67 const scaled = (x: Float32Array) => x.map((v) => v * g)
68 const buffers = which.map((w) => scaled(w === 'A' ? src.samples : result.y))
69 setPlaying(label)
70 try {
71 await playSequence(buffers)
72 } finally {
73 setPlaying(null)
74 }
75 },
76 [result],
77 )
79 // Show plots for the source the current result belongs to, even while a new render is pending.
80 const shownSource = resultSource.current
82 return (
83 <div className="app">
84 <header className="app-header">
85 <h1>Phase Perception 2</h1>
86 <p>
87 The first app asked whether a steady tone still sounds the same when its harmonic phases change. Here the same question is asked of
88 any sound. The sound is split into overlapping frequency bands, each band's phase is rotated by an amount that depends on frequency,
89 and the bands are added back together. Every band keeps its envelope, so the spectrogram at that resolution is unchanged, but the
90 waveform is not. The width of the bands is a free parameter: narrow bands (a long window) allow large group delays and smear
91 transients, wide bands (a short window) do not.
92 </p>
93 </header>
95 <SourcePanel fs={fs} source={source} setSource={setSource} busy={playing !== null} />
96 <AnalysisPanel sigmaMs={sigmaMs} setSigmaMs={setSigmaMs} map={map} setMap={setMap} fmax={fmax} result={result} />
97 {error && <div className="card error">Rendering failed: {error}</div>}
98 {result && shownSource && (
99 <PlaybackPanel source={shownSource} result={result} stale={rendering} playing={playing} play={(l, w) => void play(l, w)} stop={stopAll} />
100 )}
101 {!result && !error && <div className="card status">Rendering…</div>}
103 <footer className="app-footer">
104 {result && `Last render took ${result.elapsedMs.toFixed(0)} ms. `}
105 Playback uses one gain for A and B, chosen from A's RMS and limited so that neither sound clips. Clips are mono, at the audio
106 device's sample rate of {fs} Hz.
107 </footer>
108 </div>
109 )
110}