7d910acPhase perception 2: subband phase rotation of arbitrary sounds with filter bank, phase maps, plots, and playbackJeremy Magland 1import { useEffect, useRef, useState } from 'react'
2import { decodeFile, recordMicrophone, type Clip } from '../capture'
3import { MAX_SECONDS, excerpt, makePreset, presets } from '../sources'
4import type { Source } from '../types'
6type Props = {
7 fs: number
8 source: Source | null
9 setSource: (s: Source) => void
10 busy: boolean
11}
13/** Choose the source sound: a synthesized preset, a file, or a microphone recording. */
14export function SourcePanel({ fs, source, setSource, busy }: Props) {
15 const [clip, setClip] = useState<Clip | null>(null)
16 const [startSec, setStartSec] = useState(0)
17 const [recording, setRecording] = useState<number | null>(null)
18 const [error, setError] = useState<string | null>(null)
19 const abortRef = useRef<AbortController | null>(null)
20 const fileRef = useRef<HTMLInputElement>(null)
22 const clipSec = clip ? clip.samples.length / clip.fs : 0
23 const canScrub = clip !== null && clipSec > MAX_SECONDS
25 // A loaded clip becomes the source through its excerpt.
26 useEffect(() => {
27 if (!clip) return
28 setSource({ name: clip.name, fs: clip.fs, samples: excerpt(clip.samples, clip.fs, startSec) })
29 // eslint-disable-next-line react-hooks/exhaustive-deps
30 }, [clip, startSec])
32 const choosePreset = (i: number) => {
33 setClip(null)
34 setError(null)
35 setSource(makePreset(presets[i], fs))
36 }
38 const onFile = async (file: File | undefined) => {
39 if (!file) return
40 setError(null)
41 try {
42 const c = await decodeFile(file)
43 setStartSec(0)
44 setClip(c)
45 } catch (err) {
46 setError(`Could not decode ${file.name}: ${err instanceof Error ? err.message : String(err)}`)
47 }
48 }
50 const record = async () => {
51 setError(null)
52 const ac = new AbortController()
53 abortRef.current = ac
54 setRecording(0)
55 try {
56 const c = await recordMicrophone(MAX_SECONDS, ac, (s) => setRecording(s))
57 setStartSec(0)
58 setClip(c)
59 } catch (err) {
60 setError(`Recording failed: ${err instanceof Error ? err.message : String(err)}`)
61 } finally {
62 setRecording(null)
63 abortRef.current = null
64 }
65 }
67 return (
68 <section className="card">
9cfa9d2Plucked string default, struck string and bell sounds, numbered steps, quick-start experiments, plots behind a disclosureJeremy Magland 69 <h2>1. Choose a sound</h2>
7d910acPhase perception 2: subband phase rotation of arbitrary sounds with filter bank, phase maps, plots, and playbackJeremy Magland 70 <p className="hint">
9cfa9d2Plucked string default, struck string and bell sounds, numbered steps, quick-start experiments, plots behind a disclosureJeremy Magland 71 Pick a synthesized sound, load an audio file, or record from the microphone (clips are mono, up to {MAX_SECONDS} s). Sounds with
72 sharp attacks are where scrambled phases are most likely to be heard; steady sounds are where they are least likely.
7d910acPhase perception 2: subband phase rotation of arbitrary sounds with filter bank, phase maps, plots, and playbackJeremy Magland 73 </p>
74 <div className="button-row">
75 {presets.map((p, i) => (
76 <button key={p.name} className={source?.name === p.name ? 'selected' : ''} onClick={() => choosePreset(i)} disabled={busy || recording !== null}>
77 {p.name}
78 </button>
79 ))}
80 <span className="divider" />
81 <button onClick={() => fileRef.current?.click()} disabled={busy || recording !== null}>
82 Load file…
83 </button>
84 <input ref={fileRef} type="file" accept="audio/*" hidden onChange={(e) => void onFile(e.target.files?.[0])} />
85 {recording === null ? (
86 <button onClick={() => void record()} disabled={busy}>
87 Record {MAX_SECONDS} s from microphone
88 </button>
89 ) : (
90 <button className="primary" onClick={() => abortRef.current?.abort()}>
91 Stop recording ({recording.toFixed(1)} s)
92 </button>
93 )}
94 </div>
95 {canScrub && clip && (
96 <label className="scrub">
97 <span>Excerpt start</span>
98 <input type="range" min={0} max={Math.max(0, clipSec - MAX_SECONDS)} step={0.01} value={startSec} onChange={(e) => setStartSec(Number(e.target.value))} />
99 <span className="slider-unit">
100 {startSec.toFixed(2)} s of {clipSec.toFixed(1)} s
101 </span>
102 </label>
103 )}
104 <div className="source-info">
105 {source ? (
106 <>
107 <strong>{source.name}</strong>: {(source.samples.length / source.fs).toFixed(2)} s at {source.fs} Hz
108 </>
109 ) : (
110 'No source selected.'
111 )}
112 </div>
113 {error && <div className="error">{error}</div>}
114 </section>
115 )
116}