@swiftbrowser/web 0.18.3 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/audioEngine.ts +1443 -0
- package/src/chartDom.ts +1 -1
- package/src/hostRequests.ts +10 -0
- package/src/languageModel.ts +309 -0
- package/src/layout/engine.ts +126 -48
- package/src/layoutDom.ts +43 -3
- package/src/protocol.ts +29 -4
- package/src/renderer.ts +167 -43
- package/src/styles.css +207 -35
- package/src/styles.ts +4 -9
- package/src/symbols.ts +6 -0
|
@@ -0,0 +1,1443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An audio engine shaped as AVFoundation's `AVAudioEngine`: the page side of
|
|
3
|
+
* `AVAudioEngine` in @swiftbrowser/react (packages/react/src/audioEngine.ts),
|
|
4
|
+
* built on the Web Audio API. The app's objects (engines, nodes, files,
|
|
5
|
+
* buffers) are numbers it chooses, and every call is one command to
|
|
6
|
+
* `perform`, in order: an engine with its main mixer, output and input;
|
|
7
|
+
* player, sampler, mixer and effect nodes attached and connected; files
|
|
8
|
+
* opened and decoded; buffers scheduled on a player's timeline; notes on a
|
|
9
|
+
* sampler. The native app answers the same commands with `AVAudioEngine`
|
|
10
|
+
* itself (packages/native, AudioEngineBridge.swift); docs/ops-protocol.md,
|
|
11
|
+
* "An audio engine", lists them.
|
|
12
|
+
*
|
|
13
|
+
* Where Web Audio has no equivalent the page does less: `AVAudioUnitTimePitch`
|
|
14
|
+
* passes its input through unchanged, the reverb is a convolver with a
|
|
15
|
+
* generated tail for each factory preset, the distortion presets are
|
|
16
|
+
* waveshaper curves, and `loadInstrument` (sound banks) is unsupported.
|
|
17
|
+
*
|
|
18
|
+
* An optional package adds node kinds of its own with `registerAudioNodeKind`
|
|
19
|
+
* (@swiftbrowser/dsp's `program`, a synthesizer in an AudioWorklet): the host
|
|
20
|
+
* makes one for `attach`, sets its properties, and hands it the notes a
|
|
21
|
+
* sampler would take.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { FilesHost } from './files';
|
|
25
|
+
|
|
26
|
+
/** The kinds of node an app attaches. */
|
|
27
|
+
export type AudioNodeKind = 'player' | 'sampler' | 'mixer' | 'reverb' | 'delay' | 'distortion' | 'eq' | 'timePitch';
|
|
28
|
+
|
|
29
|
+
/** A kind an optional package adds (`registerAudioNodeKind`), by its name. */
|
|
30
|
+
export type CustomAudioNodeKind = string & {};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* When something starts (`AVAudioTime`): a sample time on a player's own
|
|
34
|
+
* timeline (frames at `sampleRate` from the moment it started playing), or
|
|
35
|
+
* `delay` seconds from when the command arrives.
|
|
36
|
+
*/
|
|
37
|
+
export type AudioTimeSpec = { sampleTime: number; sampleRate: number } | { delay: number };
|
|
38
|
+
|
|
39
|
+
/** A sample zone: a file played for the keys `low` … `high`, at its pitch on `root`. */
|
|
40
|
+
export interface SamplerZone {
|
|
41
|
+
file: number;
|
|
42
|
+
root: number;
|
|
43
|
+
low: number;
|
|
44
|
+
high: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A property's value as it crosses: numbers, a bypass flag, an EQ band's filter type. */
|
|
48
|
+
export type AudioValue = number | boolean | string;
|
|
49
|
+
|
|
50
|
+
export type AudioEngineCommand =
|
|
51
|
+
| { action: 'create'; engine: number; mixer: number; output: number; input: number }
|
|
52
|
+
| { action: 'destroy'; engine: number }
|
|
53
|
+
| { action: 'attach'; engine: number; node: number; kind: AudioNodeKind | CustomAudioNodeKind; values: Record<string, AudioValue>; bands?: number }
|
|
54
|
+
| { action: 'detach'; engine: number; node: number }
|
|
55
|
+
| { action: 'connect'; engine: number; from: number; to: number[] }
|
|
56
|
+
| { action: 'disconnectOutput' | 'disconnectInput'; engine: number; node: number }
|
|
57
|
+
| { action: 'prepare' | 'start' | 'pause' | 'stop' | 'reset'; engine: number }
|
|
58
|
+
| { action: 'set'; node: number; values: Record<string, AudioValue> }
|
|
59
|
+
| { action: 'preset'; node: number; preset: string }
|
|
60
|
+
| { action: 'openFile'; file: number; url: string }
|
|
61
|
+
| { action: 'readFile'; file: number; buffer: number; start: number; frames: number }
|
|
62
|
+
| { action: 'writeBuffer'; buffer: number; sampleRate: number; channels: Float32Array[] }
|
|
63
|
+
| { action: 'release'; id: number }
|
|
64
|
+
| { action: 'scheduleFile'; node: number; file: number; start?: number; frames?: number; at?: AudioTimeSpec }
|
|
65
|
+
| { action: 'scheduleBuffer'; node: number; buffer: number; at?: AudioTimeSpec; loops?: boolean; interrupts?: boolean; interruptsAtLoop?: boolean }
|
|
66
|
+
| { action: 'play'; node: number; at?: AudioTimeSpec }
|
|
67
|
+
| { action: 'pausePlayer' | 'stopPlayer'; node: number }
|
|
68
|
+
| { action: 'playerTime'; node: number }
|
|
69
|
+
| { action: 'loadZones'; node: number; zones: SamplerZone[]; attack: number; release: number }
|
|
70
|
+
| { action: 'loadInstrument'; node: number; url: string; program?: number; bankMSB?: number; bankLSB?: number }
|
|
71
|
+
| { action: 'startNote'; node: number; note: number; velocity: number; channel: number }
|
|
72
|
+
| { action: 'stopNote'; node: number; note: number; channel: number }
|
|
73
|
+
| { action: 'controller'; node: number; controller: number; value: number; channel: number }
|
|
74
|
+
| { action: 'pitchBend'; node: number; value: number; channel: number }
|
|
75
|
+
| { action: 'meter'; node: number };
|
|
76
|
+
|
|
77
|
+
/** The note commands an instrument takes: a sampler, or a node of a kind a package adds. */
|
|
78
|
+
export type AudioNoteCommand = Extract<AudioEngineCommand, { action: 'startNote' | 'stopNote' | 'controller' | 'pitchBend' }>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A node of a kind an optional package adds: its Web Audio nodes and how it
|
|
82
|
+
* takes properties and notes. The host puts the app's volume and pan after
|
|
83
|
+
* `output`, as it does for its own sources.
|
|
84
|
+
*/
|
|
85
|
+
export interface CustomAudioNode {
|
|
86
|
+
/** Where upstream nodes connect; null for a source, which takes no input. */
|
|
87
|
+
readonly input: AudioNode | null;
|
|
88
|
+
readonly output: AudioNode;
|
|
89
|
+
/** A property; false for one it does not have. Throws for a value it cannot take. */
|
|
90
|
+
set(key: string, value: AudioValue): boolean;
|
|
91
|
+
/** A note, controller or bend; false (the default) for a node that takes none. */
|
|
92
|
+
note?(command: AudioNoteCommand): boolean;
|
|
93
|
+
/** The engine stopped or reset: stop sounding. */
|
|
94
|
+
halt?(): void;
|
|
95
|
+
dispose?(): void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Makes a node of a package's kind in the page's context. */
|
|
99
|
+
export type CustomAudioNodeFactory = (context: BaseAudioContext) => CustomAudioNode;
|
|
100
|
+
|
|
101
|
+
const customKinds = new Map<string, CustomAudioNodeFactory>();
|
|
102
|
+
|
|
103
|
+
/** Adds a node kind for `attach`, replacing one of the same name. The host's own kinds cannot be replaced. */
|
|
104
|
+
export function registerAudioNodeKind(kind: string, make: CustomAudioNodeFactory): void {
|
|
105
|
+
if (OWN_KINDS.has(kind)) throw new Error(`exists: ${kind} is one of the engine's own kinds`);
|
|
106
|
+
customKinds.set(kind, make);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const OWN_KINDS = new Set<string>(['player', 'sampler', 'mixer', 'reverb', 'delay', 'distortion', 'eq', 'timePitch', 'output', 'input']);
|
|
110
|
+
|
|
111
|
+
/** What `openFile` answers: the file's length in frames and its format. */
|
|
112
|
+
export interface AudioFileInfo {
|
|
113
|
+
length: number;
|
|
114
|
+
sampleRate: number;
|
|
115
|
+
channelCount: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What `readFile` answers: the frames read into the buffer and its format. */
|
|
119
|
+
export interface AudioBufferInfo {
|
|
120
|
+
frameLength: number;
|
|
121
|
+
sampleRate: number;
|
|
122
|
+
channelCount: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The engine's host: one command at a time, in the order sent; what a
|
|
127
|
+
* command answers (`openFile`'s info, `playerTime`, `meter`) resolves its
|
|
128
|
+
* promise, and a scheduled file or buffer resolves once it has played (or
|
|
129
|
+
* its player stopped), as `AVAudioPlayerNode`'s completion handler is
|
|
130
|
+
* called. A failure rejects with `kind: detail` (`unsupported`, `notFound`,
|
|
131
|
+
* `decode`, `permission`, `stopped`).
|
|
132
|
+
*/
|
|
133
|
+
export interface AudioEngineHost {
|
|
134
|
+
perform(command: AudioEngineCommand): Promise<unknown>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Where the host gets its context and bytes from; tests give an `OfflineAudioContext`. */
|
|
138
|
+
export interface AudioEngineSources {
|
|
139
|
+
/** The context the engines render in (one for the page, made when the first engine is). */
|
|
140
|
+
context: () => BaseAudioContext;
|
|
141
|
+
/** A file's bytes: `file:` URLs from the app's files, the rest fetched. */
|
|
142
|
+
read: (url: string) => Promise<ArrayBuffer>;
|
|
143
|
+
/** The microphone, for the input node; absent, connecting it fails `unsupported`. */
|
|
144
|
+
microphone?: () => Promise<MediaStream>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The page's engine host, or undefined where the browser has no Web Audio. */
|
|
148
|
+
export function browserAudioEngine(files: () => FilesHost | undefined): AudioEngineHost | undefined {
|
|
149
|
+
if (typeof AudioContext === 'undefined') return undefined;
|
|
150
|
+
let context: AudioContext | null = null;
|
|
151
|
+
return new WebAudioEngineHost({
|
|
152
|
+
context: () => (context ??= new AudioContext({ latencyHint: 'interactive' })),
|
|
153
|
+
read: async (url) => {
|
|
154
|
+
const host = files();
|
|
155
|
+
if (url.startsWith('file:') && host) {
|
|
156
|
+
const bytes = await host.read(url);
|
|
157
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
158
|
+
}
|
|
159
|
+
const response = await fetch(url);
|
|
160
|
+
if (!response.ok) throw new Error(`notFound: ${url} answered ${response.status}`);
|
|
161
|
+
return response.arrayBuffer();
|
|
162
|
+
},
|
|
163
|
+
...(typeof navigator !== 'undefined' && navigator.mediaDevices?.getUserMedia
|
|
164
|
+
? { microphone: () => navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false } }) }
|
|
165
|
+
: {}),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Decibels as a gain. */
|
|
170
|
+
export const gainOf = (decibels: number): number => Math.pow(10, decibels / 20);
|
|
171
|
+
|
|
172
|
+
/** An RMS level on the meters' scale: 0 at -50 dB or below, 1 at 0 dB (as the recorder meters). */
|
|
173
|
+
export function meterLevel(rms: number): number {
|
|
174
|
+
if (rms <= 0) return 0;
|
|
175
|
+
const decibels = 20 * Math.log10(rms);
|
|
176
|
+
return Math.max(0, Math.min(1, (decibels + 50) / 50));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A MIDI note's playback rate against a sample recorded at `root`, with `cents` more. */
|
|
180
|
+
export const rateFor = (note: number, root: number, cents = 0): number => Math.pow(2, (note - root) / 12 + cents / 1200);
|
|
181
|
+
|
|
182
|
+
/** The reverb tail of each `AVAudioUnitReverbPreset`: seconds to fall 60 dB, and how bright it is (a lowpass on the tail, Hz). */
|
|
183
|
+
export const REVERB_PRESETS: Record<string, { decay: number; brightness: number }> = {
|
|
184
|
+
smallRoom: { decay: 0.4, brightness: 9000 },
|
|
185
|
+
mediumRoom: { decay: 0.7, brightness: 8000 },
|
|
186
|
+
largeRoom: { decay: 1.1, brightness: 7000 },
|
|
187
|
+
largeRoom2: { decay: 1.4, brightness: 7000 },
|
|
188
|
+
mediumHall: { decay: 1.8, brightness: 6000 },
|
|
189
|
+
mediumHall2: { decay: 2.0, brightness: 6500 },
|
|
190
|
+
mediumHall3: { decay: 2.2, brightness: 5500 },
|
|
191
|
+
largeHall: { decay: 2.6, brightness: 5000 },
|
|
192
|
+
largeHall2: { decay: 3.0, brightness: 4500 },
|
|
193
|
+
plate: { decay: 1.5, brightness: 11000 },
|
|
194
|
+
mediumChamber: { decay: 1.2, brightness: 7500 },
|
|
195
|
+
largeChamber: { decay: 1.8, brightness: 7000 },
|
|
196
|
+
cathedral: { decay: 4.5, brightness: 4000 },
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** The `AVAudioUnitDistortionPreset`s as waveshaper curves: how hard they drive, and how much of the result is heard. */
|
|
200
|
+
export const DISTORTION_PRESETS: Record<string, { drive: number; mix: number; bits?: number }> = {
|
|
201
|
+
drumsBitBrush: { drive: 2, mix: 50, bits: 6 },
|
|
202
|
+
drumsBufferBeats: { drive: 3, mix: 50, bits: 8 },
|
|
203
|
+
drumsLoFi: { drive: 4, mix: 60, bits: 5 },
|
|
204
|
+
multiBrokenSpeaker: { drive: 20, mix: 70 },
|
|
205
|
+
multiCellphoneConcert: { drive: 8, mix: 80, bits: 7 },
|
|
206
|
+
multiDecimated1: { drive: 2, mix: 50, bits: 6 },
|
|
207
|
+
multiDecimated2: { drive: 3, mix: 60, bits: 5 },
|
|
208
|
+
multiDecimated3: { drive: 4, mix: 70, bits: 4 },
|
|
209
|
+
multiDecimated4: { drive: 5, mix: 80, bits: 3 },
|
|
210
|
+
multiDistortedFunk: { drive: 10, mix: 60 },
|
|
211
|
+
multiDistortedCubed: { drive: 15, mix: 70 },
|
|
212
|
+
multiDistortedSquared: { drive: 25, mix: 70 },
|
|
213
|
+
multiEcho1: { drive: 2, mix: 40 },
|
|
214
|
+
multiEcho2: { drive: 2, mix: 50 },
|
|
215
|
+
multiEchoTight1: { drive: 2, mix: 40 },
|
|
216
|
+
multiEchoTight2: { drive: 2, mix: 50 },
|
|
217
|
+
multiEverythingIsBroken: { drive: 40, mix: 90, bits: 4 },
|
|
218
|
+
speechAlienChatter: { drive: 6, mix: 70, bits: 6 },
|
|
219
|
+
speechCosmicInterference: { drive: 8, mix: 70, bits: 5 },
|
|
220
|
+
speechGoldenPi: { drive: 5, mix: 60 },
|
|
221
|
+
speechRadioTower: { drive: 12, mix: 80, bits: 8 },
|
|
222
|
+
speechWaves: { drive: 4, mix: 60 },
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/** `AVAudioUnitEQFilterType` as a biquad's type, and whether its bandwidth is a resonance. */
|
|
226
|
+
const EQ_TYPES: Record<string, BiquadFilterType> = {
|
|
227
|
+
parametric: 'peaking',
|
|
228
|
+
lowPass: 'lowpass',
|
|
229
|
+
highPass: 'highpass',
|
|
230
|
+
resonantLowPass: 'lowpass',
|
|
231
|
+
resonantHighPass: 'highpass',
|
|
232
|
+
bandPass: 'bandpass',
|
|
233
|
+
bandStop: 'notch',
|
|
234
|
+
lowShelf: 'lowshelf',
|
|
235
|
+
highShelf: 'highshelf',
|
|
236
|
+
resonantLowShelf: 'lowshelf',
|
|
237
|
+
resonantHighShelf: 'highshelf',
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/** A bandwidth in octaves as a biquad's Q. */
|
|
241
|
+
export function qForBandwidth(octaves: number): number {
|
|
242
|
+
const n = Math.pow(2, Math.max(0.05, Math.min(5, octaves)));
|
|
243
|
+
return Math.sqrt(n) / (n - 1);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A seeded noise source, so a reverb's tail is the same each time (and in tests). */
|
|
247
|
+
function noise(seed: number): () => number {
|
|
248
|
+
let state = seed >>> 0 || 1;
|
|
249
|
+
return () => {
|
|
250
|
+
state ^= state << 13;
|
|
251
|
+
state ^= state >>> 17;
|
|
252
|
+
state ^= state << 5;
|
|
253
|
+
return ((state >>> 0) / 0xffffffff) * 2 - 1;
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A stereo impulse response: decorrelated noise falling 60 dB over `decay` seconds, darker as it falls. */
|
|
258
|
+
export function reverbImpulse(context: BaseAudioContext, preset: { decay: number; brightness: number }): AudioBuffer {
|
|
259
|
+
const rate = context.sampleRate;
|
|
260
|
+
const length = Math.max(1, Math.round(rate * preset.decay));
|
|
261
|
+
const buffer = context.createBuffer(2, length, rate);
|
|
262
|
+
for (let channel = 0; channel < 2; channel += 1) {
|
|
263
|
+
const data = buffer.getChannelData(channel);
|
|
264
|
+
const random = noise(0x9e3779b9 + channel * 7919);
|
|
265
|
+
// A one-pole lowpass closing over the tail: highs die first, as in a room.
|
|
266
|
+
let low = 0;
|
|
267
|
+
for (let i = 0; i < length; i += 1) {
|
|
268
|
+
const t = i / length;
|
|
269
|
+
const cutoff = preset.brightness * (1 - 0.8 * t);
|
|
270
|
+
const a = Math.exp((-2 * Math.PI * cutoff) / rate);
|
|
271
|
+
low = (1 - a) * random() + a * low;
|
|
272
|
+
data[i] = low * Math.pow(10, -3 * t) * 2;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return buffer;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** A waveshaper curve driving `drive` times into a soft clip, quantized to `bits` where a preset crushes. */
|
|
279
|
+
export function distortionCurve(drive: number, bits?: number): Float32Array<ArrayBuffer> {
|
|
280
|
+
const points = 2048;
|
|
281
|
+
const curve = new Float32Array(points);
|
|
282
|
+
const k = Math.max(1, drive);
|
|
283
|
+
const steps = bits ? Math.pow(2, bits - 1) : 0;
|
|
284
|
+
for (let i = 0; i < points; i += 1) {
|
|
285
|
+
const x = (i / (points - 1)) * 2 - 1;
|
|
286
|
+
let y = Math.tanh(k * x) / Math.tanh(k);
|
|
287
|
+
if (steps) y = Math.round(y * steps) / steps;
|
|
288
|
+
curve[i] = y;
|
|
289
|
+
}
|
|
290
|
+
return curve;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Fails a command with `kind: detail`. */
|
|
294
|
+
function failure(kind: string, detail: string): Error {
|
|
295
|
+
return new Error(`${kind}: ${detail}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const num = (value: AudioValue | undefined, fallback: number): number => (typeof value === 'number' && Number.isFinite(value) ? value : fallback);
|
|
299
|
+
|
|
300
|
+
/** A node in the page's graph: where connections arrive, where it sends from, its properties. */
|
|
301
|
+
abstract class WebNode {
|
|
302
|
+
/** Where upstream nodes connect (null for sources: players, samplers, the input). */
|
|
303
|
+
abstract readonly input: AudioNode | null;
|
|
304
|
+
/** What it sends downstream. */
|
|
305
|
+
abstract readonly output: AudioNode;
|
|
306
|
+
/** Upstream nodes connected into it, for `disconnectInput` and one input per effect. */
|
|
307
|
+
readonly sources = new Set<WebNode>();
|
|
308
|
+
/** Where it sends to (several for a fan-out). */
|
|
309
|
+
readonly destinations = new Set<WebNode>();
|
|
310
|
+
private analyser: AnalyserNode | null = null;
|
|
311
|
+
private samples: Float32Array<ArrayBuffer> | null = null;
|
|
312
|
+
|
|
313
|
+
constructor(
|
|
314
|
+
readonly id: number,
|
|
315
|
+
readonly kind: AudioNodeKind | CustomAudioNodeKind | 'output' | 'input',
|
|
316
|
+
protected readonly context: BaseAudioContext,
|
|
317
|
+
) {}
|
|
318
|
+
|
|
319
|
+
/** Whether it takes more than one input (a mixer sums its buses; an effect has one). */
|
|
320
|
+
get mixes(): boolean {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
abstract set(key: string, value: AudioValue): void;
|
|
325
|
+
|
|
326
|
+
preset(name: string): void {
|
|
327
|
+
throw failure('unsupported', `a ${this.kind} node has no preset ${name}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The output's level now (0 … 1), from an analyser made the first time it is asked. */
|
|
331
|
+
meter(): number {
|
|
332
|
+
if (!this.analyser) {
|
|
333
|
+
this.analyser = this.context.createAnalyser();
|
|
334
|
+
this.analyser.fftSize = 1024;
|
|
335
|
+
this.samples = new Float32Array(this.analyser.fftSize);
|
|
336
|
+
this.output.connect(this.analyser);
|
|
337
|
+
}
|
|
338
|
+
this.analyser.getFloatTimeDomainData(this.samples!);
|
|
339
|
+
let sum = 0;
|
|
340
|
+
for (const sample of this.samples!) sum += sample * sample;
|
|
341
|
+
return meterLevel(Math.sqrt(sum / this.samples!.length));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Stops what it is playing (the engine stopped or reset). */
|
|
345
|
+
halt(): void {}
|
|
346
|
+
|
|
347
|
+
dispose(): void {
|
|
348
|
+
this.halt();
|
|
349
|
+
try {
|
|
350
|
+
this.output.disconnect();
|
|
351
|
+
} catch {
|
|
352
|
+
// Not connected.
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** `AVAudioMixing`: a source's volume and pan into whatever it connects to. */
|
|
358
|
+
class Mixing {
|
|
359
|
+
readonly volume: GainNode;
|
|
360
|
+
readonly panner: StereoPannerNode;
|
|
361
|
+
|
|
362
|
+
constructor(context: BaseAudioContext) {
|
|
363
|
+
this.volume = context.createGain();
|
|
364
|
+
this.panner = context.createStereoPanner();
|
|
365
|
+
this.volume.connect(this.panner);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
set(key: string, value: AudioValue): boolean {
|
|
369
|
+
if (key === 'volume') this.volume.gain.value = Math.max(0, num(value, 1));
|
|
370
|
+
else if (key === 'pan') this.panner.pan.value = Math.max(-1, Math.min(1, num(value, 0)));
|
|
371
|
+
else return false;
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
class MixerNode extends WebNode {
|
|
377
|
+
readonly input: GainNode;
|
|
378
|
+
readonly output: AudioNode;
|
|
379
|
+
private readonly outputVolume: GainNode;
|
|
380
|
+
private readonly mixing: Mixing;
|
|
381
|
+
|
|
382
|
+
constructor(id: number, context: BaseAudioContext) {
|
|
383
|
+
super(id, 'mixer', context);
|
|
384
|
+
this.input = context.createGain();
|
|
385
|
+
this.outputVolume = context.createGain();
|
|
386
|
+
this.mixing = new Mixing(context);
|
|
387
|
+
this.input.connect(this.outputVolume).connect(this.mixing.volume);
|
|
388
|
+
this.output = this.mixing.panner;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
override get mixes(): boolean {
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
set(key: string, value: AudioValue): void {
|
|
396
|
+
if (key === 'outputVolume') this.outputVolume.gain.value = Math.max(0, num(value, 1));
|
|
397
|
+
else this.mixing.set(key, value);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The engine's output: what reaches the speakers while the engine runs. */
|
|
402
|
+
class OutputNode extends WebNode {
|
|
403
|
+
readonly input: GainNode;
|
|
404
|
+
readonly output: GainNode;
|
|
405
|
+
|
|
406
|
+
constructor(id: number, context: BaseAudioContext) {
|
|
407
|
+
super(id, 'output', context);
|
|
408
|
+
this.input = context.createGain();
|
|
409
|
+
this.output = this.input;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
set(): void {}
|
|
413
|
+
|
|
414
|
+
/** Sound on (`start`) or off (`pause`, `stop`). */
|
|
415
|
+
live(on: boolean): void {
|
|
416
|
+
try {
|
|
417
|
+
this.output.disconnect(this.context.destination);
|
|
418
|
+
} catch {
|
|
419
|
+
// Not connected.
|
|
420
|
+
}
|
|
421
|
+
if (on) this.output.connect(this.context.destination);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
override dispose(): void {
|
|
425
|
+
this.live(false);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** The microphone, opened when the engine starts with something connected to it. */
|
|
430
|
+
class InputNode extends WebNode {
|
|
431
|
+
readonly input = null;
|
|
432
|
+
readonly output: AudioNode;
|
|
433
|
+
private readonly stage: GainNode;
|
|
434
|
+
private readonly mixing: Mixing;
|
|
435
|
+
private stream: MediaStream | null = null;
|
|
436
|
+
private source: MediaStreamAudioSourceNode | null = null;
|
|
437
|
+
|
|
438
|
+
constructor(id: number, context: BaseAudioContext) {
|
|
439
|
+
super(id, 'input', context);
|
|
440
|
+
this.stage = context.createGain();
|
|
441
|
+
this.mixing = new Mixing(context);
|
|
442
|
+
this.stage.connect(this.mixing.volume);
|
|
443
|
+
this.output = this.mixing.panner;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
set(key: string, value: AudioValue): void {
|
|
447
|
+
this.mixing.set(key, value);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async open(microphone: (() => Promise<MediaStream>) | undefined): Promise<void> {
|
|
451
|
+
if (this.source) return;
|
|
452
|
+
if (!microphone || !('createMediaStreamSource' in this.context)) throw failure('unsupported', 'this page has no microphone');
|
|
453
|
+
try {
|
|
454
|
+
this.stream = await microphone();
|
|
455
|
+
} catch (error) {
|
|
456
|
+
throw failure('permission', `the microphone was not allowed (${String(error)})`);
|
|
457
|
+
}
|
|
458
|
+
this.source = (this.context as AudioContext).createMediaStreamSource(this.stream);
|
|
459
|
+
this.source.connect(this.stage);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
override halt(): void {
|
|
463
|
+
this.source?.disconnect();
|
|
464
|
+
this.source = null;
|
|
465
|
+
for (const track of this.stream?.getTracks() ?? []) track.stop();
|
|
466
|
+
this.stream = null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** One scheduled file segment or buffer on a player's timeline. */
|
|
471
|
+
interface Scheduled {
|
|
472
|
+
audio: AudioBuffer;
|
|
473
|
+
/** Seconds into `audio` it starts at, and how long it plays (once; a loop repeats it). */
|
|
474
|
+
offset: number;
|
|
475
|
+
duration: number;
|
|
476
|
+
loops: boolean;
|
|
477
|
+
/** Where on the player's timeline it starts (seconds), once known. */
|
|
478
|
+
start: number | null;
|
|
479
|
+
at: AudioTimeSpec | undefined;
|
|
480
|
+
source: AudioBufferSourceNode | null;
|
|
481
|
+
done: () => void;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* `AVAudioPlayerNode`: a timeline of scheduled buffers and file segments.
|
|
486
|
+
* Each plays after the one before, or at its own time; `play` starts the
|
|
487
|
+
* timeline (player time 0 is when it first plays), `pause` holds it where
|
|
488
|
+
* it is, `stop` drops what is scheduled (each one's completion called).
|
|
489
|
+
*/
|
|
490
|
+
class PlayerNode extends WebNode {
|
|
491
|
+
readonly input = null;
|
|
492
|
+
readonly output: AudioNode;
|
|
493
|
+
private readonly sum: GainNode;
|
|
494
|
+
private readonly mixing: Mixing;
|
|
495
|
+
private queue: Scheduled[] = [];
|
|
496
|
+
/** The context time of player time 0 while playing. */
|
|
497
|
+
private origin: number | null = null;
|
|
498
|
+
/** The player time it was paused at. */
|
|
499
|
+
private heldAt = 0;
|
|
500
|
+
/** Whether it has played since it was last stopped (`playerTime` answers null before). */
|
|
501
|
+
private started = false;
|
|
502
|
+
/** Where the next item without a time of its own starts. */
|
|
503
|
+
private end = 0;
|
|
504
|
+
/** Whether the engine runs (a player plays only then). */
|
|
505
|
+
running = false;
|
|
506
|
+
|
|
507
|
+
constructor(id: number, context: BaseAudioContext) {
|
|
508
|
+
super(id, 'player', context);
|
|
509
|
+
this.sum = context.createGain();
|
|
510
|
+
this.mixing = new Mixing(context);
|
|
511
|
+
this.sum.connect(this.mixing.volume);
|
|
512
|
+
this.output = this.mixing.panner;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
set(key: string, value: AudioValue): void {
|
|
516
|
+
this.mixing.set(key, value);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
get isPlaying(): boolean {
|
|
520
|
+
return this.origin !== null;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** The player's time now (seconds), or null before it first plays. */
|
|
524
|
+
time(): number | null {
|
|
525
|
+
if (this.origin !== null) return this.context.currentTime - this.origin;
|
|
526
|
+
return this.started ? this.heldAt : null;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Puts an item on the timeline. `interrupts` drops what is scheduled and
|
|
531
|
+
* plays it now; `atLoop` lets a loop that is sounding finish its pass and
|
|
532
|
+
* plays it from there (as `interrupts` when no loop sounds), which is how
|
|
533
|
+
* a sequencer swaps its pattern on the bar line; another `atLoop` while
|
|
534
|
+
* that one waits for the bar line takes its place there, as
|
|
535
|
+
* AVAudioPlayerNode drops what is scheduled and keeps what sounds.
|
|
536
|
+
*/
|
|
537
|
+
schedule(item: Omit<Scheduled, 'start' | 'source'>, mode: 'after' | 'interrupts' | 'atLoop' = 'after'): void {
|
|
538
|
+
const entry: Scheduled = { ...item, start: null, source: null };
|
|
539
|
+
const looping = mode === 'atLoop' && this.origin !== null ? this.queue.find((queued) => queued.loops && queued.source && queued.start !== null) : undefined;
|
|
540
|
+
if (looping) {
|
|
541
|
+
const now = this.time()!;
|
|
542
|
+
let boundary: number;
|
|
543
|
+
if (looping.start! > now) {
|
|
544
|
+
// The loop is one waiting for the bar line (an earlier swap): the newest takes its place there, and what sounds plays on to it.
|
|
545
|
+
boundary = looping.start!;
|
|
546
|
+
for (const queued of this.queue.filter((other) => other.start !== null && other.start > now)) this.drop(queued);
|
|
547
|
+
} else {
|
|
548
|
+
const passes = Math.max(1, Math.ceil((now - looping.start!) / looping.duration));
|
|
549
|
+
boundary = looping.start! + passes * looping.duration;
|
|
550
|
+
// The loop plays to the end of this pass and ends there; the rest scheduled goes.
|
|
551
|
+
looping.loops = false;
|
|
552
|
+
looping.source!.loop = false;
|
|
553
|
+
for (const queued of this.queue.filter((other) => other !== looping)) this.drop(queued);
|
|
554
|
+
}
|
|
555
|
+
entry.start = boundary;
|
|
556
|
+
this.end = entry.loops ? boundary : boundary + entry.duration;
|
|
557
|
+
this.queue.push(entry);
|
|
558
|
+
this.sound(entry);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (mode !== 'after') {
|
|
562
|
+
// An item that interrupts at a time to come lets what sounds play on until then, as AVAudioPlayerNode does.
|
|
563
|
+
const at = entry.at;
|
|
564
|
+
const now = this.time();
|
|
565
|
+
const until = at && now !== null && 'sampleTime' in at ? at.sampleTime / at.sampleRate : at && now !== null && 'delay' in at ? now + at.delay : null;
|
|
566
|
+
this.clear(until !== null && now !== null && until > now ? until : null);
|
|
567
|
+
}
|
|
568
|
+
this.queue.push(entry);
|
|
569
|
+
this.place(entry);
|
|
570
|
+
if (this.origin !== null) this.sound(entry);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Takes one item off the timeline, its completion called. */
|
|
574
|
+
private drop(entry: Scheduled): void {
|
|
575
|
+
const source = entry.source;
|
|
576
|
+
entry.source = null;
|
|
577
|
+
source?.stop();
|
|
578
|
+
this.queue = this.queue.filter((item) => item !== entry);
|
|
579
|
+
entry.done();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Puts an item on the timeline: at its sample time, `delay` from now, or after the last one. */
|
|
583
|
+
private place(entry: Scheduled): void {
|
|
584
|
+
const at = entry.at;
|
|
585
|
+
// `delay` from now means from when it plays: placed once it does.
|
|
586
|
+
if (at && 'delay' in at && this.origin === null) return;
|
|
587
|
+
let start: number;
|
|
588
|
+
if (at && 'sampleTime' in at) start = at.sampleTime / at.sampleRate;
|
|
589
|
+
else if (at && 'delay' in at) start = this.context.currentTime + at.delay - this.origin!;
|
|
590
|
+
else start = Math.max(this.end, this.time() ?? 0);
|
|
591
|
+
entry.start = start;
|
|
592
|
+
if (!entry.loops) this.end = Math.max(this.end, start + entry.duration);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
play(at: AudioTimeSpec | undefined): void {
|
|
596
|
+
if (this.origin !== null) return;
|
|
597
|
+
const delay = at && 'delay' in at ? Math.max(0, at.delay) : 0;
|
|
598
|
+
const held = this.started ? this.heldAt : 0;
|
|
599
|
+
// A sample time on `play` is when, on the timeline already there, playing resumes.
|
|
600
|
+
const from = at && 'sampleTime' in at ? at.sampleTime / at.sampleRate : held;
|
|
601
|
+
this.origin = this.context.currentTime + delay - from;
|
|
602
|
+
this.started = true;
|
|
603
|
+
for (const entry of this.queue) {
|
|
604
|
+
if (entry.at && 'delay' in entry.at && entry.start === null) this.place(entry);
|
|
605
|
+
this.sound(entry);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Starts an item's source where the timeline says, part-way through when the timeline is past its start. */
|
|
610
|
+
private sound(entry: Scheduled): void {
|
|
611
|
+
if (this.origin === null || entry.source || entry.start === null || !this.running) return;
|
|
612
|
+
const now = this.context.currentTime;
|
|
613
|
+
const startsAt = this.origin + entry.start;
|
|
614
|
+
let offset = entry.offset;
|
|
615
|
+
let when = startsAt;
|
|
616
|
+
if (startsAt < now) {
|
|
617
|
+
const late = now - startsAt;
|
|
618
|
+
if (!entry.loops && late >= entry.duration) {
|
|
619
|
+
this.finish(entry);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
offset = entry.loops ? entry.offset + (late % entry.duration) : entry.offset + late;
|
|
623
|
+
when = now;
|
|
624
|
+
}
|
|
625
|
+
const source = this.context.createBufferSource();
|
|
626
|
+
source.buffer = entry.audio;
|
|
627
|
+
if (entry.loops) {
|
|
628
|
+
source.loop = true;
|
|
629
|
+
source.loopStart = entry.offset;
|
|
630
|
+
source.loopEnd = entry.offset + entry.duration;
|
|
631
|
+
source.start(when, offset);
|
|
632
|
+
} else {
|
|
633
|
+
source.start(when, offset, entry.offset + entry.duration - offset);
|
|
634
|
+
}
|
|
635
|
+
source.connect(this.sum);
|
|
636
|
+
source.onended = () => {
|
|
637
|
+
// Ended by a pause: the item stays on the timeline for `play` to pick up.
|
|
638
|
+
if (entry.source === source) this.finish(entry);
|
|
639
|
+
};
|
|
640
|
+
entry.source = source;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private finish(entry: Scheduled): void {
|
|
644
|
+
entry.source = null;
|
|
645
|
+
this.queue = this.queue.filter((item) => item !== entry);
|
|
646
|
+
entry.done();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
pause(): void {
|
|
650
|
+
if (this.origin === null) return;
|
|
651
|
+
this.heldAt = this.context.currentTime - this.origin;
|
|
652
|
+
this.origin = null;
|
|
653
|
+
for (const entry of this.queue) {
|
|
654
|
+
const source = entry.source;
|
|
655
|
+
entry.source = null;
|
|
656
|
+
source?.stop();
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Drops everything scheduled (each completion called) and returns the timeline to 0. */
|
|
661
|
+
stop(): void {
|
|
662
|
+
this.origin = null;
|
|
663
|
+
this.started = false;
|
|
664
|
+
this.heldAt = 0;
|
|
665
|
+
this.clear();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Drops what is scheduled, each stopping now or, given `until`, at that time on the timeline. */
|
|
669
|
+
private clear(until: number | null = null): void {
|
|
670
|
+
const queue = this.queue;
|
|
671
|
+
this.queue = [];
|
|
672
|
+
this.end = this.origin !== null ? Math.max(0, this.context.currentTime - this.origin) : this.heldAt;
|
|
673
|
+
for (const entry of queue) {
|
|
674
|
+
const source = entry.source;
|
|
675
|
+
entry.source = null;
|
|
676
|
+
if (source && until !== null && this.origin !== null) {
|
|
677
|
+
source.onended = () => entry.done();
|
|
678
|
+
source.stop(this.origin + until);
|
|
679
|
+
} else {
|
|
680
|
+
source?.stop();
|
|
681
|
+
entry.done();
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** The engine started (`on`) or paused: the timeline sounds only while it runs. */
|
|
687
|
+
engineRunning(on: boolean): void {
|
|
688
|
+
this.running = on;
|
|
689
|
+
if (on) for (const entry of this.queue) this.sound(entry);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
override halt(): void {
|
|
693
|
+
this.stop();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** A voice of the sampler: its source and envelope. */
|
|
698
|
+
interface Voice {
|
|
699
|
+
note: number;
|
|
700
|
+
channel: number;
|
|
701
|
+
root: number;
|
|
702
|
+
source: AudioBufferSourceNode;
|
|
703
|
+
envelope: GainNode;
|
|
704
|
+
/** Held by the sustain pedal after its key was let go. */
|
|
705
|
+
sustained: boolean;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* `AVAudioUnitSampler` with per-note sample zones: a note plays the zone
|
|
710
|
+
* that covers it, pitched from the zone's root, with an attack and a
|
|
711
|
+
* release; velocity, the sustain pedal (64), volume (7), expression (11),
|
|
712
|
+
* pan (10), all notes off (123, 120) and a ±2 semitone pitch bend, as the
|
|
713
|
+
* sampler's default instrument responds.
|
|
714
|
+
*/
|
|
715
|
+
class SamplerNode extends WebNode {
|
|
716
|
+
readonly input = null;
|
|
717
|
+
readonly output: AudioNode;
|
|
718
|
+
private readonly gain: GainNode;
|
|
719
|
+
private readonly channelVolume: GainNode;
|
|
720
|
+
private readonly stereo: StereoPannerNode;
|
|
721
|
+
private readonly mixing: Mixing;
|
|
722
|
+
private zones: { zone: SamplerZone; audio: AudioBuffer }[] = [];
|
|
723
|
+
private attack = 0.002;
|
|
724
|
+
private release = 0.15;
|
|
725
|
+
private voices: Voice[] = [];
|
|
726
|
+
private tuning = 0;
|
|
727
|
+
private bend = 0;
|
|
728
|
+
private pedal = false;
|
|
729
|
+
private volumeCC = 1;
|
|
730
|
+
private expression = 1;
|
|
731
|
+
|
|
732
|
+
constructor(id: number, context: BaseAudioContext) {
|
|
733
|
+
super(id, 'sampler', context);
|
|
734
|
+
this.gain = context.createGain();
|
|
735
|
+
this.channelVolume = context.createGain();
|
|
736
|
+
this.stereo = context.createStereoPanner();
|
|
737
|
+
this.mixing = new Mixing(context);
|
|
738
|
+
this.gain.connect(this.channelVolume).connect(this.stereo).connect(this.mixing.volume);
|
|
739
|
+
this.output = this.mixing.panner;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
set(key: string, value: AudioValue): void {
|
|
743
|
+
if (this.mixing.set(key, value)) return;
|
|
744
|
+
if (key === 'overallGain') this.gain.gain.value = gainOf(Math.max(-90, Math.min(12, num(value, 0))));
|
|
745
|
+
else if (key === 'globalTuning') {
|
|
746
|
+
this.tuning = Math.max(-2400, Math.min(2400, num(value, 0)));
|
|
747
|
+
this.retune();
|
|
748
|
+
} else if (key === 'stereoPan') this.stereo.pan.value = Math.max(-1, Math.min(1, num(value, 0) / 100));
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
load(zones: { zone: SamplerZone; audio: AudioBuffer }[], attack: number, release: number): void {
|
|
752
|
+
this.allOff(true);
|
|
753
|
+
this.zones = zones;
|
|
754
|
+
this.attack = Math.max(0.001, attack);
|
|
755
|
+
this.release = Math.max(0.005, release);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
start(note: number, velocity: number, channel: number): void {
|
|
759
|
+
const found = this.zones.find(({ zone }) => note >= zone.low && note <= zone.high);
|
|
760
|
+
if (!found || velocity <= 0) {
|
|
761
|
+
if (velocity <= 0) this.stop(note, channel);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
// The same key again: the sounding one lets go first.
|
|
765
|
+
for (const voice of this.voices.filter((v) => v.note === note && v.channel === channel)) this.letGo(voice);
|
|
766
|
+
const now = this.context.currentTime;
|
|
767
|
+
const source = this.context.createBufferSource();
|
|
768
|
+
source.buffer = found.audio;
|
|
769
|
+
source.playbackRate.value = rateFor(note, found.zone.root, this.tuning + this.bend);
|
|
770
|
+
const envelope = this.context.createGain();
|
|
771
|
+
const level = Math.pow(Math.max(0, Math.min(127, velocity)) / 127, 2);
|
|
772
|
+
envelope.gain.setValueAtTime(0, now);
|
|
773
|
+
envelope.gain.linearRampToValueAtTime(level, now + this.attack);
|
|
774
|
+
source.connect(envelope).connect(this.gain);
|
|
775
|
+
const voice: Voice = { note, channel, root: found.zone.root, source, envelope, sustained: false };
|
|
776
|
+
source.onended = () => {
|
|
777
|
+
envelope.disconnect();
|
|
778
|
+
this.voices = this.voices.filter((v) => v !== voice);
|
|
779
|
+
};
|
|
780
|
+
source.start(now);
|
|
781
|
+
this.voices.push(voice);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
stop(note: number, channel: number): void {
|
|
785
|
+
for (const voice of this.voices.filter((v) => v.note === note && v.channel === channel && !v.sustained)) {
|
|
786
|
+
if (this.pedal) voice.sustained = true;
|
|
787
|
+
else this.letGo(voice);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
private letGo(voice: Voice, release = this.release): void {
|
|
792
|
+
const now = this.context.currentTime;
|
|
793
|
+
const gain = voice.envelope.gain;
|
|
794
|
+
gain.cancelScheduledValues(now);
|
|
795
|
+
gain.setValueAtTime(gain.value, now);
|
|
796
|
+
gain.linearRampToValueAtTime(0, now + release);
|
|
797
|
+
voice.source.stop(now + release + 0.01);
|
|
798
|
+
voice.note = -1;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
controller(controller: number, value: number): void {
|
|
802
|
+
const v = Math.max(0, Math.min(127, value));
|
|
803
|
+
if (controller === 64) {
|
|
804
|
+
this.pedal = v >= 64;
|
|
805
|
+
if (!this.pedal) for (const voice of this.voices.filter((x) => x.sustained)) this.letGo(voice);
|
|
806
|
+
} else if (controller === 7) {
|
|
807
|
+
this.volumeCC = Math.pow(v / 127, 2);
|
|
808
|
+
this.channelVolume.gain.value = this.volumeCC * this.expression;
|
|
809
|
+
} else if (controller === 11) {
|
|
810
|
+
this.expression = Math.pow(v / 127, 2);
|
|
811
|
+
this.channelVolume.gain.value = this.volumeCC * this.expression;
|
|
812
|
+
} else if (controller === 10) {
|
|
813
|
+
this.stereo.pan.value = (v - 64) / 64;
|
|
814
|
+
} else if (controller === 120) {
|
|
815
|
+
this.allOff(true);
|
|
816
|
+
} else if (controller === 123) {
|
|
817
|
+
this.allOff(false);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
pitchBend(value: number): void {
|
|
822
|
+
this.bend = ((Math.max(0, Math.min(16383, value)) - 8192) / 8192) * 200;
|
|
823
|
+
this.retune();
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private retune(): void {
|
|
827
|
+
for (const voice of this.voices) {
|
|
828
|
+
if (voice.note >= 0) voice.source.playbackRate.value = rateFor(voice.note, voice.root, this.tuning + this.bend);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** Every voice let go (`immediately`: cut off). */
|
|
833
|
+
private allOff(immediately: boolean): void {
|
|
834
|
+
for (const voice of [...this.voices]) this.letGo(voice, immediately ? 0.005 : this.release);
|
|
835
|
+
this.pedal = false;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
override halt(): void {
|
|
839
|
+
this.allOff(true);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** An effect: its input split into a dry path and a wet one through the effect, mixed by `wetDryMix` (percent). */
|
|
844
|
+
/** A node of a package's kind, with the app's volume and pan after it. */
|
|
845
|
+
class PackageNode extends WebNode {
|
|
846
|
+
readonly input: AudioNode | null;
|
|
847
|
+
readonly output: AudioNode;
|
|
848
|
+
private readonly mixing: Mixing;
|
|
849
|
+
|
|
850
|
+
constructor(id: number, kind: string, context: BaseAudioContext, readonly custom: CustomAudioNode) {
|
|
851
|
+
super(id, kind, context);
|
|
852
|
+
this.mixing = new Mixing(context);
|
|
853
|
+
custom.output.connect(this.mixing.volume);
|
|
854
|
+
this.input = custom.input;
|
|
855
|
+
this.output = this.mixing.panner;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
set(key: string, value: AudioValue): void {
|
|
859
|
+
if (this.mixing.set(key, value)) return;
|
|
860
|
+
if (!this.custom.set(key, value)) throw failure('unsupported', `a ${this.kind} node has no ${key}`);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
override halt(): void {
|
|
864
|
+
this.custom.halt?.();
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
override dispose(): void {
|
|
868
|
+
super.dispose();
|
|
869
|
+
this.custom.dispose?.();
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
abstract class EffectNode extends WebNode {
|
|
874
|
+
readonly input: GainNode;
|
|
875
|
+
readonly output: GainNode;
|
|
876
|
+
protected readonly dry: GainNode;
|
|
877
|
+
protected readonly wet: GainNode;
|
|
878
|
+
protected mix = 100;
|
|
879
|
+
private bypassed = false;
|
|
880
|
+
|
|
881
|
+
constructor(id: number, kind: AudioNodeKind, context: BaseAudioContext) {
|
|
882
|
+
super(id, kind, context);
|
|
883
|
+
this.input = context.createGain();
|
|
884
|
+
this.output = context.createGain();
|
|
885
|
+
this.dry = context.createGain();
|
|
886
|
+
this.wet = context.createGain();
|
|
887
|
+
this.input.connect(this.dry).connect(this.output);
|
|
888
|
+
this.wet.connect(this.output);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
set(key: string, value: AudioValue): void {
|
|
892
|
+
if (key === 'wetDryMix') this.mix = Math.max(0, Math.min(100, num(value, this.mix)));
|
|
893
|
+
else if (key === 'bypass') this.bypassed = value === true;
|
|
894
|
+
else this.setEffect(key, value);
|
|
895
|
+
this.balance();
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
protected abstract setEffect(key: string, value: AudioValue): void;
|
|
899
|
+
|
|
900
|
+
protected balance(): void {
|
|
901
|
+
const wet = this.bypassed ? 0 : this.mix / 100;
|
|
902
|
+
this.wet.gain.value = wet;
|
|
903
|
+
this.dry.gain.value = this.bypassed ? 1 : 1 - wet;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
class ReverbNode extends EffectNode {
|
|
908
|
+
private readonly convolver: ConvolverNode;
|
|
909
|
+
|
|
910
|
+
constructor(id: number, context: BaseAudioContext) {
|
|
911
|
+
super(id, 'reverb', context);
|
|
912
|
+
this.convolver = context.createConvolver();
|
|
913
|
+
this.input.connect(this.convolver).connect(this.wet);
|
|
914
|
+
this.preset('mediumHall');
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
protected setEffect(): void {}
|
|
918
|
+
|
|
919
|
+
override preset(name: string): void {
|
|
920
|
+
const preset = REVERB_PRESETS[name];
|
|
921
|
+
if (!preset) throw failure('unsupported', `no reverb preset ${name}`);
|
|
922
|
+
this.convolver.buffer = reverbImpulse(this.context, preset);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
class DelayEffectNode extends EffectNode {
|
|
927
|
+
private readonly delay: DelayNode;
|
|
928
|
+
private readonly lowPass: BiquadFilterNode;
|
|
929
|
+
private readonly feedback: GainNode;
|
|
930
|
+
|
|
931
|
+
constructor(id: number, context: BaseAudioContext) {
|
|
932
|
+
super(id, 'delay', context);
|
|
933
|
+
this.delay = context.createDelay(2);
|
|
934
|
+
this.lowPass = context.createBiquadFilter();
|
|
935
|
+
this.lowPass.type = 'lowpass';
|
|
936
|
+
this.feedback = context.createGain();
|
|
937
|
+
this.input.connect(this.delay).connect(this.lowPass).connect(this.wet);
|
|
938
|
+
this.lowPass.connect(this.feedback).connect(this.delay);
|
|
939
|
+
this.setEffect('delayTime', 1);
|
|
940
|
+
this.setEffect('feedback', 50);
|
|
941
|
+
this.setEffect('lowPassCutoff', 15000);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
protected setEffect(key: string, value: AudioValue): void {
|
|
945
|
+
if (key === 'delayTime') this.delay.delayTime.value = Math.max(0, Math.min(2, num(value, 1)));
|
|
946
|
+
else if (key === 'feedback') this.feedback.gain.value = Math.max(-100, Math.min(100, num(value, 50))) / 100;
|
|
947
|
+
else if (key === 'lowPassCutoff') this.lowPass.frequency.value = Math.max(10, Math.min(this.context.sampleRate / 2, num(value, 15000)));
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
class DistortionNode extends EffectNode {
|
|
952
|
+
private readonly preGain: GainNode;
|
|
953
|
+
private readonly shaper: WaveShaperNode;
|
|
954
|
+
|
|
955
|
+
constructor(id: number, context: BaseAudioContext) {
|
|
956
|
+
super(id, 'distortion', context);
|
|
957
|
+
this.preGain = context.createGain();
|
|
958
|
+
this.shaper = context.createWaveShaper();
|
|
959
|
+
this.shaper.oversample = '2x';
|
|
960
|
+
this.input.connect(this.preGain).connect(this.shaper).connect(this.wet);
|
|
961
|
+
this.mix = 50;
|
|
962
|
+
this.setEffect('preGain', -6);
|
|
963
|
+
this.shaper.curve = distortionCurve(4);
|
|
964
|
+
this.balance();
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
protected setEffect(key: string, value: AudioValue): void {
|
|
968
|
+
// The pre-gain drives into the curve: louder in, harder clipped.
|
|
969
|
+
if (key === 'preGain') this.preGain.gain.value = gainOf(Math.max(-80, Math.min(20, num(value, -6)))) * 4;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
override preset(name: string): void {
|
|
973
|
+
const preset = DISTORTION_PRESETS[name];
|
|
974
|
+
if (!preset) throw failure('unsupported', `no distortion preset ${name}`);
|
|
975
|
+
this.shaper.curve = distortionCurve(preset.drive, preset.bits);
|
|
976
|
+
this.mix = preset.mix;
|
|
977
|
+
this.balance();
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** `AVAudioUnitEQ`: a chain of biquads, one per band (each bypassed until the app turns it on, as Apple's are), and a global gain. */
|
|
982
|
+
class EQNode extends WebNode {
|
|
983
|
+
readonly input: GainNode;
|
|
984
|
+
readonly output: GainNode;
|
|
985
|
+
private readonly filters: BiquadFilterNode[] = [];
|
|
986
|
+
private readonly bands: { type: string; frequency: number; bandwidth: number; gain: number; bypass: boolean }[] = [];
|
|
987
|
+
private bypassed = false;
|
|
988
|
+
|
|
989
|
+
constructor(id: number, context: BaseAudioContext, count: number) {
|
|
990
|
+
super(id, 'eq', context);
|
|
991
|
+
this.input = context.createGain();
|
|
992
|
+
this.output = context.createGain();
|
|
993
|
+
for (let i = 0; i < Math.max(1, Math.min(32, count)); i += 1) {
|
|
994
|
+
this.filters.push(context.createBiquadFilter());
|
|
995
|
+
this.bands.push({ type: 'parametric', frequency: 1000, bandwidth: 0.5, gain: 0, bypass: true });
|
|
996
|
+
}
|
|
997
|
+
this.rewire();
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
set(key: string, value: AudioValue): void {
|
|
1001
|
+
if (key === 'globalGain') {
|
|
1002
|
+
this.output.gain.value = gainOf(Math.max(-96, Math.min(24, num(value, 0))));
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (key === 'bypass') {
|
|
1006
|
+
this.bypassed = value === true;
|
|
1007
|
+
this.rewire();
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
const match = /^bands\.(\d+)\.(\w+)$/.exec(key);
|
|
1011
|
+
const band = match ? this.bands[Number(match[1])] : undefined;
|
|
1012
|
+
if (!match || !band) return;
|
|
1013
|
+
const field = match[2];
|
|
1014
|
+
if (field === 'filterType' && typeof value === 'string' && value in EQ_TYPES) band.type = value;
|
|
1015
|
+
else if (field === 'frequency') band.frequency = Math.max(10, Math.min(this.context.sampleRate / 2, num(value, band.frequency)));
|
|
1016
|
+
else if (field === 'bandwidth') band.bandwidth = Math.max(0.05, Math.min(5, num(value, band.bandwidth)));
|
|
1017
|
+
else if (field === 'gain') band.gain = Math.max(-96, Math.min(24, num(value, band.gain)));
|
|
1018
|
+
else if (field === 'bypass') band.bypass = value === true;
|
|
1019
|
+
this.rewire();
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** Chains the bands that are on, each filter set from its band. */
|
|
1023
|
+
private rewire(): void {
|
|
1024
|
+
this.input.disconnect();
|
|
1025
|
+
for (const filter of this.filters) filter.disconnect();
|
|
1026
|
+
let last: AudioNode = this.input;
|
|
1027
|
+
if (!this.bypassed) {
|
|
1028
|
+
this.bands.forEach((band, i) => {
|
|
1029
|
+
const filter = this.filters[i]!;
|
|
1030
|
+
if (band.bypass) return;
|
|
1031
|
+
filter.type = EQ_TYPES[band.type] ?? 'peaking';
|
|
1032
|
+
filter.frequency.value = band.frequency;
|
|
1033
|
+
filter.gain.value = band.gain;
|
|
1034
|
+
// Resonant types and the peak take the bandwidth; the plain ones are Butterworth.
|
|
1035
|
+
filter.Q.value =
|
|
1036
|
+
band.type === 'lowPass' || band.type === 'highPass'
|
|
1037
|
+
? Math.SQRT1_2
|
|
1038
|
+
: band.type.includes('Shelf') && !band.type.startsWith('resonant')
|
|
1039
|
+
? 1
|
|
1040
|
+
: qForBandwidth(band.bandwidth);
|
|
1041
|
+
last.connect(filter);
|
|
1042
|
+
last = filter;
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
last.connect(this.output);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** `AVAudioUnitTimePitch`: Web Audio cannot change pitch and speed apart, so the page passes the sound through. */
|
|
1050
|
+
class TimePitchNode extends WebNode {
|
|
1051
|
+
readonly input: GainNode;
|
|
1052
|
+
readonly output: GainNode;
|
|
1053
|
+
|
|
1054
|
+
constructor(id: number, context: BaseAudioContext) {
|
|
1055
|
+
super(id, 'timePitch', context);
|
|
1056
|
+
this.input = context.createGain();
|
|
1057
|
+
this.output = this.input;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
set(): void {}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
interface Engine {
|
|
1064
|
+
id: number;
|
|
1065
|
+
nodes: Map<number, WebNode>;
|
|
1066
|
+
output: OutputNode;
|
|
1067
|
+
input: InputNode;
|
|
1068
|
+
running: boolean;
|
|
1069
|
+
/** The players that were playing when it paused, to play again on `start`. */
|
|
1070
|
+
paused: PlayerNode[];
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
interface DecodedFile {
|
|
1074
|
+
audio: AudioBuffer;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
export class WebAudioEngineHost implements AudioEngineHost {
|
|
1078
|
+
private readonly engines = new Map<number, Engine>();
|
|
1079
|
+
/** Every node by id, whichever engine it is in. */
|
|
1080
|
+
private readonly nodes = new Map<number, WebNode>();
|
|
1081
|
+
/** Nodes made by `attach` and not yet in an engine are not kept: a node exists only attached. */
|
|
1082
|
+
private readonly files = new Map<number, DecodedFile>();
|
|
1083
|
+
private readonly buffers = new Map<number, AudioBuffer>();
|
|
1084
|
+
private contextValue: BaseAudioContext | null = null;
|
|
1085
|
+
|
|
1086
|
+
constructor(private readonly sources: AudioEngineSources) {}
|
|
1087
|
+
|
|
1088
|
+
private get context(): BaseAudioContext {
|
|
1089
|
+
return (this.contextValue ??= this.sources.context());
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
perform(command: AudioEngineCommand): Promise<unknown> {
|
|
1093
|
+
try {
|
|
1094
|
+
return this.run(command);
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
return Promise.reject(error instanceof Error ? error : new Error(String(error)));
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
private engine(id: number): Engine {
|
|
1101
|
+
const engine = this.engines.get(id);
|
|
1102
|
+
if (!engine) throw failure('notFound', `no engine ${id}`);
|
|
1103
|
+
return engine;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
private node(id: number): WebNode {
|
|
1107
|
+
const node = this.nodes.get(id);
|
|
1108
|
+
if (!node) throw failure('notFound', `no node ${id} is attached`);
|
|
1109
|
+
return node;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
private player(id: number): PlayerNode {
|
|
1113
|
+
const node = this.node(id);
|
|
1114
|
+
if (!(node instanceof PlayerNode)) throw failure('unsupported', `node ${id} is not a player`);
|
|
1115
|
+
return node;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/** A player to schedule on or play: connected, as natively (AVAudioEngine traps on a player that sends nowhere). */
|
|
1119
|
+
private connectedPlayer(id: number): PlayerNode {
|
|
1120
|
+
const player = this.player(id);
|
|
1121
|
+
if (player.destinations.size === 0) throw failure('notConnected', 'connect the player before scheduling or playing');
|
|
1122
|
+
return player;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
private sampler(id: number): SamplerNode {
|
|
1126
|
+
const node = this.node(id);
|
|
1127
|
+
if (!(node instanceof SamplerNode)) throw failure('unsupported', `node ${id} is not a sampler`);
|
|
1128
|
+
return node;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** A note for a sampler, or for a package's node that takes notes. */
|
|
1132
|
+
private note(command: AudioNoteCommand): Promise<unknown> {
|
|
1133
|
+
const node = this.node(command.node);
|
|
1134
|
+
if (node instanceof PackageNode) {
|
|
1135
|
+
if (!node.custom.note?.(command)) throw failure('unsupported', `a ${node.kind} node takes no notes`);
|
|
1136
|
+
return Promise.resolve({});
|
|
1137
|
+
}
|
|
1138
|
+
const sampler = this.sampler(command.node);
|
|
1139
|
+
if (command.action === 'startNote') sampler.start(command.note, command.velocity, command.channel);
|
|
1140
|
+
else if (command.action === 'stopNote') sampler.stop(command.note, command.channel);
|
|
1141
|
+
else if (command.action === 'controller') sampler.controller(command.controller, command.value);
|
|
1142
|
+
else sampler.pitchBend(command.value);
|
|
1143
|
+
return Promise.resolve({});
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** Runs one command; what is synchronous happens before it returns, so commands keep their order. */
|
|
1147
|
+
private run(command: AudioEngineCommand): Promise<unknown> {
|
|
1148
|
+
const done = Promise.resolve({});
|
|
1149
|
+
switch (command.action) {
|
|
1150
|
+
case 'create': {
|
|
1151
|
+
const context = this.context;
|
|
1152
|
+
const output = new OutputNode(command.output, context);
|
|
1153
|
+
const input = new InputNode(command.input, context);
|
|
1154
|
+
const mixer = new MixerNode(command.mixer, context);
|
|
1155
|
+
const engine: Engine = { id: command.engine, nodes: new Map(), output, input, running: false, paused: [] };
|
|
1156
|
+
for (const node of [output, input, mixer]) {
|
|
1157
|
+
engine.nodes.set(node.id, node);
|
|
1158
|
+
this.nodes.set(node.id, node);
|
|
1159
|
+
}
|
|
1160
|
+
this.engines.set(command.engine, engine);
|
|
1161
|
+
this.link(mixer, [output]);
|
|
1162
|
+
return done;
|
|
1163
|
+
}
|
|
1164
|
+
case 'destroy': {
|
|
1165
|
+
const engine = this.engines.get(command.engine);
|
|
1166
|
+
if (!engine) return done;
|
|
1167
|
+
for (const node of engine.nodes.values()) {
|
|
1168
|
+
node.dispose();
|
|
1169
|
+
this.nodes.delete(node.id);
|
|
1170
|
+
}
|
|
1171
|
+
this.engines.delete(command.engine);
|
|
1172
|
+
return done;
|
|
1173
|
+
}
|
|
1174
|
+
case 'attach': {
|
|
1175
|
+
const engine = this.engine(command.engine);
|
|
1176
|
+
if (this.nodes.has(command.node)) throw failure('exists', `node ${command.node} is attached already`);
|
|
1177
|
+
const node = this.make(command.node, command.kind, command.bands ?? 1);
|
|
1178
|
+
for (const [key, value] of Object.entries(command.values)) {
|
|
1179
|
+
if (key === 'preset' && typeof value === 'string') node.preset(value);
|
|
1180
|
+
else node.set(key, value);
|
|
1181
|
+
}
|
|
1182
|
+
if (node instanceof PlayerNode) node.engineRunning(engine.running);
|
|
1183
|
+
engine.nodes.set(node.id, node);
|
|
1184
|
+
this.nodes.set(node.id, node);
|
|
1185
|
+
return done;
|
|
1186
|
+
}
|
|
1187
|
+
case 'detach': {
|
|
1188
|
+
const engine = this.engine(command.engine);
|
|
1189
|
+
const node = engine.nodes.get(command.node);
|
|
1190
|
+
if (!node) return done;
|
|
1191
|
+
this.unlinkOutput(node);
|
|
1192
|
+
this.unlinkInput(node);
|
|
1193
|
+
node.dispose();
|
|
1194
|
+
engine.nodes.delete(node.id);
|
|
1195
|
+
this.nodes.delete(node.id);
|
|
1196
|
+
return done;
|
|
1197
|
+
}
|
|
1198
|
+
case 'connect': {
|
|
1199
|
+
const engine = this.engine(command.engine);
|
|
1200
|
+
const from = engine.nodes.get(command.from);
|
|
1201
|
+
const to = command.to.map((id) => engine.nodes.get(id));
|
|
1202
|
+
if (!from || to.some((node) => !node)) throw failure('notFound', 'both nodes must be attached to the engine to connect them');
|
|
1203
|
+
if (to.some((node) => node!.input === null)) throw failure('unsupported', 'a player, a sampler or the input takes no input');
|
|
1204
|
+
if (from.kind === 'output' || to.includes(from)) throw failure('unsupported', 'the output sends nowhere, and a node cannot feed itself');
|
|
1205
|
+
this.link(from, to as WebNode[]);
|
|
1206
|
+
return done;
|
|
1207
|
+
}
|
|
1208
|
+
case 'disconnectOutput':
|
|
1209
|
+
this.unlinkOutput(this.engine(command.engine).nodes.get(command.node) ?? this.node(command.node));
|
|
1210
|
+
return done;
|
|
1211
|
+
case 'disconnectInput':
|
|
1212
|
+
this.unlinkInput(this.engine(command.engine).nodes.get(command.node) ?? this.node(command.node));
|
|
1213
|
+
return done;
|
|
1214
|
+
case 'prepare':
|
|
1215
|
+
this.engine(command.engine);
|
|
1216
|
+
return done;
|
|
1217
|
+
case 'start':
|
|
1218
|
+
return this.start(this.engine(command.engine));
|
|
1219
|
+
case 'pause': {
|
|
1220
|
+
const engine = this.engine(command.engine);
|
|
1221
|
+
if (!engine.running) return done;
|
|
1222
|
+
engine.running = false;
|
|
1223
|
+
engine.paused = [];
|
|
1224
|
+
for (const node of engine.nodes.values()) {
|
|
1225
|
+
if (node instanceof PlayerNode) {
|
|
1226
|
+
if (node.isPlaying) {
|
|
1227
|
+
engine.paused.push(node);
|
|
1228
|
+
node.pause();
|
|
1229
|
+
}
|
|
1230
|
+
node.engineRunning(false);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
engine.output.live(false);
|
|
1234
|
+
return done;
|
|
1235
|
+
}
|
|
1236
|
+
case 'stop':
|
|
1237
|
+
case 'reset': {
|
|
1238
|
+
const engine = this.engine(command.engine);
|
|
1239
|
+
if (command.action === 'stop') {
|
|
1240
|
+
engine.running = false;
|
|
1241
|
+
engine.output.live(false);
|
|
1242
|
+
engine.input.halt();
|
|
1243
|
+
}
|
|
1244
|
+
engine.paused = [];
|
|
1245
|
+
for (const node of engine.nodes.values()) {
|
|
1246
|
+
if (node instanceof PlayerNode) {
|
|
1247
|
+
node.stop();
|
|
1248
|
+
node.engineRunning(engine.running);
|
|
1249
|
+
} else if (node instanceof SamplerNode) node.halt();
|
|
1250
|
+
}
|
|
1251
|
+
return done;
|
|
1252
|
+
}
|
|
1253
|
+
case 'set': {
|
|
1254
|
+
const node = this.node(command.node);
|
|
1255
|
+
for (const [key, value] of Object.entries(command.values)) node.set(key, value);
|
|
1256
|
+
return done;
|
|
1257
|
+
}
|
|
1258
|
+
case 'preset':
|
|
1259
|
+
this.node(command.node).preset(command.preset);
|
|
1260
|
+
return done;
|
|
1261
|
+
case 'openFile':
|
|
1262
|
+
return this.open(command.file, command.url);
|
|
1263
|
+
case 'readFile': {
|
|
1264
|
+
const file = this.files.get(command.file);
|
|
1265
|
+
if (!file) throw failure('notFound', `no file ${command.file} is open`);
|
|
1266
|
+
const audio = file.audio;
|
|
1267
|
+
const start = Math.max(0, Math.min(audio.length, Math.floor(command.start)));
|
|
1268
|
+
const frames = Math.max(0, Math.min(audio.length - start, Math.floor(command.frames)));
|
|
1269
|
+
const buffer = this.context.createBuffer(audio.numberOfChannels, Math.max(1, frames), audio.sampleRate);
|
|
1270
|
+
for (let channel = 0; channel < audio.numberOfChannels; channel += 1) {
|
|
1271
|
+
buffer.copyToChannel(audio.getChannelData(channel).subarray(start, start + frames), channel);
|
|
1272
|
+
}
|
|
1273
|
+
this.buffers.set(command.buffer, frames === 0 ? this.context.createBuffer(audio.numberOfChannels, 1, audio.sampleRate) : buffer);
|
|
1274
|
+
return Promise.resolve({ frameLength: frames, sampleRate: audio.sampleRate, channelCount: audio.numberOfChannels } satisfies AudioBufferInfo);
|
|
1275
|
+
}
|
|
1276
|
+
case 'writeBuffer': {
|
|
1277
|
+
// The app's own arrays: a view over a SharedArrayBuffer is its doing.
|
|
1278
|
+
const channels = command.channels as Float32Array<ArrayBuffer>[];
|
|
1279
|
+
const frames = Math.max(1, ...channels.map((data) => data.length));
|
|
1280
|
+
const buffer = this.context.createBuffer(Math.max(1, channels.length), frames, Math.max(3000, Math.min(768000, command.sampleRate)));
|
|
1281
|
+
channels.forEach((data, channel) => buffer.copyToChannel(data, channel));
|
|
1282
|
+
this.buffers.set(command.buffer, buffer);
|
|
1283
|
+
return done;
|
|
1284
|
+
}
|
|
1285
|
+
case 'release':
|
|
1286
|
+
this.files.delete(command.id);
|
|
1287
|
+
this.buffers.delete(command.id);
|
|
1288
|
+
return done;
|
|
1289
|
+
case 'scheduleFile': {
|
|
1290
|
+
const player = this.connectedPlayer(command.node);
|
|
1291
|
+
const file = this.files.get(command.file);
|
|
1292
|
+
if (!file) throw failure('notFound', `no file ${command.file} is open`);
|
|
1293
|
+
const rate = file.audio.sampleRate;
|
|
1294
|
+
const start = Math.max(0, Math.min(file.audio.length, command.start ?? 0));
|
|
1295
|
+
const frames = Math.max(0, Math.min(file.audio.length - start, command.frames ?? file.audio.length - start));
|
|
1296
|
+
return new Promise((resolve) => {
|
|
1297
|
+
player.schedule({ audio: file.audio, offset: start / rate, duration: frames / rate, loops: false, at: command.at, done: () => resolve({}) });
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
case 'scheduleBuffer': {
|
|
1301
|
+
const player = this.connectedPlayer(command.node);
|
|
1302
|
+
const audio = this.buffers.get(command.buffer);
|
|
1303
|
+
if (!audio) throw failure('notFound', `no buffer ${command.buffer}`);
|
|
1304
|
+
return new Promise((resolve) => {
|
|
1305
|
+
player.schedule(
|
|
1306
|
+
{ audio, offset: 0, duration: audio.duration, loops: command.loops === true, at: command.at, done: () => resolve({}) },
|
|
1307
|
+
command.interruptsAtLoop === true ? 'atLoop' : command.interrupts === true ? 'interrupts' : 'after',
|
|
1308
|
+
);
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
case 'play': {
|
|
1312
|
+
const player = this.connectedPlayer(command.node);
|
|
1313
|
+
if (!player.running) throw failure('stopped', 'a player plays only while its engine runs');
|
|
1314
|
+
player.play(command.at);
|
|
1315
|
+
return done;
|
|
1316
|
+
}
|
|
1317
|
+
case 'pausePlayer':
|
|
1318
|
+
this.player(command.node).pause();
|
|
1319
|
+
return done;
|
|
1320
|
+
case 'stopPlayer':
|
|
1321
|
+
this.player(command.node).stop();
|
|
1322
|
+
return done;
|
|
1323
|
+
case 'playerTime': {
|
|
1324
|
+
const player = this.player(command.node);
|
|
1325
|
+
const time = player.time();
|
|
1326
|
+
const rate = this.context.sampleRate;
|
|
1327
|
+
return Promise.resolve(time === null ? null : { sampleTime: Math.round(time * rate), sampleRate: rate });
|
|
1328
|
+
}
|
|
1329
|
+
case 'loadZones': {
|
|
1330
|
+
const sampler = this.sampler(command.node);
|
|
1331
|
+
const zones = command.zones.map((zone) => {
|
|
1332
|
+
const file = this.files.get(zone.file);
|
|
1333
|
+
if (!file) throw failure('notFound', `no file ${zone.file} is open`);
|
|
1334
|
+
return { zone, audio: file.audio };
|
|
1335
|
+
});
|
|
1336
|
+
sampler.load(zones, command.attack, command.release);
|
|
1337
|
+
return done;
|
|
1338
|
+
}
|
|
1339
|
+
case 'loadInstrument':
|
|
1340
|
+
this.sampler(command.node);
|
|
1341
|
+
throw failure('unsupported', 'this page cannot load a sound bank or preset; load audio files instead');
|
|
1342
|
+
case 'startNote':
|
|
1343
|
+
case 'stopNote':
|
|
1344
|
+
case 'controller':
|
|
1345
|
+
case 'pitchBend':
|
|
1346
|
+
return this.note(command);
|
|
1347
|
+
case 'meter':
|
|
1348
|
+
return Promise.resolve({ level: this.node(command.node).meter() });
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
private make(id: number, kind: AudioNodeKind | CustomAudioNodeKind, bands: number): WebNode {
|
|
1353
|
+
const context = this.context;
|
|
1354
|
+
switch (kind as AudioNodeKind) {
|
|
1355
|
+
case 'player':
|
|
1356
|
+
return new PlayerNode(id, context);
|
|
1357
|
+
case 'sampler':
|
|
1358
|
+
return new SamplerNode(id, context);
|
|
1359
|
+
case 'mixer':
|
|
1360
|
+
return new MixerNode(id, context);
|
|
1361
|
+
case 'reverb':
|
|
1362
|
+
return new ReverbNode(id, context);
|
|
1363
|
+
case 'delay':
|
|
1364
|
+
return new DelayEffectNode(id, context);
|
|
1365
|
+
case 'distortion':
|
|
1366
|
+
return new DistortionNode(id, context);
|
|
1367
|
+
case 'eq':
|
|
1368
|
+
return new EQNode(id, context, bands);
|
|
1369
|
+
case 'timePitch':
|
|
1370
|
+
return new TimePitchNode(id, context);
|
|
1371
|
+
default: {
|
|
1372
|
+
const custom = customKinds.get(kind);
|
|
1373
|
+
if (!custom) throw failure('unsupported', `no node kind ${kind}`);
|
|
1374
|
+
return new PackageNode(id, kind, context, custom(context));
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* Connects `from`'s output to `to` (several: a fan-out), replacing where
|
|
1381
|
+
* it went before, as `AVAudioEngine.connect` does; a node that is not a
|
|
1382
|
+
* mixer takes one input, so what was connected into it is let go.
|
|
1383
|
+
*/
|
|
1384
|
+
private link(from: WebNode, to: WebNode[]): void {
|
|
1385
|
+
this.unlinkOutput(from);
|
|
1386
|
+
for (const node of to) {
|
|
1387
|
+
if (!node.mixes) for (const source of [...node.sources]) this.cut(source, node);
|
|
1388
|
+
from.output.connect(node.input!);
|
|
1389
|
+
from.destinations.add(node);
|
|
1390
|
+
node.sources.add(from);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
private cut(from: WebNode, to: WebNode): void {
|
|
1395
|
+
try {
|
|
1396
|
+
from.output.disconnect(to.input!);
|
|
1397
|
+
} catch {
|
|
1398
|
+
// Not connected.
|
|
1399
|
+
}
|
|
1400
|
+
from.destinations.delete(to);
|
|
1401
|
+
to.sources.delete(from);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
private unlinkOutput(node: WebNode): void {
|
|
1405
|
+
for (const destination of [...node.destinations]) this.cut(node, destination);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
private unlinkInput(node: WebNode): void {
|
|
1409
|
+
for (const source of [...node.sources]) this.cut(source, node);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
private async start(engine: Engine): Promise<unknown> {
|
|
1413
|
+
if (engine.running) return {};
|
|
1414
|
+
// The input opens when something listens to it (the microphone is asked for then).
|
|
1415
|
+
if (engine.input.destinations.size > 0) await engine.input.open(this.sources.microphone);
|
|
1416
|
+
const context = this.context;
|
|
1417
|
+
if ('resume' in context && context.state === 'suspended') await (context as AudioContext).resume().catch(() => undefined);
|
|
1418
|
+
engine.running = true;
|
|
1419
|
+
engine.output.live(true);
|
|
1420
|
+
for (const node of engine.nodes.values()) if (node instanceof PlayerNode) node.engineRunning(true);
|
|
1421
|
+
for (const player of engine.paused) player.play(undefined);
|
|
1422
|
+
engine.paused = [];
|
|
1423
|
+
return {};
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
private async open(id: number, url: string): Promise<AudioFileInfo> {
|
|
1427
|
+
let bytes: ArrayBuffer;
|
|
1428
|
+
try {
|
|
1429
|
+
bytes = await this.sources.read(url);
|
|
1430
|
+
} catch (error) {
|
|
1431
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1432
|
+
throw message.includes(':') ? new Error(message) : failure('notFound', `${url} could not be read (${message})`);
|
|
1433
|
+
}
|
|
1434
|
+
let audio: AudioBuffer;
|
|
1435
|
+
try {
|
|
1436
|
+
audio = await this.context.decodeAudioData(bytes);
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
throw failure('decode', `${url} is not audio this page can read (${String(error)})`);
|
|
1439
|
+
}
|
|
1440
|
+
this.files.set(id, { audio });
|
|
1441
|
+
return { length: audio.length, sampleRate: audio.sampleRate, channelCount: audio.numberOfChannels };
|
|
1442
|
+
}
|
|
1443
|
+
}
|