/** * Additive synthesis of a periodic tone from harmonic amplitudes and phases. * * A tone with fundamental frequency f0 is * * x(t) = sum_k a_k sin(2 pi k f0 t + phi_k), k = 1..N * * Everything here is a pure function of the parameters, so the same code * produces the plotted period and the audio that is played. */ export type ToneParams = { f0: number /** harmonic amplitudes a_1..a_N (non-negative) */ amplitudes: number[] /** harmonic phases phi_1..phi_N in radians */ phases: number[] } export const degToRad = (d: number): number => (d * Math.PI) / 180 /** Sum of harmonics at angle theta = 2 pi f0 t (radians). Not gain-scaled. */ export function harmonicSum(amplitudes: number[], phases: number[], theta: number): number { let s = 0 for (let k = 0; k < amplitudes.length; k++) { const a = amplitudes[k] if (a === 0) continue s += a * Math.sin((k + 1) * theta + (phases[k] ?? 0)) } return s } /** Upper bound on |x(t)| given the amplitudes, whatever the phases are. */ export function peakBound(amplitudes: number[]): number { return amplitudes.reduce((s, a) => s + Math.abs(a), 0) } /** RMS of the unscaled waveform. By Parseval this depends only on the amplitudes. */ export function rms(amplitudes: number[]): number { return Math.sqrt(amplitudes.reduce((s, a) => s + a * a, 0) / 2) } /** * Output gain applied before playback. It is a function of the amplitudes * alone, so two tones with the same amplitudes and different phases receive * exactly the same gain and therefore the same RMS level. The gain targets a * fixed RMS but never lets the worst-case peak exceed 0.9, so no phase * setting can clip. */ export function outputGain(amplitudes: number[]): number { const p = peakBound(amplitudes) const r = rms(amplitudes) if (p === 0 || r === 0) return 0 return Math.min(0.9 / p, 0.2 / r) } /** One period of the unscaled waveform, sampled at n points. */ export function renderPeriod(amplitudes: number[], phases: number[], n = 512): Float32Array { const out = new Float32Array(n) for (let i = 0; i < n; i++) out[i] = harmonicSum(amplitudes, phases, (2 * Math.PI * i) / n) return out } /** The full audio signal: gain-scaled, with raised-cosine fades to avoid clicks. */ export function renderTone(tone: ToneParams, sampleRate: number, durationSec: number, fadeSec = 0.01): Float32Array { const n = Math.round(sampleRate * durationSec) const out = new Float32Array(n) const g = outputGain(tone.amplitudes) const nFade = Math.min(Math.round(sampleRate * fadeSec), Math.floor(n / 2)) const w = (2 * Math.PI * tone.f0) / sampleRate for (let i = 0; i < n; i++) { let env = 1 if (i < nFade) env = 0.5 - 0.5 * Math.cos((Math.PI * i) / nFade) else if (i >= n - nFade) env = 0.5 - 0.5 * Math.cos((Math.PI * (n - 1 - i)) / nFade) out[i] = g * env * harmonicSum(tone.amplitudes, tone.phases, w * i) } return out } export type AmplitudePreset = { name: string; make: (n: number) => number[] } const rawPresets: AmplitudePreset[] = [ { name: '1/k, all harmonics', make: (n) => Array.from({ length: n }, (_, k) => 1 / (k + 1)) }, { name: '1/k, odd harmonics', make: (n) => Array.from({ length: n }, (_, k) => (k % 2 === 0 ? 1 / (k + 1) : 0)) }, { name: '1/k², odd harmonics', make: (n) => Array.from({ length: n }, (_, k) => (k % 2 === 0 ? 1 / (k + 1) ** 2 : 0)) }, { name: 'Flat', make: (n) => Array.from({ length: n }, () => 1) }, { name: 'Fundamental only', make: (n) => Array.from({ length: n }, (_, k) => (k === 0 ? 1 : 0)) }, ] /** Presets with values rounded to the slider resolution (two decimals). */ export const amplitudePresets: AmplitudePreset[] = rawPresets.map((p) => ({ name: p.name, make: (n) => p.make(n).map((a) => round(a, 2)), })) export const zeros = (n: number): number[] => Array.from({ length: n }, () => 0) /** * Random amplitudes for a new example: a decaying envelope with random * per-harmonic variation, normalized so the largest is 1. Rounded to the * slider resolution. */ export function randomAmplitudes(n: number): number[] { const raw = Array.from({ length: n }, (_, k) => Math.random() / (k + 1) ** (0.5 + Math.random())) const m = Math.max(...raw) return raw.map((a) => round(a / m, 2)) } export const randomPhasesDeg = (n: number): number[] => Array.from({ length: n }, () => Math.round(Math.random() * 360 - 180)) /** Resize an array to length n, keeping existing entries and padding with `fill`. */ export function resized(values: number[], n: number, fill = 0): number[] { return Array.from({ length: n }, (_, k) => (k < values.length ? values[k] : fill)) } /** Round to a fixed number of decimals (for slider values). */ export const round = (x: number, decimals: number): number => { const p = 10 ** decimals return Math.round(x * p) / p }