@hraness/dawg 0.3.0 → 0.4.1

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/DAWG.md +608 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +109 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +339 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +394 -0
  42. package/src/audio/engine.ts +112 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +481 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +411 -295
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1222 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +521 -0
  72. package/src/tui/euclid.ts +516 -0
  73. package/src/tui/menu.ts +1305 -244
  74. package/src/tui/play-chords.ts +630 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +286 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Send effects on the stereo pair: delay and reverb. Without their
3
+ * optional parameters both render exactly as the original renderer did.
4
+ */
5
+ import type { Track } from "../../../core/score.ts";
6
+ import type { DecodedSample } from "../samples.ts";
7
+ import {
8
+ addConvolved,
9
+ builtinImpulse,
10
+ convolveRaw,
11
+ sampleImpulse,
12
+ type Impulse,
13
+ } from "./convolution.ts";
14
+ import {
15
+ CONTROL_SAMPLES,
16
+ OnePole,
17
+ Param,
18
+ interpolateAutomation,
19
+ type EffectContext,
20
+ } from "./common.ts";
21
+
22
+ /** Delay length in seconds: `time` when set, otherwise `beats` at the tempo. */
23
+ export function delaySeconds(track: Track, tempoBpm: number): number {
24
+ const delay = track.delay;
25
+ if (!delay) return 0;
26
+ return delay.time !== undefined && delay.time > 0
27
+ ? delay.time
28
+ : (delay.beats * 60) / tempoBpm;
29
+ }
30
+
31
+ /**
32
+ * Stereo feedback delay. Absent `pingpong`, each side echoes its own input
33
+ * and feeds back into the opposite side (the original sound). With
34
+ * `pingpong`, the mono sum enters the left line only and every repeat
35
+ * crosses over, so echoes alternate left, right, left. `highcut` runs a
36
+ * one-pole low-pass on everything written into the lines, so each repeat
37
+ * is darker than the last. Feedback and mix follow their lanes. `wetOnly`
38
+ * (a shared orbit bus) replaces the input with the repeats alone.
39
+ */
40
+ export function applyDelay(
41
+ left: Float64Array,
42
+ right: Float64Array,
43
+ track: Track,
44
+ context: EffectContext,
45
+ wetOnly = false,
46
+ ): void {
47
+ const delay = track.delay;
48
+ if (!delay) return;
49
+ const feedbackLane = track.delayFeedbackAutomation ?? [];
50
+ const mixLane = track.delayMixAutomation ?? [];
51
+ if (delay.mix <= 0 && mixLane.length === 0) return;
52
+ const { sampleRate, samplesPerTick } = context;
53
+ const length = Math.max(
54
+ 1,
55
+ Math.round(delaySeconds(track, context.tempoBpm) * sampleRate),
56
+ );
57
+ const lineL = new Float64Array(length);
58
+ const lineR = new Float64Array(length);
59
+ let feedback = delay.feedback;
60
+ let mix = delay.mix;
61
+ const automated = feedbackLane.length > 0 || mixLane.length > 0;
62
+ const pingpong = delay.pingpong === true;
63
+ const highcut =
64
+ delay.highcut === undefined
65
+ ? undefined
66
+ : [
67
+ new OnePole(delay.highcut, sampleRate),
68
+ new OnePole(delay.highcut, sampleRate),
69
+ ];
70
+ for (let index = 0; index < left.length; index += 1) {
71
+ if (automated && index % CONTROL_SAMPLES === 0) {
72
+ const tick = index / samplesPerTick;
73
+ feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
74
+ mix = interpolateAutomation(mixLane, tick, delay.mix);
75
+ }
76
+ const slot = index % length;
77
+ const wetL = lineL[slot]!;
78
+ const wetR = lineR[slot]!;
79
+ const dryL = left[index]!;
80
+ const dryR = right[index]!;
81
+ let writeL: number;
82
+ let writeR: number;
83
+ if (pingpong) {
84
+ writeL = (dryL + dryR) * 0.5 + wetR * feedback;
85
+ writeR = wetL * feedback;
86
+ } else {
87
+ writeL = dryL + wetR * feedback;
88
+ writeR = dryR + wetL * feedback;
89
+ }
90
+ if (highcut) {
91
+ writeL = highcut[0]!.process(writeL);
92
+ writeR = highcut[1]!.process(writeR);
93
+ }
94
+ lineL[slot] = writeL;
95
+ lineR[slot] = writeR;
96
+ left[index] = (wetOnly ? 0 : dryL) + wetL * mix;
97
+ right[index] = (wetOnly ? 0 : dryR) + wetR * mix;
98
+ }
99
+ }
100
+
101
+ /** Seconds until the track's delay decays below -60 dB, bounded. */
102
+ export function delayTailFor(track: Track, tempoBpm: number): number {
103
+ if (!track.delay) return 0;
104
+ const feedback = Math.max(
105
+ track.delay.feedback,
106
+ ...(track.delayFeedbackAutomation ?? []).map((point) => point.value),
107
+ );
108
+ const repeats =
109
+ feedback <= 0.001 ? 1 : Math.min(64, Math.log(0.001) / Math.log(feedback));
110
+ return delaySeconds(track, tempoBpm) * (repeats + 1);
111
+ }
112
+
113
+ /** Freeverb comb and allpass tunings at 44.1 kHz; the right side is spread. */
114
+ const COMB_TUNING = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617] as const;
115
+ const ALLPASS_TUNING = [556, 441, 341, 225] as const;
116
+ const STEREO_SPREAD = 23;
117
+ const REVERB_INPUT_GAIN = 0.015;
118
+ const REVERB_WET_SCALE = 3;
119
+
120
+ /**
121
+ * Convolution wet level: an energy-normalized impulse on the summed pair,
122
+ * scaled so `mix` sounds as loud as the algorithmic tail at the same mix.
123
+ */
124
+ const IR_WET_SCALE = 2.1;
125
+
126
+ /** Impulses decoded from samples, by sha256 and rate (small: a few IRs). */
127
+ const SAMPLE_IMPULSES = new Map<string, Impulse>();
128
+ const MAX_SAMPLE_IMPULSES = 8;
129
+
130
+ /**
131
+ * The impulse a track's `reverb.ir` names at this rate: a generated
132
+ * built-in, or the decoded sample in `irs`. Undefined when the track has no
133
+ * `ir` or its sample did not load (the algorithmic reverb then plays).
134
+ */
135
+ export function reverbImpulse(
136
+ track: Track,
137
+ sampleRate: number,
138
+ irs?: ReadonlyMap<string, DecodedSample>,
139
+ ): Impulse | undefined {
140
+ const ir = track.reverb?.ir;
141
+ if (!ir) return undefined;
142
+ if (ir.src.startsWith("builtin:"))
143
+ return builtinImpulse(ir.src.slice("builtin:".length), sampleRate);
144
+ const sample = irs?.get(track.id);
145
+ if (!sample) return undefined;
146
+ const key = `${sample.sha256}@${sampleRate}`;
147
+ let impulse = SAMPLE_IMPULSES.get(key);
148
+ if (!impulse) {
149
+ impulse = sampleImpulse(
150
+ `${ir.src}#${sample.sha256}`,
151
+ sample.mono,
152
+ sample.sampleRate,
153
+ sampleRate,
154
+ );
155
+ if (SAMPLE_IMPULSES.size >= MAX_SAMPLE_IMPULSES)
156
+ SAMPLE_IMPULSES.delete(SAMPLE_IMPULSES.keys().next().value!);
157
+ SAMPLE_IMPULSES.set(key, impulse);
158
+ }
159
+ return impulse;
160
+ }
161
+
162
+ /** Seconds of reverb tail a render keeps for this track. */
163
+ export function reverbTailFor(
164
+ track: Track,
165
+ sampleRate = 44_100,
166
+ irs?: ReadonlyMap<string, DecodedSample>,
167
+ ): number {
168
+ const reverb = track.reverb;
169
+ const lane = track.fxAutomation?.["reverb-mix"] ?? [];
170
+ if (!reverb || (reverb.mix <= 0 && lane.length === 0)) return 0;
171
+ const impulse = reverbImpulse(track, sampleRate, irs);
172
+ const decay = impulse
173
+ ? impulse.left.length / sampleRate
174
+ : (reverb.fade ?? 1 + 3 * reverb.size);
175
+ return Math.min(20, decay + (reverb.predelay ?? 0));
176
+ }
177
+
178
+ /**
179
+ * Schroeder–Moorer reverb in the Freeverb arrangement: eight damped
180
+ * feedback combs then four allpasses per side, the right side detuned.
181
+ * `size` sets comb feedback and damping; `fade` instead sets each comb's
182
+ * feedback for an exact -60 dB decay time; `dim` sets the in-loop damping
183
+ * frequency, `lowpass` filters the input and `predelay` delays it.
184
+ * `wetOnly` (a shared orbit bus) replaces the input with the tail alone.
185
+ *
186
+ * It runs in two steps: `reverbWet` (the tail before its mix gain, which
187
+ * does not depend on `mix`) and `addReverbWet`. The stem cache keeps the
188
+ * first, so a mix-only edit skips the room entirely.
189
+ */
190
+ export function applyReverb(
191
+ left: Float64Array,
192
+ right: Float64Array,
193
+ track: Track,
194
+ context: EffectContext,
195
+ wetOnly = false,
196
+ ): void {
197
+ const wet = reverbWet(left, right, track, context);
198
+ if (wet) addReverbWet(left, right, wet, track, context, wetOnly);
199
+ }
200
+
201
+ /** A reverb's tail before its mix gain (`scale` for an impulse response). */
202
+ export type ReverbWet = Readonly<{
203
+ left: Float64Array;
204
+ right: Float64Array;
205
+ /** The convolution's inverse-FFT scale; absent for the algorithmic room. */
206
+ scale?: number;
207
+ }>;
208
+
209
+ function mixParamOf(track: Track, context: EffectContext): Param {
210
+ return new Param(
211
+ track.reverb?.mix ?? 0,
212
+ track.fxAutomation?.["reverb-mix"],
213
+ context.samplesPerTick,
214
+ );
215
+ }
216
+
217
+ /** Whether the track's reverb sounds at all (a mix above 0, or a lane). */
218
+ export function reverbActive(track: Track): boolean {
219
+ const reverb = track.reverb;
220
+ if (!reverb) return false;
221
+ return (
222
+ reverb.mix > 0 || (track.fxAutomation?.["reverb-mix"] ?? []).length > 0
223
+ );
224
+ }
225
+
226
+ /**
227
+ * The reverb tail of the pair, before the mix gain; `left`/`right` are
228
+ * read, not changed. Undefined when the reverb is off.
229
+ */
230
+ export function reverbWet(
231
+ left: Float64Array,
232
+ right: Float64Array,
233
+ track: Track,
234
+ context: EffectContext,
235
+ ): ReverbWet | undefined {
236
+ const reverb = track.reverb;
237
+ if (!reverb || !reverbActive(track)) return undefined;
238
+ const { sampleRate } = context;
239
+ const impulse = reverbImpulse(track, sampleRate, context.irs);
240
+ if (impulse) return convolutionWet(left, right, track, context, impulse);
241
+ const scale = sampleRate / 44_100;
242
+ const sizeFeedback = 0.7 + 0.28 * reverb.size;
243
+ const damp =
244
+ reverb.dim === undefined
245
+ ? 0.2 + 0.3 * reverb.size
246
+ : Math.exp(
247
+ (-2 * Math.PI * Math.min(reverb.dim, sampleRate * 0.45)) / sampleRate,
248
+ );
249
+ const channel = (spread: number) => ({
250
+ combs: COMB_TUNING.map((tuning) => {
251
+ const length = Math.max(1, Math.round((tuning + spread) * scale));
252
+ return {
253
+ buffer: new Float64Array(length),
254
+ index: 0,
255
+ store: 0,
256
+ feedback:
257
+ reverb.fade === undefined
258
+ ? sizeFeedback
259
+ : Math.min(0.995, 10 ** ((-3 * length) / sampleRate / reverb.fade)),
260
+ };
261
+ }),
262
+ allpasses: ALLPASS_TUNING.map((tuning) => ({
263
+ buffer: new Float64Array(
264
+ Math.max(1, Math.round((tuning + spread) * scale)),
265
+ ),
266
+ index: 0,
267
+ })),
268
+ });
269
+ const sides = [channel(0), channel(STEREO_SPREAD)] as const;
270
+ const process = (side: (typeof sides)[number], input: number): number => {
271
+ let output = 0;
272
+ for (const comb of side.combs) {
273
+ const delayed = comb.buffer[comb.index]!;
274
+ comb.store = delayed * (1 - damp) + comb.store * damp;
275
+ comb.buffer[comb.index] = input + comb.store * comb.feedback;
276
+ comb.index = comb.index + 1 === comb.buffer.length ? 0 : comb.index + 1;
277
+ output += delayed;
278
+ }
279
+ for (const allpass of side.allpasses) {
280
+ const delayed = allpass.buffer[allpass.index]!;
281
+ allpass.buffer[allpass.index] = output + delayed * 0.5;
282
+ output = delayed - output;
283
+ allpass.index =
284
+ allpass.index + 1 === allpass.buffer.length ? 0 : allpass.index + 1;
285
+ }
286
+ return output;
287
+ };
288
+ const inputFilter =
289
+ reverb.lowpass === undefined
290
+ ? undefined
291
+ : new OnePole(reverb.lowpass, sampleRate);
292
+ const predelaySamples = Math.round((reverb.predelay ?? 0) * sampleRate);
293
+ const predelay =
294
+ predelaySamples > 0 ? new Float64Array(predelaySamples) : undefined;
295
+ const wetL = new Float64Array(left.length);
296
+ const wetR = new Float64Array(left.length);
297
+ for (let index = 0; index < left.length; index += 1) {
298
+ let input = (left[index]! + right[index]!) * REVERB_INPUT_GAIN;
299
+ if (inputFilter) input = inputFilter.process(input);
300
+ if (predelay) {
301
+ const slot = index % predelaySamples;
302
+ const delayed = predelay[slot]!;
303
+ predelay[slot] = input;
304
+ input = delayed;
305
+ }
306
+ wetL[index] = process(sides[0], input);
307
+ wetR[index] = process(sides[1], input);
308
+ }
309
+ return { left: wetL, right: wetR };
310
+ }
311
+
312
+ /**
313
+ * Add (or with `wetOnly`, write) a tail from `reverbWet` at the track's
314
+ * mix and mix lane, in the order the one-pass reverb always used, so a
315
+ * cached tail mixes to the same samples.
316
+ */
317
+ export function addReverbWet(
318
+ left: Float64Array,
319
+ right: Float64Array,
320
+ tail: ReverbWet,
321
+ track: Track,
322
+ context: EffectContext,
323
+ wetOnly = false,
324
+ ): void {
325
+ const mixParam = mixParamOf(track, context);
326
+ if (tail.scale !== undefined) {
327
+ if (wetOnly) {
328
+ left.fill(0);
329
+ right.fill(0);
330
+ }
331
+ if (tail.scale === 0) return;
332
+ let wet = mixParam.fallback * IR_WET_SCALE;
333
+ addConvolved(
334
+ tail.left,
335
+ tail.right,
336
+ tail.scale,
337
+ left,
338
+ right,
339
+ wet,
340
+ mixParam.automated
341
+ ? (index) => {
342
+ if (index % CONTROL_SAMPLES === 0)
343
+ wet = mixParam.at(index) * IR_WET_SCALE;
344
+ return wet;
345
+ }
346
+ : undefined,
347
+ );
348
+ return;
349
+ }
350
+ let wet = mixParam.fallback * REVERB_WET_SCALE;
351
+ for (let index = 0; index < left.length; index += 1) {
352
+ if (mixParam.automated && index % CONTROL_SAMPLES === 0)
353
+ wet = mixParam.at(index) * REVERB_WET_SCALE;
354
+ if (wetOnly) {
355
+ left[index] = tail.left[index]! * wet;
356
+ right[index] = tail.right[index]! * wet;
357
+ } else {
358
+ left[index]! += tail.left[index]! * wet;
359
+ right[index]! += tail.right[index]! * wet;
360
+ }
361
+ }
362
+ }
363
+
364
+ /**
365
+ * `reverb.ir`: the summed pair (through `lowpass` and `predelay`, as the
366
+ * algorithmic reverb) convolved with the impulse and added at `mix`.
367
+ * `size`, `fade` and `dim` describe the algorithmic tail and do nothing
368
+ * here: the impulse is the room.
369
+ */
370
+ function convolutionWet(
371
+ left: Float64Array,
372
+ right: Float64Array,
373
+ track: Track,
374
+ context: EffectContext,
375
+ impulse: Impulse,
376
+ ): ReverbWet {
377
+ const reverb = track.reverb!;
378
+ const { sampleRate } = context;
379
+ const input = new Float64Array(left.length);
380
+ const inputFilter =
381
+ reverb.lowpass === undefined
382
+ ? undefined
383
+ : new OnePole(reverb.lowpass, sampleRate);
384
+ const offset = Math.round((reverb.predelay ?? 0) * sampleRate);
385
+ for (let index = 0; index + offset < left.length; index += 1) {
386
+ let value = (left[index]! + right[index]!) * 0.5;
387
+ if (inputFilter) value = inputFilter.process(value);
388
+ input[index + offset] = value;
389
+ }
390
+ const wetL = new Float64Array(left.length);
391
+ const wetR = new Float64Array(left.length);
392
+ const scale = convolveRaw(input, impulse, wetL, wetR);
393
+ return { left: wetL, right: wetR, scale };
394
+ }
@@ -4,6 +4,7 @@ import { LoopPlayer } from "./player.ts";
4
4
  import { LoopRenderer, type LoopRender } from "./renderer.ts";
5
5
  import { clickSounds, clicksIn, type ClickLevel } from "./click.ts";
6
6
  import type { LiveNotePcm } from "./live.ts";
7
+ import { levelOf, type SoundLevel } from "./preview.ts";
7
8
  import { DEFAULT_SAMPLE_RATE, RENDER_CHANNELS } from "./wav.ts";
8
9
 
9
10
  /**
@@ -169,6 +170,17 @@ function parseCommand(value: string): string[] {
169
170
  return parts;
170
171
  }
171
172
 
173
+ /**
174
+ * Swaps crossfade old to new over this window with equal-power gains
175
+ * (cos/sin), reading both loops at the same beat. 20 ms is long enough that
176
+ * the step a hard cut makes is spread below ~50 Hz (no click, even on a
177
+ * full-scale low bass), short enough to sit inside the transport's 30 ms
178
+ * resync tolerance and below the shortest audible echo (~30-50 ms), so an
179
+ * edit never sounds smeared or doubled. Playback only: renders, exports and
180
+ * the loop buffers themselves never pass through it.
181
+ */
182
+ export const SWAP_FADE_SECONDS = 0.02;
183
+
172
184
  /** Swaps re-anchor to the transport only past this disagreement. */
173
185
  const RESYNC_SECONDS = 0.03;
174
186
 
@@ -227,6 +239,16 @@ type Loop = Readonly<{
227
239
  framesPerBeat: number;
228
240
  }>;
229
241
 
242
+ /** A loop fading out after a swap, read on from where it was. */
243
+ type FadeOut = {
244
+ loop: Loop;
245
+ cursor: number;
246
+ /** Its gain when the fade began (below 1 when swapped out mid-fade). */
247
+ from: number;
248
+ /** Frames of the fade already written. */
249
+ done: number;
250
+ };
251
+
230
252
  /**
231
253
  * The click bus: the engine asks `beatAt` for the transport beat at a stream
232
254
  * frame's wall time (undefined while the transport is stopped; negative
@@ -292,6 +314,11 @@ export class AudioEngine {
292
314
  private child: PlayerProcess | undefined;
293
315
  private timer: ReturnType<typeof setInterval> | undefined;
294
316
  private loop: Loop | undefined;
317
+ /** Loops fading out after swaps (see `SWAP_FADE_SECONDS`). */
318
+ private fades: FadeOut[] = [];
319
+ /** Frames of the current loop's fade-in already written, while fading. */
320
+ private fadeIn: number | undefined;
321
+ private readonly fadeFrames: number;
295
322
  /** Monotonic ms that stream frame 0 belongs to. */
296
323
  private startMs = 0;
297
324
  /** Stream frames written since `startMs`. */
@@ -304,10 +331,15 @@ export class AudioEngine {
304
331
  private queued: PlayRequest | undefined;
305
332
  private draining = false;
306
333
  private lastRenderMs = 0;
334
+ /** The loop last swapped in, for `level` (measured on demand, once). */
335
+ private lastLoopPcm: Int16Array | undefined;
336
+ private measured: { pcm: Int16Array; level: SoundLevel } | undefined;
307
337
  private respawns = 0;
308
338
  private respawnTimer: ReturnType<typeof setTimeout> | undefined;
309
339
  /** Play mode: keep a player running without a loop for live voices. */
310
340
  private monitoring = false;
341
+ /** A lead set by `setLeadMs` (play mode, auditions) is not adapted. */
342
+ private leadPinned = false;
311
343
  private readonly voices = new Map<number, LiveVoice>();
312
344
  private click: ClickBus | undefined;
313
345
  private clickVoices: { sound: Float32Array; position: number }[] = [];
@@ -322,6 +354,10 @@ export class AudioEngine {
322
354
  ((options.leadMs ?? 200) * this.sampleRate) / 1000,
323
355
  );
324
356
  this.defaultLeadFrames = this.leadFrames;
357
+ this.fadeFrames = Math.max(
358
+ 1,
359
+ Math.round(SWAP_FADE_SECONDS * this.sampleRate),
360
+ );
325
361
  this.tickMs = options.tickMs ?? 20;
326
362
  this.now = options.now ?? (() => performance.now());
327
363
  this.useTimer = options.timer ?? true;
@@ -359,6 +395,7 @@ export class AudioEngine {
359
395
  * after it is pressed); `undefined` restores the constructed lead.
360
396
  */
361
397
  public setLeadMs(ms: number | undefined): void {
398
+ this.leadPinned = ms !== undefined;
362
399
  this.leadFrames =
363
400
  ms === undefined
364
401
  ? this.defaultLeadFrames
@@ -371,6 +408,19 @@ export class AudioEngine {
371
408
  }
372
409
 
373
410
  /** Whether live voices and the click can sound (a streaming backend). */
411
+ /**
412
+ * RMS, peak and clips of the loop now playing (the audition meter);
413
+ * measured once per render, on first read. Undefined before any render
414
+ * or with no audio device.
415
+ */
416
+ public get level(): SoundLevel | undefined {
417
+ const pcm = this.lastLoopPcm;
418
+ if (!pcm) return undefined;
419
+ if (this.measured?.pcm !== pcm)
420
+ this.measured = { pcm, level: levelOf(pcm) };
421
+ return this.measured.level;
422
+ }
423
+
374
424
  public get canMonitor(): boolean {
375
425
  return this.info.backend !== "none" && !this.fallback;
376
426
  }
@@ -451,10 +501,10 @@ export class AudioEngine {
451
501
 
452
502
  /** Frames kept queued ahead of the clock right now. */
453
503
  public get lead(): number {
454
- // Play mode keeps its short lead: renders run off-thread, so a slow
455
- // render delays the swap, not the stream.
504
+ // Play mode and auditions keep their short lead: renders run
505
+ // off-thread, so a slow render delays the swap, not the stream.
456
506
  const adaptive =
457
- this.monitoring && this.renderer.offThread
507
+ (this.monitoring || this.leadPinned) && this.renderer.offThread
458
508
  ? 0
459
509
  : Math.round(
460
510
  (LEAD_RENDER_FACTOR * this.lastRenderMs * this.sampleRate) / 1000,
@@ -499,8 +549,10 @@ export class AudioEngine {
499
549
  const render = await this.renderer.render(request.score);
500
550
  this.lastRenderMs = render.renderMs;
501
551
  // Stopped while rendering: the result is stale, not an error.
502
- if (request.generation === this.generation)
552
+ if (request.generation === this.generation) {
503
553
  await this.apply(this.toLoop(render, request.score), request);
554
+ this.lastLoopPcm = render.pcm;
555
+ }
504
556
  for (const waiter of request.waiters) waiter.resolve();
505
557
  } catch (error) {
506
558
  for (const waiter of request.waiters)
@@ -569,6 +621,7 @@ export class AudioEngine {
569
621
 
570
622
  public async stopAsync(): Promise<void> {
571
623
  this.generation += 1;
624
+ this.lastLoopPcm = undefined;
572
625
  this.respawns = 0;
573
626
  if (this.respawnTimer) clearTimeout(this.respawnTimer);
574
627
  this.respawnTimer = undefined;
@@ -580,6 +633,7 @@ export class AudioEngine {
580
633
  if (this.monitoring && this.child) {
581
634
  // Play mode: the transport stopped, the live player keeps running.
582
635
  this.loop = undefined;
636
+ this.endFades();
583
637
  return;
584
638
  }
585
639
  if (this.timer) clearInterval(this.timer);
@@ -587,6 +641,7 @@ export class AudioEngine {
587
641
  const child = this.child;
588
642
  this.child = undefined;
589
643
  this.loop = undefined;
644
+ this.endFades();
590
645
  if (child) {
591
646
  try {
592
647
  child.stdin.end?.();
@@ -623,6 +678,7 @@ export class AudioEngine {
623
678
  if (remaining > this.sampleRate) {
624
679
  const skipped = remaining - this.sampleRate;
625
680
  if (loop) this.cursor = (this.cursor + skipped) % loop.frames;
681
+ this.endFades();
626
682
  this.written += skipped;
627
683
  remaining = this.sampleRate;
628
684
  }
@@ -640,6 +696,7 @@ export class AudioEngine {
640
696
  offset += take;
641
697
  this.cursor = (this.cursor + take) % loop.frames;
642
698
  }
699
+ if (loop) this.crossfade(out, remaining);
643
700
  this.mixLive(out, this.written, remaining);
644
701
  this.written += remaining;
645
702
  try {
@@ -650,6 +707,49 @@ export class AudioEngine {
650
707
  }
651
708
  }
652
709
 
710
+ /** Equal-power gain of a fade-in `done` frames along (0 to 1). */
711
+ private rampGain(done: number): number {
712
+ return Math.sin((Math.PI / 2) * Math.min(1, done / this.fadeFrames));
713
+ }
714
+
715
+ /**
716
+ * Blend the start of a block (already the current loop) with the loops
717
+ * fading out: new·sin + old·cos over `fadeFrames`, both read at the same
718
+ * beat, so a swap never steps the waveform.
719
+ */
720
+ private crossfade(out: Int16Array, frames: number): void {
721
+ if (this.fadeIn === undefined) return;
722
+ const start = this.fadeIn;
723
+ const count = Math.min(frames, this.fadeFrames - start);
724
+ for (let index = 0; index < count; index += 1) {
725
+ const gain = this.rampGain(start + index);
726
+ const at = index * RENDER_CHANNELS;
727
+ let left = out[at]! * gain;
728
+ let right = out[at + 1]! * gain;
729
+ for (const fade of this.fades) {
730
+ if (fade.done >= this.fadeFrames) continue;
731
+ const g =
732
+ fade.from * Math.cos((Math.PI / 2) * (fade.done / this.fadeFrames));
733
+ const from = fade.cursor * RENDER_CHANNELS;
734
+ left += fade.loop.pcm[from]! * g;
735
+ right += fade.loop.pcm[from + 1]! * g;
736
+ fade.cursor = (fade.cursor + 1) % fade.loop.frames;
737
+ fade.done += 1;
738
+ }
739
+ out[at] = clamp16(left);
740
+ out[at + 1] = clamp16(right);
741
+ }
742
+ this.fadeIn = start + count;
743
+ // Older fades that began earlier finish before the newest fade-in does.
744
+ this.fades = this.fades.filter((fade) => fade.done < this.fadeFrames);
745
+ if (this.fadeIn >= this.fadeFrames) this.endFades();
746
+ }
747
+
748
+ private endFades(): void {
749
+ this.fades = [];
750
+ this.fadeIn = undefined;
751
+ }
752
+
653
753
  /** Wall time (monotonic ms) stream frame `frame` sounds at. */
654
754
  private frameMs(frame: number): number {
655
755
  return this.startMs + (frame * 1000) / this.sampleRate;
@@ -740,6 +840,13 @@ export class AudioEngine {
740
840
  if (!previous || wrapped > this.sampleRate * RESYNC_SECONDS)
741
841
  cursor = anchored;
742
842
  }
843
+ if (previous && this.child) {
844
+ // The outgoing loop keeps its current gain (below 1 when it was
845
+ // itself still fading in) and fades from there.
846
+ const from = this.fadeIn === undefined ? 1 : this.rampGain(this.fadeIn);
847
+ this.fades.push({ loop: previous, cursor: this.cursor, from, done: 0 });
848
+ this.fadeIn = 0;
849
+ }
743
850
  this.cursor = cursor;
744
851
  this.loop = next;
745
852
  }
@@ -761,6 +868,7 @@ export class AudioEngine {
761
868
  this.spawns += 1;
762
869
  this.child = child;
763
870
  this.loop = loop;
871
+ this.endFades();
764
872
  this.startMs = this.now();
765
873
  this.written = 0;
766
874
  this.cursor = loop ? this.frameForBeat(loop, beat) : 0;