char-slop/chordpad.vibed.software

git clone https://git.t4t.associates/char-slop/chordpad.vibed.software

Charlotte Somtrack controlsfcab920

main
4.0 KiB130 linesraw
1import { midiToFreq } from "../theory/pitch.ts";
2
3let engine: { ctx: AudioContext; out: AudioNode } | undefined;
4
5export function audio(): { ctx: AudioContext; out: AudioNode } {
6  if (!engine) {
7    const ctx = new AudioContext({ latencyHint: "interactive" });
8    const compressor = ctx.createDynamicsCompressor();
9    compressor.threshold.value = -16;
10    compressor.ratio.value = 3;
11    compressor.connect(ctx.destination);
12    const out = ctx.createGain();
13    out.gain.value = 0.22;
14    out.connect(compressor);
15    engine = { ctx, out };
16  }
17  if (engine.ctx.state === "suspended") void engine.ctx.resume();
18  return engine;
19}
20
21/** a sounding (or scheduled) note, which can be cut short */
22export type Voice = { stop(at?: number): void; readonly done: boolean };
23
24const voices = new Set<Voice>();
25
26/** seconds: a note's fade when released, and when cut short (about as quick as a damper) */
27const RELEASE = 0.08;
28const CHOKE = 0.03;
29
30/** a fade from 1 to 0 without corners: half a cosine */
31const CHOKE_CURVE = Float32Array.from(
32  { length: 64 },
33  (_, i) => (1 + Math.cos((Math.PI * i) / 63)) / 2,
34);
35
36/**
37 * a two-operator-pair electric piano, after the DX7 E.PIANO patches: a 1:1 pair whose
38 * brightness decays into a near-sine body, and a 1:14 pair for the struck-tine transient
39 */
40export function playNote(
41  midi: number,
42  when: number,
43  dur: number,
44  vel = 0.7,
45  out = audio().out,
46): Voice {
47  const { ctx } = audio();
48  const t = Math.max(when, ctx.currentTime);
49  const end = t + Math.max(dur, 0.05);
50  const f = midiToFreq(midi);
51  // key tracking: higher notes are darker and shorter, like the real thing
52  const kt = Math.min(1, Math.max(-1, (midi - 60) / 36));
53
54  const release = ctx.createGain();
55  // -3dB per octave around middle C, so the top end doesn't shout
56  const level = 10 ** ((-3 / 20) * ((midi - 60) / 12));
57  release.gain.setValueAtTime(level, t);
58  release.gain.setTargetAtTime(0, end, RELEASE);
59  // sits at 1 until the note is cut short, so the fade out needn't know where the envelopes are
60  const choke = ctx.createGain();
61  release.connect(choke).connect(out);
62
63  let stoppedAt = Infinity;
64  const voice = {
65    done: false,
66    stop(at = ctx.currentTime) {
67      if (at >= stoppedAt) return;
68      stoppedAt = at;
69      // this also cancels a start still to come, leaving it silent
70      choke.gain.cancelScheduledValues(at);
71      choke.gain.setValueCurveAtTime(CHOKE_CURVE, at, CHOKE);
72    },
73  };
74  voices.add(voice);
75
76  const osc = (freq: number, detune = 0) => {
77    const o = ctx.createOscillator();
78    o.frequency.value = freq;
79    o.detune.value = detune;
80    o.start(t);
81    o.stop(end + 0.8);
82    return o;
83  };
84  const env = (peak: number, tau: number, floor = 0) => {
85    const g = ctx.createGain();
86    g.gain.setValueAtTime(0, t);
87    g.gain.linearRampToValueAtTime(peak, t + 0.002);
88    g.gain.setTargetAtTime(peak * floor, t + 0.002, tau);
89    return g;
90  };
91  const pan = (x: number) => {
92    const p = ctx.createStereoPanner();
93    p.pan.value = x;
94    p.connect(release);
95    return p;
96  };
97
98  const brightness = (0.35 + 0.65 * vel) * (1 - 0.4 * kt);
99  const bodyIndex = env(f * 2 * brightness, 0.4, 0.18);
100  osc(f).connect(bodyIndex);
101  const bodyLevel = 0.4 * (0.45 + 0.55 * vel);
102  for (const [detune, side] of [
103    [-4, -0.3],
104    [4, 0.3],
105  ]) {
106    const carrier = osc(f, detune);
107    bodyIndex.connect(carrier.frequency);
108    carrier.connect(env(bodyLevel, 2.4 * (1 - 0.5 * kt))).connect(pan(side));
109  }
110
111  const tine = osc(f);
112  // up top the modulator would be past what the output can carry, so it's left out
113  if (f * 14 < ctx.sampleRate / 2) {
114    const tineIndex = env(f * 14 * 0.3 * vel * (1 - 0.3 * kt), 0.035);
115    osc(f * 14).connect(tineIndex);
116    tineIndex.connect(tine.frequency);
117  }
118  tine.connect(env(0.28 * vel, 0.3 * (1 - 0.5 * kt))).connect(pan(0));
119
120  tine.onended = () => {
121    choke.disconnect();
122    voice.done = true;
123    voices.delete(voice);
124  };
125  return voice;
126}
127
128export function silence(): void {
129  for (const voice of voices) voice.stop();
130}