@hraness/dawg 0.4.1 → 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 (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/DAWG.md +293 -31
  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/common.ts +30 -1
  64. package/src/audio/effects/dynamics.ts +2 -1
  65. package/src/audio/effects/filter.ts +5 -3
  66. package/src/audio/effects/modulation.ts +5 -1
  67. package/src/audio/effects/space.ts +92 -2
  68. package/src/audio/engine.ts +90 -20
  69. package/src/audio/live.ts +33 -6
  70. package/src/audio/loudness.ts +551 -0
  71. package/src/audio/master.ts +660 -0
  72. package/src/audio/measure-worker.ts +45 -0
  73. package/src/audio/measure.ts +110 -0
  74. package/src/audio/player.ts +11 -6
  75. package/src/audio/preview.ts +74 -14
  76. package/src/audio/render-worker.ts +6 -1
  77. package/src/audio/renderer.ts +7 -1
  78. package/src/audio/sampler.ts +108 -19
  79. package/src/audio/synth/voice.ts +60 -13
  80. package/src/audio/synth/zzfx.ts +10 -4
  81. package/src/audio/warp.ts +86 -0
  82. package/src/audio/wav.ts +301 -26
  83. package/src/commands/arrange.ts +949 -0
  84. package/src/commands/expression.ts +934 -0
  85. package/src/commands/help.ts +281 -24
  86. package/src/commands/master.ts +361 -0
  87. package/src/commands/music.ts +6 -1
  88. package/src/commands/synth.ts +11 -1
  89. package/src/commands/time.ts +964 -0
  90. package/src/commands/tuning.ts +490 -0
  91. package/src/main.ts +709 -33
  92. package/src/render.ts +109 -7
  93. package/src/session/daemon.ts +18 -8
  94. package/src/session/naming.ts +8 -1
  95. package/src/session/rebase.ts +21 -0
  96. package/src/tui/arrange-menu.ts +390 -0
  97. package/src/tui/audition.ts +38 -5
  98. package/src/tui/fader.ts +409 -0
  99. package/src/tui/menu-time.ts +401 -0
  100. package/src/tui/menu.ts +635 -14
  101. package/src/tui/performance-menu.ts +235 -0
  102. package/src/tui/play-chords.ts +3 -1
  103. package/src/tui/play-mode.ts +150 -6
  104. package/src/tui/play-session.ts +414 -41
  105. package/tui/app.ts +262 -13
  106. package/tui/arrange-strip.ts +174 -0
  107. package/tui/drawer.ts +478 -0
  108. package/tui/grammar.ts +63 -1
  109. package/tui/guide.ts +351 -0
  110. package/tui/highway.ts +87 -4
  111. package/tui/hits.ts +68 -0
  112. package/tui/input.ts +7 -0
  113. 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,
@@ -53,12 +59,21 @@ import {
53
59
  type OrbitBus,
54
60
  } from "./effects/bus.ts";
55
61
  import { tableFor, wavetableOscillator } from "./wavetable.ts";
62
+ import { noteHz, resolveTuning, type TuningTable } from "../../core/tuning.ts";
56
63
  import {
57
64
  duckGains,
58
65
  duckSettings,
59
66
  orbitOf,
60
67
  type Ducker,
61
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";
62
77
 
63
78
  export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
64
79
 
@@ -75,6 +90,12 @@ export type RenderOptions = WavOptions &
75
90
  * tracks render silent; scores without samplers are unaffected.
76
91
  */
77
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;
78
99
  }>;
79
100
 
80
101
  /** Interleaved stereo 16-bit PCM plus its frame count. */
@@ -83,6 +104,8 @@ export type RenderedAudio = Readonly<{
83
104
  channels: 2;
84
105
  frames: number;
85
106
  pcm: Int16Array;
107
+ /** Loudness the song master reached; absent without a master. */
108
+ master?: MasterReport;
86
109
  }>;
87
110
 
88
111
  /** Output channel count for every render and export. */
@@ -145,6 +168,16 @@ const MAX_DRUM_SECONDS = 0.6;
145
168
  const CONTROL_SAMPLES = 32;
146
169
  /** One-shot renders keep this much ring-out after the last bar. */
147
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
+ }
148
181
  /** Loop renders fold at most this much tail back onto the loop start. */
149
182
  const MAX_LOOP_TAIL_SECONDS = 8;
150
183
 
@@ -155,10 +188,14 @@ type RenderContext = Readonly<{
155
188
  samplesPerTick: number;
156
189
  tempoBpm: number;
157
190
  irs?: ReadonlyMap<string, DecodedSample>;
191
+ /** Tempo map in samples (core/tempo.ts); absent: constant tempo. */
192
+ warp?: SampleWarp;
193
+ seedTick?: number;
158
194
  }>;
159
195
 
160
196
  /** Exact (fractional) loop length in frames at a sample rate. */
161
197
  export function loopFrames(score: TrackScore, sampleRate: number): number {
198
+ if (score.time) return loopSecondsOf(score) * sampleRate;
162
199
  return (score.bars * score.beatsPerBar * 60 * sampleRate) / score.tempoBpm;
163
200
  }
164
201
 
@@ -256,11 +293,38 @@ export class StemRenderer {
256
293
  return { stems: this.stems.size, bytes: this.cacheBytes };
257
294
  }
258
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
+
259
322
  public render(score: TrackScore, options: RenderOptions = {}): RenderedAudio {
260
323
  const sampleRate = clampSampleRate(options.sampleRate);
261
324
  const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
262
- const loopSeconds = (score.bars * score.beatsPerBar * 60) / score.tempoBpm;
325
+ const loopSeconds = loopSecondsOf(score);
263
326
  const bank = options.samples ?? EMPTY_SAMPLE_BANK;
327
+ const warp = sampleWarpFor(score, sampleRate);
264
328
  const reverbTail = Math.max(
265
329
  0,
266
330
  ...score.tracks.map((track) =>
@@ -293,14 +357,17 @@ export class StemRenderer {
293
357
  reverbTail +
294
358
  Math.max(
295
359
  0,
296
- ...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
+ ),
297
364
  ),
298
365
  );
299
366
  samples = frames + Math.ceil(tailSeconds * sampleRate);
300
367
  } else {
301
368
  const seconds = Math.min(
302
369
  maxSeconds,
303
- loopSeconds + Math.max(ONE_SHOT_TAIL_SECONDS, samplerTail) + reverbTail,
370
+ oneShotSeconds(loopSeconds, samplerTail, reverbTail),
304
371
  );
305
372
  frames = Math.max(1, Math.ceil(seconds * sampleRate));
306
373
  samples = frames;
@@ -312,6 +379,8 @@ export class StemRenderer {
312
379
  samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
313
380
  tempoBpm: score.tempoBpm,
314
381
  ...(bank.irs ? { irs: bank.irs } : {}),
382
+ ...(warp ? { warp } : {}),
383
+ ...(options.seedTick ? { seedTick: options.seedTick } : {}),
315
384
  };
316
385
  this.renders += 1;
317
386
  const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
@@ -319,20 +388,34 @@ export class StemRenderer {
319
388
  mixR.fill(0);
320
389
  const tracks = new Map(score.tracks.map((track) => [track.id, track]));
321
390
  const groups = new Map<string, Note[]>();
322
- 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)) {
323
393
  const group = groups.get(note.trackId);
324
394
  if (group) group.push(note);
325
395
  else groups.set(note.trackId, [note]);
326
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
+ }
327
408
  // Orbit ducking is a gain on finished stems (src/audio/effects/duck.ts).
328
409
  const duckers: Ducker[] = [];
329
- for (const [trackId, notes] of groups) {
410
+ for (const [trackId] of groups) {
330
411
  const settings = duckSettings(tracks.get(trackId));
331
412
  if (settings && isTrackAudible(score, trackId))
332
413
  duckers.push({
333
414
  trackId,
334
415
  ...settings,
335
- onsets: notes.map((note) => noteSpan(note, context).start),
416
+ onsets: performed
417
+ .get(trackId)!
418
+ .map((note) => noteSpan(note, context).start),
336
419
  });
337
420
  }
338
421
  const ducked = duckGains(
@@ -349,11 +432,16 @@ export class StemRenderer {
349
432
  for (const [trackId, notes] of groups) {
350
433
  if (!isTrackAudible(score, trackId)) continue;
351
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)!;
352
438
  const busOrbit = sharedOrbitOf(track);
353
439
  const sampler = isSamplerInstrument(track?.instrument);
354
440
  // Wavetable hook: the oscillator factory for a wavetable track (its
355
441
  // table id joins the stem key), undefined for every other instrument.
356
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);
357
445
  const key = stemKey(
358
446
  track,
359
447
  notes,
@@ -406,16 +494,28 @@ export class StemRenderer {
406
494
  const stereo = synthVoice && isStereoVoice(track);
407
495
  if (stereo) dryR.fill(0);
408
496
  if (sampler) {
409
- if (track) renderSamplerNotes(dry, notes, track, context, bank);
497
+ if (track) renderSamplerNotes(dry, played, track, context, bank);
410
498
  } else if (synthVoice && track) {
411
499
  const gainAt = (tick: number) => trackGainAt(track, tick);
412
500
  const voice = {
413
501
  ...context,
414
502
  ticksPerBeat: score.ticksPerBeat,
415
503
  ...(wavetable ? { oscillatorFor: wavetable.oscillatorFor } : {}),
504
+ ...(tuning ? { tuning } : {}),
416
505
  };
417
- for (const note of notes) {
506
+ const warp = context.warp;
507
+ for (const note of played) {
418
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;
419
519
  renderSynthNote(
420
520
  dry,
421
521
  stereo ? dryR : undefined,
@@ -424,14 +524,14 @@ export class StemRenderer {
424
524
  start,
425
525
  length,
426
526
  voice,
427
- gainAt,
527
+ noteGainAt,
428
528
  );
429
529
  }
430
530
  } else {
431
531
  const drums = isDrumInstrument(track?.instrument);
432
- for (const note of notes) {
532
+ for (const note of played) {
433
533
  if (drums) renderDrumNote(dry, note, track, context);
434
- else renderToneNote(dry, note, track, context);
534
+ else renderToneNote(dry, note, track, context, tuning);
435
535
  }
436
536
  }
437
537
  if (track) applyMonoChain(dry, track, context);
@@ -526,12 +626,26 @@ export class StemRenderer {
526
626
  // Linear effects superpose, so folding the tail onto the start yields
527
627
  // the steady state of the loop playing forever.
528
628
  if (samples > frames) foldTail(mixL, mixR, frames, samples);
529
- const pcm = toPcm(mixL, mixR, frames);
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);
530
643
  return Object.freeze({
531
644
  sampleRate,
532
645
  channels: RENDER_CHANNELS,
533
646
  frames,
534
647
  pcm,
648
+ ...(mastered ? { master: mastered.report } : {}),
535
649
  });
536
650
  }
537
651
 
@@ -630,12 +744,59 @@ function foldTail(
630
744
  }
631
745
 
632
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
+
633
773
  function toPcm(
634
774
  mixL: Float64Array,
635
775
  mixR: Float64Array,
636
776
  frames: number,
777
+ dither = false,
637
778
  ): Int16Array {
638
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
+ }
639
800
  for (let index = 0; index < frames; index += 1) {
640
801
  pcm[index * 2] = clamp16(mixL[index]! * 32767);
641
802
  pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
@@ -680,12 +841,20 @@ function stemKey(
680
841
  context.score.beatsPerBar,
681
842
  context.sampleRate,
682
843
  context.samples,
844
+ // Tempo map, meter changes and fermatas move every note.
845
+ ...(context.score.time ? [context.score.time] : []),
683
846
  // Sampler stems also depend on the decoded files: a replaced or missing
684
847
  // sample changes the key even when the score did not change.
685
848
  ...(bank && track?.sampler ? [samplerVoiceDigest(track, bank)] : []),
686
849
  ...(wavetableId ? [wavetableId] : []),
687
850
  // A convolution reverb's impulse (its sha256 for a sample).
688
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}`] : []),
689
858
  ]);
690
859
  }
691
860
 
@@ -708,7 +877,11 @@ function renderSamplerNotes(
708
877
  context: RenderContext,
709
878
  bank: SampleBank,
710
879
  ): void {
711
- 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
+ };
712
885
  renderSamplerVoices(
713
886
  target,
714
887
  planSamplerVoices(track, notes, bank, timing),
@@ -732,7 +905,8 @@ function noteSpan(
732
905
  note: Note,
733
906
  context: RenderContext,
734
907
  ): { start: number; length: number } {
735
- const { score, sampleRate } = context;
908
+ const { score, sampleRate, warp } = context;
909
+ if (warp) return warpedSpan(warp, note.startTick, note.durationTicks);
736
910
  const start = Math.max(
737
911
  0,
738
912
  Math.floor(
@@ -755,13 +929,18 @@ function renderToneNote(
755
929
  note: Note,
756
930
  track: Track | undefined,
757
931
  context: RenderContext,
932
+ tuning?: TuningTable,
758
933
  ): void {
759
934
  const { sampleRate, samples, samplesPerTick } = context;
760
935
  const instrument = track?.instrument ?? "sine";
761
936
  const { start, length } = noteSpan(note, context);
762
937
  const end = Math.min(samples, start + length);
763
- const frequency = 440 * 2 ** ((note.pitch - 69) / 12);
938
+ const frequency = noteHz(note.pitch, note.cents, tuning);
939
+ if (!(frequency > 0)) return;
764
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;
765
944
  for (let index = start; index < end; index += 1) {
766
945
  const elapsed = index - start;
767
946
  const remaining = end - index;
@@ -771,9 +950,24 @@ function renderToneNote(
771
950
  Math.min(attack, release) *
772
951
  velocity *
773
952
  0.28 *
774
- trackGainAt(track, note.startTick + elapsed / samplesPerTick);
775
- const phase = (frequency * elapsed) / sampleRate;
776
- 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;
777
971
  }
778
972
  }
779
973
 
@@ -799,7 +993,9 @@ function renderDrumNote(
799
993
  start + Math.ceil((kit?.seconds ?? MAX_DRUM_SECONDS) * sampleRate),
800
994
  );
801
995
  const voice = drumVoiceForPitch(note.pitch);
802
- const random = seededRandom(`${note.id}:${note.startTick}`);
996
+ const random = seededRandom(
997
+ `${note.id}:${note.startTick + (context.seedTick ?? 0)}`,
998
+ );
803
999
  const velocity = Math.max(0, Math.min(1, note.velocity));
804
1000
  let phase = 0;
805
1001
  let previousNoise = 0;
@@ -865,7 +1061,12 @@ function renderDrumNote(
865
1061
  release *
866
1062
  velocity *
867
1063
  0.5 *
868
- 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
+ );
869
1070
  }
870
1071
  }
871
1072
 
@@ -892,7 +1093,13 @@ function applyPan(
892
1093
  for (let index = 0; index < dry.length; index += 1) {
893
1094
  if (lane.length > 0 && index % CONTROL_SAMPLES === 0)
894
1095
  [gainL, gainR] = gains(
895
- 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
+ ),
896
1103
  );
897
1104
  const sample = dry[index]!;
898
1105
  left[index] = sample * gainL;
@@ -930,16 +1137,29 @@ function wavetableHook(
930
1137
  lane.length > 0
931
1138
  ? (tick: number) => interpolateAutomation(lane, tick, settings.wt ?? 0)
932
1139
  : undefined;
1140
+ const warp = context.warp;
933
1141
  return {
934
1142
  id: table.id,
935
- oscillatorFor: (note) =>
936
- wavetableOscillator(table, settings, note, {
1143
+ oscillatorFor: (note) => {
1144
+ const span = noteSpan(note, context);
1145
+ return wavetableOscillator(table, settings, note, {
937
1146
  sampleRate: context.sampleRate,
938
1147
  samplesPerTick: context.samplesPerTick,
939
1148
  secondsPerTick: context.samplesPerTick / context.sampleRate,
940
- gate: noteSpan(note, context).length / context.sampleRate,
941
- positionAt,
942
- }),
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
+ },
943
1163
  };
944
1164
  }
945
1165
 
@@ -975,3 +1195,58 @@ function writeAscii(target: Uint8Array, offset: number, value: string): void {
975
1195
  function clamp16(value: number): number {
976
1196
  return Math.max(-32_768, Math.min(32_767, Math.round(value)));
977
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
+ }