1/**
2 * Additive synthesis of a periodic tone from harmonic amplitudes and phases.
3 *
4 * A tone with fundamental frequency f0 is
5 *
6 * x(t) = sum_k a_k sin(2 pi k f0 t + phi_k), k = 1..N
7 *
8 * Everything here is a pure function of the parameters, so the same code
9 * produces the plotted period and the audio that is played.
10 */
12export type ToneParams = {
13 f0: number
14 /** harmonic amplitudes a_1..a_N (non-negative) */
15 amplitudes: number[]
16 /** harmonic phases phi_1..phi_N in radians */
17 phases: number[]
18}
20export const degToRad = (d: number): number => (d * Math.PI) / 180
22/** Sum of harmonics at angle theta = 2 pi f0 t (radians). Not gain-scaled. */
23export function harmonicSum(amplitudes: number[], phases: number[], theta: number): number {
24 let s = 0
25 for (let k = 0; k < amplitudes.length; k++) {
26 const a = amplitudes[k]
27 if (a === 0) continue
28 s += a * Math.sin((k + 1) * theta + (phases[k] ?? 0))
29 }
30 return s
31}
33/** Upper bound on |x(t)| given the amplitudes, whatever the phases are. */
34export function peakBound(amplitudes: number[]): number {
35 return amplitudes.reduce((s, a) => s + Math.abs(a), 0)
36}
38/** RMS of the unscaled waveform. By Parseval this depends only on the amplitudes. */
39export function rms(amplitudes: number[]): number {
40 return Math.sqrt(amplitudes.reduce((s, a) => s + a * a, 0) / 2)
41}
43/**
44 * Output gain applied before playback. It is a function of the amplitudes
45 * alone, so two tones with the same amplitudes and different phases receive
46 * exactly the same gain and therefore the same RMS level. The gain targets a
47 * fixed RMS but never lets the worst-case peak exceed 0.9, so no phase
48 * setting can clip.
49 */
50export function outputGain(amplitudes: number[]): number {
51 const p = peakBound(amplitudes)
52 const r = rms(amplitudes)
53 if (p === 0 || r === 0) return 0
54 return Math.min(0.9 / p, 0.2 / r)
55}
57/** One period of the unscaled waveform, sampled at n points. */
58export function renderPeriod(amplitudes: number[], phases: number[], n = 512): Float32Array {
59 const out = new Float32Array(n)
60 for (let i = 0; i < n; i++) out[i] = harmonicSum(amplitudes, phases, (2 * Math.PI * i) / n)
61 return out
62}
64/** The full audio signal: gain-scaled, with raised-cosine fades to avoid clicks. */
65export function renderTone(tone: ToneParams, sampleRate: number, durationSec: number, fadeSec = 0.01): Float32Array {
66 const n = Math.round(sampleRate * durationSec)
67 const out = new Float32Array(n)
68 const g = outputGain(tone.amplitudes)
69 const nFade = Math.min(Math.round(sampleRate * fadeSec), Math.floor(n / 2))
70 const w = (2 * Math.PI * tone.f0) / sampleRate
71 for (let i = 0; i < n; i++) {
72 let env = 1
73 if (i < nFade) env = 0.5 - 0.5 * Math.cos((Math.PI * i) / nFade)
74 else if (i >= n - nFade) env = 0.5 - 0.5 * Math.cos((Math.PI * (n - 1 - i)) / nFade)
75 out[i] = g * env * harmonicSum(tone.amplitudes, tone.phases, w * i)
76 }
77 return out
78}
80export type AmplitudePreset = { name: string; make: (n: number) => number[] }
82const rawPresets: AmplitudePreset[] = [
83 { name: '1/k, all harmonics', make: (n) => Array.from({ length: n }, (_, k) => 1 / (k + 1)) },
84 { name: '1/k, odd harmonics', make: (n) => Array.from({ length: n }, (_, k) => (k % 2 === 0 ? 1 / (k + 1) : 0)) },
85 { name: '1/k², odd harmonics', make: (n) => Array.from({ length: n }, (_, k) => (k % 2 === 0 ? 1 / (k + 1) ** 2 : 0)) },
86 { name: 'Flat', make: (n) => Array.from({ length: n }, () => 1) },
87 { name: 'Fundamental only', make: (n) => Array.from({ length: n }, (_, k) => (k === 0 ? 1 : 0)) },
88]
90/** Presets with values rounded to the slider resolution (two decimals). */
91export const amplitudePresets: AmplitudePreset[] = rawPresets.map((p) => ({
92 name: p.name,
93 make: (n) => p.make(n).map((a) => round(a, 2)),
94}))
96export const zeros = (n: number): number[] => Array.from({ length: n }, () => 0)
98/**
99 * Random amplitudes for a new example: a decaying envelope with random
100 * per-harmonic variation, normalized so the largest is 1. Rounded to the
101 * slider resolution.
102 */
103export function randomAmplitudes(n: number): number[] {
104 const raw = Array.from({ length: n }, (_, k) => Math.random() / (k + 1) ** (0.5 + Math.random()))
105 const m = Math.max(...raw)
106 return raw.map((a) => round(a / m, 2))
107}
109export const randomPhasesDeg = (n: number): number[] =>
110 Array.from({ length: n }, () => Math.round(Math.random() * 360 - 180))
112/** Resize an array to length n, keeping existing entries and padding with `fill`. */
113export function resized(values: number[], n: number, fill = 0): number[] {
114 return Array.from({ length: n }, (_, k) => (k < values.length ? values[k] : fill))
115}
117/** Round to a fixed number of decimals (for slider values). */
118export const round = (x: number, decimals: number): number => {
119 const p = 10 ** decimals
120 return Math.round(x * p) / p
121}