/ proof-of-concept / phase-perception-2
Sign in
proof-of-concept / phase-perception-2
phase-perception-2 / src / App.tsx
112 lines · 4.5 KBBlameHistoryRaw
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[0], 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 rendererRef = useRef<Renderer | null>(null)
27 const resultSource = useRef<Source | null>(null)
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(() => {
44 const renderer = rendererRef.current ?? (rendererRef.current = new Renderer())
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)
60 }, [source, sigmaMs, map, fmax])
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 Take a sound and keep how strong each frequency is at each moment, but scramble the timing (the phase) of the frequency
88 components. Does it still sound the same? Sound A is the original and sound B is the scrambled version.
89 </p>
90 <ol className="steps">
91 <li>Choose a sound.</li>
92 <li>Choose how the phases are scrambled. The quick-start buttons cover the main cases.</li>
93 <li>Play A and B and listen for a difference.</li>
94 </ol>
95 </header>
97 <SourcePanel fs={fs} source={source} setSource={setSource} busy={playing !== null} />
98 <AnalysisPanel sigmaMs={sigmaMs} setSigmaMs={setSigmaMs} map={map} setMap={setMap} fmax={fmax} result={result} />
99 {error && <div className="card error">Rendering failed: {error}</div>}
100 {result && shownSource && (
101 <PlaybackPanel source={shownSource} result={result} stale={rendering} playing={playing} play={(l, w) => void play(l, w)} stop={stopAll} />
102 )}
103 {!result && !error && <div className="card status">Rendering…</div>}
105 <footer className="app-footer">
106 {result && `Last render took ${result.elapsedMs.toFixed(0)} ms. `}
107 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
108 device's sample rate of {fs} Hz. Headphones make small differences easier to hear.
109 </footer>
110 </div>
111 )
↑↓ move↵openescclose