import { midiToFreq } from "../theory/pitch.ts"; let engine: { ctx: AudioContext; out: AudioNode } | undefined; export function audio(): { ctx: AudioContext; out: AudioNode } { if (!engine) { const ctx = new AudioContext({ latencyHint: "interactive" }); const compressor = ctx.createDynamicsCompressor(); compressor.threshold.value = -16; compressor.ratio.value = 3; compressor.connect(ctx.destination); const out = ctx.createGain(); out.gain.value = 0.22; out.connect(compressor); engine = { ctx, out }; } if (engine.ctx.state === "suspended") void engine.ctx.resume(); return engine; } /** a sounding (or scheduled) note, which can be cut short */ export type Voice = { stop(at?: number): void; readonly done: boolean }; const voices = new Set(); /** seconds: a note's fade when released, and when cut short (about as quick as a damper) */ const RELEASE = 0.08; const CHOKE = 0.03; /** a fade from 1 to 0 without corners: half a cosine */ const CHOKE_CURVE = Float32Array.from( { length: 64 }, (_, i) => (1 + Math.cos((Math.PI * i) / 63)) / 2, ); /** * a two-operator-pair electric piano, after the DX7 E.PIANO patches: a 1:1 pair whose * brightness decays into a near-sine body, and a 1:14 pair for the struck-tine transient */ export function playNote( midi: number, when: number, dur: number, vel = 0.7, out = audio().out, ): Voice { const { ctx } = audio(); const t = Math.max(when, ctx.currentTime); const end = t + Math.max(dur, 0.05); const f = midiToFreq(midi); // key tracking: higher notes are darker and shorter, like the real thing const kt = Math.min(1, Math.max(-1, (midi - 60) / 36)); const release = ctx.createGain(); // -3dB per octave around middle C, so the top end doesn't shout const level = 10 ** ((-3 / 20) * ((midi - 60) / 12)); release.gain.setValueAtTime(level, t); release.gain.setTargetAtTime(0, end, RELEASE); // sits at 1 until the note is cut short, so the fade out needn't know where the envelopes are const choke = ctx.createGain(); release.connect(choke).connect(out); let stoppedAt = Infinity; const voice = { done: false, stop(at = ctx.currentTime) { if (at >= stoppedAt) return; stoppedAt = at; // this also cancels a start still to come, leaving it silent choke.gain.cancelScheduledValues(at); choke.gain.setValueCurveAtTime(CHOKE_CURVE, at, CHOKE); }, }; voices.add(voice); const osc = (freq: number, detune = 0) => { const o = ctx.createOscillator(); o.frequency.value = freq; o.detune.value = detune; o.start(t); o.stop(end + 0.8); return o; }; const env = (peak: number, tau: number, floor = 0) => { const g = ctx.createGain(); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(peak, t + 0.002); g.gain.setTargetAtTime(peak * floor, t + 0.002, tau); return g; }; const pan = (x: number) => { const p = ctx.createStereoPanner(); p.pan.value = x; p.connect(release); return p; }; const brightness = (0.35 + 0.65 * vel) * (1 - 0.4 * kt); const bodyIndex = env(f * 2 * brightness, 0.4, 0.18); osc(f).connect(bodyIndex); const bodyLevel = 0.4 * (0.45 + 0.55 * vel); for (const [detune, side] of [ [-4, -0.3], [4, 0.3], ]) { const carrier = osc(f, detune); bodyIndex.connect(carrier.frequency); carrier.connect(env(bodyLevel, 2.4 * (1 - 0.5 * kt))).connect(pan(side)); } const tine = osc(f); // up top the modulator would be past what the output can carry, so it's left out if (f * 14 < ctx.sampleRate / 2) { const tineIndex = env(f * 14 * 0.3 * vel * (1 - 0.3 * kt), 0.035); osc(f * 14).connect(tineIndex); tineIndex.connect(tine.frequency); } tine.connect(env(0.28 * vel, 0.3 * (1 - 0.5 * kt))).connect(pan(0)); tine.onended = () => { choke.disconnect(); voice.done = true; voices.delete(voice); }; return voice; } export function silence(): void { for (const voice of voices) voice.stop(); }