@hraness/dawg 0.4.0 → 0.5.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.
Files changed (116) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/DAWG.md +300 -35
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/chain.ts +12 -2
  64. package/src/audio/effects/common.ts +30 -1
  65. package/src/audio/effects/convolution.ts +43 -5
  66. package/src/audio/effects/dynamics.ts +2 -1
  67. package/src/audio/effects/filter.ts +5 -3
  68. package/src/audio/effects/modulation.ts +5 -1
  69. package/src/audio/effects/space.ts +197 -50
  70. package/src/audio/engine.ts +175 -20
  71. package/src/audio/live.ts +33 -6
  72. package/src/audio/loudness.ts +551 -0
  73. package/src/audio/master.ts +660 -0
  74. package/src/audio/measure-worker.ts +45 -0
  75. package/src/audio/measure.ts +110 -0
  76. package/src/audio/player.ts +11 -6
  77. package/src/audio/preview.ts +87 -16
  78. package/src/audio/render-worker.ts +6 -1
  79. package/src/audio/renderer.ts +7 -1
  80. package/src/audio/sampler.ts +108 -19
  81. package/src/audio/synth/voice.ts +60 -13
  82. package/src/audio/synth/zzfx.ts +10 -4
  83. package/src/audio/warp.ts +86 -0
  84. package/src/audio/wav.ts +448 -59
  85. package/src/commands/arrange.ts +949 -0
  86. package/src/commands/expression.ts +934 -0
  87. package/src/commands/help.ts +281 -24
  88. package/src/commands/master.ts +361 -0
  89. package/src/commands/music.ts +6 -1
  90. package/src/commands/synth.ts +11 -1
  91. package/src/commands/time.ts +964 -0
  92. package/src/commands/tuning.ts +490 -0
  93. package/src/main.ts +826 -47
  94. package/src/render.ts +109 -7
  95. package/src/session/daemon.ts +18 -8
  96. package/src/session/naming.ts +8 -1
  97. package/src/session/rebase.ts +21 -0
  98. package/src/tui/arrange-menu.ts +390 -0
  99. package/src/tui/audition.ts +58 -5
  100. package/src/tui/euclid.ts +50 -6
  101. package/src/tui/fader.ts +409 -0
  102. package/src/tui/menu-time.ts +401 -0
  103. package/src/tui/menu.ts +656 -16
  104. package/src/tui/performance-menu.ts +235 -0
  105. package/src/tui/play-chords.ts +95 -1
  106. package/src/tui/play-mode.ts +150 -6
  107. package/src/tui/play-session.ts +414 -41
  108. package/tui/app.ts +262 -13
  109. package/tui/arrange-strip.ts +174 -0
  110. package/tui/drawer.ts +478 -0
  111. package/tui/grammar.ts +79 -12
  112. package/tui/guide.ts +351 -0
  113. package/tui/highway.ts +87 -4
  114. package/tui/hits.ts +68 -0
  115. package/tui/input.ts +7 -0
  116. package/tui/keys.ts +72 -0
@@ -5,8 +5,9 @@
5
5
  import type { Track } from "../../../core/score.ts";
6
6
  import type { DecodedSample } from "../samples.ts";
7
7
  import {
8
+ addConvolved,
8
9
  builtinImpulse,
9
- convolveStereo,
10
+ convolveRaw,
10
11
  sampleImpulse,
11
12
  type Impulse,
12
13
  } from "./convolution.ts";
@@ -15,6 +16,7 @@ import {
15
16
  OnePole,
16
17
  Param,
17
18
  interpolateAutomation,
19
+ tickAtSample,
18
20
  type EffectContext,
19
21
  } from "./common.ts";
20
22
 
@@ -48,7 +50,12 @@ export function applyDelay(
48
50
  const feedbackLane = track.delayFeedbackAutomation ?? [];
49
51
  const mixLane = track.delayMixAutomation ?? [];
50
52
  if (delay.mix <= 0 && mixLane.length === 0) return;
51
- const { sampleRate, samplesPerTick } = context;
53
+ const { sampleRate } = context;
54
+ const synced = !(delay.time !== undefined && delay.time > 0);
55
+ if (context.warp && synced) {
56
+ applyWarpedDelay(left, right, track, context, wetOnly);
57
+ return;
58
+ }
52
59
  const length = Math.max(
53
60
  1,
54
61
  Math.round(delaySeconds(track, context.tempoBpm) * sampleRate),
@@ -68,7 +75,7 @@ export function applyDelay(
68
75
  ];
69
76
  for (let index = 0; index < left.length; index += 1) {
70
77
  if (automated && index % CONTROL_SAMPLES === 0) {
71
- const tick = index / samplesPerTick;
78
+ const tick = tickAtSample(context, index);
72
79
  feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
73
80
  mix = interpolateAutomation(mixLane, tick, delay.mix);
74
81
  }
@@ -97,6 +104,89 @@ export function applyDelay(
97
104
  }
98
105
  }
99
106
 
107
+ /**
108
+ * The beat-synced delay under a tempo map: the read head trails the write
109
+ * head by `beats` of score time at every control block, read with linear
110
+ * interpolation, so each echo lands `beats` later on the grid through tempo
111
+ * steps, ramps and fermatas. Same routing and lanes as `applyDelay`.
112
+ */
113
+ function applyWarpedDelay(
114
+ left: Float64Array,
115
+ right: Float64Array,
116
+ track: Track,
117
+ context: EffectContext,
118
+ wetOnly: boolean,
119
+ ): void {
120
+ const delay = track.delay!;
121
+ const warp = context.warp!;
122
+ const { sampleRate } = context;
123
+ const feedbackLane = track.delayFeedbackAutomation ?? [];
124
+ const mixLane = track.delayMixAutomation ?? [];
125
+ // Ticks per beat, recovered from the context's start-tempo sample rate.
126
+ const ticksPerBeat = Math.round(
127
+ (sampleRate * 60) / (context.tempoBpm * context.samplesPerTick),
128
+ );
129
+ const lag = delay.beats * ticksPerBeat;
130
+ const lagAt = (index: number) =>
131
+ Math.max(2, index - warp.sample(warp.tick(index) - lag));
132
+ // Longest lag over the buffer sizes the lines.
133
+ let longest = 1;
134
+ for (let index = 0; index <= left.length; index += CONTROL_SAMPLES)
135
+ longest = Math.max(longest, lagAt(index));
136
+ const size = Math.ceil(longest) + 2;
137
+ const lineL = new Float64Array(size);
138
+ const lineR = new Float64Array(size);
139
+ const pingpong = delay.pingpong === true;
140
+ const highcut =
141
+ delay.highcut === undefined
142
+ ? undefined
143
+ : [
144
+ new OnePole(delay.highcut, sampleRate),
145
+ new OnePole(delay.highcut, sampleRate),
146
+ ];
147
+ let feedback = delay.feedback;
148
+ let mix = delay.mix;
149
+ let lagNow = lagAt(0);
150
+ let lagStep = 0;
151
+ for (let index = 0; index < left.length; index += 1) {
152
+ if (index % CONTROL_SAMPLES === 0) {
153
+ const tick = tickAtSample(context, index);
154
+ feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
155
+ mix = interpolateAutomation(mixLane, tick, delay.mix);
156
+ lagNow = lagAt(index);
157
+ lagStep = (lagAt(index + CONTROL_SAMPLES) - lagNow) / CONTROL_SAMPLES;
158
+ } else lagNow += lagStep;
159
+ const read = index - Math.min(size - 2, lagNow);
160
+ const base = Math.floor(read);
161
+ const fraction = read - base;
162
+ const at = (line: Float64Array, position: number) =>
163
+ position < 0 ? 0 : line[position % size]!;
164
+ const wetL =
165
+ at(lineL, base) * (1 - fraction) + at(lineL, base + 1) * fraction;
166
+ const wetR =
167
+ at(lineR, base) * (1 - fraction) + at(lineR, base + 1) * fraction;
168
+ const dryL = left[index]!;
169
+ const dryR = right[index]!;
170
+ let writeL: number;
171
+ let writeR: number;
172
+ if (pingpong) {
173
+ writeL = (dryL + dryR) * 0.5 + wetR * feedback;
174
+ writeR = wetL * feedback;
175
+ } else {
176
+ writeL = dryL + wetR * feedback;
177
+ writeR = dryR + wetL * feedback;
178
+ }
179
+ if (highcut) {
180
+ writeL = highcut[0]!.process(writeL);
181
+ writeR = highcut[1]!.process(writeR);
182
+ }
183
+ lineL[index % size] = writeL;
184
+ lineR[index % size] = writeR;
185
+ left[index] = (wetOnly ? 0 : dryL) + wetL * mix;
186
+ right[index] = (wetOnly ? 0 : dryR) + wetR * mix;
187
+ }
188
+ }
189
+
100
190
  /** Seconds until the track's delay decays below -60 dB, bounded. */
101
191
  export function delayTailFor(track: Track, tempoBpm: number): number {
102
192
  if (!track.delay) return 0;
@@ -181,6 +271,10 @@ export function reverbTailFor(
181
271
  * feedback for an exact -60 dB decay time; `dim` sets the in-loop damping
182
272
  * frequency, `lowpass` filters the input and `predelay` delays it.
183
273
  * `wetOnly` (a shared orbit bus) replaces the input with the tail alone.
274
+ *
275
+ * It runs in two steps: `reverbWet` (the tail before its mix gain, which
276
+ * does not depend on `mix`) and `addReverbWet`. The stem cache keeps the
277
+ * first, so a mix-only edit skips the room entirely.
184
278
  */
185
279
  export function applyReverb(
186
280
  left: Float64Array,
@@ -189,28 +283,51 @@ export function applyReverb(
189
283
  context: EffectContext,
190
284
  wetOnly = false,
191
285
  ): void {
192
- const reverb = track.reverb;
193
- if (!reverb) return;
194
- const mixParam = new Param(
195
- reverb.mix,
286
+ const wet = reverbWet(left, right, track, context);
287
+ if (wet) addReverbWet(left, right, wet, track, context, wetOnly);
288
+ }
289
+
290
+ /** A reverb's tail before its mix gain (`scale` for an impulse response). */
291
+ export type ReverbWet = Readonly<{
292
+ left: Float64Array;
293
+ right: Float64Array;
294
+ /** The convolution's inverse-FFT scale; absent for the algorithmic room. */
295
+ scale?: number;
296
+ }>;
297
+
298
+ function mixParamOf(track: Track, context: EffectContext): Param {
299
+ return new Param(
300
+ track.reverb?.mix ?? 0,
196
301
  track.fxAutomation?.["reverb-mix"],
197
302
  context.samplesPerTick,
303
+ context.warp,
304
+ );
305
+ }
306
+
307
+ /** Whether the track's reverb sounds at all (a mix above 0, or a lane). */
308
+ export function reverbActive(track: Track): boolean {
309
+ const reverb = track.reverb;
310
+ if (!reverb) return false;
311
+ return (
312
+ reverb.mix > 0 || (track.fxAutomation?.["reverb-mix"] ?? []).length > 0
198
313
  );
199
- if (reverb.mix <= 0 && !mixParam.automated) return;
314
+ }
315
+
316
+ /**
317
+ * The reverb tail of the pair, before the mix gain; `left`/`right` are
318
+ * read, not changed. Undefined when the reverb is off.
319
+ */
320
+ export function reverbWet(
321
+ left: Float64Array,
322
+ right: Float64Array,
323
+ track: Track,
324
+ context: EffectContext,
325
+ ): ReverbWet | undefined {
326
+ const reverb = track.reverb;
327
+ if (!reverb || !reverbActive(track)) return undefined;
200
328
  const { sampleRate } = context;
201
329
  const impulse = reverbImpulse(track, sampleRate, context.irs);
202
- if (impulse) {
203
- applyConvolutionReverb(
204
- left,
205
- right,
206
- track,
207
- context,
208
- impulse,
209
- mixParam,
210
- wetOnly,
211
- );
212
- return;
213
- }
330
+ if (impulse) return convolutionWet(left, right, track, context, impulse);
214
331
  const scale = sampleRate / 44_100;
215
332
  const sizeFeedback = 0.7 + 0.28 * reverb.size;
216
333
  const damp =
@@ -219,7 +336,6 @@ export function applyReverb(
219
336
  : Math.exp(
220
337
  (-2 * Math.PI * Math.min(reverb.dim, sampleRate * 0.45)) / sampleRate,
221
338
  );
222
- let wet = reverb.mix * REVERB_WET_SCALE;
223
339
  const channel = (spread: number) => ({
224
340
  combs: COMB_TUNING.map((tuning) => {
225
341
  const length = Math.max(1, Math.round((tuning + spread) * scale));
@@ -266,9 +382,9 @@ export function applyReverb(
266
382
  const predelaySamples = Math.round((reverb.predelay ?? 0) * sampleRate);
267
383
  const predelay =
268
384
  predelaySamples > 0 ? new Float64Array(predelaySamples) : undefined;
385
+ const wetL = new Float64Array(left.length);
386
+ const wetR = new Float64Array(left.length);
269
387
  for (let index = 0; index < left.length; index += 1) {
270
- if (mixParam.automated && index % CONTROL_SAMPLES === 0)
271
- wet = mixParam.at(index) * REVERB_WET_SCALE;
272
388
  let input = (left[index]! + right[index]!) * REVERB_INPUT_GAIN;
273
389
  if (inputFilter) input = inputFilter.process(input);
274
390
  if (predelay) {
@@ -277,12 +393,60 @@ export function applyReverb(
277
393
  predelay[slot] = input;
278
394
  input = delayed;
279
395
  }
396
+ wetL[index] = process(sides[0], input);
397
+ wetR[index] = process(sides[1], input);
398
+ }
399
+ return { left: wetL, right: wetR };
400
+ }
401
+
402
+ /**
403
+ * Add (or with `wetOnly`, write) a tail from `reverbWet` at the track's
404
+ * mix and mix lane, in the order the one-pass reverb always used, so a
405
+ * cached tail mixes to the same samples.
406
+ */
407
+ export function addReverbWet(
408
+ left: Float64Array,
409
+ right: Float64Array,
410
+ tail: ReverbWet,
411
+ track: Track,
412
+ context: EffectContext,
413
+ wetOnly = false,
414
+ ): void {
415
+ const mixParam = mixParamOf(track, context);
416
+ if (tail.scale !== undefined) {
417
+ if (wetOnly) {
418
+ left.fill(0);
419
+ right.fill(0);
420
+ }
421
+ if (tail.scale === 0) return;
422
+ let wet = mixParam.fallback * IR_WET_SCALE;
423
+ addConvolved(
424
+ tail.left,
425
+ tail.right,
426
+ tail.scale,
427
+ left,
428
+ right,
429
+ wet,
430
+ mixParam.automated
431
+ ? (index) => {
432
+ if (index % CONTROL_SAMPLES === 0)
433
+ wet = mixParam.at(index) * IR_WET_SCALE;
434
+ return wet;
435
+ }
436
+ : undefined,
437
+ );
438
+ return;
439
+ }
440
+ let wet = mixParam.fallback * REVERB_WET_SCALE;
441
+ for (let index = 0; index < left.length; index += 1) {
442
+ if (mixParam.automated && index % CONTROL_SAMPLES === 0)
443
+ wet = mixParam.at(index) * REVERB_WET_SCALE;
280
444
  if (wetOnly) {
281
- left[index] = process(sides[0], input) * wet;
282
- right[index] = process(sides[1], input) * wet;
445
+ left[index] = tail.left[index]! * wet;
446
+ right[index] = tail.right[index]! * wet;
283
447
  } else {
284
- left[index]! += process(sides[0], input) * wet;
285
- right[index]! += process(sides[1], input) * wet;
448
+ left[index]! += tail.left[index]! * wet;
449
+ right[index]! += tail.right[index]! * wet;
286
450
  }
287
451
  }
288
452
  }
@@ -293,15 +457,13 @@ export function applyReverb(
293
457
  * `size`, `fade` and `dim` describe the algorithmic tail and do nothing
294
458
  * here: the impulse is the room.
295
459
  */
296
- function applyConvolutionReverb(
460
+ function convolutionWet(
297
461
  left: Float64Array,
298
462
  right: Float64Array,
299
463
  track: Track,
300
464
  context: EffectContext,
301
465
  impulse: Impulse,
302
- mixParam: Param,
303
- wetOnly: boolean,
304
- ): void {
466
+ ): ReverbWet {
305
467
  const reverb = track.reverb!;
306
468
  const { sampleRate } = context;
307
469
  const input = new Float64Array(left.length);
@@ -315,23 +477,8 @@ function applyConvolutionReverb(
315
477
  if (inputFilter) value = inputFilter.process(value);
316
478
  input[index + offset] = value;
317
479
  }
318
- if (wetOnly) {
319
- left.fill(0);
320
- right.fill(0);
321
- }
322
- let wet = reverb.mix * IR_WET_SCALE;
323
- convolveStereo(
324
- input,
325
- impulse,
326
- left,
327
- right,
328
- wet,
329
- mixParam.automated
330
- ? (index) => {
331
- if (index % CONTROL_SAMPLES === 0)
332
- wet = mixParam.at(index) * IR_WET_SCALE;
333
- return wet;
334
- }
335
- : undefined,
336
- );
480
+ const wetL = new Float64Array(left.length);
481
+ const wetR = new Float64Array(left.length);
482
+ const scale = convolveRaw(input, impulse, wetL, wetR);
483
+ return { left: wetL, right: wetR, scale };
337
484
  }
@@ -2,10 +2,19 @@ import type { TrackScore } from "../../core/score.ts";
2
2
  import { PlaybackLock } from "./lock.ts";
3
3
  import { LoopPlayer } from "./player.ts";
4
4
  import { LoopRenderer, type LoopRender } from "./renderer.ts";
5
- import { clickSounds, clicksIn, type ClickLevel } from "./click.ts";
5
+ import {
6
+ clickSounds,
7
+ clicksIn,
8
+ type ClickGrid,
9
+ type ClickLevel,
10
+ } from "./click.ts";
6
11
  import type { LiveNotePcm } from "./live.ts";
12
+ import { measureLoudness, pcmChannels } from "./loudness.ts";
13
+ import type { MasterReport } from "./master.ts";
7
14
  import { levelOf, type SoundLevel } from "./preview.ts";
8
15
  import { DEFAULT_SAMPLE_RATE, RENDER_CHANNELS } from "./wav.ts";
16
+ import { playbackTime } from "./arrange.ts";
17
+ import { transportMapFor, type TransportMap } from "./clock.ts";
9
18
 
10
19
  /**
11
20
  * How dawgd (or a file-mode window) makes sound.
@@ -170,6 +179,17 @@ function parseCommand(value: string): string[] {
170
179
  return parts;
171
180
  }
172
181
 
182
+ /**
183
+ * Swaps crossfade old to new over this window with equal-power gains
184
+ * (cos/sin), reading both loops at the same beat. 20 ms is long enough that
185
+ * the step a hard cut makes is spread below ~50 Hz (no click, even on a
186
+ * full-scale low bass), short enough to sit inside the transport's 30 ms
187
+ * resync tolerance and below the shortest audible echo (~30-50 ms), so an
188
+ * edit never sounds smeared or doubled. Playback only: renders, exports and
189
+ * the loop buffers themselves never pass through it.
190
+ */
191
+ export const SWAP_FADE_SECONDS = 0.02;
192
+
173
193
  /** Swaps re-anchor to the transport only past this disagreement. */
174
194
  const RESYNC_SECONDS = 0.03;
175
195
 
@@ -226,8 +246,45 @@ type Loop = Readonly<{
226
246
  pcm: Int16Array;
227
247
  frames: number;
228
248
  framesPerBeat: number;
249
+ /** The song's tempo map; absent, a beat is `framesPerBeat` frames. */
250
+ map?: TransportMap;
251
+ sampleRate: number;
229
252
  }>;
230
253
 
254
+ /** Loop frame (unwrapped without a map) where transport `beat` sounds. */
255
+ function frameOfBeat(loop: Loop, beat: number): number {
256
+ const map = loop.map;
257
+ if (!map) return beat * loop.framesPerBeat;
258
+ const inner = ((beat % map.loopBeats) + map.loopBeats) % map.loopBeats;
259
+ return map.seconds(inner) * loop.sampleRate;
260
+ }
261
+
262
+ /** Transport beat at loop `frame` (inside one pass with a map). */
263
+ function beatOfFrame(loop: Loop, frame: number): number {
264
+ const map = loop.map;
265
+ if (!map) return frame / loop.framesPerBeat;
266
+ const seconds = frame / loop.sampleRate;
267
+ const inner =
268
+ ((seconds % map.loopSeconds) + map.loopSeconds) % map.loopSeconds;
269
+ return map.beat(inner);
270
+ }
271
+
272
+ /** The beat `frames` stream frames after `beat`. */
273
+ function beatAfter(loop: Loop, beat: number, frames: number): number {
274
+ if (!loop.map) return beat + frames / loop.framesPerBeat;
275
+ return beatOfFrame(loop, frameOfBeat(loop, beat) + frames);
276
+ }
277
+
278
+ /** A loop fading out after a swap, read on from where it was. */
279
+ type FadeOut = {
280
+ loop: Loop;
281
+ cursor: number;
282
+ /** Its gain when the fade began (below 1 when swapped out mid-fade). */
283
+ from: number;
284
+ /** Frames of the fade already written. */
285
+ done: number;
286
+ };
287
+
231
288
  /**
232
289
  * The click bus: the engine asks `beatAt` for the transport beat at a stream
233
290
  * frame's wall time (undefined while the transport is stopped; negative
@@ -238,6 +295,8 @@ export type ClickBus = Readonly<{
238
295
  volume: number;
239
296
  beatsPerBar: number;
240
297
  subdivision: number;
298
+ /** Meter-aware clicks (core/tempo.ts) replacing the fixed grid. */
299
+ grid?: ClickGrid;
241
300
  beatAt: (monotonicMs: number) => number | undefined;
242
301
  }>;
243
302
 
@@ -293,6 +352,11 @@ export class AudioEngine {
293
352
  private child: PlayerProcess | undefined;
294
353
  private timer: ReturnType<typeof setInterval> | undefined;
295
354
  private loop: Loop | undefined;
355
+ /** Loops fading out after swaps (see `SWAP_FADE_SECONDS`). */
356
+ private fades: FadeOut[] = [];
357
+ /** Frames of the current loop's fade-in already written, while fading. */
358
+ private fadeIn: number | undefined;
359
+ private readonly fadeFrames: number;
296
360
  /** Monotonic ms that stream frame 0 belongs to. */
297
361
  private startMs = 0;
298
362
  /** Stream frames written since `startMs`. */
@@ -308,6 +372,9 @@ export class AudioEngine {
308
372
  /** The loop last swapped in, for `level` (measured on demand, once). */
309
373
  private lastLoopPcm: Int16Array | undefined;
310
374
  private measured: { pcm: Int16Array; level: SoundLevel } | undefined;
375
+ private loudnessOf: { pcm: Int16Array; report: MasterReport } | undefined;
376
+ /** Loudness the song master reached on the loop now playing. */
377
+ private lastMaster: MasterReport | undefined;
311
378
  private respawns = 0;
312
379
  private respawnTimer: ReturnType<typeof setTimeout> | undefined;
313
380
  /** Play mode: keep a player running without a loop for live voices. */
@@ -328,6 +395,10 @@ export class AudioEngine {
328
395
  ((options.leadMs ?? 200) * this.sampleRate) / 1000,
329
396
  );
330
397
  this.defaultLeadFrames = this.leadFrames;
398
+ this.fadeFrames = Math.max(
399
+ 1,
400
+ Math.round(SWAP_FADE_SECONDS * this.sampleRate),
401
+ );
331
402
  this.tickMs = options.tickMs ?? 20;
332
403
  this.now = options.now ?? (() => performance.now());
333
404
  this.useTimer = options.timer ?? true;
@@ -391,6 +462,33 @@ export class AudioEngine {
391
462
  return this.measured.level;
392
463
  }
393
464
 
465
+ /**
466
+ * Integrated loudness and true peak of the loop now playing, after the
467
+ * song master when there is one (the header meter). Without a master the
468
+ * loop's PCM is measured once per render, on first read. Undefined before
469
+ * any render.
470
+ */
471
+ public get loudness(): MasterReport | undefined {
472
+ const pcm = this.lastLoopPcm;
473
+ if (!pcm) return undefined;
474
+ if (this.lastMaster) return this.lastMaster;
475
+ if (this.loudnessOf?.pcm !== pcm) {
476
+ const [left, right] = pcmChannels(pcm);
477
+ const measured = measureLoudness(left, right, this.sampleRate, {
478
+ loop: true,
479
+ });
480
+ this.loudnessOf = {
481
+ pcm,
482
+ report: {
483
+ gainDb: 0,
484
+ integrated: measured.integrated,
485
+ truePeak: measured.truePeak,
486
+ },
487
+ };
488
+ }
489
+ return this.loudnessOf.report;
490
+ }
491
+
394
492
  public get canMonitor(): boolean {
395
493
  return this.info.backend !== "none" && !this.fallback;
396
494
  }
@@ -522,6 +620,7 @@ export class AudioEngine {
522
620
  if (request.generation === this.generation) {
523
621
  await this.apply(this.toLoop(render, request.score), request);
524
622
  this.lastLoopPcm = render.pcm;
623
+ this.lastMaster = render.master;
525
624
  }
526
625
  for (const waiter of request.waiters) waiter.resolve();
527
626
  } catch (error) {
@@ -541,10 +640,11 @@ export class AudioEngine {
541
640
  const beat =
542
641
  request.beat === undefined
543
642
  ? undefined
544
- : request.beat +
545
- ((this.now() - request.atMs) * this.sampleRate) /
546
- 1000 /
547
- loop.framesPerBeat;
643
+ : beatAfter(
644
+ loop,
645
+ request.beat,
646
+ ((this.now() - request.atMs) * this.sampleRate) / 1000,
647
+ );
548
648
  if (this.child) {
549
649
  this.swap(loop, beat);
550
650
  return;
@@ -565,19 +665,10 @@ export class AudioEngine {
565
665
  if (!this.loop || !this.child) return;
566
666
  this.cursor = this.frameForBeat(
567
667
  this.loop,
568
- beat + this.queuedBeats(this.loop),
668
+ beatAfter(this.loop, beat, this.queuedFrames()),
569
669
  );
570
670
  }
571
671
 
572
- /**
573
- * Beats already queued ahead of the wall clock. The next frame written
574
- * sounds this far after now, so a transport beat maps to the write head
575
- * by adding it.
576
- */
577
- private queuedBeats(loop: Loop): number {
578
- return this.queuedFrames() / loop.framesPerBeat;
579
- }
580
-
581
672
  private queuedFrames(): number {
582
673
  const elapsed = Math.floor(
583
674
  ((this.now() - this.startMs) * this.sampleRate) / 1000,
@@ -592,6 +683,7 @@ export class AudioEngine {
592
683
  public async stopAsync(): Promise<void> {
593
684
  this.generation += 1;
594
685
  this.lastLoopPcm = undefined;
686
+ this.lastMaster = undefined;
595
687
  this.respawns = 0;
596
688
  if (this.respawnTimer) clearTimeout(this.respawnTimer);
597
689
  this.respawnTimer = undefined;
@@ -603,6 +695,7 @@ export class AudioEngine {
603
695
  if (this.monitoring && this.child) {
604
696
  // Play mode: the transport stopped, the live player keeps running.
605
697
  this.loop = undefined;
698
+ this.endFades();
606
699
  return;
607
700
  }
608
701
  if (this.timer) clearInterval(this.timer);
@@ -610,6 +703,7 @@ export class AudioEngine {
610
703
  const child = this.child;
611
704
  this.child = undefined;
612
705
  this.loop = undefined;
706
+ this.endFades();
613
707
  if (child) {
614
708
  try {
615
709
  child.stdin.end?.();
@@ -646,6 +740,7 @@ export class AudioEngine {
646
740
  if (remaining > this.sampleRate) {
647
741
  const skipped = remaining - this.sampleRate;
648
742
  if (loop) this.cursor = (this.cursor + skipped) % loop.frames;
743
+ this.endFades();
649
744
  this.written += skipped;
650
745
  remaining = this.sampleRate;
651
746
  }
@@ -663,6 +758,7 @@ export class AudioEngine {
663
758
  offset += take;
664
759
  this.cursor = (this.cursor + take) % loop.frames;
665
760
  }
761
+ if (loop) this.crossfade(out, remaining);
666
762
  this.mixLive(out, this.written, remaining);
667
763
  this.written += remaining;
668
764
  try {
@@ -673,6 +769,49 @@ export class AudioEngine {
673
769
  }
674
770
  }
675
771
 
772
+ /** Equal-power gain of a fade-in `done` frames along (0 to 1). */
773
+ private rampGain(done: number): number {
774
+ return Math.sin((Math.PI / 2) * Math.min(1, done / this.fadeFrames));
775
+ }
776
+
777
+ /**
778
+ * Blend the start of a block (already the current loop) with the loops
779
+ * fading out: new·sin + old·cos over `fadeFrames`, both read at the same
780
+ * beat, so a swap never steps the waveform.
781
+ */
782
+ private crossfade(out: Int16Array, frames: number): void {
783
+ if (this.fadeIn === undefined) return;
784
+ const start = this.fadeIn;
785
+ const count = Math.min(frames, this.fadeFrames - start);
786
+ for (let index = 0; index < count; index += 1) {
787
+ const gain = this.rampGain(start + index);
788
+ const at = index * RENDER_CHANNELS;
789
+ let left = out[at]! * gain;
790
+ let right = out[at + 1]! * gain;
791
+ for (const fade of this.fades) {
792
+ if (fade.done >= this.fadeFrames) continue;
793
+ const g =
794
+ fade.from * Math.cos((Math.PI / 2) * (fade.done / this.fadeFrames));
795
+ const from = fade.cursor * RENDER_CHANNELS;
796
+ left += fade.loop.pcm[from]! * g;
797
+ right += fade.loop.pcm[from + 1]! * g;
798
+ fade.cursor = (fade.cursor + 1) % fade.loop.frames;
799
+ fade.done += 1;
800
+ }
801
+ out[at] = clamp16(left);
802
+ out[at + 1] = clamp16(right);
803
+ }
804
+ this.fadeIn = start + count;
805
+ // Older fades that began earlier finish before the newest fade-in does.
806
+ this.fades = this.fades.filter((fade) => fade.done < this.fadeFrames);
807
+ if (this.fadeIn >= this.fadeFrames) this.endFades();
808
+ }
809
+
810
+ private endFades(): void {
811
+ this.fades = [];
812
+ this.fadeIn = undefined;
813
+ }
814
+
676
815
  /** Wall time (monotonic ms) stream frame `frame` sounds at. */
677
816
  private frameMs(frame: number): number {
678
817
  return this.startMs + (frame * 1000) / this.sampleRate;
@@ -736,15 +875,19 @@ export class AudioEngine {
736
875
  }
737
876
 
738
877
  private toLoop(render: LoopRender, score: TrackScore): Loop {
878
+ const timeline = playbackTime(score);
879
+ const map = transportMapFor(timeline);
739
880
  return {
740
881
  pcm: render.pcm,
741
882
  frames: render.frames,
742
- framesPerBeat: (60 * this.sampleRate) / score.tempoBpm,
883
+ framesPerBeat: (60 * this.sampleRate) / timeline.tempoBpm,
884
+ sampleRate: this.sampleRate,
885
+ ...(map ? { map } : {}),
743
886
  };
744
887
  }
745
888
 
746
889
  private frameForBeat(loop: Loop, beat: number): number {
747
- const frame = Math.round(Math.max(0, beat) * loop.framesPerBeat);
890
+ const frame = Math.round(frameOfBeat(loop, Math.max(0, beat)));
748
891
  return ((frame % loop.frames) + loop.frames) % loop.frames;
749
892
  }
750
893
 
@@ -752,17 +895,28 @@ export class AudioEngine {
752
895
  const previous = this.loop;
753
896
  // Keep the musical position: the same beat inside the new loop.
754
897
  let cursor = previous
755
- ? this.frameForBeat(next, this.cursor / previous.framesPerBeat)
898
+ ? this.frameForBeat(next, beatOfFrame(previous, this.cursor))
756
899
  : 0;
757
900
  if (beat !== undefined) {
758
901
  // Re-anchor to the transport only when it disagrees audibly (a seek
759
902
  // or a tempo change); sub-frame rounding must not click on edits.
760
- const anchored = this.frameForBeat(next, beat + this.queuedBeats(next));
903
+ // The next frame written sounds `queuedFrames` after now.
904
+ const anchored = this.frameForBeat(
905
+ next,
906
+ beatAfter(next, beat, this.queuedFrames()),
907
+ );
761
908
  const distance = Math.abs(anchored - cursor);
762
909
  const wrapped = Math.min(distance, next.frames - distance);
763
910
  if (!previous || wrapped > this.sampleRate * RESYNC_SECONDS)
764
911
  cursor = anchored;
765
912
  }
913
+ if (previous && this.child) {
914
+ // The outgoing loop keeps its current gain (below 1 when it was
915
+ // itself still fading in) and fades from there.
916
+ const from = this.fadeIn === undefined ? 1 : this.rampGain(this.fadeIn);
917
+ this.fades.push({ loop: previous, cursor: this.cursor, from, done: 0 });
918
+ this.fadeIn = 0;
919
+ }
766
920
  this.cursor = cursor;
767
921
  this.loop = next;
768
922
  }
@@ -784,6 +938,7 @@ export class AudioEngine {
784
938
  this.spawns += 1;
785
939
  this.child = child;
786
940
  this.loop = loop;
941
+ this.endFades();
787
942
  this.startMs = this.now();
788
943
  this.written = 0;
789
944
  this.cursor = loop ? this.frameForBeat(loop, beat) : 0;
@@ -835,7 +990,7 @@ export class AudioEngine {
835
990
  if (generation !== this.generation || this.child || this.starting)
836
991
  return;
837
992
  const frame = heard + ((this.now() - diedAt) * this.sampleRate) / 1000;
838
- const beat = loop ? frame / loop.framesPerBeat : 0;
993
+ const beat = loop ? beatOfFrame(loop, frame) : 0;
839
994
  this.starting = this.start(loop, beat).finally(() => {
840
995
  this.starting = undefined;
841
996
  });