@hraness/dawg 0.4.1 → 0.6.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 (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. 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,
@@ -17,6 +23,7 @@ import {
17
23
  } from "./sampler.ts";
18
24
  import { EFFECT_NAMES, FX_PRESETS, effectSpec } from "../../core/fx.ts";
19
25
  import { seededRandom } from "./random.ts";
26
+ import { engineFor, engineTailSeconds } from "./instruments.ts";
20
27
  import {
21
28
  isStereoVoice,
22
29
  renderSynthNote,
@@ -53,12 +60,21 @@ import {
53
60
  type OrbitBus,
54
61
  } from "./effects/bus.ts";
55
62
  import { tableFor, wavetableOscillator } from "./wavetable.ts";
63
+ import { noteHz, resolveTuning, type TuningTable } from "../../core/tuning.ts";
56
64
  import {
57
65
  duckGains,
58
66
  duckSettings,
59
67
  orbitOf,
60
68
  type Ducker,
61
69
  } from "./effects/duck.ts";
70
+ import {
71
+ loopSecondsOf,
72
+ performedNotes,
73
+ slowestBpmOf,
74
+ } from "../../core/tempo.ts";
75
+ import { sampleWarpFor, warpedSpan, type SampleWarp } from "./warp.ts";
76
+ import { applyMaster, type MasterReport } from "./master.ts";
77
+ import type { SongMaster } from "../../core/master.ts";
62
78
 
63
79
  export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
64
80
 
@@ -75,6 +91,12 @@ export type RenderOptions = WavOptions &
75
91
  * tracks render silent; scores without samplers are unaffected.
76
92
  */
77
93
  samples?: SampleBank;
94
+ /**
95
+ * Ticks added to each note's start when seeding its noise. An arranged
96
+ * render window passes its origin so seeded voices sound as they do in
97
+ * a single pass of the whole song. Zero (the default) changes nothing.
98
+ */
99
+ seedTick?: number;
78
100
  }>;
79
101
 
80
102
  /** Interleaved stereo 16-bit PCM plus its frame count. */
@@ -83,6 +105,8 @@ export type RenderedAudio = Readonly<{
83
105
  channels: 2;
84
106
  frames: number;
85
107
  pcm: Int16Array;
108
+ /** Loudness the song master reached; absent without a master. */
109
+ master?: MasterReport;
86
110
  }>;
87
111
 
88
112
  /** Output channel count for every render and export. */
@@ -116,6 +140,9 @@ export const AVAILABLE_INSTRUMENTS = Object.freeze([
116
140
  "z_square",
117
141
  "z_tan",
118
142
  "z_noise",
143
+ // 0.6 instruments (src/audio/instruments.ts), one line per lane.
144
+ "granular",
145
+ "modal",
119
146
  ] as const);
120
147
 
121
148
  /** Per-track effects understood by the renderer and the agent, in chain
@@ -145,20 +172,58 @@ const MAX_DRUM_SECONDS = 0.6;
145
172
  const CONTROL_SAMPLES = 32;
146
173
  /** One-shot renders keep this much ring-out after the last bar. */
147
174
  const ONE_SHOT_TAIL_SECONDS = 0.35;
175
+
176
+ function oneShotSeconds(
177
+ loopSeconds: number,
178
+ samplerTail: number,
179
+ reverbTail: number,
180
+ ): number {
181
+ return (
182
+ loopSeconds + Math.max(ONE_SHOT_TAIL_SECONDS, samplerTail) + reverbTail
183
+ );
184
+ }
148
185
  /** Loop renders fold at most this much tail back onto the loop start. */
149
186
  const MAX_LOOP_TAIL_SECONDS = 8;
187
+ /** A 0.6 engine's one-shot ring-out is capped here, not at the loop fold. */
188
+ const MAX_ENGINE_TAIL_SECONDS = 30;
150
189
 
151
- type RenderContext = Readonly<{
190
+ /**
191
+ * The longest 0.6 engine ring-out in `score`, capped at 30 s. One-shot
192
+ * renders (exports, previews) let engine tails reach their own cap; the
193
+ * 8 s cap applies only to loop folding. Zero without an engine, so legacy
194
+ * renders are unchanged.
195
+ */
196
+ function oneShotEngineTail(score: TrackScore): number {
197
+ let tail = 0;
198
+ for (const track of score.tracks) {
199
+ if (!engineFor(track)) continue;
200
+ let lowest: number | undefined;
201
+ for (const note of score.notes)
202
+ if (
203
+ note.trackId === track.id &&
204
+ (lowest === undefined || note.pitch < lowest)
205
+ )
206
+ lowest = note.pitch;
207
+ tail = Math.max(tail, engineTailSeconds(track, lowest));
208
+ }
209
+ return Math.min(MAX_ENGINE_TAIL_SECONDS, tail);
210
+ }
211
+
212
+ export type RenderContext = Readonly<{
152
213
  score: TrackScore;
153
214
  sampleRate: number;
154
215
  samples: number;
155
216
  samplesPerTick: number;
156
217
  tempoBpm: number;
157
218
  irs?: ReadonlyMap<string, DecodedSample>;
219
+ /** Tempo map in samples (core/tempo.ts); absent: constant tempo. */
220
+ warp?: SampleWarp;
221
+ seedTick?: number;
158
222
  }>;
159
223
 
160
224
  /** Exact (fractional) loop length in frames at a sample rate. */
161
225
  export function loopFrames(score: TrackScore, sampleRate: number): number {
226
+ if (score.time) return loopSecondsOf(score) * sampleRate;
162
227
  return (score.bars * score.beatsPerBar * 60 * sampleRate) / score.tempoBpm;
163
228
  }
164
229
 
@@ -256,11 +321,44 @@ export class StemRenderer {
256
321
  return { stems: this.stems.size, bytes: this.cacheBytes };
257
322
  }
258
323
 
324
+ /**
325
+ * Seconds a one-shot render of `score` needs (song plus tail) before the
326
+ * renderer's cap, so callers can tell when a single pass would cut it.
327
+ */
328
+ public oneShotSeconds(
329
+ score: TrackScore,
330
+ options: RenderOptions = {},
331
+ ): number {
332
+ const sampleRate = clampSampleRate(options.sampleRate);
333
+ const bank = options.samples ?? EMPTY_SAMPLE_BANK;
334
+ const reverbTail = Math.max(
335
+ 0,
336
+ ...score.tracks.map((track) =>
337
+ reverbTailFor(track, sampleRate, bank.irs),
338
+ ),
339
+ );
340
+ const samplerTail = Math.min(
341
+ MAX_LOOP_TAIL_SECONDS,
342
+ Math.max(
343
+ samplerTailSeconds(score, bank, sampleRate),
344
+ ...score.tracks.map(synthTailSeconds),
345
+ // Zero without a registered 0.6 instrument engine.
346
+ ...score.tracks.map((track) => engineTailSeconds(track)),
347
+ ),
348
+ );
349
+ return oneShotSeconds(
350
+ loopSecondsOf(score),
351
+ Math.max(samplerTail, oneShotEngineTail(score)),
352
+ reverbTail,
353
+ );
354
+ }
355
+
259
356
  public render(score: TrackScore, options: RenderOptions = {}): RenderedAudio {
260
357
  const sampleRate = clampSampleRate(options.sampleRate);
261
358
  const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
262
- const loopSeconds = (score.bars * score.beatsPerBar * 60) / score.tempoBpm;
359
+ const loopSeconds = loopSecondsOf(score);
263
360
  const bank = options.samples ?? EMPTY_SAMPLE_BANK;
361
+ const warp = sampleWarpFor(score, sampleRate);
264
362
  const reverbTail = Math.max(
265
363
  0,
266
364
  ...score.tracks.map((track) =>
@@ -274,6 +372,8 @@ export class StemRenderer {
274
372
  Math.max(
275
373
  samplerTailSeconds(score, bank, sampleRate),
276
374
  ...score.tracks.map(synthTailSeconds),
375
+ // Zero without a registered 0.6 instrument engine.
376
+ ...score.tracks.map((track) => engineTailSeconds(track)),
277
377
  ),
278
378
  );
279
379
  let frames: number;
@@ -293,14 +393,21 @@ export class StemRenderer {
293
393
  reverbTail +
294
394
  Math.max(
295
395
  0,
296
- ...score.tracks.map((track) => delayTailFor(track, score.tempoBpm)),
396
+ // The slowest tempo in the map gives the longest echoes.
397
+ ...score.tracks.map((track) =>
398
+ delayTailFor(track, slowestBpmOf(score)),
399
+ ),
297
400
  ),
298
401
  );
299
402
  samples = frames + Math.ceil(tailSeconds * sampleRate);
300
403
  } else {
301
404
  const seconds = Math.min(
302
405
  maxSeconds,
303
- loopSeconds + Math.max(ONE_SHOT_TAIL_SECONDS, samplerTail) + reverbTail,
406
+ oneShotSeconds(
407
+ loopSeconds,
408
+ Math.max(samplerTail, oneShotEngineTail(score)),
409
+ reverbTail,
410
+ ),
304
411
  );
305
412
  frames = Math.max(1, Math.ceil(seconds * sampleRate));
306
413
  samples = frames;
@@ -312,6 +419,8 @@ export class StemRenderer {
312
419
  samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
313
420
  tempoBpm: score.tempoBpm,
314
421
  ...(bank.irs ? { irs: bank.irs } : {}),
422
+ ...(warp ? { warp } : {}),
423
+ ...(options.seedTick ? { seedTick: options.seedTick } : {}),
315
424
  };
316
425
  this.renders += 1;
317
426
  const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
@@ -319,20 +428,34 @@ export class StemRenderer {
319
428
  mixR.fill(0);
320
429
  const tracks = new Map(score.tracks.map((track) => [track.id, track]));
321
430
  const groups = new Map<string, Note[]>();
322
- for (const note of score.notes) {
431
+ // Track time (rate, phase, cycle) places notes on the song timeline.
432
+ for (const note of performedNotes(score)) {
323
433
  const group = groups.get(note.trackId);
324
434
  if (group) group.push(note);
325
435
  else groups.set(note.trackId, [note]);
326
436
  }
437
+ // Note expression and track performance (core/expression.ts): the
438
+ // notes as played. A track with neither gets its notes back unchanged.
439
+ const timing = performanceTimingFor(score);
440
+ const performed = new Map<string, readonly PerformedNote[]>();
441
+ for (const [trackId, notes] of groups) {
442
+ const track = tracks.get(trackId);
443
+ performed.set(
444
+ trackId,
445
+ performNotes(track, notes, tunedTiming(timing, score, track)),
446
+ );
447
+ }
327
448
  // Orbit ducking is a gain on finished stems (src/audio/effects/duck.ts).
328
449
  const duckers: Ducker[] = [];
329
- for (const [trackId, notes] of groups) {
450
+ for (const [trackId] of groups) {
330
451
  const settings = duckSettings(tracks.get(trackId));
331
452
  if (settings && isTrackAudible(score, trackId))
332
453
  duckers.push({
333
454
  trackId,
334
455
  ...settings,
335
- onsets: notes.map((note) => noteSpan(note, context).start),
456
+ onsets: performed
457
+ .get(trackId)!
458
+ .map((note) => noteSpan(note, context).start),
336
459
  });
337
460
  }
338
461
  const ducked = duckGains(
@@ -349,11 +472,28 @@ export class StemRenderer {
349
472
  for (const [trackId, notes] of groups) {
350
473
  if (!isTrackAudible(score, trackId)) continue;
351
474
  const track = tracks.get(trackId);
475
+ // Stems key on the written notes (with the track's performance
476
+ // settings); the voices render the performed ones.
477
+ const played = performed.get(trackId)!;
352
478
  const busOrbit = sharedOrbitOf(track);
353
479
  const sampler = isSamplerInstrument(track?.instrument);
480
+ // A 0.6 instrument engine (src/audio/instruments.ts), if registered
481
+ // and the track carries its field; undefined keeps today's voices.
482
+ const engine = engineFor(track);
483
+ // The song key always joins an engine's stem key: engines may tune to
484
+ // it (sympathetic strings) without a song tuning.
485
+ const engineDigests =
486
+ engine && track
487
+ ? [
488
+ ...(engine.assetDigests?.(track, bank) ?? []),
489
+ `key:${score.key ?? ""}`,
490
+ ]
491
+ : undefined;
354
492
  // Wavetable hook: the oscillator factory for a wavetable track (its
355
493
  // table id joins the stem key), undefined for every other instrument.
356
494
  const wavetable = track ? wavetableHook(track, bank, context) : undefined;
495
+ // The merged song and track tuning; undefined keeps 12-TET untouched.
496
+ const tuning = resolveTuning(score.tuning, track?.tuning, score.key);
357
497
  const key = stemKey(
358
498
  track,
359
499
  notes,
@@ -361,6 +501,7 @@ export class StemRenderer {
361
501
  sampler ? bank : undefined,
362
502
  wavetable?.id,
363
503
  track ? reverbImpulse(track, sampleRate, bank.irs)?.id : undefined,
504
+ engineDigests,
364
505
  );
365
506
  let stem = this.stems.get(trackId);
366
507
  const caching = this.maxCacheBytes > 0;
@@ -376,6 +517,7 @@ export class StemRenderer {
376
517
  sampler ? bank : undefined,
377
518
  wavetable?.id,
378
519
  reverbImpulse(track, sampleRate, bank.irs)?.id,
520
+ engineDigests,
379
521
  )
380
522
  : undefined;
381
523
  if (stem?.key === key) stem.used = this.renders;
@@ -402,20 +544,47 @@ export class StemRenderer {
402
544
  }
403
545
  : { left, right };
404
546
  dry.fill(0);
405
- const synthVoice = !sampler && usesSynthVoice(track);
406
- const stereo = synthVoice && isStereoVoice(track);
547
+ const synthVoice = !engine && !sampler && usesSynthVoice(track);
548
+ const stereo = engine
549
+ ? engine.stereo(track!)
550
+ : synthVoice && isStereoVoice(track);
407
551
  if (stereo) dryR.fill(0);
408
- if (sampler) {
409
- if (track) renderSamplerNotes(dry, notes, track, context, bank);
552
+ if (engine && track) {
553
+ engine.render(
554
+ dry,
555
+ stereo ? dryR : undefined,
556
+ played,
557
+ track,
558
+ {
559
+ ...context,
560
+ ticksPerBeat: score.ticksPerBeat,
561
+ ...(tuning ? { tuning } : {}),
562
+ },
563
+ bank,
564
+ );
565
+ } else if (sampler) {
566
+ if (track) renderSamplerNotes(dry, played, track, context, bank);
410
567
  } else if (synthVoice && track) {
411
568
  const gainAt = (tick: number) => trackGainAt(track, tick);
412
569
  const voice = {
413
570
  ...context,
414
571
  ticksPerBeat: score.ticksPerBeat,
415
572
  ...(wavetable ? { oscillatorFor: wavetable.oscillatorFor } : {}),
573
+ ...(tuning ? { tuning } : {}),
416
574
  };
417
- for (const note of notes) {
575
+ const warp = context.warp;
576
+ for (const note of played) {
418
577
  const { start, length } = noteSpan(note, context);
578
+ // The voice asks for `startTick + elapsed / samplesPerTick`;
579
+ // through a tempo map that sample's tick comes from the map.
580
+ const noteGainAt = warp
581
+ ? (tick: number) =>
582
+ gainAt(
583
+ warp.tick(
584
+ start + (tick - note.startTick) * context.samplesPerTick,
585
+ ),
586
+ )
587
+ : gainAt;
419
588
  renderSynthNote(
420
589
  dry,
421
590
  stereo ? dryR : undefined,
@@ -424,14 +593,14 @@ export class StemRenderer {
424
593
  start,
425
594
  length,
426
595
  voice,
427
- gainAt,
596
+ noteGainAt,
428
597
  );
429
598
  }
430
599
  } else {
431
600
  const drums = isDrumInstrument(track?.instrument);
432
- for (const note of notes) {
601
+ for (const note of played) {
433
602
  if (drums) renderDrumNote(dry, note, track, context);
434
- else renderToneNote(dry, note, track, context);
603
+ else renderToneNote(dry, note, track, context, tuning);
435
604
  }
436
605
  }
437
606
  if (track) applyMonoChain(dry, track, context);
@@ -526,12 +695,26 @@ export class StemRenderer {
526
695
  // Linear effects superpose, so folding the tail onto the start yields
527
696
  // the steady state of the loop playing forever.
528
697
  if (samples > frames) foldTail(mixL, mixR, frames, samples);
529
- const pcm = toPcm(mixL, mixR, frames);
698
+ // The song master (src/audio/master.ts); none leaves the mix untouched.
699
+ const mastered = applyMaster(
700
+ mixL,
701
+ mixR,
702
+ frames,
703
+ sampleRate,
704
+ score.master,
705
+ options.loop === true,
706
+ );
707
+ // A master's output is a finished level, so its reduction to 16 bits is
708
+ // TPDF-dithered (seeded: renders stay byte-identical run to run).
709
+ const pcm = mastered
710
+ ? toPcm(mastered.left, mastered.right, frames, true)
711
+ : toPcm(mixL, mixR, frames);
530
712
  return Object.freeze({
531
713
  sampleRate,
532
714
  channels: RENDER_CHANNELS,
533
715
  frames,
534
716
  pcm,
717
+ ...(mastered ? { master: mastered.report } : {}),
535
718
  });
536
719
  }
537
720
 
@@ -630,12 +813,59 @@ function foldTail(
630
813
  }
631
814
 
632
815
  /** Interleaved 16-bit PCM of the first `frames` mix samples. */
816
+ /**
817
+ * The song master over an already summed 16-bit mix (an arrangement
818
+ * rendered in windows), so the whole song is limited and normalized as one
819
+ * pass is. Undefined when the master is absent or bypassed.
820
+ */
821
+ export function masterSummedPcm(
822
+ pcm: Int16Array,
823
+ frames: number,
824
+ sampleRate: number,
825
+ master: SongMaster | undefined,
826
+ loop: boolean,
827
+ ): { pcm: Int16Array; report: MasterReport } | undefined {
828
+ const left = new Float64Array(frames);
829
+ const right = new Float64Array(frames);
830
+ for (let index = 0; index < frames; index += 1) {
831
+ left[index] = pcm[index * 2]! / 32767;
832
+ right[index] = pcm[index * 2 + 1]! / 32767;
833
+ }
834
+ const mastered = applyMaster(left, right, frames, sampleRate, master, loop);
835
+ if (!mastered) return undefined;
836
+ return {
837
+ pcm: toPcm(mastered.left, mastered.right, frames, true),
838
+ report: mastered.report,
839
+ };
840
+ }
841
+
633
842
  function toPcm(
634
843
  mixL: Float64Array,
635
844
  mixR: Float64Array,
636
845
  frames: number,
846
+ dither = false,
637
847
  ): Int16Array {
638
848
  const pcm = new Int16Array(frames * RENDER_CHANNELS);
849
+ if (dither) {
850
+ // Triangular dither of +-1 LSB: the sum of two uniform values, from a
851
+ // fixed-seed xorshift so every render of a score dithers the same way.
852
+ let state = 0x9e3779b9;
853
+ const uniform = () => {
854
+ state ^= state << 13;
855
+ state ^= state >>> 17;
856
+ state ^= state << 5;
857
+ return (state >>> 0) / 4_294_967_296;
858
+ };
859
+ for (let index = 0; index < frames; index += 1) {
860
+ pcm[index * 2] = clamp16(
861
+ Math.round(mixL[index]! * 32767 + uniform() - uniform()),
862
+ );
863
+ pcm[index * 2 + 1] = clamp16(
864
+ Math.round(mixR[index]! * 32767 + uniform() - uniform()),
865
+ );
866
+ }
867
+ return pcm;
868
+ }
639
869
  for (let index = 0; index < frames; index += 1) {
640
870
  pcm[index * 2] = clamp16(mixL[index]! * 32767);
641
871
  pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
@@ -665,6 +895,7 @@ function stemKey(
665
895
  bank?: SampleBank,
666
896
  wavetableId?: string,
667
897
  impulseId?: string,
898
+ engineDigests?: readonly string[],
668
899
  ): string {
669
900
  let settings: Record<string, unknown> | null = null;
670
901
  if (track) {
@@ -680,12 +911,22 @@ function stemKey(
680
911
  context.score.beatsPerBar,
681
912
  context.sampleRate,
682
913
  context.samples,
914
+ // Tempo map, meter changes and fermatas move every note.
915
+ ...(context.score.time ? [context.score.time] : []),
683
916
  // Sampler stems also depend on the decoded files: a replaced or missing
684
917
  // sample changes the key even when the score did not change.
685
918
  ...(bank && track?.sampler ? [samplerVoiceDigest(track, bank)] : []),
686
919
  ...(wavetableId ? [wavetableId] : []),
687
920
  // A convolution reverb's impulse (its sha256 for a sample).
688
921
  ...(impulseId ? [impulseId] : []),
922
+ // Assets a 0.6 instrument engine reads (absent without an engine).
923
+ ...(engineDigests ? [engineDigests] : []),
924
+ // The song tuning, and the key whose tonic is the default root.
925
+ ...(context.score.tuning || track?.tuning
926
+ ? [context.score.tuning ?? null, context.score.key]
927
+ : []),
928
+ // Seeded noise follows the window origin of an arranged render.
929
+ ...(context.seedTick ? [`seed:${context.seedTick}`] : []),
689
930
  ]);
690
931
  }
691
932
 
@@ -708,7 +949,11 @@ function renderSamplerNotes(
708
949
  context: RenderContext,
709
950
  bank: SampleBank,
710
951
  ): void {
711
- const timing = { score: context.score, sampleRate: context.sampleRate };
952
+ const timing = {
953
+ score: context.score,
954
+ sampleRate: context.sampleRate,
955
+ ...(context.warp ? { warp: context.warp } : {}),
956
+ };
712
957
  renderSamplerVoices(
713
958
  target,
714
959
  planSamplerVoices(track, notes, bank, timing),
@@ -732,7 +977,8 @@ function noteSpan(
732
977
  note: Note,
733
978
  context: RenderContext,
734
979
  ): { start: number; length: number } {
735
- const { score, sampleRate } = context;
980
+ const { score, sampleRate, warp } = context;
981
+ if (warp) return warpedSpan(warp, note.startTick, note.durationTicks);
736
982
  const start = Math.max(
737
983
  0,
738
984
  Math.floor(
@@ -755,13 +1001,18 @@ function renderToneNote(
755
1001
  note: Note,
756
1002
  track: Track | undefined,
757
1003
  context: RenderContext,
1004
+ tuning?: TuningTable,
758
1005
  ): void {
759
1006
  const { sampleRate, samples, samplesPerTick } = context;
760
1007
  const instrument = track?.instrument ?? "sine";
761
1008
  const { start, length } = noteSpan(note, context);
762
1009
  const end = Math.min(samples, start + length);
763
- const frequency = 440 * 2 ** ((note.pitch - 69) / 12);
1010
+ const frequency = noteHz(note.pitch, note.cents, tuning);
1011
+ if (!(frequency > 0)) return;
764
1012
  const velocity = Math.max(0, Math.min(1, note.velocity));
1013
+ const performance = (note as PerformedNote).performance;
1014
+ const cents = performance?.cents;
1015
+ let bent = 0;
765
1016
  for (let index = start; index < end; index += 1) {
766
1017
  const elapsed = index - start;
767
1018
  const remaining = end - index;
@@ -771,9 +1022,24 @@ function renderToneNote(
771
1022
  Math.min(attack, release) *
772
1023
  velocity *
773
1024
  0.28 *
774
- trackGainAt(track, note.startTick + elapsed / samplesPerTick);
775
- const phase = (frequency * elapsed) / sampleRate;
776
- target[index]! += legacyWave(instrument, phase) * envelope;
1025
+ trackGainAt(
1026
+ track,
1027
+ context.warp
1028
+ ? context.warp.tick(index)
1029
+ : note.startTick + elapsed / samplesPerTick,
1030
+ );
1031
+ // Plain notes keep the closed-form phase (byte-identical output).
1032
+ let phase = (frequency * elapsed) / sampleRate;
1033
+ if (cents) {
1034
+ phase = bent;
1035
+ bent +=
1036
+ (frequency * 2 ** (cents(elapsed / sampleRate) / 1200)) / sampleRate;
1037
+ }
1038
+ const damp = performance?.damp;
1039
+ const t = elapsed / sampleRate;
1040
+ const held =
1041
+ damp && t > damp.from ? Math.exp(-(t - damp.from) / damp.tau) : 1;
1042
+ target[index]! += legacyWave(instrument, phase) * envelope * held;
777
1043
  }
778
1044
  }
779
1045
 
@@ -799,7 +1065,9 @@ function renderDrumNote(
799
1065
  start + Math.ceil((kit?.seconds ?? MAX_DRUM_SECONDS) * sampleRate),
800
1066
  );
801
1067
  const voice = drumVoiceForPitch(note.pitch);
802
- const random = seededRandom(`${note.id}:${note.startTick}`);
1068
+ const random = seededRandom(
1069
+ `${note.id}:${note.startTick + (context.seedTick ?? 0)}`,
1070
+ );
803
1071
  const velocity = Math.max(0, Math.min(1, note.velocity));
804
1072
  let phase = 0;
805
1073
  let previousNoise = 0;
@@ -865,7 +1133,12 @@ function renderDrumNote(
865
1133
  release *
866
1134
  velocity *
867
1135
  0.5 *
868
- trackGainAt(track, note.startTick + (index - start) / samplesPerTick);
1136
+ trackGainAt(
1137
+ track,
1138
+ context.warp
1139
+ ? context.warp.tick(index)
1140
+ : note.startTick + (index - start) / samplesPerTick,
1141
+ );
869
1142
  }
870
1143
  }
871
1144
 
@@ -892,7 +1165,13 @@ function applyPan(
892
1165
  for (let index = 0; index < dry.length; index += 1) {
893
1166
  if (lane.length > 0 && index % CONTROL_SAMPLES === 0)
894
1167
  [gainL, gainR] = gains(
895
- interpolateAutomation(lane, index / context.samplesPerTick, staticPan),
1168
+ interpolateAutomation(
1169
+ lane,
1170
+ context.warp
1171
+ ? context.warp.tick(index)
1172
+ : index / context.samplesPerTick,
1173
+ staticPan,
1174
+ ),
896
1175
  );
897
1176
  const sample = dry[index]!;
898
1177
  left[index] = sample * gainL;
@@ -930,16 +1209,29 @@ function wavetableHook(
930
1209
  lane.length > 0
931
1210
  ? (tick: number) => interpolateAutomation(lane, tick, settings.wt ?? 0)
932
1211
  : undefined;
1212
+ const warp = context.warp;
933
1213
  return {
934
1214
  id: table.id,
935
- oscillatorFor: (note) =>
936
- wavetableOscillator(table, settings, note, {
1215
+ oscillatorFor: (note) => {
1216
+ const span = noteSpan(note, context);
1217
+ return wavetableOscillator(table, settings, note, {
937
1218
  sampleRate: context.sampleRate,
938
1219
  samplesPerTick: context.samplesPerTick,
939
1220
  secondsPerTick: context.samplesPerTick / context.sampleRate,
940
- gate: noteSpan(note, context).length / context.sampleRate,
941
- positionAt,
942
- }),
1221
+ gate: span.length / context.sampleRate,
1222
+ // Through a tempo map the lane is read at the sample's song tick.
1223
+ positionAt:
1224
+ warp && positionAt
1225
+ ? (tick: number) =>
1226
+ positionAt(
1227
+ warp.tick(
1228
+ span.start +
1229
+ (tick - note.startTick) * context.samplesPerTick,
1230
+ ),
1231
+ )
1232
+ : positionAt,
1233
+ });
1234
+ },
943
1235
  };
944
1236
  }
945
1237
 
@@ -975,3 +1267,58 @@ function writeAscii(target: Uint8Array, offset: number, value: string): void {
975
1267
  function clamp16(value: number): number {
976
1268
  return Math.max(-32_768, Math.min(32_767, Math.round(value)));
977
1269
  }
1270
+
1271
+ /** A named point in a WAV export: a sample frame and its label. */
1272
+ export type WavCue = Readonly<{ frame: number; label: string }>;
1273
+
1274
+ /**
1275
+ * Append a `cue ` chunk and a `LIST adtl` chunk of `labl` labels, so DAWs
1276
+ * and DJ tools show the cues (dawg writes section starts). No cues returns
1277
+ * the bytes unchanged.
1278
+ */
1279
+ export function withWavCues(
1280
+ bytes: Uint8Array,
1281
+ cues: readonly WavCue[],
1282
+ ): Uint8Array {
1283
+ if (cues.length === 0 || bytes.byteLength < 12) return bytes;
1284
+ const encoder = new TextEncoder();
1285
+ const labels = cues.map((cue) =>
1286
+ encoder.encode(`${cue.label.slice(0, 200)}\0`),
1287
+ );
1288
+ const cueSize = 4 + 24 * cues.length;
1289
+ const lablSizes = labels.map((text) => 4 + text.byteLength);
1290
+ const adtlSize =
1291
+ 4 + lablSizes.reduce((sum, size) => sum + 8 + size + (size % 2), 0);
1292
+ const base = bytes.byteLength + (bytes.byteLength % 2);
1293
+ const out = new Uint8Array(base + 8 + cueSize + 8 + adtlSize);
1294
+ out.set(bytes);
1295
+ const view = new DataView(out.buffer);
1296
+ let at = base;
1297
+ writeAscii(out, at, "cue ");
1298
+ view.setUint32(at + 4, cueSize, true);
1299
+ view.setUint32(at + 8, cues.length, true);
1300
+ at += 12;
1301
+ cues.forEach((cue, index) => {
1302
+ const frame = Math.max(0, Math.round(cue.frame));
1303
+ view.setUint32(at, index + 1, true);
1304
+ view.setUint32(at + 4, frame, true);
1305
+ writeAscii(out, at + 8, "data");
1306
+ view.setUint32(at + 12, 0, true);
1307
+ view.setUint32(at + 16, 0, true);
1308
+ view.setUint32(at + 20, frame, true);
1309
+ at += 24;
1310
+ });
1311
+ writeAscii(out, at, "LIST");
1312
+ view.setUint32(at + 4, adtlSize, true);
1313
+ writeAscii(out, at + 8, "adtl");
1314
+ at += 12;
1315
+ labels.forEach((text, index) => {
1316
+ writeAscii(out, at, "labl");
1317
+ view.setUint32(at + 4, lablSizes[index]!, true);
1318
+ view.setUint32(at + 8, index + 1, true);
1319
+ out.set(text, at + 12);
1320
+ at += 8 + lablSizes[index]! + (lablSizes[index]! % 2);
1321
+ });
1322
+ view.setUint32(4, out.byteLength - 8, true);
1323
+ return out;
1324
+ }