1// Pure per-note synthesis. Every function here is deterministic (noise
2// comes from a PRNG seeded by pitch) and free of Web Audio, so the node
3// test harness can hash the actual audio.
5export function midiToFreq(pitch: number): number {
6 return 440 * Math.pow(2, (pitch - 69) / 12);
7}
9export function mulberry32(seed: number): () => number {
10 let a = seed >>> 0;
11 return () => {
12 a |= 0;
13 a = (a + 0x6d2b79f5) | 0;
14 let t = Math.imul(a ^ (a >>> 15), 1 | a);
15 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
16 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
17 };
18}
20// Karplus-Strong plucked string. Rings past the nominal duration with a
21// release envelope so a short note still sounds like a pluck.
22export function pluckNote(sr: number, pitch: number, velocity: number, durationSec: number): Float32Array {
23 const f0 = midiToFreq(pitch);
24 const period = Math.max(2, Math.round(sr / f0));
25 const tail = 1.2;
26 const n = Math.max(period + 1, Math.floor(sr * (durationSec + tail)));
27 const out = new Float32Array(n);
28 const delay = new Float32Array(period);
29 const rng = mulberry32(0x9e3779b9 ^ pitch);
30 for (let i = 0; i < period; i++) delay[i] = rng() * 2 - 1;
31 const g = 0.996;
32 let idx = 0;
33 for (let i = 0; i < n; i++) {
34 const cur = delay[idx];
35 const nxt = delay[(idx + 1) % period];
36 out[i] = cur;
37 delay[idx] = g * 0.5 * (cur + nxt);
38 idx = (idx + 1) % period;
39 }
40 const relStart = Math.floor(durationSec * sr);
41 for (let i = 0; i < n; i++) {
42 let env = 1;
43 if (i > relStart) env = Math.exp(-(i - relStart) / (0.12 * sr));
44 out[i] *= env;
45 }
46 scale(out, 0.6 * velocity);
47 return out;
48}
50// Two-operator FM electric piano: octave modulator with a decaying index.
51export function epianoNote(sr: number, pitch: number, velocity: number, durationSec: number): Float32Array {
52 const f0 = midiToFreq(pitch);
53 const tail = 0.8;
54 const n = Math.floor(sr * (durationSec + tail));
55 const out = new Float32Array(n);
56 const wc = (2 * Math.PI * f0) / sr;
57 const wm = 2 * wc;
58 const relStart = durationSec * sr;
59 for (let i = 0; i < n; i++) {
60 const t = i / sr;
61 const idxEnv = (1.2 + 2.5 * velocity) * Math.exp(-t * 4);
62 let env = Math.min(1, i / (0.002 * sr)) * Math.exp(-t * 1.8);
63 if (i > relStart) env *= Math.exp(-(i - relStart) / (0.07 * sr));
64 out[i] = env * Math.sin(wc * i + idxEnv * Math.sin(wm * i));
65 }
66 scale(out, 0.5 * velocity);
67 return out;
68}
70// Square-ish lead: summed odd harmonics through a one-pole lowpass whose
71// cutoff falls with time, with an ADSR gated by the note duration.
72export function leadNote(sr: number, pitch: number, velocity: number, durationSec: number): Float32Array {
73 const f0 = midiToFreq(pitch);
74 const tail = 0.35;
75 const n = Math.floor(sr * (durationSec + tail));
76 const out = new Float32Array(n);
77 const w = (2 * Math.PI * f0) / sr;
78 const nyq = sr / 2;
79 let y = 0;
80 const relStart = durationSec * sr;
81 for (let i = 0; i < n; i++) {
82 const t = i / sr;
83 let x = 0;
84 for (let k = 1; k <= 15; k += 2) {
85 if (f0 * k > nyq * 0.9) break;
86 x += Math.sin(w * k * i) / k;
87 }
88 const cutoff = 500 + (1500 + 5000 * velocity) * Math.exp(-t * 3);
89 const a = 1 - Math.exp((-2 * Math.PI * cutoff) / sr);
90 y += a * (x - y);
91 const atk = Math.min(1, i / (0.005 * sr));
92 let env = atk * (0.7 + 0.3 * Math.exp(-t * 6));
93 if (i > relStart) env *= Math.exp(-(i - relStart) / (0.06 * sr));
94 out[i] = y * env;
95 }
96 scale(out, 0.45 * velocity);
97 return out;
98}
100// General MIDI drum kit, keyed by pitch. Duration is ignored: a struck
101// drum runs to its own decay.
102export function drumNote(sr: number, pitch: number, velocity: number, _durationSec: number): Float32Array {
103 const rng = mulberry32(0x51ed270b ^ pitch);
104 const make = (len: number, fn: (t: number, i: number, noise: () => number) => number) => {
105 const n = Math.floor(sr * len);
106 const out = new Float32Array(n);
107 for (let i = 0; i < n; i++) out[i] = fn(i / sr, i, () => rng() * 2 - 1);
108 return out;
109 };
110 let buf: Float32Array;
111 if (pitch === 35 || pitch === 36) {
112 // kick: falling sine sweep
113 let phase = 0;
114 buf = make(0.5, (t) => {
115 const f = 40 + 120 * Math.exp(-t * 28);
116 phase += (2 * Math.PI * f) / sr;
117 return Math.sin(phase) * Math.exp(-t * 9);
118 });
119 } else if (pitch === 38 || pitch === 40) {
120 // snare: tone plus noise burst
121 buf = make(0.3, (t, i, noise) => Math.sin((2 * Math.PI * 190 * i) / sr) * 0.4 * Math.exp(-t * 30) + noise() * 0.9 * Math.exp(-t * 18));
122 } else if (pitch === 39) {
123 // clap
124 buf = make(0.25, (t, _i, noise) => noise() * (Math.exp(-t * 40) + 0.6 * Math.exp(-Math.max(0, t - 0.012) * 40) + 0.35 * Math.exp(-Math.max(0, t - 0.024) * 30)));
125 } else if (pitch === 42 || pitch === 44) {
126 // closed hat: highpassed noise
127 buf = hp(make(0.1, (t, _i, noise) => noise() * Math.exp(-t * 60)));
128 } else if (pitch === 46) {
129 // open hat
130 buf = hp(make(0.6, (t, _i, noise) => noise() * Math.exp(-t * 8)));
131 } else if (pitch === 49 || pitch === 57 || pitch === 55 || pitch === 52) {
132 // crashes
133 buf = hp(make(1.6, (t, _i, noise) => noise() * Math.exp(-t * 3)));
134 } else if (pitch === 51 || pitch === 59 || pitch === 53) {
135 // ride
136 buf = hp(make(1.0, (t, i, noise) => noise() * 0.5 * Math.exp(-t * 5) + Math.sin((2 * Math.PI * 5200 * i) / sr) * 0.1 * Math.exp(-t * 6)));
137 } else if (pitch >= 41 && pitch <= 50) {
138 // toms: pitch-mapped sweep
139 const f0 = 70 + (pitch - 41) * 14;
140 let phase = 0;
141 buf = make(0.45, (t) => {
142 const f = f0 * (1 + 0.6 * Math.exp(-t * 20));
143 phase += (2 * Math.PI * f) / sr;
144 return Math.sin(phase) * Math.exp(-t * 11);
145 });
146 } else {
147 // default percussion tick
148 buf = make(0.12, (t, _i, noise) => noise() * Math.exp(-t * 45));
149 }
150 scale(buf, 0.7 * velocity);
151 return buf;
152}
154function hp(buf: Float32Array): Float32Array {
155 let lp = 0;
156 for (let i = 0; i < buf.length; i++) {
157 lp += 0.15 * (buf[i] - lp);
158 buf[i] = buf[i] - lp;
159 }
160 return buf;
161}
163function scale(buf: Float32Array, s: number) {
164 for (let i = 0; i < buf.length; i++) buf[i] *= s;
165}
167// FM piano: two slightly detuned carriers with an inharmonic modulator and
168// a hammer transient; decay time falls with pitch the way a real string's
169// does, and the release gates the tail.
170export function pianoNote(sr: number, pitch: number, velocity: number, durationSec: number): Float32Array {
171 const f0 = midiToFreq(pitch);
172 const decay = Math.max(0.6, 5.5 - pitch * 0.045); // seconds to fall ~e^-1
173 const tail = Math.min(2.5, decay);
174 const n = Math.floor(sr * (durationSec + tail));
175 const out = new Float32Array(n);
176 const wc = (2 * Math.PI * f0) / sr;
177 const wm = wc * 3.001; // slightly inharmonic upper partials
178 const relStart = durationSec * sr;
179 for (let i = 0; i < n; i++) {
180 const t = i / sr;
181 const idxEnv = (0.6 + 2.2 * velocity) * Math.exp(-t * 7);
182 let env = Math.min(1, i / (0.0015 * sr)) * Math.exp(-t / decay);
183 if (i > relStart) env *= Math.exp(-(i - relStart) / (0.09 * sr));
184 const a = Math.sin(wc * i + idxEnv * Math.sin(wm * i));
185 const b = Math.sin(wc * 1.0007 * i + 0.5 * idxEnv * Math.sin(2 * wc * i));
186 out[i] = env * (0.7 * a + 0.3 * b);
187 }
188 scale(out, 0.5 * velocity);
189 return out;
190}
192// Drawbar organ: a fixed set of additive partials at classic drawbar
193// footages, held at constant level while the key is down, with a small
194// percussive click and a quick release. Velocity moves level only a
195// little, as on the real instrument.
196export function organNote(sr: number, pitch: number, velocity: number, durationSec: number): Float32Array {
197 const f0 = midiToFreq(pitch);
198 const tail = 0.15;
199 const n = Math.floor(sr * (durationSec + tail));
200 const out = new Float32Array(n);
201 const nyq = sr / 2;
202 // ratios and levels: 16', 8', 5 1/3', 4', 2 2/3', 2'
203 const ratios = [0.5, 1, 1.5, 2, 3, 4];
204 const levels = [0.35, 1, 0.45, 0.55, 0.3, 0.25];
205 const relStart = durationSec * sr;
206 const vib = 2 * Math.PI * 6.3;
207 for (let i = 0; i < n; i++) {
208 const t = i / sr;
209 const fmod = 1 + 0.0018 * Math.sin(vib * t); // light chorus/vibrato
210 let x = 0;
211 for (let k = 0; k < ratios.length; k++) {
212 const f = f0 * ratios[k];
213 if (f > nyq * 0.9) break;
214 x += levels[k] * Math.sin(((2 * Math.PI * f * fmod) / sr) * i);
215 }
216 const click = Math.exp(-t / 0.004) * 0.6 * Math.sin(((2 * Math.PI * f0 * 4) / sr) * i);
217 let env = Math.min(1, i / (0.003 * sr));
218 if (i > relStart) env *= Math.exp(-(i - relStart) / (0.025 * sr));
219 out[i] = env * (x + click);
220 }
221 scale(out, 0.3 * (0.6 + 0.4 * velocity));
222 return out;
223}
225// String ensemble pad: three detuned sawtooth voices through a gentle
226// lowpass, with the slow attack and slow release that make it a pad.
227export function stringsNote(sr: number, pitch: number, velocity: number, durationSec: number): Float32Array {
228 const f0 = midiToFreq(pitch);
229 const tail = 0.6;
230 const n = Math.floor(sr * (durationSec + tail));
231 const out = new Float32Array(n);
232 const nyq = sr / 2;
233 const detunes = [0.9965, 1, 1.0042];
234 const rng = mulberry32(0x51ab17 ^ pitch);
235 const phases = detunes.map(() => rng() * 2 * Math.PI);
236 let y = 0;
237 const relStart = durationSec * sr;
238 for (let i = 0; i < n; i++) {
239 const t = i / sr;
240 let x = 0;
241 for (let v = 0; v < detunes.length; v++) {
242 const f = f0 * detunes[v];
243 // band-limited-ish saw: first 10 harmonics
244 for (let k = 1; k <= 10; k++) {
245 if (f * k > nyq * 0.9) break;
246 x += Math.sin(((2 * Math.PI * f * k) / sr) * i + phases[v] * k) / k;
247 }
248 }
249 const a = 1 - Math.exp((-2 * Math.PI * (900 + 900 * velocity)) / sr);
250 y += a * (x - y);
251 let env = 1 - Math.exp(-t / 0.15); // slow bow-in
252 if (i > relStart) env *= Math.exp(-(i - relStart) / (0.3 * sr));
253 out[i] = y * env;
254 }
255 scale(out, 0.28 * velocity);
256 return out;
257}
259// Synth bass: square plus a sub-octave sine with a snappy filter
260// envelope; velocity opens the filter.
261export function synthBassNote(sr: number, pitch: number, velocity: number, durationSec: number): Float32Array {
262 const f0 = midiToFreq(pitch);
263 const tail = 0.2;
264 const n = Math.floor(sr * (durationSec + tail));
265 const out = new Float32Array(n);
266 const nyq = sr / 2;
267 let y = 0;
268 const relStart = durationSec * sr;
269 for (let i = 0; i < n; i++) {
270 const t = i / sr;
271 let x = 0;
272 for (let k = 1; k <= 9; k += 2) {
273 if (f0 * k > nyq * 0.9) break;
274 x += Math.sin(((2 * Math.PI * f0 * k) / sr) * i) / k;
275 }
276 x += 0.6 * Math.sin(((Math.PI * f0) / sr) * i); // sub-octave
277 const cutoff = 80 + (400 + 2600 * velocity) * Math.exp(-t * 9);
278 const a = 1 - Math.exp((-2 * Math.PI * cutoff) / sr);
279 y += a * (x - y);
280 const atk = Math.min(1, i / (0.002 * sr));
281 let env = atk * (0.75 + 0.25 * Math.exp(-t * 5));
282 if (i > relStart) env *= Math.exp(-(i - relStart) / (0.04 * sr));
283 out[i] = y * env;
284 }
285 scale(out, 0.5 * velocity);
286 return out;
287}
289// Brass section: a sawtooth whose lowpass opens over the first tenth of a
290// second (the brass "blip"), light vibrato arriving late, medium release.
291export function brassNote(sr: number, pitch: number, velocity: number, durationSec: number): Float32Array {
292 const f0 = midiToFreq(pitch);
293 const tail = 0.3;
294 const n = Math.floor(sr * (durationSec + tail));
295 const out = new Float32Array(n);
296 const nyq = sr / 2;
297 let y = 0;
298 const relStart = durationSec * sr;
299 for (let i = 0; i < n; i++) {
300 const t = i / sr;
301 const vibDepth = 0.004 * Math.min(1, Math.max(0, (t - 0.25) / 0.4));
302 const fmod = 1 + vibDepth * Math.sin(2 * Math.PI * 5.5 * t);
303 let x = 0;
304 for (let k = 1; k <= 12; k++) {
305 if (f0 * k > nyq * 0.9) break;
306 x += Math.sin(((2 * Math.PI * f0 * fmod * k) / sr) * i) / k;
307 }
308 const open = 1 - Math.exp(-t / 0.09);
309 const cutoff = 300 + (1200 + 3800 * velocity) * open;
310 const a = 1 - Math.exp((-2 * Math.PI * cutoff) / sr);
311 y += a * (x - y);
312 const atk = Math.min(1, i / (0.012 * sr));
313 let env = atk;
314 if (i > relStart) env *= Math.exp(-(i - relStart) / (0.08 * sr));
315 out[i] = y * env;
316 }
317 scale(out, 0.4 * velocity);
318 return out;
319}