@hraness/dawg 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/DAWG.md +592 -49
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +9 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +689 -2
  8. package/core/granular.ts +619 -0
  9. package/core/instruments.ts +442 -0
  10. package/core/keys.ts +955 -0
  11. package/core/resonators.ts +862 -0
  12. package/core/score.ts +529 -8
  13. package/core/sdk/eval-child.ts +2 -0
  14. package/core/sdk/print.ts +247 -16
  15. package/core/sdk/sync-instruments.ts +58 -0
  16. package/core/sdk/v1.ts +2417 -40
  17. package/core/sections.ts +44 -10
  18. package/core/strings.ts +1080 -0
  19. package/core/winds.ts +652 -0
  20. package/guides/automation.md +1 -0
  21. package/guides/chords.md +3 -1
  22. package/guides/effects.md +5 -2
  23. package/guides/media.md +1 -1
  24. package/guides/performance.md +3 -0
  25. package/guides/resample.md +26 -0
  26. package/guides/sounds.md +7 -2
  27. package/guides/tempo.md +1 -0
  28. package/package.json +1 -1
  29. package/src/agent/agent.ts +13 -0
  30. package/src/agent/chord-tools.ts +153 -1
  31. package/src/agent/expression-tools.ts +91 -0
  32. package/src/agent/granular-tools.ts +138 -0
  33. package/src/agent/models.ts +4 -4
  34. package/src/agent/ops.ts +42 -2
  35. package/src/agent/preview-tool.ts +7 -1
  36. package/src/agent/resample-tool.ts +131 -0
  37. package/src/agent/tools.ts +726 -17
  38. package/src/agent/xcb-agent.ts +13 -0
  39. package/src/audio/arrange.ts +42 -3
  40. package/src/audio/dsp/bank.ts +233 -0
  41. package/src/audio/dsp/envelope.ts +161 -0
  42. package/src/audio/dsp/fft.ts +6 -0
  43. package/src/audio/dsp/filters.ts +57 -0
  44. package/src/audio/dsp/interp.ts +112 -0
  45. package/src/audio/dsp/modal.ts +684 -0
  46. package/src/audio/dsp/onset.ts +197 -0
  47. package/src/audio/dsp/oversample.ts +202 -0
  48. package/src/audio/dsp/rng.ts +37 -0
  49. package/src/audio/dsp/shape.ts +81 -0
  50. package/src/audio/dsp/shift.ts +256 -0
  51. package/src/audio/dsp/stft.ts +73 -0
  52. package/src/audio/dsp/window.ts +77 -0
  53. package/src/audio/effects/chain.ts +23 -3
  54. package/src/audio/effects/common.ts +14 -0
  55. package/src/audio/effects/convolution.ts +106 -1
  56. package/src/audio/effects/gaze.ts +354 -0
  57. package/src/audio/effects/rig/cab.ts +99 -0
  58. package/src/audio/effects/rig/filters.ts +152 -0
  59. package/src/audio/effects/rig/gate.ts +39 -0
  60. package/src/audio/effects/rig/head.ts +487 -0
  61. package/src/audio/effects/rig/index.ts +170 -0
  62. package/src/audio/effects/rig/section.ts +78 -0
  63. package/src/audio/effects/rig/stomp.ts +238 -0
  64. package/src/audio/engine.ts +56 -6
  65. package/src/audio/fit.ts +447 -0
  66. package/src/audio/granular.ts +995 -0
  67. package/src/audio/instrument-check.ts +137 -0
  68. package/src/audio/instruments.ts +140 -0
  69. package/src/audio/keys/dsp.ts +323 -0
  70. package/src/audio/keys/electric.ts +420 -0
  71. package/src/audio/keys/engine.ts +485 -0
  72. package/src/audio/keys/organ.ts +1335 -0
  73. package/src/audio/keys/piano.ts +476 -0
  74. package/src/audio/keys/sympathetic.ts +127 -0
  75. package/src/audio/live-worker.ts +56 -0
  76. package/src/audio/live.ts +361 -15
  77. package/src/audio/loudness.ts +65 -20
  78. package/src/audio/preview.ts +17 -1
  79. package/src/audio/resample.ts +285 -0
  80. package/src/audio/resonators.ts +295 -0
  81. package/src/audio/sampler.ts +328 -35
  82. package/src/audio/samples.ts +56 -8
  83. package/src/audio/strings/body.ts +263 -0
  84. package/src/audio/strings/bow.ts +656 -0
  85. package/src/audio/strings/engine.ts +615 -0
  86. package/src/audio/strings/loop.ts +119 -0
  87. package/src/audio/strings/measure.test-helpers.ts +200 -0
  88. package/src/audio/strings/pluck.ts +354 -0
  89. package/src/audio/warp.ts +61 -0
  90. package/src/audio/wav.ts +191 -19
  91. package/src/audio/winds/engine.ts +302 -0
  92. package/src/audio/winds/filters.ts +153 -0
  93. package/src/audio/winds/pitch.ts +104 -0
  94. package/src/audio/winds/trim.ts +80 -0
  95. package/src/audio/winds/trims.ts +917 -0
  96. package/src/audio/winds/voice.ts +479 -0
  97. package/src/commands/expression.ts +111 -23
  98. package/src/commands/fit.ts +135 -0
  99. package/src/commands/fx.ts +35 -1
  100. package/src/commands/granular.ts +432 -0
  101. package/src/commands/help.ts +159 -5
  102. package/src/commands/keys.ts +629 -0
  103. package/src/commands/modal.ts +310 -0
  104. package/src/commands/resample.ts +281 -0
  105. package/src/commands/rig.ts +264 -0
  106. package/src/commands/sample.ts +52 -2
  107. package/src/commands/shift.ts +119 -0
  108. package/src/commands/string.ts +201 -0
  109. package/src/commands/strum.ts +473 -0
  110. package/src/commands/time.ts +4 -1
  111. package/src/commands/wind.ts +244 -0
  112. package/src/main.ts +339 -15
  113. package/src/project/check.ts +17 -0
  114. package/src/render.ts +49 -4
  115. package/src/session/presence.ts +16 -3
  116. package/src/tui/audition.ts +2 -2
  117. package/src/tui/granular-menu.ts +278 -0
  118. package/src/tui/menu.ts +934 -9
  119. package/src/tui/modal-menu.ts +145 -0
  120. package/src/tui/performance-menu.ts +42 -0
  121. package/src/tui/play-chords.ts +104 -3
  122. package/src/tui/play-mode.ts +1 -0
  123. package/src/tui/play-session.ts +70 -2
  124. package/src/tui/wind-menu.ts +144 -0
package/src/audio/warp.ts CHANGED
@@ -84,3 +84,64 @@ export function warpedSpan(
84
84
  length: Math.max(1, Math.floor(to - from)),
85
85
  };
86
86
  }
87
+
88
+ /**
89
+ * A fitted sample window's position map (0.6 `bpm`/`len`/`fit`): output
90
+ * frames after the note start to window frames and back, both at the
91
+ * render rate. Through a tempo map the window follows ramps and steps;
92
+ * at constant tempo both directions are linear.
93
+ */
94
+ export type FitMap = Readonly<{
95
+ /** Output frames the whole window takes. */
96
+ length: number;
97
+ /** Window frame sounding `e` output frames after the start. */
98
+ at(e: number): number;
99
+ /** Output frame (after the start) where window frame `p` sounds. */
100
+ outAt(p: number): number;
101
+ }>;
102
+
103
+ /**
104
+ * The map for a window of `frames` render-rate frames that advances
105
+ * `perBeat` window frames per song beat from `startTick`. Without a warp the
106
+ * song runs at `tempoBpm` throughout.
107
+ */
108
+ export function beatFitMap(
109
+ warp: SampleWarp | undefined,
110
+ timing: Readonly<{
111
+ tempoBpm: number;
112
+ ticksPerBeat: number;
113
+ sampleRate: number;
114
+ }>,
115
+ startTick: number,
116
+ frames: number,
117
+ perBeat: number,
118
+ ): FitMap {
119
+ const { tempoBpm, ticksPerBeat, sampleRate } = timing;
120
+ if (!warp) {
121
+ const ratio = perBeat / ((sampleRate * 60) / tempoBpm);
122
+ return Object.freeze({
123
+ length: frames / ratio,
124
+ at: (e: number) => e * ratio,
125
+ outAt: (p: number) => p / ratio,
126
+ });
127
+ }
128
+ const origin = warp.sample(startTick);
129
+ const outAt = (p: number) =>
130
+ warp.sample(startTick + (p / perBeat) * ticksPerBeat) - origin;
131
+ return Object.freeze({
132
+ length: outAt(frames),
133
+ at: (e: number) =>
134
+ ((warp.tick(origin + e) - startTick) / ticksPerBeat) * perBeat,
135
+ outAt,
136
+ });
137
+ }
138
+
139
+ /** `fit`: the window fills `held` output frames, linearly. */
140
+ export function spanFitMap(frames: number, held: number): FitMap {
141
+ const ratio = frames / Math.max(1, held);
142
+ return Object.freeze({
143
+ length: held,
144
+ at: (e: number) => e * ratio,
145
+ outAt: (p: number) => p / ratio,
146
+ });
147
+ }
package/src/audio/wav.ts CHANGED
@@ -23,6 +23,11 @@ import {
23
23
  } from "./sampler.ts";
24
24
  import { EFFECT_NAMES, FX_PRESETS, effectSpec } from "../../core/fx.ts";
25
25
  import { seededRandom } from "./random.ts";
26
+ import {
27
+ engineFor,
28
+ engineTailSeconds,
29
+ RING_OUT_FADE_SECONDS,
30
+ } from "./instruments.ts";
26
31
  import {
27
32
  isStereoVoice,
28
33
  renderSynthNote,
@@ -45,6 +50,8 @@ import {
45
50
  applyStereoChain,
46
51
  delayTailFor,
47
52
  interpolateAutomation,
53
+ needsEffectNotes,
54
+ type EffectNote,
48
55
  reverbActive,
49
56
  reverbImpulse,
50
57
  reverbTailFor,
@@ -96,6 +103,19 @@ export type RenderOptions = WavOptions &
96
103
  * a single pass of the whole song. Zero (the default) changes nothing.
97
104
  */
98
105
  seedTick?: number;
106
+ /**
107
+ * Song seconds at an arranged window's first frame (with a tempo map,
108
+ * more than `seedTick` at the base tempo); engines whose state runs on
109
+ * the song clock (organ wheels and rotors) start there.
110
+ */
111
+ seedSeconds?: number;
112
+ /** Per-track engine state at the window origin (`windowSeed`). */
113
+ seedState?: Readonly<Record<string, string>>;
114
+ /**
115
+ * The song whose chords a granular `quant chord` reads, at `tick` for
116
+ * this render's tick 0 (a live note renders alone). Absent: the score.
117
+ */
118
+ quantChord?: Readonly<{ score: TrackScore; tick: number }>;
99
119
  }>;
100
120
 
101
121
  /** Interleaved stereo 16-bit PCM plus its frame count. */
@@ -139,6 +159,9 @@ export const AVAILABLE_INSTRUMENTS = Object.freeze([
139
159
  "z_square",
140
160
  "z_tan",
141
161
  "z_noise",
162
+ // 0.6 instruments (src/audio/instruments.ts), one line per lane.
163
+ "granular",
164
+ "modal",
142
165
  ] as const);
143
166
 
144
167
  /** Per-track effects understood by the renderer and the agent, in chain
@@ -180,8 +203,33 @@ function oneShotSeconds(
180
203
  }
181
204
  /** Loop renders fold at most this much tail back onto the loop start. */
182
205
  const MAX_LOOP_TAIL_SECONDS = 8;
206
+ /** A 0.6 engine's one-shot ring-out is capped here, not at the loop fold. */
207
+ const MAX_ENGINE_TAIL_SECONDS = 30;
183
208
 
184
- type RenderContext = Readonly<{
209
+ /**
210
+ * The longest 0.6 engine ring-out in `score`, capped at 30 s. One-shot
211
+ * renders (exports, previews) let engine tails reach their own cap; the
212
+ * 8 s cap applies only to loop folding. Zero without an engine, so legacy
213
+ * renders are unchanged. `ringing` counts only ring-out tracks (0.6.1).
214
+ */
215
+ function oneShotEngineTail(score: TrackScore, ringing = false): number {
216
+ let tail = 0;
217
+ for (const track of score.tracks) {
218
+ const engine = engineFor(track);
219
+ if (!engine || (ringing && engine.ringOut?.(track) !== true)) continue;
220
+ let lowest: number | undefined;
221
+ for (const note of score.notes)
222
+ if (
223
+ note.trackId === track.id &&
224
+ (lowest === undefined || note.pitch < lowest)
225
+ )
226
+ lowest = note.pitch;
227
+ tail = Math.max(tail, engineTailSeconds(track, lowest));
228
+ }
229
+ return Math.min(MAX_ENGINE_TAIL_SECONDS, tail);
230
+ }
231
+
232
+ export type RenderContext = Readonly<{
185
233
  score: TrackScore;
186
234
  sampleRate: number;
187
235
  samples: number;
@@ -191,6 +239,9 @@ type RenderContext = Readonly<{
191
239
  /** Tempo map in samples (core/tempo.ts); absent: constant tempo. */
192
240
  warp?: SampleWarp;
193
241
  seedTick?: number;
242
+ seedSeconds?: number;
243
+ seedState?: Readonly<Record<string, string>>;
244
+ quantChord?: Readonly<{ score: TrackScore; tick: number }>;
194
245
  }>;
195
246
 
196
247
  /** Exact (fractional) loop length in frames at a sample rate. */
@@ -314,9 +365,15 @@ export class StemRenderer {
314
365
  Math.max(
315
366
  samplerTailSeconds(score, bank, sampleRate),
316
367
  ...score.tracks.map(synthTailSeconds),
368
+ // Zero without a registered 0.6 instrument engine.
369
+ ...score.tracks.map((track) => engineTailSeconds(track)),
317
370
  ),
318
371
  );
319
- return oneShotSeconds(loopSecondsOf(score), samplerTail, reverbTail);
372
+ return oneShotSeconds(
373
+ loopSecondsOf(score),
374
+ Math.max(samplerTail, oneShotEngineTail(score)),
375
+ reverbTail,
376
+ );
320
377
  }
321
378
 
322
379
  public render(score: TrackScore, options: RenderOptions = {}): RenderedAudio {
@@ -338,6 +395,8 @@ export class StemRenderer {
338
395
  Math.max(
339
396
  samplerTailSeconds(score, bank, sampleRate),
340
397
  ...score.tracks.map(synthTailSeconds),
398
+ // Zero without a registered 0.6 instrument engine.
399
+ ...score.tracks.map((track) => engineTailSeconds(track)),
341
400
  ),
342
401
  );
343
402
  let frames: number;
@@ -367,7 +426,11 @@ export class StemRenderer {
367
426
  } else {
368
427
  const seconds = Math.min(
369
428
  maxSeconds,
370
- oneShotSeconds(loopSeconds, samplerTail, reverbTail),
429
+ oneShotSeconds(
430
+ loopSeconds,
431
+ Math.max(samplerTail, oneShotEngineTail(score)),
432
+ reverbTail,
433
+ ),
371
434
  );
372
435
  frames = Math.max(1, Math.ceil(seconds * sampleRate));
373
436
  samples = frames;
@@ -381,6 +444,9 @@ export class StemRenderer {
381
444
  ...(bank.irs ? { irs: bank.irs } : {}),
382
445
  ...(warp ? { warp } : {}),
383
446
  ...(options.seedTick ? { seedTick: options.seedTick } : {}),
447
+ ...(options.seedSeconds ? { seedSeconds: options.seedSeconds } : {}),
448
+ ...(options.seedState ? { seedState: options.seedState } : {}),
449
+ ...(options.quantChord ? { quantChord: options.quantChord } : {}),
384
450
  };
385
451
  this.renders += 1;
386
452
  const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
@@ -437,6 +503,18 @@ export class StemRenderer {
437
503
  const played = performed.get(trackId)!;
438
504
  const busOrbit = sharedOrbitOf(track);
439
505
  const sampler = isSamplerInstrument(track?.instrument);
506
+ // A 0.6 instrument engine (src/audio/instruments.ts), if registered
507
+ // and the track carries its field; undefined keeps today's voices.
508
+ const engine = engineFor(track);
509
+ // The song key always joins an engine's stem key: engines may tune to
510
+ // it (sympathetic strings) without a song tuning.
511
+ const engineDigests =
512
+ engine && track
513
+ ? [
514
+ ...(engine.assetDigests?.(track, bank, score) ?? []),
515
+ `key:${score.key ?? ""}`,
516
+ ]
517
+ : undefined;
440
518
  // Wavetable hook: the oscillator factory for a wavetable track (its
441
519
  // table id joins the stem key), undefined for every other instrument.
442
520
  const wavetable = track ? wavetableHook(track, bank, context) : undefined;
@@ -449,6 +527,7 @@ export class StemRenderer {
449
527
  sampler ? bank : undefined,
450
528
  wavetable?.id,
451
529
  track ? reverbImpulse(track, sampleRate, bank.irs)?.id : undefined,
530
+ engineDigests,
452
531
  );
453
532
  let stem = this.stems.get(trackId);
454
533
  const caching = this.maxCacheBytes > 0;
@@ -464,6 +543,7 @@ export class StemRenderer {
464
543
  sampler ? bank : undefined,
465
544
  wavetable?.id,
466
545
  reverbImpulse(track, sampleRate, bank.irs)?.id,
546
+ engineDigests,
467
547
  )
468
548
  : undefined;
469
549
  if (stem?.key === key) stem.used = this.renders;
@@ -490,11 +570,35 @@ export class StemRenderer {
490
570
  }
491
571
  : { left, right };
492
572
  dry.fill(0);
493
- const synthVoice = !sampler && usesSynthVoice(track);
494
- const stereo = synthVoice && isStereoVoice(track);
573
+ const synthVoice = !engine && !sampler && usesSynthVoice(track);
574
+ let stereo = engine
575
+ ? engine.stereo(track!)
576
+ : synthVoice && isStereoVoice(track);
495
577
  if (stereo) dryR.fill(0);
496
- if (sampler) {
497
- if (track) renderSamplerNotes(dry, played, track, context, bank);
578
+ if (engine && track) {
579
+ engine.render(
580
+ dry,
581
+ stereo ? dryR : undefined,
582
+ played,
583
+ track,
584
+ {
585
+ ...context,
586
+ ticksPerBeat: score.ticksPerBeat,
587
+ ...(tuning ? { tuning } : {}),
588
+ },
589
+ bank,
590
+ );
591
+ } else if (sampler) {
592
+ // A resampled stereo file plays as stereo (0.6.1).
593
+ if (track)
594
+ stereo = renderSamplerNotes(
595
+ dry,
596
+ played,
597
+ track,
598
+ context,
599
+ bank,
600
+ dryR,
601
+ );
498
602
  } else if (synthVoice && track) {
499
603
  const gainAt = (tick: number) => trackGainAt(track, tick);
500
604
  const voice = {
@@ -534,8 +638,13 @@ export class StemRenderer {
534
638
  else renderToneNote(dry, note, track, context, tuning);
535
639
  }
536
640
  }
537
- if (track) applyMonoChain(dry, track, context);
538
- if (stereo && track) applyMonoChain(dryR, track, context);
641
+ // Note-aware stages (bloom, swell) see the notes; others never do.
642
+ const chain =
643
+ track && needsEffectNotes(track)
644
+ ? { ...context, notes: effectNotes(played, context, tuning) }
645
+ : context;
646
+ if (track) applyMonoChain(dry, track, chain);
647
+ if (stereo && track) applyMonoChain(dryR, track, chain);
539
648
  applyPan(
540
649
  dry,
541
650
  target.left,
@@ -551,7 +660,7 @@ export class StemRenderer {
551
660
  target.left,
552
661
  target.right,
553
662
  track,
554
- context,
663
+ chain,
555
664
  true,
556
665
  false,
557
666
  );
@@ -569,7 +678,7 @@ export class StemRenderer {
569
678
  target.left,
570
679
  target.right,
571
680
  track,
572
- context,
681
+ chain,
573
682
  busOrbit === undefined,
574
683
  );
575
684
  stem = {
@@ -625,6 +734,20 @@ export class StemRenderer {
625
734
  if (!groups.has(trackId)) this.evict(trackId);
626
735
  // Linear effects superpose, so folding the tail onto the start yields
627
736
  // the steady state of the loop playing forever.
737
+ // A ring-out longer than the fold (a gong past 8 s) would be cut there:
738
+ // fade its last RING_OUT_FADE_SECONDS so the fold never clicks. Only
739
+ // ringing 0.6 engine tracks qualify, so legacy loops are untouched.
740
+ if (
741
+ options.loop &&
742
+ samples > frames &&
743
+ oneShotEngineTail(score, true) > (samples - frames) / sampleRate
744
+ )
745
+ fadeEnd(
746
+ mixL,
747
+ mixR,
748
+ samples,
749
+ Math.round(RING_OUT_FADE_SECONDS * sampleRate),
750
+ );
628
751
  if (samples > frames) foldTail(mixL, mixR, frames, samples);
629
752
  // The song master (src/audio/master.ts); none leaves the mix untouched.
630
753
  const mastered = applyMaster(
@@ -731,6 +854,21 @@ function addStem(
731
854
  }
732
855
 
733
856
  /** Fold everything past `frames` back onto the loop start. */
857
+ /** Fade the last `fade` of `samples` mix frames linearly to zero. */
858
+ function fadeEnd(
859
+ mixL: Float64Array,
860
+ mixR: Float64Array,
861
+ samples: number,
862
+ fade: number,
863
+ ): void {
864
+ const n = Math.min(fade, samples);
865
+ for (let i = 0; i < n; i += 1) {
866
+ const gain = (n - 1 - i) / n;
867
+ mixL[samples - n + i]! *= gain;
868
+ mixR[samples - n + i]! *= gain;
869
+ }
870
+ }
871
+
734
872
  function foldTail(
735
873
  mixL: Float64Array,
736
874
  mixR: Float64Array,
@@ -826,6 +964,7 @@ function stemKey(
826
964
  bank?: SampleBank,
827
965
  wavetableId?: string,
828
966
  impulseId?: string,
967
+ engineDigests?: readonly string[],
829
968
  ): string {
830
969
  let settings: Record<string, unknown> | null = null;
831
970
  if (track) {
@@ -849,12 +988,18 @@ function stemKey(
849
988
  ...(wavetableId ? [wavetableId] : []),
850
989
  // A convolution reverb's impulse (its sha256 for a sample).
851
990
  ...(impulseId ? [impulseId] : []),
991
+ // Assets a 0.6 instrument engine reads (absent without an engine).
992
+ ...(engineDigests ? [engineDigests] : []),
852
993
  // The song tuning, and the key whose tonic is the default root.
853
994
  ...(context.score.tuning || track?.tuning
854
995
  ? [context.score.tuning ?? null, context.score.key]
855
996
  : []),
856
997
  // Seeded noise follows the window origin of an arranged render.
857
998
  ...(context.seedTick ? [`seed:${context.seedTick}`] : []),
999
+ ...(context.seedSeconds ? [`at:${context.seedSeconds}`] : []),
1000
+ ...(track && context.seedState?.[track.id]
1001
+ ? [`state:${context.seedState[track.id]}`]
1002
+ : []),
858
1003
  ]);
859
1004
  }
860
1005
 
@@ -869,25 +1014,34 @@ function samplerVoiceDigest(track: Track, bank: SampleBank): string[] {
869
1014
  });
870
1015
  }
871
1016
 
872
- /** Sample voices of a sampler track into its mono dry buffer. */
1017
+ /**
1018
+ * Sample voices of a sampler track into its mono dry buffer. When a voice
1019
+ * plays a resampled stereo file (0.6.1), `target` takes the left channel,
1020
+ * `targetRight` the right, and the result is true (the track is stereo).
1021
+ */
873
1022
  function renderSamplerNotes(
874
1023
  target: Float64Array,
875
1024
  notes: readonly Note[],
876
1025
  track: Track,
877
1026
  context: RenderContext,
878
1027
  bank: SampleBank,
879
- ): void {
1028
+ targetRight: Float64Array,
1029
+ ): boolean {
880
1030
  const timing = {
881
1031
  score: context.score,
882
1032
  sampleRate: context.sampleRate,
883
1033
  ...(context.warp ? { warp: context.warp } : {}),
884
1034
  };
885
- renderSamplerVoices(
886
- target,
887
- planSamplerVoices(track, notes, bank, timing),
888
- timing,
889
- (tick) => trackGainAt(track, tick),
890
- );
1035
+ const voices = planSamplerVoices(track, notes, bank, timing);
1036
+ const gainAt = (tick: number) => trackGainAt(track, tick);
1037
+ if (!voices.some((voice) => voice.stereo)) {
1038
+ renderSamplerVoices(target, voices, timing, gainAt);
1039
+ return false;
1040
+ }
1041
+ targetRight.fill(0);
1042
+ renderSamplerVoices(target, voices, timing, gainAt, "left");
1043
+ renderSamplerVoices(targetRight, voices, timing, gainAt, "right");
1044
+ return true;
891
1045
  }
892
1046
 
893
1047
  /** Track volume and volume automation at a score tick (pan is applied in stereo). */
@@ -901,6 +1055,24 @@ function trackGainAt(track: Track | undefined, tick: number): number {
901
1055
  return trackGain * automatedGain;
902
1056
  }
903
1057
 
1058
+ /** The notes as note-aware effects see them (EffectContext.notes). */
1059
+ function effectNotes(
1060
+ notes: readonly Note[],
1061
+ context: RenderContext,
1062
+ tuning: TuningTable | undefined,
1063
+ ): EffectNote[] {
1064
+ return notes.map((note) => {
1065
+ const { start, length } = noteSpan(note, context);
1066
+ return {
1067
+ id: note.id,
1068
+ start,
1069
+ length,
1070
+ hz: noteHz(note.pitch, note.cents, tuning),
1071
+ velocity: note.velocity,
1072
+ };
1073
+ });
1074
+ }
1075
+
904
1076
  function noteSpan(
905
1077
  note: Note,
906
1078
  context: RenderContext,