// Pure per-note synthesis. Every function here is deterministic (noise // comes from a PRNG seeded by pitch) and free of Web Audio, so the node // test harness can hash the actual audio. export function midiToFreq(pitch: number): number { return 440 * Math.pow(2, (pitch - 69) / 12); } export function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // Karplus-Strong plucked string. Rings past the nominal duration with a // release envelope so a short note still sounds like a pluck. export function pluckNote(sr: number, pitch: number, velocity: number, durationSec: number): Float32Array { const f0 = midiToFreq(pitch); const period = Math.max(2, Math.round(sr / f0)); const tail = 1.2; const n = Math.max(period + 1, Math.floor(sr * (durationSec + tail))); const out = new Float32Array(n); const delay = new Float32Array(period); const rng = mulberry32(0x9e3779b9 ^ pitch); for (let i = 0; i < period; i++) delay[i] = rng() * 2 - 1; const g = 0.996; let idx = 0; for (let i = 0; i < n; i++) { const cur = delay[idx]; const nxt = delay[(idx + 1) % period]; out[i] = cur; delay[idx] = g * 0.5 * (cur + nxt); idx = (idx + 1) % period; } const relStart = Math.floor(durationSec * sr); for (let i = 0; i < n; i++) { let env = 1; if (i > relStart) env = Math.exp(-(i - relStart) / (0.12 * sr)); out[i] *= env; } scale(out, 0.6 * velocity); return out; } // Two-operator FM electric piano: octave modulator with a decaying index. export function epianoNote(sr: number, pitch: number, velocity: number, durationSec: number): Float32Array { const f0 = midiToFreq(pitch); const tail = 0.8; const n = Math.floor(sr * (durationSec + tail)); const out = new Float32Array(n); const wc = (2 * Math.PI * f0) / sr; const wm = 2 * wc; const relStart = durationSec * sr; for (let i = 0; i < n; i++) { const t = i / sr; const idxEnv = (1.2 + 2.5 * velocity) * Math.exp(-t * 4); let env = Math.min(1, i / (0.002 * sr)) * Math.exp(-t * 1.8); if (i > relStart) env *= Math.exp(-(i - relStart) / (0.07 * sr)); out[i] = env * Math.sin(wc * i + idxEnv * Math.sin(wm * i)); } scale(out, 0.5 * velocity); return out; } // Square-ish lead: summed odd harmonics through a one-pole lowpass whose // cutoff falls with time, with an ADSR gated by the note duration. export function leadNote(sr: number, pitch: number, velocity: number, durationSec: number): Float32Array { const f0 = midiToFreq(pitch); const tail = 0.35; const n = Math.floor(sr * (durationSec + tail)); const out = new Float32Array(n); const w = (2 * Math.PI * f0) / sr; const nyq = sr / 2; let y = 0; const relStart = durationSec * sr; for (let i = 0; i < n; i++) { const t = i / sr; let x = 0; for (let k = 1; k <= 15; k += 2) { if (f0 * k > nyq * 0.9) break; x += Math.sin(w * k * i) / k; } const cutoff = 500 + (1500 + 5000 * velocity) * Math.exp(-t * 3); const a = 1 - Math.exp((-2 * Math.PI * cutoff) / sr); y += a * (x - y); const atk = Math.min(1, i / (0.005 * sr)); let env = atk * (0.7 + 0.3 * Math.exp(-t * 6)); if (i > relStart) env *= Math.exp(-(i - relStart) / (0.06 * sr)); out[i] = y * env; } scale(out, 0.45 * velocity); return out; } // General MIDI drum kit, keyed by pitch. Duration is ignored: a struck // drum runs to its own decay. export function drumNote(sr: number, pitch: number, velocity: number, _durationSec: number): Float32Array { const rng = mulberry32(0x51ed270b ^ pitch); const make = (len: number, fn: (t: number, i: number, noise: () => number) => number) => { const n = Math.floor(sr * len); const out = new Float32Array(n); for (let i = 0; i < n; i++) out[i] = fn(i / sr, i, () => rng() * 2 - 1); return out; }; let buf: Float32Array; if (pitch === 35 || pitch === 36) { // kick: falling sine sweep let phase = 0; buf = make(0.5, (t) => { const f = 40 + 120 * Math.exp(-t * 28); phase += (2 * Math.PI * f) / sr; return Math.sin(phase) * Math.exp(-t * 9); }); } else if (pitch === 38 || pitch === 40) { // snare: tone plus noise burst 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)); } else if (pitch === 39) { // clap 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))); } else if (pitch === 42 || pitch === 44) { // closed hat: highpassed noise buf = hp(make(0.1, (t, _i, noise) => noise() * Math.exp(-t * 60))); } else if (pitch === 46) { // open hat buf = hp(make(0.6, (t, _i, noise) => noise() * Math.exp(-t * 8))); } else if (pitch === 49 || pitch === 57 || pitch === 55 || pitch === 52) { // crashes buf = hp(make(1.6, (t, _i, noise) => noise() * Math.exp(-t * 3))); } else if (pitch === 51 || pitch === 59 || pitch === 53) { // ride 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))); } else if (pitch >= 41 && pitch <= 50) { // toms: pitch-mapped sweep const f0 = 70 + (pitch - 41) * 14; let phase = 0; buf = make(0.45, (t) => { const f = f0 * (1 + 0.6 * Math.exp(-t * 20)); phase += (2 * Math.PI * f) / sr; return Math.sin(phase) * Math.exp(-t * 11); }); } else { // default percussion tick buf = make(0.12, (t, _i, noise) => noise() * Math.exp(-t * 45)); } scale(buf, 0.7 * velocity); return buf; } function hp(buf: Float32Array): Float32Array { let lp = 0; for (let i = 0; i < buf.length; i++) { lp += 0.15 * (buf[i] - lp); buf[i] = buf[i] - lp; } return buf; } function scale(buf: Float32Array, s: number) { for (let i = 0; i < buf.length; i++) buf[i] *= s; }