/ proof-of-concept / phase-perception-2
Sign in
proof-of-concept / phase-perception-2
Plucked string default, struck string and bell sounds, numbered steps, quick-start experiments, plots behind a disclosure
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 9cfa9d2c5b9a parent e9f92d1 Browse files
6 changed files+237−79
src/App.tsxmodified+9−7View file
@@ -16,7 +16,7 @@ const CURVE_POINTS = 1024
1616 export default function App() {
1717 const fs = useMemo(() => getSampleRate(), [])
1818 const fmax = Math.min(10000, fs / 2)
19- const [source, setSource] = useState<Source | null>(() => makePreset(presets[4], fs))
19+ const [source, setSource] = useState<Source | null>(() => makePreset(presets[0], fs))
2020 const [sigmaMs, setSigmaMs] = useState(5)
2121 const [map, setMap] = useState<PhaseMapSpec>(defaultPhaseMap)
2222 const [result, setResult] = useState<RenderResult | null>(null)
@@ -84,12 +84,14 @@ export default function App() {
8484 <header className="app-header">
8585 <h1>Phase Perception 2</h1>
8686 <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.
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.
9289 </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>
9395 </header>
9496
9597 <SourcePanel fs={fs} source={source} setSource={setSource} busy={playing !== null} />
@@ -103,7 +105,7 @@ export default function App() {
103105 <footer className="app-footer">
104106 {result && `Last render took ${result.elapsedMs.toFixed(0)} ms. `}
105107 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.
108+ device's sample rate of {fs} Hz. Headphones make small differences easier to hear.
107109 </footer>
108110 </div>
109111 )
src/components/AnalysisPanel.tsxmodified+85−53View file
@@ -20,34 +20,55 @@ const TIME_LOG_MAX = Math.log10(3000)
2020 const sig3 = (x: number) => Number(x.toPrecision(3))
2121 const THRESHOLD_MS = 1
2222
23+type QuickStart = { name: string; expect: string; sigmaMs: number; map: PhaseMapSpec }
24+
25+const randomMap = (timeScaleMs: number | null): PhaseMapSpec => ({ kind: 'random', smoothBands: 8, timeScaleMs, seed: 1, strength: 1 })
26+
27+const QUICK: QuickStart[] = [
28+ { name: 'Fixed scramble, short window', expect: 'Expected inaudible: transients can only smear by a fraction of a millisecond.', sigmaMs: 0.5, map: randomMap(null) },
29+ { name: 'Fixed scramble, medium window', expect: 'Borderline: attacks smear by a few milliseconds, near the reported threshold. Steady parts should sound the same.', sigmaMs: 5, map: randomMap(null) },
30+ { name: 'Fixed scramble, long window', expect: 'Expected audible on attacks: they smear over tens of milliseconds. Steady parts should sound the same.', sigmaMs: 20, map: randomMap(null) },
31+ { name: 'Slowly changing scramble', expect: 'Expected inaudible or nearly so: each frequency drifts by about 1 Hz.', sigmaMs: 5, map: randomMap(500) },
32+ { name: 'Quickly changing scramble', expect: 'Expected audible: each frequency wobbles by tens of Hz, heard as warble or roughness.', sigmaMs: 5, map: randomMap(20) },
33+ { name: 'Pure delay (control)', expect: 'B is A delayed by a little over a millisecond. Must be inaudible; a check on the pipeline and the listener.', sigmaMs: 5, map: { kind: 'delay', delayMs: 1.25 } },
34+]
35+
36+const sameSpec = (a: PhaseMapSpec, b: PhaseMapSpec) => JSON.stringify(a) === JSON.stringify(b)
37+
2338 /** Window width, phase map choice and parameters, and the plots that describe the resulting filter. */
2439 export function AnalysisPanel({ sigmaMs, setSigmaMs, map, setMap, fmax, result }: Props) {
2540 const sigmaF = 1000 / (2 * Math.PI * sigmaMs)
2641 const setKind = (kind: PhaseMapSpec['kind']) => {
2742 if (kind === map.kind) return
28- if (kind === 'random') setMap({ kind, smoothBands: 8, timeScaleMs: null, seed: 1, strength: 1 })
43+ if (kind === 'random') setMap(randomMap(null))
2944 else if (kind === 'delay') setMap({ kind, delayMs: sig3(sigmaMs / 4) })
3045 else setMap({ kind, delayAtNyquistMs: sig3(sigmaMs / 4) })
3146 }
3247 const timeVarying = map.kind === 'random' && map.timeScaleMs !== null
3348 // a Gaussian-smoothed field with std s and time scale T has a derivative of std s / T
3449 const fmHz = map.kind === 'random' && map.timeScaleMs !== null ? (map.strength * UNIFORM_STD) / (2 * Math.PI * (map.timeScaleMs / 1000)) : 0
50+ const active = QUICK.find((q) => q.sigmaMs === sigmaMs && sameSpec(q.map, map))
3551
3652 return (
3753 <section className="card">
38- <h2>Filter bank and phase map</h2>
54+ <h2>2. Choose how the phases are scrambled</h2>
3955 <p className="hint">
40- The sound is split into overlapping Gaussian bands of width sigma in time (and 1 / (2 pi sigma) in frequency). Sound B is made by
41- rotating the phase of each band by phi(f, t) and adding the bands back together, which leaves every band's envelope unchanged. A map
42- that is fixed in time is an all-pass filter; its group delay, minus the slope of the phase in frequency, is what smears transients,
43- and peaks beyond about {THRESHOLD_MS} ms are reported to be audible on impulsive sounds. A map that changes in time shifts the
44- frequency of each band by the rate of change of its phase, which is audible on steady sounds once the shift approaches the pitch
45- threshold. Because the map is sampled at the band centers, it must change by less than about a radian from one band to the next; the
46- magnitude plot shows when it does not.
56+ The sound is split into overlapping frequency bands. Each band keeps its loudness over time but its phase is shifted by an amount
57+ that depends on the band and, optionally, on time. The bands are then added back together to make B.
4758 </p>
59+ <div className="quick-start">
60+ <span className="quick-label">Quick start:</span>
61+ {QUICK.map((q) => (
62+ <button key={q.name} className={active?.name === q.name ? 'selected' : ''} title={q.expect} onClick={() => { setSigmaMs(q.sigmaMs); setMap(q.map) }}>
63+ {q.name}
64+ </button>
65+ ))}
66+ </div>
67+ <div className="expect">{active ? active.expect : 'Custom settings.'}</div>
68+
4869 <div className="global-controls">
49- <label>
50- <span>Window sigma</span>
70+ <label title="Width of the time window, and so of each frequency band. Longer windows mean narrower bands and allow attacks to smear more.">
71+ <span>Time window</span>
5172 <input
5273 type="range"
5374 className="wide-range"
@@ -71,35 +92,34 @@ export function AnalysisPanel({ sigmaMs, setSigmaMs, map, setMap, fmax, result }
7192 />
7293 <span className="slider-unit">ms</span>
7394 <span className="info">
74- band width {sig3(sigmaF)} Hz{result ? `, ${result.bands} bands` : ''}
95+ bands {sig3(sigmaF)} Hz wide{result ? `, ${result.bands} of them` : ''}
7596 </span>
7697 </label>
7798 </div>
7899 <div className="global-controls">
79100 <label>
80- <span>Phase map</span>
101+ <span>Scramble pattern</span>
81102 <select value={map.kind} onChange={(e) => setKind(e.target.value as PhaseMapSpec['kind'])}>
82- <option value="random">Smoothed random</option>
83- <option value="delay">Pure delay (control)</option>
84- <option value="chirp">Chirp (group delay linear in frequency)</option>
103+ <option value="random">Random, smoothed across bands</option>
104+ <option value="delay">Pure delay (control: must be inaudible)</option>
105+ <option value="chirp">Chirp: delay growing with frequency</option>
85106 </select>
86107 </label>
87108 {map.kind === 'random' && (
88109 <>
89- <label>
90- <span>Smoothing in frequency</span>
110+ <label title="How many neighbouring bands share a similar phase. Too little smoothing and neighbouring bands cancel each other (see the warning).">
111+ <span>Smoothness across bands</span>
91112 <input type="range" min={1} max={40} step={1} value={map.smoothBands} onChange={(e) => setMap({ ...map, smoothBands: Number(e.target.value) })} />
92113 <span className="slider-unit">
93114 {map.smoothBands} bands ({sig3(map.smoothBands * sigmaF)} Hz)
94115 </span>
95116 </label>
96- <label>
97- <span>Strength</span>
117+ <label title="Size of the phase shifts. 0 makes B identical to A.">
118+ <span>Amount</span>
98119 <input type="range" min={0} max={2} step={0.05} value={map.strength} onChange={(e) => setMap({ ...map, strength: Number(e.target.value) })} />
99120 <span className="slider-unit">{map.strength.toFixed(2)}</span>
100121 </label>
101- <button onClick={() => setMap({ ...map, seed: (map.seed % 100000) + 1 })}>New random map</button>
102- <span className="info">seed {map.seed}</span>
122+ <button onClick={() => setMap({ ...map, seed: (map.seed % 100000) + 1 })}>New random pattern</button>
103123 </>
104124 )}
105125 {map.kind === 'delay' && (
@@ -112,7 +132,7 @@ export function AnalysisPanel({ sigmaMs, setSigmaMs, map, setMap, fmax, result }
112132 )}
113133 {map.kind === 'chirp' && (
114134 <label>
115- <span>Group delay at Nyquist</span>
135+ <span>Delay at the highest frequency</span>
116136 <input type="range" min={-20} max={20} step={0.05} value={map.delayAtNyquistMs} onChange={(e) => setMap({ ...map, delayAtNyquistMs: Number(e.target.value) })} />
117137 <input type="number" className="slider-value" step="any" value={map.delayAtNyquistMs} onChange={(e) => setMap({ ...map, delayAtNyquistMs: Number(e.target.value) || 0 })} />
118138 <span className="slider-unit">ms</span>
@@ -121,13 +141,13 @@ export function AnalysisPanel({ sigmaMs, setSigmaMs, map, setMap, fmax, result }
121141 </div>
122142 {map.kind === 'random' && (
123143 <div className="global-controls">
124- <label>
144+ <label title="A fixed pattern is an all-pass filter. A changing pattern shifts each band's frequency by the rate of change of its phase.">
125145 <input type="checkbox" checked={map.timeScaleMs === null} onChange={(e) => setMap({ ...map, timeScaleMs: e.target.checked ? null : 200 })} />
126- <span>Fixed in time</span>
146+ <span>Pattern fixed in time</span>
127147 </label>
128148 {map.timeScaleMs !== null && (
129- <label>
130- <span>Smoothing in time</span>
149+ <label title="How slowly the pattern changes. Faster change means larger frequency wobble.">
150+ <span>Changes over</span>
131151 <input
132152 type="range"
133153 className="wide-range"
@@ -149,7 +169,7 @@ export function AnalysisPanel({ sigmaMs, setSigmaMs, map, setMap, fmax, result }
149169 }}
150170 />
151171 <span className="slider-unit">ms</span>
152- <span className="info">typical frequency shift {sig3(fmHz)} Hz; the window is {sigmaMs} ms</span>
172+ <span className="info">frequencies wobble by about {sig3(fmHz)} Hz</span>
153173 </label>
154174 )}
155175 </div>
@@ -157,37 +177,49 @@ export function AnalysisPanel({ sigmaMs, setSigmaMs, map, setMap, fmax, result }
157177
158178 {result && result.maxMagDeviationDb > 1 && (
159179 <div className="warning">
160- The magnitude response deviates by up to {result.maxMagDeviationDb.toFixed(1)} dB, so B is not just a phase change of A. The map
161- changes too quickly across neighbouring bands for this window: increase sigma, increase the smoothing, or reduce the delay.
180+ The loudness of some frequencies changes by up to {result.maxMagDeviationDb.toFixed(1)} dB, so B is not just a phase change of A.
181+ The pattern changes too quickly from one band to the next for this window: lengthen the time window, increase the smoothness, or
182+ reduce the delay.
162183 </div>
163184 )}
185+
164186 {result && (
165- <div className="response-plots">
166- <div>
167- <div className="plot-label">
168- Phase map phi(f, t){result.timeVarying ? '' : ', fixed in time'} <span className="phase-legend" /> -180° to 180°
187+ <details className="details">
188+ <summary>Show the phase pattern and the equivalent filter</summary>
189+ <p className="hint">
190+ A pattern that is fixed in time is an all-pass filter: it leaves every frequency's loudness alone and only delays each
191+ frequency by its <em>group delay</em>, the slope of the phase across frequency. Group delay peaks beyond about {THRESHOLD_MS} ms
192+ have been reported to be audible on impulsive sounds, and the achievable group delay grows with the time window. A pattern
193+ that changes in time shifts each band's frequency by the rate of change of its phase, which becomes audible on steady sounds
194+ once the shift approaches the pitch threshold, roughly 1 Hz for low tones.
195+ </p>
196+ <div className="response-plots">
197+ <div>
198+ <div className="plot-label">
199+ Phase shift by frequency and time{result.timeVarying ? '' : ' (fixed in time)'} <span className="phase-legend" /> -180° to 180°
200+ </div>
201+ <ImagePlot image={result.phaseField} color={phaseColor} label="" labelColor="#000" />
169202 </div>
170- <ImagePlot image={result.phaseField} color={phaseColor} label="" labelColor="#000" />
171- </div>
172- <div>
173- <div className="plot-label">Magnitude response of the equivalent filter{timeVarying ? ' at mid-clip' : ''} (should stay near 0 dB)</div>
174- <LinePlot series={[{ x: result.magDb.f, y: result.magDb.y, color: '#1f5fa8' }]} xRange={[0, fmax]} yRange={[-30, 3]} xLabel="frequency (Hz)" yLabel="dB" zeroLine />
175- </div>
176- <div>
177- <div className="plot-label">
178- Group delay{timeVarying ? ' at mid-clip' : ''} (largest {result.maxGroupDelayMs.toFixed(2)} ms)
203+ <div>
204+ <div className="plot-label">Loudness change by frequency{timeVarying ? ', at mid-clip' : ''} (should stay near 0 dB)</div>
205+ <LinePlot series={[{ x: result.magDb.f, y: result.magDb.y, color: '#1f5fa8' }]} xRange={[0, fmax]} yRange={[-30, 3]} xLabel="frequency (Hz)" yLabel="dB" zeroLine />
206+ </div>
207+ <div>
208+ <div className="plot-label">
209+ Group delay by frequency{timeVarying ? ', at mid-clip' : ''} (largest {result.maxGroupDelayMs.toFixed(2)} ms)
210+ </div>
211+ <LinePlot
212+ series={[{ x: result.groupDelayMs.f, y: result.groupDelayMs.y, color: '#1f5fa8' }]}
213+ xRange={[0, fmax]}
214+ symmetric
215+ xLabel="frequency (Hz)"
216+ yLabel="ms"
217+ band={{ y0: -THRESHOLD_MS, y1: THRESHOLD_MS, color: 'rgba(46, 160, 67, 0.15)', label: 'approximate audibility threshold' }}
218+ zeroLine
219+ />
179220 </div>
180- <LinePlot
181- series={[{ x: result.groupDelayMs.f, y: result.groupDelayMs.y, color: '#1f5fa8' }]}
182- xRange={[0, fmax]}
183- symmetric
184- xLabel="frequency (Hz)"
185- yLabel="ms"
186- band={{ y0: -THRESHOLD_MS, y1: THRESHOLD_MS, color: 'rgba(46, 160, 67, 0.15)', label: 'approximate audibility threshold' }}
187- zeroLine
188- />
189221 </div>
190- </div>
222+ </details>
191223 )}
192224 </section>
193225 )
src/components/PlaybackPanel.tsxmodified+16−13View file
@@ -62,11 +62,12 @@ export function PlaybackPanel({ source, result, stale, playing, play, stop }: Pr
6262
6363 return (
6464 <section className="card">
65- <h2>Listen and compare</h2>
65+ <h2>3. Listen: can you hear a difference?</h2>
6666 <p className="hint">
67- A is the source; B has the phase map applied. Both are played with the same gain{stale ? ' (B is being re-rendered)' : ''}. The
68- waveforms differ by {Math.round(100 * result.relDiff)}% of A's RMS, while the spectrograms below, computed at the analysis
69- resolution, should look the same{result.timeVarying ? ' when the map changes slowly compared with the window' : ''}.
67+ A is the original and B is the scrambled version, played at the same level{stale ? ' (B is being re-rendered)' : ''}. If they sound
68+ the same, phase is inaudible for this sound and these settings. The waveforms differ by {Math.round(100 * result.relDiff)}% of A's
69+ RMS; the spectrograms, which show how strong each frequency is over time, should look the same
70+ {result.timeVarying ? ' as long as the scramble changes slowly compared with the time window' : ''}.
7071 </p>
7172 <div className="button-row">
7273 <button className="primary" onClick={() => play('A', ['A'])} disabled={busy || stale}>
@@ -91,15 +92,17 @@ export function PlaybackPanel({ source, result, stale, playing, play, stop }: Pr
9192 <span className="info">Drag across a waveform to zoom into that range. The scroll wheel also zooms; the bar below the waveforms pans.</span>
9293 <span className="spacer" />
9394 <span className="info">{zoomed ? `${formatSeconds(view.span)} from ${formatSeconds(view.start)}` : `full clip, ${formatSeconds(duration)}`}</span>
94- <button className="small" onClick={() => zoomBy(0.5)} disabled={view.span <= minSpan}>
95- Zoom in
96- </button>
97- <button className="small" onClick={() => zoomBy(2)} disabled={!zoomed}>
98- Zoom out
99- </button>
100- <button className="small" onClick={() => setView({ start: 0, span: duration })} disabled={!zoomed}>
101- Full clip
102- </button>
95+ <span className="button-group">
96+ <button className="small" onClick={() => zoomBy(0.5)} disabled={view.span <= minSpan}>
97+ Zoom in
98+ </button>
99+ <button className="small" onClick={() => zoomBy(2)} disabled={!zoomed}>
100+ Zoom out
101+ </button>
102+ <button className="small" onClick={() => setView({ start: 0, span: duration })} disabled={!zoomed}>
103+ Full clip
104+ </button>
105+ </span>
103106 </div>
104107 <WaveformPlot samples={source.samples} fs={fs} view={view} onView={onView} yMax={yMax} label="A" color={COLOR_A} />
105108 <WaveformPlot samples={result.y} fs={fs} view={view} onView={onView} yMax={yMax} label="B" color={COLOR_B} />
src/components/SourcePanel.tsxmodified+3−3View file
@@ -66,10 +66,10 @@ export function SourcePanel({ fs, source, setSource, busy }: Props) {
6666
6767 return (
6868 <section className="card">
69- <h2>Source sound</h2>
69+ <h2>1. Choose a sound</h2>
7070 <p className="hint">
71- Pick a synthesized test sound, load an audio file, or record from the microphone. Clips are mono and limited to {MAX_SECONDS} s.
72- Transient sounds (clicks, bursts, plucks) are where phase changes are expected to be audible; steady sounds are where they are not.
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.
7373 </p>
7474 <div className="button-row">
7575 {presets.map((p, i) => (
src/index.cssmodified+54−0View file
@@ -317,3 +317,57 @@ input[type='range'] {
317317 margin: 8px 0;
318318 font-size: 0.9rem;
319319 }
320+
321+.steps {
322+ margin: 0 0 16px;
323+ padding-left: 1.4em;
324+ color: var(--text);
325+}
326+
327+.steps li {
328+ margin: 2px 0;
329+}
330+
331+.quick-start {
332+ display: flex;
333+ align-items: center;
334+ flex-wrap: wrap;
335+ gap: 8px;
336+ margin: 4px 0 4px;
337+}
338+
339+.quick-label {
340+ font-weight: 600;
341+ font-size: 0.9rem;
342+ margin-right: 4px;
343+}
344+
345+.expect {
346+ color: var(--muted);
347+ font-size: 0.9rem;
348+ margin: 0 0 12px;
349+ min-height: 1.4em;
350+}
351+
352+.details {
353+ margin-top: 12px;
354+ border-top: 1px solid var(--border);
355+ padding-top: 8px;
356+}
357+
358+.details summary {
359+ cursor: pointer;
360+ color: var(--accent);
361+ font-size: 0.9rem;
362+ user-select: none;
363+}
364+
365+.details .hint {
366+ margin-top: 8px;
367+}
368+
369+.button-group {
370+ display: inline-flex;
371+ gap: 6px;
372+ white-space: nowrap;
373+}
src/sources.tsmodified+70−3View file
@@ -65,6 +65,67 @@ function plucks(fs: number): Float32Array {
6565 return x
6666 }
6767
68+/**
69+ * A struck string, piano-like: partials with slightly stretched (inharmonic)
70+ * frequencies, amplitudes falling with partial number, higher partials
71+ * decaying faster, and a sharp attack.
72+ */
73+function struckNote(fs: number, f0: number, seconds: number, seed: number): Float32Array {
74+ const n = Math.round(seconds * fs)
75+ const y = new Float32Array(n)
76+ const nPart = 14
77+ const B = 0.0004 // inharmonicity coefficient
78+ const rnd = mulberry32(seed)
79+ for (let k = 1; k <= nPart; k++) {
80+ const fk = k * f0 * Math.sqrt(1 + B * k * k)
81+ if (fk > fs * 0.45) break
82+ const amp = 1 / k ** 1.3
83+ const tau = 1.1 / (1 + 0.35 * k) // seconds
84+ const ph = rnd() * 2 * Math.PI
85+ for (let i = 0; i < n; i++) {
86+ const t = i / fs
87+ y[i] += amp * Math.exp(-t / tau) * Math.sin(2 * Math.PI * fk * t + ph)
88+ }
89+ }
90+ const attack = Math.round(0.002 * fs)
91+ for (let i = 0; i < attack; i++) y[i] *= i / attack
92+ raisedCosineFades(y, Math.round(0.005 * fs))
93+ return y
94+}
95+
96+function struckString(fs: number): Float32Array {
97+ const x = new Float32Array(Math.round(2.2 * fs))
98+ const notes = [261.63, 329.63, 392.0] // C4 E4 G4
99+ notes.forEach((f0, i) => add(x, Math.round((0.05 + 0.4 * i) * fs), struckNote(fs, f0, 1.3, 20 + i)))
100+ return x
101+}
102+
103+/** A bell: inharmonic partials (hum, prime, tierce, quint, nominal and above), the low ones ringing longest. */
104+function bell(fs: number): Float32Array {
105+ const seconds = 2.4
106+ const n = Math.round(seconds * fs)
107+ const y = new Float32Array(n)
108+ const f0 = 440
109+ const ratios = [0.5, 1, 1.2, 1.5, 2, 2.5, 2.67, 3, 3.5, 4, 4.5, 5.33]
110+ const rnd = mulberry32(31)
111+ ratios.forEach((r, j) => {
112+ const fk = r * f0
113+ const amp = j === 0 ? 0.6 : 1 / (1 + 0.6 * j)
114+ const tau = 1.6 / r ** 0.8
115+ const ph = rnd() * 2 * Math.PI
116+ for (let i = 0; i < n; i++) {
117+ const t = i / fs
118+ y[i] += amp * Math.exp(-t / tau) * Math.sin(2 * Math.PI * fk * t + ph)
119+ }
120+ })
121+ const attack = Math.round(0.001 * fs)
122+ for (let i = 0; i < attack; i++) y[i] *= i / attack
123+ raisedCosineFades(y, Math.round(0.005 * fs))
124+ const x = new Float32Array(Math.round((seconds + 0.05) * fs))
125+ add(x, Math.round(0.05 * fs), y)
126+ return x
127+}
128+
68129 function harmonicTone(fs: number, f0: number, seconds: number, nHarm: number, amp: number): Float32Array {
69130 const n = Math.round(seconds * fs)
70131 const y = new Float32Array(n)
@@ -97,15 +158,21 @@ function clickThenTone(fs: number): Float32Array {
97158 export type Preset = { name: string; make: (fs: number) => Float32Array }
98159
99160 export const presets: Preset[] = [
100- { name: 'Click train', make: clickTrain },
101- { name: 'Noise bursts', make: noiseBursts },
102161 { name: 'Plucked string', make: plucks },
162+ { name: 'Struck string', make: struckString },
163+ { name: 'Bell', make: bell },
103164 { name: 'Harmonic tone', make: tone },
165+ { name: 'Click train', make: clickTrain },
166+ { name: 'Noise bursts', make: noiseBursts },
104167 { name: 'Click then tone', make: clickThenTone },
105168 ]
106169
170+/** Presets are normalized to a peak of 1 so their waveforms share a scale; playback gain is set separately. */
107171 export function makePreset(p: Preset, fs: number): Source {
108- return { name: p.name, fs, samples: p.make(fs) }
172+ const samples = p.make(fs)
173+ const m = peak(samples)
174+ if (m > 0) for (let i = 0; i < samples.length; i++) samples[i] /= m
175+ return { name: p.name, fs, samples }
109176 }
110177
111178 /** Average the channels of a decoded buffer into one. */
↑↓ move↵openescclose