src/audio/engine.ts 7.4 K raw
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
}