/ proof-of-concept / phase-perception-2
Sign in
proof-of-concept / phase-perception-2
phase-perception-2 / src / App.tsx
113 lines · 4.6 KBCodeBlameHistory
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)
9cfa9d2Plucked string default, struck string and bell sounds, numbered steps, quick-start experiments, plots behind a disclosureJeremy Magland 19 const [source, setSource] = useState<Source | null>(() => makePreset(presets[0], fs))
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)
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 )
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())
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)
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>
5fe383dTechnical wording and labels, presets with stated predictions, phase map plots always visibleJeremy Magland 87 Sound A is split into overlapping Gaussian frequency bands (a short-time Fourier transform with a hop of one sample). Sound B is
88 built by rotating the phase of each band by phi(f, t) and summing the bands. A and B have identical subband envelopes at the
89 analysis resolution and different phases. The question is whether they sound the same, and under which conditions they do not.
93 <li>Set the window width and the phase map, or use a preset.</li>
94 <li>Play A and B.</li>
98 <SourcePanel fs={fs} source={source} setSource={setSource} busy={playing !== null} />
99 <AnalysisPanel sigmaMs={sigmaMs} setSigmaMs={setSigmaMs} map={map} setMap={setMap} fmax={fmax} result={result} />
100 {error && <div className="card error">Rendering failed: {error}</div>}
101 {result && shownSource && (
102 <PlaybackPanel source={shownSource} result={result} stale={rendering} playing={playing} play={(l, w) => void play(l, w)} stop={stopAll} />
103 )}
104 {!result && !error && <div className="card status">Rendering…</div>}
106 <footer className="app-footer">
107 {result && `Last render took ${result.elapsedMs.toFixed(0)} ms. `}
108 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
5fe383dTechnical wording and labels, presets with stated predictions, phase map plots always visibleJeremy Magland 109 device's sample rate of {fs} Hz. Headphones are recommended.
111 </div>
112 )
↑↓ move↵openescclose