/** Synthesized test sounds, and helpers for clips loaded from files or the microphone. */ import { mulberry32 } from './phasemap' import type { Source } from './types' export const MAX_SECONDS = 3 const raisedCosineFades = (x: Float32Array, nFade: number): void => { const n = x.length const m = Math.min(nFade, Math.floor(n / 2)) for (let i = 0; i < m; i++) { const e = 0.5 - 0.5 * Math.cos((Math.PI * i) / m) x[i] *= e x[n - 1 - i] *= e } } /** Raised-cosine fades at both ends, in place. */ export function applyFades(x: Float32Array, fs: number, fadeSec: number): Float32Array { raisedCosineFades(x, Math.round(fadeSec * fs)) return x } const add = (x: Float32Array, at: number, seg: Float32Array): void => { for (let i = 0; i < seg.length && at + i < x.length; i++) if (at + i >= 0) x[at + i] += seg[i] } function clickTrain(fs: number): Float32Array { const x = new Float32Array(Math.round(1.1 * fs)) const w = Math.max(3, Math.round(0.0001 * fs)) // 0.1 ms pulse const pulse = new Float32Array(w) for (let i = 0; i < w; i++) pulse[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * (i + 1)) / (w + 1)) for (const t of [0.1, 0.35, 0.6, 0.85]) add(x, Math.round(t * fs), pulse) return x } function noiseBursts(fs: number): Float32Array { const x = new Float32Array(Math.round(1.2 * fs)) const rnd = mulberry32(7) const len = Math.round(0.06 * fs) for (const t of [0.1, 0.5, 0.9]) { const seg = new Float32Array(len) for (let i = 0; i < len; i++) seg[i] = rnd() * 2 - 1 raisedCosineFades(seg, Math.round(0.002 * fs)) add(x, Math.round(t * fs), seg) } return x } function pluck(fs: number, f0: number, seconds: number, seed: number): Float32Array { // Karplus-Strong: a noise burst circulating through a delay line with a two-point average const n = Math.round(seconds * fs) const period = Math.max(2, Math.round(fs / f0)) const y = new Float32Array(n) const rnd = mulberry32(seed) for (let i = 0; i < period; i++) y[i] = rnd() * 2 - 1 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]) raisedCosineFades(y, Math.round(0.002 * fs)) return y } function plucks(fs: number): Float32Array { const x = new Float32Array(Math.round(1.6 * fs)) add(x, Math.round(0.05 * fs), pluck(fs, 220, 0.9, 11)) add(x, Math.round(0.8 * fs), pluck(fs, 330, 0.8, 12)) return x } /** * A struck string, piano-like: partials with slightly stretched (inharmonic) * frequencies, amplitudes falling with partial number, higher partials * decaying faster, and a sharp attack. */ function struckNote(fs: number, f0: number, seconds: number, seed: number): Float32Array { const n = Math.round(seconds * fs) const y = new Float32Array(n) const nPart = 14 const B = 0.0004 // inharmonicity coefficient const rnd = mulberry32(seed) for (let k = 1; k <= nPart; k++) { const fk = k * f0 * Math.sqrt(1 + B * k * k) if (fk > fs * 0.45) break const amp = 1 / k ** 1.3 const tau = 1.1 / (1 + 0.35 * k) // seconds const ph = rnd() * 2 * Math.PI for (let i = 0; i < n; i++) { const t = i / fs y[i] += amp * Math.exp(-t / tau) * Math.sin(2 * Math.PI * fk * t + ph) } } const attack = Math.round(0.002 * fs) for (let i = 0; i < attack; i++) y[i] *= i / attack raisedCosineFades(y, Math.round(0.005 * fs)) return y } function struckString(fs: number): Float32Array { const x = new Float32Array(Math.round(2.2 * fs)) const notes = [261.63, 329.63, 392.0] // C4 E4 G4 notes.forEach((f0, i) => add(x, Math.round((0.05 + 0.4 * i) * fs), struckNote(fs, f0, 1.3, 20 + i))) return x } /** A bell: inharmonic partials (hum, prime, tierce, quint, nominal and above), the low ones ringing longest. */ function bell(fs: number): Float32Array { const seconds = 2.4 const n = Math.round(seconds * fs) const y = new Float32Array(n) const f0 = 440 const ratios = [0.5, 1, 1.2, 1.5, 2, 2.5, 2.67, 3, 3.5, 4, 4.5, 5.33] const rnd = mulberry32(31) ratios.forEach((r, j) => { const fk = r * f0 const amp = j === 0 ? 0.6 : 1 / (1 + 0.6 * j) const tau = 1.6 / r ** 0.8 const ph = rnd() * 2 * Math.PI for (let i = 0; i < n; i++) { const t = i / fs y[i] += amp * Math.exp(-t / tau) * Math.sin(2 * Math.PI * fk * t + ph) } }) const attack = Math.round(0.001 * fs) for (let i = 0; i < attack; i++) y[i] *= i / attack raisedCosineFades(y, Math.round(0.005 * fs)) const x = new Float32Array(Math.round((seconds + 0.05) * fs)) add(x, Math.round(0.05 * fs), y) return x } function harmonicTone(fs: number, f0: number, seconds: number, nHarm: number, amp: number): Float32Array { const n = Math.round(seconds * fs) const y = new Float32Array(n) for (let i = 0; i < n; i++) { let s = 0 for (let k = 1; k <= nHarm; k++) s += Math.sin((2 * Math.PI * k * f0 * i) / fs) / k y[i] = amp * s } raisedCosineFades(y, Math.round(0.01 * fs)) return y } function tone(fs: number): Float32Array { const x = new Float32Array(Math.round(1.0 * fs)) add(x, 0, harmonicTone(fs, 220, 1.0, 8, 0.5)) return x } function clickThenTone(fs: number): Float32Array { // the test signal used in the design document const x = new Float32Array(Math.round(0.8 * fs)) const w = Math.max(3, Math.round(0.0002 * fs)) const pulse = new Float32Array(w) for (let i = 0; i < w; i++) pulse[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * (i + 1)) / (w + 1)) add(x, Math.round(0.15 * fs), pulse) add(x, Math.round(0.35 * fs), harmonicTone(fs, 220, 0.35, 8, 0.25)) return x } export type Preset = { name: string; make: (fs: number) => Float32Array } export const presets: Preset[] = [ { name: 'Plucked string', make: plucks }, { name: 'Struck string', make: struckString }, { name: 'Bell', make: bell }, { name: 'Harmonic tone', make: tone }, { name: 'Click train', make: clickTrain }, { name: 'Noise bursts', make: noiseBursts }, { name: 'Click then tone', make: clickThenTone }, ] /** Presets are normalized to a peak of 1 so their waveforms share a scale; playback gain is set separately. */ export function makePreset(p: Preset, fs: number): Source { const samples = p.make(fs) const m = peak(samples) if (m > 0) for (let i = 0; i < samples.length; i++) samples[i] /= m return { name: p.name, fs, samples } } /** Average the channels of a decoded buffer into one. */ export function toMono(buffer: AudioBuffer): Float32Array { const n = buffer.length const out = new Float32Array(n) const nc = buffer.numberOfChannels for (let c = 0; c < nc; c++) { const d = buffer.getChannelData(c) for (let i = 0; i < n; i++) out[i] += d[i] / nc } return out } /** A clip of at most MAX_SECONDS starting at startSec, with short fades. */ export function excerpt(samples: Float32Array, fs: number, startSec: number): Float32Array { const i0 = Math.max(0, Math.min(samples.length, Math.round(startSec * fs))) const i1 = Math.min(samples.length, i0 + Math.round(MAX_SECONDS * fs)) return applyFades(samples.slice(i0, i1), fs, 0.005) } export const rms = (x: Float32Array): number => { let s = 0 for (let i = 0; i < x.length; i++) s += x[i] * x[i] return Math.sqrt(s / Math.max(1, x.length)) } export const peak = (x: Float32Array): number => { let m = 0 for (let i = 0; i < x.length; i++) { const a = Math.abs(x[i]) if (a > m) m = a } return m }