| 1 | export interface MidiOutputInfo { |
| 2 | id: string; |
| 3 | name: string; |
| 4 | } |
| 5 | |
| 6 | export interface SynthParams { |
| 7 | attack: number; |
| 8 | release: number; |
| 9 | cutoff: number; |
| 10 | detune: number; |
| 11 | polyphony: number; |
| 12 | chorus: number; |
| 13 | } |
| 14 | |
| 15 | export const DEFAULT_SYNTH_PARAMS: SynthParams = { |
| 16 | attack: 0.6, |
| 17 | release: 1.4, |
| 18 | cutoff: 1800, |
| 19 | detune: 9, |
| 20 | polyphony: 6, |
| 21 | chorus: 0.45, |
| 22 | }; |
| 23 | |
| 24 | export interface NoteOpts { |
| 25 | attack?: number; |
| 26 | release?: number; |
| 27 | bypassRateLimit?: boolean; |
| 28 | } |
| 29 | |
| 30 | export interface AudioEngine { |
| 31 | kind: 'midi' | 'synth'; |
| 32 | noteOn(note: number, velocity: number, durationMs: number, opts?: NoteOpts): void; |
| 33 | setModulation?(value: number): void; |
| 34 | setVolume?(v: number): void; |
| 35 | setSynthParams?(p: Partial<SynthParams>): void; |
| 36 | listOutputs?(): MidiOutputInfo[]; |
| 37 | selectOutput?(id: string): void; |
| 38 | currentOutputId?(): string | null; |
| 39 | dispose(): void; |
| 40 | } |
| 41 | |
| 42 | interface PadVoice { |
| 43 | oscs: OscillatorNode[]; |
| 44 | gain: GainNode; |
| 45 | filter: BiquadFilterNode; |
| 46 | end: number; |
| 47 | } |
| 48 | |
| 49 | class SynthEngine implements AudioEngine { |
| 50 | kind = 'synth' as const; |
| 51 | private ctx: AudioContext; |
| 52 | private master: GainNode; |
| 53 | private padBus: GainNode; |
| 54 | private wetBus: GainNode; |
| 55 | private morph: BiquadFilterNode; |
| 56 | private active: PadVoice[] = []; |
| 57 | private params: SynthParams = { ...DEFAULT_SYNTH_PARAMS }; |
| 58 | private lastNoteAt = 0; |
| 59 | private minNoteGapMs = 35; |
| 60 | |
| 61 | constructor() { |
| 62 | this.ctx = new (window.AudioContext || |
| 63 | (window as unknown as { webkitAudioContext: typeof AudioContext }) |
| 64 | .webkitAudioContext)(); |
| 65 | this.master = this.ctx.createGain(); |
| 66 | this.master.gain.value = 0.25; |
| 67 | this.master.connect(this.ctx.destination); |
| 68 | |
| 69 | this.padBus = this.ctx.createGain(); |
| 70 | this.padBus.gain.value = 0.6; |
| 71 | |
| 72 | this.wetBus = this.ctx.createGain(); |
| 73 | this.wetBus.gain.value = this.params.chorus; |
| 74 | |
| 75 | this.morph = this.ctx.createBiquadFilter(); |
| 76 | this.morph.type = 'lowpass'; |
| 77 | this.morph.Q.value = 0.4; |
| 78 | this.morph.frequency.value = 1200; |
| 79 | this.morph.connect(this.padBus); |
| 80 | |
| 81 | this.buildChorus(this.padBus, this.wetBus); |
| 82 | this.padBus.connect(this.master); |
| 83 | this.wetBus.connect(this.master); |
| 84 | } |
| 85 | |
| 86 | setModulation(value: number) { |
| 87 | const v = Math.max(0, Math.min(1, value)); |
| 88 | const target = 350 + v * v * 5500; |
| 89 | this.morph.frequency.setTargetAtTime(target, this.ctx.currentTime, 0.15); |
| 90 | } |
| 91 | |
| 92 | setSynthParams(p: Partial<SynthParams>) { |
| 93 | Object.assign(this.params, p); |
| 94 | if (p.chorus !== undefined) { |
| 95 | this.wetBus.gain.setTargetAtTime( |
| 96 | p.chorus, |
| 97 | this.ctx.currentTime, |
| 98 | 0.05, |
| 99 | ); |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | private buildChorus(input: GainNode, output: GainNode) { |
| 104 | const ctx = this.ctx; |
| 105 | const make = (delayMs: number, lfoHz: number, depthMs: number, phase: number) => { |
| 106 | const delay = ctx.createDelay(0.1); |
| 107 | delay.delayTime.value = delayMs / 1000; |
| 108 | const lfo = ctx.createOscillator(); |
| 109 | lfo.frequency.value = lfoHz; |
| 110 | const lfoGain = ctx.createGain(); |
| 111 | lfoGain.gain.value = depthMs / 1000; |
| 112 | lfo.connect(lfoGain).connect(delay.delayTime); |
| 113 | input.connect(delay).connect(output); |
| 114 | lfo.start(ctx.currentTime + phase); |
| 115 | }; |
| 116 | make(18, 0.6, 4, 0); |
| 117 | make(24, 0.83, 5, 0.25); |
| 118 | make(30, 0.41, 3.5, 0.5); |
| 119 | } |
| 120 | |
| 121 | async resume() { |
| 122 | if (this.ctx.state !== 'running') await this.ctx.resume(); |
| 123 | } |
| 124 | |
| 125 | setVolume(v: number) { |
| 126 | this.master.gain.setTargetAtTime(v, this.ctx.currentTime, 0.02); |
| 127 | } |
| 128 | |
| 129 | noteOn(note: number, velocity: number, _durationMs: number, opts?: NoteOpts) { |
| 130 | const nowMs = performance.now(); |
| 131 | if (!opts?.bypassRateLimit) { |
| 132 | if (nowMs - this.lastNoteAt < this.minNoteGapMs) return; |
| 133 | this.lastNoteAt = nowMs; |
| 134 | } |
| 135 | |
| 136 | const { cutoff, detune, polyphony } = this.params; |
| 137 | const attack = opts?.attack ?? this.params.attack; |
| 138 | const release = opts?.release ?? this.params.release; |
| 139 | |
| 140 | while (this.active.length >= polyphony) { |
| 141 | const oldest = this.active.shift()!; |
| 142 | const t0 = this.ctx.currentTime; |
| 143 | oldest.gain.gain.cancelScheduledValues(t0); |
| 144 | oldest.gain.gain.setTargetAtTime(0, t0, 0.08); |
| 145 | for (const o of oldest.oscs) o.stop(t0 + 0.4); |
| 146 | } |
| 147 | |
| 148 | const t = this.ctx.currentTime; |
| 149 | const freq = 440 * Math.pow(2, (note - 69) / 12); |
| 150 | |
| 151 | const hold = 0.15; |
| 152 | const total = attack + hold + release; |
| 153 | |
| 154 | const polyScale = 1 / Math.sqrt(Math.max(1, polyphony)); |
| 155 | const peak = (velocity / 127) * 0.32 * polyScale; |
| 156 | |
| 157 | const filter = this.ctx.createBiquadFilter(); |
| 158 | filter.type = 'lowpass'; |
| 159 | filter.Q.value = 0.6; |
| 160 | const cutoffStart = Math.min(freq * 1.8, cutoff * 0.4); |
| 161 | const cutoffPeak = Math.min(cutoff * 2.2, 8000); |
| 162 | filter.frequency.setValueAtTime(cutoffStart, t); |
| 163 | filter.frequency.linearRampToValueAtTime(cutoffPeak, t + attack); |
| 164 | filter.frequency.setTargetAtTime( |
| 165 | cutoffStart * 1.2, |
| 166 | t + attack + hold, |
| 167 | release / 3, |
| 168 | ); |
| 169 | |
| 170 | const g = this.ctx.createGain(); |
| 171 | g.gain.setValueAtTime(0, t); |
| 172 | g.gain.linearRampToValueAtTime(peak, t + attack); |
| 173 | g.gain.setValueAtTime(peak, t + attack + hold); |
| 174 | g.gain.exponentialRampToValueAtTime(0.0001, t + attack + hold + release); |
| 175 | |
| 176 | const detuneCents = [-detune, 0, detune]; |
| 177 | const oscs: OscillatorNode[] = []; |
| 178 | for (const cents of detuneCents) { |
| 179 | const osc = this.ctx.createOscillator(); |
| 180 | osc.type = 'sawtooth'; |
| 181 | osc.frequency.value = freq; |
| 182 | osc.detune.value = cents; |
| 183 | osc.connect(filter); |
| 184 | osc.start(t); |
| 185 | osc.stop(t + total + 0.1); |
| 186 | oscs.push(osc); |
| 187 | } |
| 188 | |
| 189 | const subOsc = this.ctx.createOscillator(); |
| 190 | subOsc.type = 'sine'; |
| 191 | subOsc.frequency.value = freq / 2; |
| 192 | const subGain = this.ctx.createGain(); |
| 193 | subGain.gain.value = 0.3; |
| 194 | subOsc.connect(subGain).connect(filter); |
| 195 | subOsc.start(t); |
| 196 | subOsc.stop(t + total + 0.1); |
| 197 | oscs.push(subOsc); |
| 198 | |
| 199 | filter.connect(g).connect(this.morph); |
| 200 | |
| 201 | const entry: PadVoice = { oscs, gain: g, filter, end: t + total }; |
| 202 | this.active.push(entry); |
| 203 | oscs[0].onended = () => { |
| 204 | const i = this.active.indexOf(entry); |
| 205 | if (i >= 0) this.active.splice(i, 1); |
| 206 | }; |
| 207 | } |
| 208 | |
| 209 | dispose() { |
| 210 | this.ctx.close(); |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | class MidiEngine implements AudioEngine { |
| 215 | kind = 'midi' as const; |
| 216 | private access: MIDIAccess; |
| 217 | private outputId: string | null = null; |
| 218 | private channel = 0; |
| 219 | private lastModSendAt = 0; |
| 220 | private lastModValue = -1; |
| 221 | |
| 222 | constructor(access: MIDIAccess) { |
| 223 | this.access = access; |
| 224 | const first = Array.from(access.outputs.values())[0]; |
| 225 | this.outputId = first ? first.id : null; |
| 226 | } |
| 227 | |
| 228 | listOutputs(): MidiOutputInfo[] { |
| 229 | return Array.from(this.access.outputs.values()).map((o) => ({ |
| 230 | id: o.id, |
| 231 | name: o.name ?? o.id, |
| 232 | })); |
| 233 | } |
| 234 | |
| 235 | selectOutput(id: string) { |
| 236 | this.outputId = id; |
| 237 | } |
| 238 | |
| 239 | currentOutputId() { |
| 240 | return this.outputId; |
| 241 | } |
| 242 | |
| 243 | private send(bytes: number[]) { |
| 244 | if (!this.outputId) return; |
| 245 | const out = this.access.outputs.get(this.outputId); |
| 246 | if (out) out.send(bytes); |
| 247 | } |
| 248 | |
| 249 | noteOn(note: number, velocity: number, durationMs: number) { |
| 250 | const v = Math.max(1, Math.min(127, Math.round(velocity))); |
| 251 | const n = Math.max(0, Math.min(127, Math.round(note))); |
| 252 | this.send([0x90 | this.channel, n, v]); |
| 253 | setTimeout(() => { |
| 254 | this.send([0x80 | this.channel, n, 0]); |
| 255 | }, durationMs); |
| 256 | } |
| 257 | |
| 258 | setModulation(value: number) { |
| 259 | const now = performance.now(); |
| 260 | if (now - this.lastModSendAt < 90) return; |
| 261 | const cc = Math.max(0, Math.min(127, Math.round(value * 127))); |
| 262 | if (cc === this.lastModValue) return; |
| 263 | this.lastModValue = cc; |
| 264 | this.lastModSendAt = now; |
| 265 | this.send([0xb0 | this.channel, 74, cc]); |
| 266 | } |
| 267 | |
| 268 | dispose() {} |
| 269 | } |
| 270 | |
| 271 | export async function createEngine( |
| 272 | preferMidi: boolean, |
| 273 | ): Promise<AudioEngine> { |
| 274 | if (preferMidi && 'requestMIDIAccess' in navigator) { |
| 275 | try { |
| 276 | const access = await navigator.requestMIDIAccess({ sysex: false }); |
| 277 | if (access.outputs.size > 0) { |
| 278 | return new MidiEngine(access); |
| 279 | } |
| 280 | } catch { |
| 281 | // fall through to synth |
| 282 | } |
| 283 | } |
| 284 | const synth = new SynthEngine(); |
| 285 | await synth.resume(); |
| 286 | return synth; |
| 287 | } |