7d910acPhase perception 2: subband phase rotation of arbitrary sounds with filter bank, phase maps, plots, and playbackJeremy Magland 1/** Synthesized test sounds, and helpers for clips loaded from files or the microphone. */
2import { mulberry32 } from './phasemap'
3import type { Source } from './types'
5export const MAX_SECONDS = 3
7const raisedCosineFades = (x: Float32Array, nFade: number): void => {
8 const n = x.length
9 const m = Math.min(nFade, Math.floor(n / 2))
10 for (let i = 0; i < m; i++) {
11 const e = 0.5 - 0.5 * Math.cos((Math.PI * i) / m)
12 x[i] *= e
13 x[n - 1 - i] *= e
14 }
15}
17/** Raised-cosine fades at both ends, in place. */
18export function applyFades(x: Float32Array, fs: number, fadeSec: number): Float32Array {
19 raisedCosineFades(x, Math.round(fadeSec * fs))
20 return x
21}
23const add = (x: Float32Array, at: number, seg: Float32Array): void => {
24 for (let i = 0; i < seg.length && at + i < x.length; i++) if (at + i >= 0) x[at + i] += seg[i]
25}
27function clickTrain(fs: number): Float32Array {
28 const x = new Float32Array(Math.round(1.1 * fs))
29 const w = Math.max(3, Math.round(0.0001 * fs)) // 0.1 ms pulse
30 const pulse = new Float32Array(w)
31 for (let i = 0; i < w; i++) pulse[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * (i + 1)) / (w + 1))
32 for (const t of [0.1, 0.35, 0.6, 0.85]) add(x, Math.round(t * fs), pulse)
33 return x
34}
36function noiseBursts(fs: number): Float32Array {
37 const x = new Float32Array(Math.round(1.2 * fs))
38 const rnd = mulberry32(7)
39 const len = Math.round(0.06 * fs)
40 for (const t of [0.1, 0.5, 0.9]) {
41 const seg = new Float32Array(len)
42 for (let i = 0; i < len; i++) seg[i] = rnd() * 2 - 1
43 raisedCosineFades(seg, Math.round(0.002 * fs))
44 add(x, Math.round(t * fs), seg)
45 }
46 return x
47}
49function pluck(fs: number, f0: number, seconds: number, seed: number): Float32Array {
50 // Karplus-Strong: a noise burst circulating through a delay line with a two-point average
51 const n = Math.round(seconds * fs)
52 const period = Math.max(2, Math.round(fs / f0))
53 const y = new Float32Array(n)
54 const rnd = mulberry32(seed)
55 for (let i = 0; i < period; i++) y[i] = rnd() * 2 - 1
56 for (let i = period; i < n; i++) y[i] = 0.996 * 0.5 * (y[i - period] + y[i - period - 1 < 0 ? 0 : i - period - 1])
57 raisedCosineFades(y, Math.round(0.002 * fs))
58 return y
59}
61function plucks(fs: number): Float32Array {
62 const x = new Float32Array(Math.round(1.6 * fs))
63 add(x, Math.round(0.05 * fs), pluck(fs, 220, 0.9, 11))
64 add(x, Math.round(0.8 * fs), pluck(fs, 330, 0.8, 12))
65 return x
66}
9cfa9d2Plucked string default, struck string and bell sounds, numbered steps, quick-start experiments, plots behind a disclosureJeremy Magland 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 */
73function 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}
96function 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}
103/** A bell: inharmonic partials (hum, prime, tierce, quint, nominal and above), the low ones ringing longest. */
104function 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}
7d910acPhase perception 2: subband phase rotation of arbitrary sounds with filter bank, phase maps, plots, and playbackJeremy Magland 129function harmonicTone(fs: number, f0: number, seconds: number, nHarm: number, amp: number): Float32Array {
130 const n = Math.round(seconds * fs)
131 const y = new Float32Array(n)
132 for (let i = 0; i < n; i++) {
133 let s = 0
134 for (let k = 1; k <= nHarm; k++) s += Math.sin((2 * Math.PI * k * f0 * i) / fs) / k
135 y[i] = amp * s
136 }
137 raisedCosineFades(y, Math.round(0.01 * fs))
138 return y
139}
141function tone(fs: number): Float32Array {
142 const x = new Float32Array(Math.round(1.0 * fs))
143 add(x, 0, harmonicTone(fs, 220, 1.0, 8, 0.5))
144 return x
145}
147function clickThenTone(fs: number): Float32Array {
148 // the test signal used in the design document
149 const x = new Float32Array(Math.round(0.8 * fs))
150 const w = Math.max(3, Math.round(0.0002 * fs))
151 const pulse = new Float32Array(w)
152 for (let i = 0; i < w; i++) pulse[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * (i + 1)) / (w + 1))
153 add(x, Math.round(0.15 * fs), pulse)
154 add(x, Math.round(0.35 * fs), harmonicTone(fs, 220, 0.35, 8, 0.25))
155 return x
156}
158export type Preset = { name: string; make: (fs: number) => Float32Array }
160export const presets: Preset[] = [
161 { name: 'Plucked string', make: plucks },
9cfa9d2Plucked string default, struck string and bell sounds, numbered steps, quick-start experiments, plots behind a disclosureJeremy Magland 162 { name: 'Struck string', make: struckString },
163 { name: 'Bell', make: bell },
7d910acPhase perception 2: subband phase rotation of arbitrary sounds with filter bank, phase maps, plots, and playbackJeremy Magland 164 { name: 'Harmonic tone', make: tone },
9cfa9d2Plucked string default, struck string and bell sounds, numbered steps, quick-start experiments, plots behind a disclosureJeremy Magland 165 { name: 'Click train', make: clickTrain },
166 { name: 'Noise bursts', make: noiseBursts },
7d910acPhase perception 2: subband phase rotation of arbitrary sounds with filter bank, phase maps, plots, and playbackJeremy Magland 167 { name: 'Click then tone', make: clickThenTone },
168]
9cfa9d2Plucked string default, struck string and bell sounds, numbered steps, quick-start experiments, plots behind a disclosureJeremy Magland 170/** Presets are normalized to a peak of 1 so their waveforms share a scale; playback gain is set separately. */
7d910acPhase perception 2: subband phase rotation of arbitrary sounds with filter bank, phase maps, plots, and playbackJeremy Magland 171export function makePreset(p: Preset, fs: number): Source {
9cfa9d2Plucked string default, struck string and bell sounds, numbered steps, quick-start experiments, plots behind a disclosureJeremy Magland 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 }
7d910acPhase perception 2: subband phase rotation of arbitrary sounds with filter bank, phase maps, plots, and playbackJeremy Magland 176}
178/** Average the channels of a decoded buffer into one. */
179export function toMono(buffer: AudioBuffer): Float32Array {
180 const n = buffer.length
181 const out = new Float32Array(n)
182 const nc = buffer.numberOfChannels
183 for (let c = 0; c < nc; c++) {
184 const d = buffer.getChannelData(c)
185 for (let i = 0; i < n; i++) out[i] += d[i] / nc
186 }
187 return out
188}
190/** A clip of at most MAX_SECONDS starting at startSec, with short fades. */
191export function excerpt(samples: Float32Array, fs: number, startSec: number): Float32Array {
192 const i0 = Math.max(0, Math.min(samples.length, Math.round(startSec * fs)))
193 const i1 = Math.min(samples.length, i0 + Math.round(MAX_SECONDS * fs))
194 return applyFades(samples.slice(i0, i1), fs, 0.005)
195}
197export const rms = (x: Float32Array): number => {
198 let s = 0
199 for (let i = 0; i < x.length; i++) s += x[i] * x[i]
200 return Math.sqrt(s / Math.max(1, x.length))
201}
203export const peak = (x: Float32Array): number => {
204 let m = 0
205 for (let i = 0; i < x.length; i++) {
206 const a = Math.abs(x[i])
207 if (a > m) m = a
208 }
209 return m
210}