@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
package/src/audio/wav.ts CHANGED
@@ -10,6 +10,12 @@ import {
10
10
  type TrackScore,
11
11
  } from "../../core/score.ts";
12
12
  import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
13
+ import {
14
+ performanceTimingFor,
15
+ tunedTiming,
16
+ performNotes,
17
+ type PerformedNote,
18
+ } from "../../core/expression.ts";
13
19
  import {
14
20
  planSamplerVoices,
15
21
  renderSamplerVoices,
@@ -34,12 +40,16 @@ import {
34
40
  import { synthKit } from "../../core/kits.ts";
35
41
  import { kitDrumSample, kitTailSeconds, newKitVoiceState } from "./kits.ts";
36
42
  import {
43
+ addReverbWet,
37
44
  applyMonoChain,
38
45
  applyStereoChain,
39
46
  delayTailFor,
40
47
  interpolateAutomation,
48
+ reverbActive,
41
49
  reverbImpulse,
42
50
  reverbTailFor,
51
+ reverbWet,
52
+ type ReverbWet,
43
53
  } from "./effects/chain.ts";
44
54
  import {
45
55
  createOrbitBus,
@@ -49,12 +59,21 @@ import {
49
59
  type OrbitBus,
50
60
  } from "./effects/bus.ts";
51
61
  import { tableFor, wavetableOscillator } from "./wavetable.ts";
62
+ import { noteHz, resolveTuning, type TuningTable } from "../../core/tuning.ts";
52
63
  import {
53
64
  duckGains,
54
65
  duckSettings,
55
66
  orbitOf,
56
67
  type Ducker,
57
68
  } from "./effects/duck.ts";
69
+ import {
70
+ loopSecondsOf,
71
+ performedNotes,
72
+ slowestBpmOf,
73
+ } from "../../core/tempo.ts";
74
+ import { sampleWarpFor, warpedSpan, type SampleWarp } from "./warp.ts";
75
+ import { applyMaster, type MasterReport } from "./master.ts";
76
+ import type { SongMaster } from "../../core/master.ts";
58
77
 
59
78
  export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
60
79
 
@@ -71,6 +90,12 @@ export type RenderOptions = WavOptions &
71
90
  * tracks render silent; scores without samplers are unaffected.
72
91
  */
73
92
  samples?: SampleBank;
93
+ /**
94
+ * Ticks added to each note's start when seeding its noise. An arranged
95
+ * render window passes its origin so seeded voices sound as they do in
96
+ * a single pass of the whole song. Zero (the default) changes nothing.
97
+ */
98
+ seedTick?: number;
74
99
  }>;
75
100
 
76
101
  /** Interleaved stereo 16-bit PCM plus its frame count. */
@@ -79,6 +104,8 @@ export type RenderedAudio = Readonly<{
79
104
  channels: 2;
80
105
  frames: number;
81
106
  pcm: Int16Array;
107
+ /** Loudness the song master reached; absent without a master. */
108
+ master?: MasterReport;
82
109
  }>;
83
110
 
84
111
  /** Output channel count for every render and export. */
@@ -141,6 +168,16 @@ const MAX_DRUM_SECONDS = 0.6;
141
168
  const CONTROL_SAMPLES = 32;
142
169
  /** One-shot renders keep this much ring-out after the last bar. */
143
170
  const ONE_SHOT_TAIL_SECONDS = 0.35;
171
+
172
+ function oneShotSeconds(
173
+ loopSeconds: number,
174
+ samplerTail: number,
175
+ reverbTail: number,
176
+ ): number {
177
+ return (
178
+ loopSeconds + Math.max(ONE_SHOT_TAIL_SECONDS, samplerTail) + reverbTail
179
+ );
180
+ }
144
181
  /** Loop renders fold at most this much tail back onto the loop start. */
145
182
  const MAX_LOOP_TAIL_SECONDS = 8;
146
183
 
@@ -151,10 +188,14 @@ type RenderContext = Readonly<{
151
188
  samplesPerTick: number;
152
189
  tempoBpm: number;
153
190
  irs?: ReadonlyMap<string, DecodedSample>;
191
+ /** Tempo map in samples (core/tempo.ts); absent: constant tempo. */
192
+ warp?: SampleWarp;
193
+ seedTick?: number;
154
194
  }>;
155
195
 
156
196
  /** Exact (fractional) loop length in frames at a sample rate. */
157
197
  export function loopFrames(score: TrackScore, sampleRate: number): number {
198
+ if (score.time) return loopSecondsOf(score) * sampleRate;
158
199
  return (score.bars * score.beatsPerBar * 60 * sampleRate) / score.tempoBpm;
159
200
  }
160
201
 
@@ -193,8 +234,16 @@ type Stem = {
193
234
  key: string;
194
235
  left: Float64Array;
195
236
  right: Float64Array;
237
+ /** Stem, pre-reverb pair and tail (counted in the cache budget). */
196
238
  bytes: number;
197
239
  used: number;
240
+ /** For a track with a reverb: its stem before the reverb, and the tail. */
241
+ room?: Readonly<{
242
+ /** `stemKey` of the track with its reverb mix and mix lane left out. */
243
+ key: string;
244
+ pre: Readonly<{ left: Float64Array; right: Float64Array }>;
245
+ wet: ReverbWet;
246
+ }>;
198
247
  };
199
248
 
200
249
  /**
@@ -244,11 +293,38 @@ export class StemRenderer {
244
293
  return { stems: this.stems.size, bytes: this.cacheBytes };
245
294
  }
246
295
 
296
+ /**
297
+ * Seconds a one-shot render of `score` needs (song plus tail) before the
298
+ * renderer's cap, so callers can tell when a single pass would cut it.
299
+ */
300
+ public oneShotSeconds(
301
+ score: TrackScore,
302
+ options: RenderOptions = {},
303
+ ): number {
304
+ const sampleRate = clampSampleRate(options.sampleRate);
305
+ const bank = options.samples ?? EMPTY_SAMPLE_BANK;
306
+ const reverbTail = Math.max(
307
+ 0,
308
+ ...score.tracks.map((track) =>
309
+ reverbTailFor(track, sampleRate, bank.irs),
310
+ ),
311
+ );
312
+ const samplerTail = Math.min(
313
+ MAX_LOOP_TAIL_SECONDS,
314
+ Math.max(
315
+ samplerTailSeconds(score, bank, sampleRate),
316
+ ...score.tracks.map(synthTailSeconds),
317
+ ),
318
+ );
319
+ return oneShotSeconds(loopSecondsOf(score), samplerTail, reverbTail);
320
+ }
321
+
247
322
  public render(score: TrackScore, options: RenderOptions = {}): RenderedAudio {
248
323
  const sampleRate = clampSampleRate(options.sampleRate);
249
324
  const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
250
- const loopSeconds = (score.bars * score.beatsPerBar * 60) / score.tempoBpm;
325
+ const loopSeconds = loopSecondsOf(score);
251
326
  const bank = options.samples ?? EMPTY_SAMPLE_BANK;
327
+ const warp = sampleWarpFor(score, sampleRate);
252
328
  const reverbTail = Math.max(
253
329
  0,
254
330
  ...score.tracks.map((track) =>
@@ -281,14 +357,17 @@ export class StemRenderer {
281
357
  reverbTail +
282
358
  Math.max(
283
359
  0,
284
- ...score.tracks.map((track) => delayTailFor(track, score.tempoBpm)),
360
+ // The slowest tempo in the map gives the longest echoes.
361
+ ...score.tracks.map((track) =>
362
+ delayTailFor(track, slowestBpmOf(score)),
363
+ ),
285
364
  ),
286
365
  );
287
366
  samples = frames + Math.ceil(tailSeconds * sampleRate);
288
367
  } else {
289
368
  const seconds = Math.min(
290
369
  maxSeconds,
291
- loopSeconds + Math.max(ONE_SHOT_TAIL_SECONDS, samplerTail) + reverbTail,
370
+ oneShotSeconds(loopSeconds, samplerTail, reverbTail),
292
371
  );
293
372
  frames = Math.max(1, Math.ceil(seconds * sampleRate));
294
373
  samples = frames;
@@ -300,6 +379,8 @@ export class StemRenderer {
300
379
  samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
301
380
  tempoBpm: score.tempoBpm,
302
381
  ...(bank.irs ? { irs: bank.irs } : {}),
382
+ ...(warp ? { warp } : {}),
383
+ ...(options.seedTick ? { seedTick: options.seedTick } : {}),
303
384
  };
304
385
  this.renders += 1;
305
386
  const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
@@ -307,20 +388,34 @@ export class StemRenderer {
307
388
  mixR.fill(0);
308
389
  const tracks = new Map(score.tracks.map((track) => [track.id, track]));
309
390
  const groups = new Map<string, Note[]>();
310
- for (const note of score.notes) {
391
+ // Track time (rate, phase, cycle) places notes on the song timeline.
392
+ for (const note of performedNotes(score)) {
311
393
  const group = groups.get(note.trackId);
312
394
  if (group) group.push(note);
313
395
  else groups.set(note.trackId, [note]);
314
396
  }
397
+ // Note expression and track performance (core/expression.ts): the
398
+ // notes as played. A track with neither gets its notes back unchanged.
399
+ const timing = performanceTimingFor(score);
400
+ const performed = new Map<string, readonly PerformedNote[]>();
401
+ for (const [trackId, notes] of groups) {
402
+ const track = tracks.get(trackId);
403
+ performed.set(
404
+ trackId,
405
+ performNotes(track, notes, tunedTiming(timing, score, track)),
406
+ );
407
+ }
315
408
  // Orbit ducking is a gain on finished stems (src/audio/effects/duck.ts).
316
409
  const duckers: Ducker[] = [];
317
- for (const [trackId, notes] of groups) {
410
+ for (const [trackId] of groups) {
318
411
  const settings = duckSettings(tracks.get(trackId));
319
412
  if (settings && isTrackAudible(score, trackId))
320
413
  duckers.push({
321
414
  trackId,
322
415
  ...settings,
323
- onsets: notes.map((note) => noteSpan(note, context).start),
416
+ onsets: performed
417
+ .get(trackId)!
418
+ .map((note) => noteSpan(note, context).start),
324
419
  });
325
420
  }
326
421
  const ducked = duckGains(
@@ -337,11 +432,16 @@ export class StemRenderer {
337
432
  for (const [trackId, notes] of groups) {
338
433
  if (!isTrackAudible(score, trackId)) continue;
339
434
  const track = tracks.get(trackId);
435
+ // Stems key on the written notes (with the track's performance
436
+ // settings); the voices render the performed ones.
437
+ const played = performed.get(trackId)!;
340
438
  const busOrbit = sharedOrbitOf(track);
341
439
  const sampler = isSamplerInstrument(track?.instrument);
342
440
  // Wavetable hook: the oscillator factory for a wavetable track (its
343
441
  // table id joins the stem key), undefined for every other instrument.
344
442
  const wavetable = track ? wavetableHook(track, bank, context) : undefined;
443
+ // The merged song and track tuning; undefined keeps 12-TET untouched.
444
+ const tuning = resolveTuning(score.tuning, track?.tuning, score.key);
345
445
  const key = stemKey(
346
446
  track,
347
447
  notes,
@@ -351,9 +451,38 @@ export class StemRenderer {
351
451
  track ? reverbImpulse(track, sampleRate, bank.irs)?.id : undefined,
352
452
  );
353
453
  let stem = this.stems.get(trackId);
454
+ const caching = this.maxCacheBytes > 0;
455
+ // A cached track with a reverb also keeps its pre-reverb pair and the
456
+ // tail before its mix gain, keyed without the mix: a mix-only edit
457
+ // re-adds the tail instead of re-running the voices and the room.
458
+ const room =
459
+ caching && track && busOrbit === undefined && reverbActive(track)
460
+ ? stemKey(
461
+ roomTrack(track),
462
+ notes,
463
+ context,
464
+ sampler ? bank : undefined,
465
+ wavetable?.id,
466
+ reverbImpulse(track, sampleRate, bank.irs)?.id,
467
+ )
468
+ : undefined;
354
469
  if (stem?.key === key) stem.used = this.renders;
355
- else {
356
- const caching = this.maxCacheBytes > 0;
470
+ else if (track && room !== undefined && stem?.room?.key === room) {
471
+ const { pre, wet } = stem.room;
472
+ const target = {
473
+ left: Float64Array.from(pre.left),
474
+ right: Float64Array.from(pre.right),
475
+ };
476
+ addReverbWet(target.left, target.right, wet, track, context);
477
+ stem = {
478
+ ...stem,
479
+ key,
480
+ left: target.left,
481
+ right: target.right,
482
+ used: this.renders,
483
+ };
484
+ this.store(trackId, stem);
485
+ } else {
357
486
  const target = caching
358
487
  ? {
359
488
  left: new Float64Array(samples),
@@ -365,16 +494,28 @@ export class StemRenderer {
365
494
  const stereo = synthVoice && isStereoVoice(track);
366
495
  if (stereo) dryR.fill(0);
367
496
  if (sampler) {
368
- if (track) renderSamplerNotes(dry, notes, track, context, bank);
497
+ if (track) renderSamplerNotes(dry, played, track, context, bank);
369
498
  } else if (synthVoice && track) {
370
499
  const gainAt = (tick: number) => trackGainAt(track, tick);
371
500
  const voice = {
372
501
  ...context,
373
502
  ticksPerBeat: score.ticksPerBeat,
374
503
  ...(wavetable ? { oscillatorFor: wavetable.oscillatorFor } : {}),
504
+ ...(tuning ? { tuning } : {}),
375
505
  };
376
- for (const note of notes) {
506
+ const warp = context.warp;
507
+ for (const note of played) {
377
508
  const { start, length } = noteSpan(note, context);
509
+ // The voice asks for `startTick + elapsed / samplesPerTick`;
510
+ // through a tempo map that sample's tick comes from the map.
511
+ const noteGainAt = warp
512
+ ? (tick: number) =>
513
+ gainAt(
514
+ warp.tick(
515
+ start + (tick - note.startTick) * context.samplesPerTick,
516
+ ),
517
+ )
518
+ : gainAt;
378
519
  renderSynthNote(
379
520
  dry,
380
521
  stereo ? dryR : undefined,
@@ -383,14 +524,14 @@ export class StemRenderer {
383
524
  start,
384
525
  length,
385
526
  voice,
386
- gainAt,
527
+ noteGainAt,
387
528
  );
388
529
  }
389
530
  } else {
390
531
  const drums = isDrumInstrument(track?.instrument);
391
- for (const note of notes) {
532
+ for (const note of played) {
392
533
  if (drums) renderDrumNote(dry, note, track, context);
393
- else renderToneNote(dry, note, track, context);
534
+ else renderToneNote(dry, note, track, context, tuning);
394
535
  }
395
536
  }
396
537
  if (track) applyMonoChain(dry, track, context);
@@ -403,7 +544,27 @@ export class StemRenderer {
403
544
  context,
404
545
  stereo ? dryR : undefined,
405
546
  );
406
- if (track)
547
+ let saved: Stem["room"];
548
+ if (track && room !== undefined) {
549
+ // Same chain, the reverb split in two (see `applyReverb`).
550
+ applyStereoChain(
551
+ target.left,
552
+ target.right,
553
+ track,
554
+ context,
555
+ true,
556
+ false,
557
+ );
558
+ const pre = {
559
+ left: Float64Array.from(target.left),
560
+ right: Float64Array.from(target.right),
561
+ };
562
+ const wet = reverbWet(target.left, target.right, track, context);
563
+ if (wet) {
564
+ addReverbWet(target.left, target.right, wet, track, context);
565
+ saved = { key: room, pre, wet };
566
+ }
567
+ } else if (track)
407
568
  applyStereoChain(
408
569
  target.left,
409
570
  target.right,
@@ -415,8 +576,9 @@ export class StemRenderer {
415
576
  key,
416
577
  left: target.left,
417
578
  right: target.right,
418
- bytes: target.left.byteLength + target.right.byteLength,
579
+ bytes: target.left.byteLength * (saved ? 6 : 2),
419
580
  used: this.renders,
581
+ ...(saved ? { room: saved } : {}),
420
582
  };
421
583
  if (caching) this.store(trackId, stem);
422
584
  }
@@ -426,17 +588,7 @@ export class StemRenderer {
426
588
  buses.set(busOrbit, (bus = createOrbitBus(busOrbit, samples)));
427
589
  sendToBus(bus, track, stem.left, stem.right, context, key);
428
590
  }
429
- const gain = ducked.get(trackId);
430
- if (gain)
431
- for (let index = 0; index < samples; index += 1) {
432
- mixL[index]! += stem.left[index]! * gain[index]!;
433
- mixR[index]! += stem.right[index]! * gain[index]!;
434
- }
435
- else
436
- for (let index = 0; index < samples; index += 1) {
437
- mixL[index]! += stem.left[index]!;
438
- mixR[index]! += stem.right[index]!;
439
- }
591
+ addStem(mixL, mixR, stem.left, stem.right, samples, ducked.get(trackId));
440
592
  }
441
593
  // Bus returns join after every stem, in orbit order; the orbit's ducking
442
594
  // applies to them as to its tracks.
@@ -455,12 +607,15 @@ export class StemRenderer {
455
607
  wet = { key, ...renderOrbitBus(bus, context) };
456
608
  if (this.maxCacheBytes > 0) this.buses.set(orbit, wet);
457
609
  }
458
- const gain = busGains.get(`bus:${orbit}`);
459
- for (let index = 0; index < samples; index += 1) {
460
- const g = gain ? gain[index]! : 1;
461
- mixL[index]! += wet.left[index]! * g;
462
- mixR[index]! += wet.right[index]! * g;
463
- }
610
+ // (x * 1 === x, so an unducked bus adds its plain samples.)
611
+ addStem(
612
+ mixL,
613
+ mixR,
614
+ wet.left,
615
+ wet.right,
616
+ samples,
617
+ busGains.get(`bus:${orbit}`),
618
+ );
464
619
  }
465
620
  for (const orbit of [...this.buses.keys()])
466
621
  if (!buses.has(orbit)) this.buses.delete(orbit);
@@ -468,24 +623,29 @@ export class StemRenderer {
468
623
  // unsoloed tracks keep theirs so toggling them back is free.
469
624
  for (const trackId of [...this.stems.keys()])
470
625
  if (!groups.has(trackId)) this.evict(trackId);
471
- if (samples > frames) {
472
- // Linear effects superpose, so folding the tail onto the start yields
473
- // the steady state of the loop playing forever.
474
- for (let index = frames; index < samples; index += 1) {
475
- mixL[index % frames]! += mixL[index]!;
476
- mixR[index % frames]! += mixR[index]!;
477
- }
478
- }
479
- const pcm = new Int16Array(frames * RENDER_CHANNELS);
480
- for (let index = 0; index < frames; index += 1) {
481
- pcm[index * 2] = clamp16(mixL[index]! * 32767);
482
- pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
483
- }
626
+ // Linear effects superpose, so folding the tail onto the start yields
627
+ // the steady state of the loop playing forever.
628
+ if (samples > frames) foldTail(mixL, mixR, frames, samples);
629
+ // The song master (src/audio/master.ts); none leaves the mix untouched.
630
+ const mastered = applyMaster(
631
+ mixL,
632
+ mixR,
633
+ frames,
634
+ sampleRate,
635
+ score.master,
636
+ options.loop === true,
637
+ );
638
+ // A master's output is a finished level, so its reduction to 16 bits is
639
+ // TPDF-dithered (seeded: renders stay byte-identical run to run).
640
+ const pcm = mastered
641
+ ? toPcm(mastered.left, mastered.right, frames, true)
642
+ : toPcm(mixL, mixR, frames);
484
643
  return Object.freeze({
485
644
  sampleRate,
486
645
  channels: RENDER_CHANNELS,
487
646
  frames,
488
647
  pcm,
648
+ ...(mastered ? { master: mastered.report } : {}),
489
649
  });
490
650
  }
491
651
 
@@ -543,7 +703,122 @@ export class StemRenderer {
543
703
  }
544
704
  }
545
705
 
706
+ /*
707
+ * The mix loops live in small functions of their own: inside `render` (a
708
+ * large function the JIT optimizes late) they ran several times slower.
709
+ * Same operations in the same order, so the output is unchanged.
710
+ */
711
+
712
+ /** Add a stem (times its ducking gain, if any) into the mix. */
713
+ function addStem(
714
+ mixL: Float64Array,
715
+ mixR: Float64Array,
716
+ left: Float64Array,
717
+ right: Float64Array,
718
+ samples: number,
719
+ gain: Float64Array | undefined,
720
+ ): void {
721
+ if (gain)
722
+ for (let index = 0; index < samples; index += 1) {
723
+ mixL[index]! += left[index]! * gain[index]!;
724
+ mixR[index]! += right[index]! * gain[index]!;
725
+ }
726
+ else
727
+ for (let index = 0; index < samples; index += 1) {
728
+ mixL[index]! += left[index]!;
729
+ mixR[index]! += right[index]!;
730
+ }
731
+ }
732
+
733
+ /** Fold everything past `frames` back onto the loop start. */
734
+ function foldTail(
735
+ mixL: Float64Array,
736
+ mixR: Float64Array,
737
+ frames: number,
738
+ samples: number,
739
+ ): void {
740
+ for (let index = frames; index < samples; index += 1) {
741
+ mixL[index % frames]! += mixL[index]!;
742
+ mixR[index % frames]! += mixR[index]!;
743
+ }
744
+ }
745
+
746
+ /** Interleaved 16-bit PCM of the first `frames` mix samples. */
747
+ /**
748
+ * The song master over an already summed 16-bit mix (an arrangement
749
+ * rendered in windows), so the whole song is limited and normalized as one
750
+ * pass is. Undefined when the master is absent or bypassed.
751
+ */
752
+ export function masterSummedPcm(
753
+ pcm: Int16Array,
754
+ frames: number,
755
+ sampleRate: number,
756
+ master: SongMaster | undefined,
757
+ loop: boolean,
758
+ ): { pcm: Int16Array; report: MasterReport } | undefined {
759
+ const left = new Float64Array(frames);
760
+ const right = new Float64Array(frames);
761
+ for (let index = 0; index < frames; index += 1) {
762
+ left[index] = pcm[index * 2]! / 32767;
763
+ right[index] = pcm[index * 2 + 1]! / 32767;
764
+ }
765
+ const mastered = applyMaster(left, right, frames, sampleRate, master, loop);
766
+ if (!mastered) return undefined;
767
+ return {
768
+ pcm: toPcm(mastered.left, mastered.right, frames, true),
769
+ report: mastered.report,
770
+ };
771
+ }
772
+
773
+ function toPcm(
774
+ mixL: Float64Array,
775
+ mixR: Float64Array,
776
+ frames: number,
777
+ dither = false,
778
+ ): Int16Array {
779
+ const pcm = new Int16Array(frames * RENDER_CHANNELS);
780
+ if (dither) {
781
+ // Triangular dither of +-1 LSB: the sum of two uniform values, from a
782
+ // fixed-seed xorshift so every render of a score dithers the same way.
783
+ let state = 0x9e3779b9;
784
+ const uniform = () => {
785
+ state ^= state << 13;
786
+ state ^= state >>> 17;
787
+ state ^= state << 5;
788
+ return (state >>> 0) / 4_294_967_296;
789
+ };
790
+ for (let index = 0; index < frames; index += 1) {
791
+ pcm[index * 2] = clamp16(
792
+ Math.round(mixL[index]! * 32767 + uniform() - uniform()),
793
+ );
794
+ pcm[index * 2 + 1] = clamp16(
795
+ Math.round(mixR[index]! * 32767 + uniform() - uniform()),
796
+ );
797
+ }
798
+ return pcm;
799
+ }
800
+ for (let index = 0; index < frames; index += 1) {
801
+ pcm[index * 2] = clamp16(mixL[index]! * 32767);
802
+ pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
803
+ }
804
+ return pcm;
805
+ }
806
+
546
807
  /** Everything a track's stem depends on, except mute and solo. */
808
+ /**
809
+ * The track as its reverb tail sees it: everything but the reverb's mix
810
+ * and mix lane, which only scale the tail.
811
+ */
812
+ function roomTrack(track: Track): Track {
813
+ const lanes = { ...track.fxAutomation };
814
+ delete lanes["reverb-mix"];
815
+ return {
816
+ ...track,
817
+ reverb: { ...track.reverb!, mix: 1 },
818
+ fxAutomation: lanes,
819
+ } as Track;
820
+ }
821
+
547
822
  function stemKey(
548
823
  track: Track | undefined,
549
824
  notes: readonly Note[],
@@ -566,12 +841,20 @@ function stemKey(
566
841
  context.score.beatsPerBar,
567
842
  context.sampleRate,
568
843
  context.samples,
844
+ // Tempo map, meter changes and fermatas move every note.
845
+ ...(context.score.time ? [context.score.time] : []),
569
846
  // Sampler stems also depend on the decoded files: a replaced or missing
570
847
  // sample changes the key even when the score did not change.
571
848
  ...(bank && track?.sampler ? [samplerVoiceDigest(track, bank)] : []),
572
849
  ...(wavetableId ? [wavetableId] : []),
573
850
  // A convolution reverb's impulse (its sha256 for a sample).
574
851
  ...(impulseId ? [impulseId] : []),
852
+ // The song tuning, and the key whose tonic is the default root.
853
+ ...(context.score.tuning || track?.tuning
854
+ ? [context.score.tuning ?? null, context.score.key]
855
+ : []),
856
+ // Seeded noise follows the window origin of an arranged render.
857
+ ...(context.seedTick ? [`seed:${context.seedTick}`] : []),
575
858
  ]);
576
859
  }
577
860
 
@@ -594,7 +877,11 @@ function renderSamplerNotes(
594
877
  context: RenderContext,
595
878
  bank: SampleBank,
596
879
  ): void {
597
- const timing = { score: context.score, sampleRate: context.sampleRate };
880
+ const timing = {
881
+ score: context.score,
882
+ sampleRate: context.sampleRate,
883
+ ...(context.warp ? { warp: context.warp } : {}),
884
+ };
598
885
  renderSamplerVoices(
599
886
  target,
600
887
  planSamplerVoices(track, notes, bank, timing),
@@ -618,7 +905,8 @@ function noteSpan(
618
905
  note: Note,
619
906
  context: RenderContext,
620
907
  ): { start: number; length: number } {
621
- const { score, sampleRate } = context;
908
+ const { score, sampleRate, warp } = context;
909
+ if (warp) return warpedSpan(warp, note.startTick, note.durationTicks);
622
910
  const start = Math.max(
623
911
  0,
624
912
  Math.floor(
@@ -641,13 +929,18 @@ function renderToneNote(
641
929
  note: Note,
642
930
  track: Track | undefined,
643
931
  context: RenderContext,
932
+ tuning?: TuningTable,
644
933
  ): void {
645
934
  const { sampleRate, samples, samplesPerTick } = context;
646
935
  const instrument = track?.instrument ?? "sine";
647
936
  const { start, length } = noteSpan(note, context);
648
937
  const end = Math.min(samples, start + length);
649
- const frequency = 440 * 2 ** ((note.pitch - 69) / 12);
938
+ const frequency = noteHz(note.pitch, note.cents, tuning);
939
+ if (!(frequency > 0)) return;
650
940
  const velocity = Math.max(0, Math.min(1, note.velocity));
941
+ const performance = (note as PerformedNote).performance;
942
+ const cents = performance?.cents;
943
+ let bent = 0;
651
944
  for (let index = start; index < end; index += 1) {
652
945
  const elapsed = index - start;
653
946
  const remaining = end - index;
@@ -657,9 +950,24 @@ function renderToneNote(
657
950
  Math.min(attack, release) *
658
951
  velocity *
659
952
  0.28 *
660
- trackGainAt(track, note.startTick + elapsed / samplesPerTick);
661
- const phase = (frequency * elapsed) / sampleRate;
662
- target[index]! += legacyWave(instrument, phase) * envelope;
953
+ trackGainAt(
954
+ track,
955
+ context.warp
956
+ ? context.warp.tick(index)
957
+ : note.startTick + elapsed / samplesPerTick,
958
+ );
959
+ // Plain notes keep the closed-form phase (byte-identical output).
960
+ let phase = (frequency * elapsed) / sampleRate;
961
+ if (cents) {
962
+ phase = bent;
963
+ bent +=
964
+ (frequency * 2 ** (cents(elapsed / sampleRate) / 1200)) / sampleRate;
965
+ }
966
+ const damp = performance?.damp;
967
+ const t = elapsed / sampleRate;
968
+ const held =
969
+ damp && t > damp.from ? Math.exp(-(t - damp.from) / damp.tau) : 1;
970
+ target[index]! += legacyWave(instrument, phase) * envelope * held;
663
971
  }
664
972
  }
665
973
 
@@ -685,7 +993,9 @@ function renderDrumNote(
685
993
  start + Math.ceil((kit?.seconds ?? MAX_DRUM_SECONDS) * sampleRate),
686
994
  );
687
995
  const voice = drumVoiceForPitch(note.pitch);
688
- const random = seededRandom(`${note.id}:${note.startTick}`);
996
+ const random = seededRandom(
997
+ `${note.id}:${note.startTick + (context.seedTick ?? 0)}`,
998
+ );
689
999
  const velocity = Math.max(0, Math.min(1, note.velocity));
690
1000
  let phase = 0;
691
1001
  let previousNoise = 0;
@@ -751,7 +1061,12 @@ function renderDrumNote(
751
1061
  release *
752
1062
  velocity *
753
1063
  0.5 *
754
- trackGainAt(track, note.startTick + (index - start) / samplesPerTick);
1064
+ trackGainAt(
1065
+ track,
1066
+ context.warp
1067
+ ? context.warp.tick(index)
1068
+ : note.startTick + (index - start) / samplesPerTick,
1069
+ );
755
1070
  }
756
1071
  }
757
1072
 
@@ -778,7 +1093,13 @@ function applyPan(
778
1093
  for (let index = 0; index < dry.length; index += 1) {
779
1094
  if (lane.length > 0 && index % CONTROL_SAMPLES === 0)
780
1095
  [gainL, gainR] = gains(
781
- interpolateAutomation(lane, index / context.samplesPerTick, staticPan),
1096
+ interpolateAutomation(
1097
+ lane,
1098
+ context.warp
1099
+ ? context.warp.tick(index)
1100
+ : index / context.samplesPerTick,
1101
+ staticPan,
1102
+ ),
782
1103
  );
783
1104
  const sample = dry[index]!;
784
1105
  left[index] = sample * gainL;
@@ -816,16 +1137,29 @@ function wavetableHook(
816
1137
  lane.length > 0
817
1138
  ? (tick: number) => interpolateAutomation(lane, tick, settings.wt ?? 0)
818
1139
  : undefined;
1140
+ const warp = context.warp;
819
1141
  return {
820
1142
  id: table.id,
821
- oscillatorFor: (note) =>
822
- wavetableOscillator(table, settings, note, {
1143
+ oscillatorFor: (note) => {
1144
+ const span = noteSpan(note, context);
1145
+ return wavetableOscillator(table, settings, note, {
823
1146
  sampleRate: context.sampleRate,
824
1147
  samplesPerTick: context.samplesPerTick,
825
1148
  secondsPerTick: context.samplesPerTick / context.sampleRate,
826
- gate: noteSpan(note, context).length / context.sampleRate,
827
- positionAt,
828
- }),
1149
+ gate: span.length / context.sampleRate,
1150
+ // Through a tempo map the lane is read at the sample's song tick.
1151
+ positionAt:
1152
+ warp && positionAt
1153
+ ? (tick: number) =>
1154
+ positionAt(
1155
+ warp.tick(
1156
+ span.start +
1157
+ (tick - note.startTick) * context.samplesPerTick,
1158
+ ),
1159
+ )
1160
+ : positionAt,
1161
+ });
1162
+ },
829
1163
  };
830
1164
  }
831
1165
 
@@ -861,3 +1195,58 @@ function writeAscii(target: Uint8Array, offset: number, value: string): void {
861
1195
  function clamp16(value: number): number {
862
1196
  return Math.max(-32_768, Math.min(32_767, Math.round(value)));
863
1197
  }
1198
+
1199
+ /** A named point in a WAV export: a sample frame and its label. */
1200
+ export type WavCue = Readonly<{ frame: number; label: string }>;
1201
+
1202
+ /**
1203
+ * Append a `cue ` chunk and a `LIST adtl` chunk of `labl` labels, so DAWs
1204
+ * and DJ tools show the cues (dawg writes section starts). No cues returns
1205
+ * the bytes unchanged.
1206
+ */
1207
+ export function withWavCues(
1208
+ bytes: Uint8Array,
1209
+ cues: readonly WavCue[],
1210
+ ): Uint8Array {
1211
+ if (cues.length === 0 || bytes.byteLength < 12) return bytes;
1212
+ const encoder = new TextEncoder();
1213
+ const labels = cues.map((cue) =>
1214
+ encoder.encode(`${cue.label.slice(0, 200)}\0`),
1215
+ );
1216
+ const cueSize = 4 + 24 * cues.length;
1217
+ const lablSizes = labels.map((text) => 4 + text.byteLength);
1218
+ const adtlSize =
1219
+ 4 + lablSizes.reduce((sum, size) => sum + 8 + size + (size % 2), 0);
1220
+ const base = bytes.byteLength + (bytes.byteLength % 2);
1221
+ const out = new Uint8Array(base + 8 + cueSize + 8 + adtlSize);
1222
+ out.set(bytes);
1223
+ const view = new DataView(out.buffer);
1224
+ let at = base;
1225
+ writeAscii(out, at, "cue ");
1226
+ view.setUint32(at + 4, cueSize, true);
1227
+ view.setUint32(at + 8, cues.length, true);
1228
+ at += 12;
1229
+ cues.forEach((cue, index) => {
1230
+ const frame = Math.max(0, Math.round(cue.frame));
1231
+ view.setUint32(at, index + 1, true);
1232
+ view.setUint32(at + 4, frame, true);
1233
+ writeAscii(out, at + 8, "data");
1234
+ view.setUint32(at + 12, 0, true);
1235
+ view.setUint32(at + 16, 0, true);
1236
+ view.setUint32(at + 20, frame, true);
1237
+ at += 24;
1238
+ });
1239
+ writeAscii(out, at, "LIST");
1240
+ view.setUint32(at + 4, adtlSize, true);
1241
+ writeAscii(out, at + 8, "adtl");
1242
+ at += 12;
1243
+ labels.forEach((text, index) => {
1244
+ writeAscii(out, at, "labl");
1245
+ view.setUint32(at + 4, lablSizes[index]!, true);
1246
+ view.setUint32(at + 8, index + 1, true);
1247
+ out.set(text, at + 12);
1248
+ at += 8 + lablSizes[index]! + (lablSizes[index]! % 2);
1249
+ });
1250
+ view.setUint32(4, out.byteLength - 8, true);
1251
+ return out;
1252
+ }