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)
26 const renderer = useMemo(() => new Renderer(), [])
27 const resultSource = useRef<Source | null>(null)
29 useEffect(() => () => renderer.terminate(), [renderer])
31 // Re-render B whenever the source, the window, or the map changes (debounced for slider drags).
32 useEffect(() => {
33 if (!source) return
34 setRendering(true)
35 const timer = setTimeout(() => {
36 renderer
37 .request({ samples: source.samples, fs: source.fs, sigmaMs, map, fmax, maxCols: MAX_COLS, maxRows: MAX_ROWS, curvePoints: CURVE_POINTS })
38 .then((r) => {
39 if (!r) return // superseded
40 resultSource.current = source
41 setResult(r)
42 setError(null)
43 setRendering(false)
44 })
45 .catch((err: unknown) => {
46 setError(err instanceof Error ? err.message : String(err))
47 setRendering(false)
48 })
49 }, RENDER_DEBOUNCE_MS)
50 return () => clearTimeout(timer)
51 }, [source, sigmaMs, map, fmax, renderer])
53 const play = useCallback(
54 async (label: string, which: ('A' | 'B')[]) => {
55 const src = resultSource.current
56 if (!src || !result) return
57 const g = result.gain
58 const scaled = (x: Float32Array) => x.map((v) => v * g)
59 const buffers = which.map((w) => scaled(w === 'A' ? src.samples : result.y))
60 setPlaying(label)
61 try {
62 await playSequence(buffers)
63 } finally {
64 setPlaying(null)
65 }
66 },
67 [result],
68 )
70 // Show plots for the source the current result belongs to, even while a new render is pending.
71 const shownSource = resultSource.current
73 return (
74 <div className="app">
75 <header className="app-header">
76 <h1>Phase Perception 2</h1>
77 <p>
78 The first app asked whether a steady tone still sounds the same when its harmonic phases change. Here the same question is asked of
79 any sound. The sound is split into overlapping frequency bands, each band's phase is rotated by an amount that depends on frequency,
80 and the bands are added back together. Every band keeps its envelope, so the spectrogram at that resolution is unchanged, but the
81 waveform is not. The width of the bands is a free parameter: narrow bands (a long window) allow large group delays and smear
82 transients, wide bands (a short window) do not.
83 </p>
84 </header>
86 <SourcePanel fs={fs} source={source} setSource={setSource} busy={playing !== null} />
87 <AnalysisPanel sigmaMs={sigmaMs} setSigmaMs={setSigmaMs} map={map} setMap={setMap} fmax={fmax} result={result} />
88 {error && <div className="card error">Rendering failed: {error}</div>}
89 {result && shownSource && (
90 <PlaybackPanel source={shownSource} result={result} stale={rendering} playing={playing} play={(l, w) => void play(l, w)} stop={stopAll} />
91 )}
92 {!result && !error && <div className="card status">Rendering…</div>}
94 <footer className="app-footer">
95 {result && `Last render took ${result.elapsedMs.toFixed(0)} ms. `}
96 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
97 device's sample rate of {fs} Hz.
98 </footer>
99 </div>
100 )
101}