@hraness/dawg 0.6.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 (93) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/DAWG.md +338 -51
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +5 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +258 -6
  8. package/core/granular.ts +91 -0
  9. package/core/instruments.ts +161 -0
  10. package/core/keys.ts +573 -4
  11. package/core/resonators.ts +291 -3
  12. package/core/score.ts +349 -3
  13. package/core/sdk/print.ts +78 -3
  14. package/core/sdk/v1.ts +1320 -36
  15. package/core/sections.ts +12 -4
  16. package/core/strings.ts +237 -2
  17. package/core/winds.ts +652 -0
  18. package/guides/automation.md +1 -0
  19. package/guides/chords.md +3 -1
  20. package/guides/effects.md +3 -2
  21. package/guides/media.md +1 -1
  22. package/guides/performance.md +3 -0
  23. package/guides/resample.md +26 -0
  24. package/guides/sounds.md +7 -6
  25. package/package.json +1 -1
  26. package/src/agent/agent.ts +8 -2
  27. package/src/agent/chord-tools.ts +153 -1
  28. package/src/agent/expression-tools.ts +91 -0
  29. package/src/agent/ops.ts +4 -1
  30. package/src/agent/preview-tool.ts +2 -1
  31. package/src/agent/resample-tool.ts +131 -0
  32. package/src/agent/tools.ts +200 -15
  33. package/src/agent/xcb-agent.ts +8 -2
  34. package/src/audio/arrange.ts +21 -0
  35. package/src/audio/dsp/envelope.ts +161 -0
  36. package/src/audio/dsp/shift.ts +256 -0
  37. package/src/audio/effects/chain.ts +18 -3
  38. package/src/audio/effects/common.ts +14 -0
  39. package/src/audio/effects/convolution.ts +106 -1
  40. package/src/audio/effects/gaze.ts +354 -0
  41. package/src/audio/engine.ts +46 -5
  42. package/src/audio/fit.ts +45 -0
  43. package/src/audio/granular.ts +352 -21
  44. package/src/audio/instrument-check.ts +9 -5
  45. package/src/audio/instruments.ts +32 -3
  46. package/src/audio/keys/electric.ts +420 -0
  47. package/src/audio/keys/engine.ts +143 -19
  48. package/src/audio/keys/organ.ts +1335 -0
  49. package/src/audio/keys/piano.ts +50 -6
  50. package/src/audio/keys/sympathetic.ts +127 -0
  51. package/src/audio/live-worker.ts +3 -1
  52. package/src/audio/live.ts +136 -9
  53. package/src/audio/preview.ts +15 -1
  54. package/src/audio/resample.ts +285 -0
  55. package/src/audio/resonators.ts +10 -2
  56. package/src/audio/sampler.ts +168 -21
  57. package/src/audio/samples.ts +26 -1
  58. package/src/audio/strings/body.ts +29 -16
  59. package/src/audio/strings/bow.ts +656 -0
  60. package/src/audio/strings/engine.ts +266 -20
  61. package/src/audio/strings/measure.test-helpers.ts +2 -0
  62. package/src/audio/wav.ts +119 -19
  63. package/src/audio/winds/engine.ts +302 -0
  64. package/src/audio/winds/filters.ts +153 -0
  65. package/src/audio/winds/pitch.ts +104 -0
  66. package/src/audio/winds/trim.ts +80 -0
  67. package/src/audio/winds/trims.ts +917 -0
  68. package/src/audio/winds/voice.ts +479 -0
  69. package/src/commands/expression.ts +103 -22
  70. package/src/commands/fx.ts +4 -1
  71. package/src/commands/granular.ts +7 -2
  72. package/src/commands/help.ts +91 -8
  73. package/src/commands/keys.ts +287 -11
  74. package/src/commands/modal.ts +67 -4
  75. package/src/commands/resample.ts +281 -0
  76. package/src/commands/rig.ts +5 -1
  77. package/src/commands/sample.ts +49 -2
  78. package/src/commands/shift.ts +119 -0
  79. package/src/commands/string.ts +28 -2
  80. package/src/commands/strum.ts +473 -0
  81. package/src/commands/wind.ts +244 -0
  82. package/src/main.ts +129 -7
  83. package/src/project/check.ts +5 -1
  84. package/src/render.ts +16 -0
  85. package/src/session/presence.ts +16 -3
  86. package/src/tui/audition.ts +1 -1
  87. package/src/tui/menu.ts +637 -35
  88. package/src/tui/modal-menu.ts +34 -7
  89. package/src/tui/performance-menu.ts +42 -0
  90. package/src/tui/play-chords.ts +104 -3
  91. package/src/tui/play-mode.ts +1 -0
  92. package/src/tui/play-session.ts +34 -9
  93. package/src/tui/wind-menu.ts +144 -0
@@ -22,6 +22,12 @@ import type { EngineContext, InstrumentEngine } from "../instruments.ts";
22
22
  import { applyBody, bodyFor, symKeys, sympathetic } from "./body.ts";
23
23
  import { clamp } from "./loop.ts";
24
24
  import { PluckString, type Exciter, type PluckSpec } from "./pluck.ts";
25
+ import {
26
+ BowedString,
27
+ type BowControl,
28
+ type BowSpec,
29
+ type BowNote,
30
+ } from "./bow.ts";
25
31
 
26
32
  const BLOCK = 128;
27
33
  /** Longest a single string may ring (seconds). */
@@ -84,8 +90,155 @@ export function noteSpec(
84
90
  };
85
91
  }
86
92
 
93
+ /** Whether resolved values play the bowed voice (`exciter bow`). */
94
+ export function isBowed(values: StringValues): boolean {
95
+ return values.exciter === "bow";
96
+ }
97
+
98
+ /** Play mode keeps a bowed section to two players (live budget). */
99
+ export const LIVE_BOW_UNISON = 2;
100
+
101
+ /** The track as play mode hears it: a bowed section capped at 2 players. */
102
+ export function liveStringTrack(track: Track): Track {
103
+ if (track.instrument !== "string" || !track.string) return track;
104
+ const values = resolveString(track.string);
105
+ if (!isBowed(values) || (values.unison as number) <= LIVE_BOW_UNISON)
106
+ return track;
107
+ return { ...track, string: { ...track.string, unison: LIVE_BOW_UNISON } };
108
+ }
109
+
110
+ /** Bow lanes read every 32 samples (`string-<name>`, absent: the value). */
111
+ const BOW_LANES = ["pressure", "speed", "sord", "dyn"] as const;
112
+
113
+ /** The bowed spec and note fields for one note. */
114
+ export function bowSpec(
115
+ values: StringValues,
116
+ note: PerformedNote,
117
+ track: Track,
118
+ context: EngineContext,
119
+ ): { spec: BowSpec; velocity: number; control?: (t: number) => BowControl } {
120
+ const at = (name: string) => valueAt(track, values, name, note.startTick);
121
+ let velocity = clamp(note.velocity, 0, 1);
122
+ const articulation = note.articulation;
123
+ const sens = clamp(values.vel as number, 0, 1);
124
+ velocity = 1 - sens * (1 - velocity);
125
+ let attack = values.attack as number;
126
+ let release = values.release as number;
127
+ // Detache (spec 4): staccato and ghost are short strokes, quick on and off.
128
+ if (articulation === "staccato" || articulation === "ghost") {
129
+ attack = Math.min(attack, 0.01);
130
+ release = Math.min(release, 0.03);
131
+ }
132
+ // A harder stroke presses harder (spec 4: pressure += 0.3 (vel - 0.5)).
133
+ const press = (p: number) => clamp(p + 0.3 * (velocity - 0.5), 0, 1);
134
+ // Accent and marcato bite: pressure +0.2 for the first 80 ms.
135
+ const bite = articulation === "accent" || articulation === "marcato";
136
+ const spec: BowSpec = {
137
+ decay: Math.max(0.02, at("ring")),
138
+ track: values.track as number,
139
+ damp: clamp(values.damp as number, 0, 1),
140
+ pos: clamp(at("pos"), 0.01, 0.5),
141
+ pressure: press(at("pressure")),
142
+ speed: clamp(at("speed"), 0, 1),
143
+ attack,
144
+ release,
145
+ tremhz: values.tremhz as number,
146
+ sord: clamp(at("sord"), 0, 1),
147
+ };
148
+ const lanes = BOW_LANES.filter(
149
+ (name) =>
150
+ (track.fxAutomation?.[`string-${name}` as FxLane]?.length ?? 0) > 0,
151
+ );
152
+ const dyn = clamp(values.dyn as number, 0, 1);
153
+ if (lanes.length === 0 && dyn === 1 && !bite) return { spec, velocity };
154
+ const fixed: BowControl = {
155
+ pressure: spec.pressure,
156
+ speed: spec.speed,
157
+ sord: spec.sord,
158
+ dyn,
159
+ };
160
+ const bitten: BowControl = {
161
+ ...fixed,
162
+ pressure: clamp(spec.pressure + 0.2, 0, 1),
163
+ };
164
+ if (lanes.length === 0)
165
+ return {
166
+ spec,
167
+ velocity,
168
+ control: bite ? (t) => (t < 0.08 ? bitten : fixed) : () => fixed,
169
+ };
170
+ // Lanes are read in ticks; warped songs read them at the note's tempo.
171
+ const ticksPerSecond = context.sampleRate / context.samplesPerTick;
172
+ const control = (t: number): BowControl => {
173
+ const tick = note.startTick + t * ticksPerSecond;
174
+ const lane = (name: string) =>
175
+ clamp(valueAt(track, values, name, tick), 0, 1);
176
+ return {
177
+ pressure: clamp(
178
+ press(lane("pressure")) + (bite && t < 0.08 ? 0.2 : 0),
179
+ 0,
180
+ 1,
181
+ ),
182
+ speed: lane("speed"),
183
+ sord: lane("sord"),
184
+ dyn: lane("dyn"),
185
+ };
186
+ };
187
+ return { spec, velocity, control };
188
+ }
189
+
190
+ /** The preset vibrato alone (a slur keeps it running). */
191
+ function vibratoCurve(
192
+ note: PerformedNote,
193
+ values: StringValues,
194
+ track: Track,
195
+ player: Readonly<{ rate: number; depth: number; phase: number }> = SOLO,
196
+ ): ((t: number) => number) | undefined {
197
+ if (note.performance?.replaceVibrato) return undefined;
198
+ const rate = valueAt(track, values, "vib", note.startTick) * player.rate;
199
+ const depth =
200
+ valueAt(track, values, "vibmod", note.startTick) * 100 * player.depth;
201
+ const delay = values.vibdelay as number;
202
+ if (!(rate > 0 && depth > 0)) return undefined;
203
+ const phase = player.phase;
204
+ return (t: number) => {
205
+ if (t <= delay) return 0;
206
+ const fade = Math.min(1, (t - delay) / 0.15);
207
+ return depth * fade * Math.sin(2 * Math.PI * (rate * (t - delay) + phase));
208
+ };
209
+ }
210
+
211
+ const SOLO = { rate: 1, depth: 1, phase: 0 } as const;
212
+
213
+ /** Longest seeded onset spread of a bowed section's players (spec 2). */
214
+ export const SECTION_SPREAD = 0.025;
215
+
216
+ /**
217
+ * One section player (spec 2, Ensemble): seeded vibrato rate x0.92-1.08,
218
+ * depth x0.8-1.2 and phase, and an onset up to 25 ms late (scaled down
219
+ * for notes under 0.25 s). The first player stays on the grid.
220
+ */
221
+ export function sectionPlayer(
222
+ seed: number,
223
+ index: number,
224
+ hold: number,
225
+ ): { rate: number; depth: number; phase: number; onset: number } {
226
+ const h = seedHash(`${seed}:${index}:player`);
227
+ return {
228
+ rate: 0.92 + 0.16 * unit(h, 0, 1),
229
+ depth: 0.8 + 0.4 * unit(h, 1, 1),
230
+ phase: unit(h, 2, 1),
231
+ onset:
232
+ index === 0
233
+ ? 0
234
+ : SECTION_SPREAD * unit(h, 3, 1) * Math.min(1, hold / 0.25),
235
+ };
236
+ }
237
+
87
238
  type Voice = {
88
- string: PluckString;
239
+ string: PluckString | BowedString;
240
+ /** The string's pitch over the note's base pitch (a slur keeps it). */
241
+ ratio: number;
89
242
  start: number;
90
243
  pitch: number;
91
244
  group: number;
@@ -94,6 +247,26 @@ type Voice = {
94
247
  damp?: Readonly<{ from: number; tau: number }>;
95
248
  };
96
249
 
250
+ /**
251
+ * Whether pending note `index` (starting at `start`) is a lone note: no
252
+ * other note starts with it, so a held single note may slur into it.
253
+ */
254
+ function slurs(
255
+ pending: readonly { start: number }[],
256
+ index: number,
257
+ start: number,
258
+ ): boolean {
259
+ return (
260
+ pending[index - 1]?.start !== start && pending[index + 1]?.start !== start
261
+ );
262
+ }
263
+
264
+ /** A 64th note in samples at the song's start tempo (the slur window). */
265
+ function slurWindow(context: EngineContext): number {
266
+ const ticks = (context.score.ticksPerBeat ?? 480) / 16;
267
+ return ticks * context.samplesPerTick;
268
+ }
269
+
97
270
  /** Ring-out after the last note-off, in seconds. */
98
271
  export function stringTailSeconds(track: Track): number {
99
272
  const values = resolveString(track.string);
@@ -163,6 +336,7 @@ export function renderStrings(
163
336
  const octbelow = values.octbelow as number;
164
337
  const cap = Math.max(1, Math.round(values.voices as number));
165
338
  const tuning = context.tuning;
339
+ const bowed = isBowed(values);
166
340
  const L = new Float64Array(total);
167
341
  const R = dryR ? new Float64Array(total) : undefined;
168
342
  const voices: Voice[] = [];
@@ -183,6 +357,47 @@ export function renderStrings(
183
357
  next += 1;
184
358
  const baseHz = noteHz(note.pitch, note.cents, tuning);
185
359
  if (!(baseHz > 0) || baseHz >= 0.45 * sr) continue;
360
+ // Slur (bowed): a single note that starts while one earlier single
361
+ // note is still bowed moves that bow to the new pitch in 7 ms, with
362
+ // no new attack (legato articulation overlaps its notes, so it
363
+ // slurs too). Chords and double stops start new strokes.
364
+ if (bowed && slurs(pending, next - 1, start)) {
365
+ const held = voices.filter(
366
+ (v) => !v.string.done && v.string.offAt > start - v.start,
367
+ );
368
+ const groups = new Set(held.map((v) => v.group));
369
+ const pitches = new Set(held.map((v) => v.pitch));
370
+ // A true legato only: the held note lets go within a short window
371
+ // of the new one (a 64th note or 150 ms, legato's own overlap), or
372
+ // the new note is marked legato. A pedal or a note held under a
373
+ // moving line keeps sounding and the new note takes a new stroke.
374
+ const window = Math.max(0.15 * sr, slurWindow(context));
375
+ const letsGo =
376
+ note.articulation === "legato" ||
377
+ held.every((v) => v.start + v.string.offAt <= start + window);
378
+ const range = held.every((v) =>
379
+ (v.string as BowedString).canSlurTo(baseHz * v.ratio),
380
+ );
381
+ if (
382
+ letsGo &&
383
+ range &&
384
+ groups.size === 1 &&
385
+ pitches.size === 1 &&
386
+ held[0]!.start < start
387
+ ) {
388
+ const hold = holdSamples(note, start, context) / sr;
389
+ for (const v of held) {
390
+ (v.string as BowedString).slurTo(
391
+ baseHz * v.ratio,
392
+ start - v.start,
393
+ Math.max(0.01, hold),
394
+ note.performance?.cents,
395
+ );
396
+ v.pitch = note.pitch;
397
+ }
398
+ continue;
399
+ }
400
+ }
186
401
  // Restrike: the same key damps its previous string.
187
402
  for (const v of voices)
188
403
  if (v.pitch === note.pitch) v.string.releaseAt(start - v.start);
@@ -197,11 +412,10 @@ export function renderStrings(
197
412
  for (const v of held)
198
413
  if (v.group === oldest) v.string.releaseAt(start - v.start);
199
414
  }
200
- const { spec, velocity } = noteSpec(values, note, track);
201
415
  const seed = seedHash(
202
416
  `${note.id}:${note.startTick + (context.seedTick ?? 0)}:string`,
203
417
  );
204
- const strings: { hz: number; gain: number }[] = [];
418
+ const strings: { hz: number; gain: number; ratio: number }[] = [];
205
419
  // The course's tuning error belongs to the key, not to the strike: it
206
420
  // is seeded by track and key (so every velocity of a key beats the
207
421
  // same way) and centred, so the course's mean pitch is the table's.
@@ -213,42 +427,74 @@ export function renderStrings(
213
427
  for (let i = 0; i < unison; i += 1) {
214
428
  const d = unison > 1 ? (i / (unison - 1) - 0.5) * detune : 0;
215
429
  const jitter = jitters[i]! - meanJitter;
430
+ const ratio = 2 ** ((d * 100 + jitter) / 1200);
216
431
  strings.push({
217
- hz: baseHz * 2 ** ((d * 100 + jitter) / 1200),
432
+ hz: baseHz * ratio,
218
433
  gain: 1 / Math.sqrt(unison),
434
+ ratio,
219
435
  });
220
436
  }
221
437
  if (oct > 0 && note.pitch < octbelow) {
222
438
  const up = noteHz(Math.min(127, note.pitch + 12), note.cents, tuning);
223
439
  const hz = up > 0 ? up : baseHz * 2;
224
- if (hz < 0.45 * sr)
440
+ if (hz < 0.45 * sr) {
441
+ const tuned =
442
+ hz * 2 ** (((unit(course, unison, 1) - 0.5) * 4) / 1200);
225
443
  strings.push({
226
- hz: hz * 2 ** (((unit(course, unison, 1) - 0.5) * 4) / 1200),
444
+ hz: tuned,
227
445
  gain: oct / Math.sqrt(unison),
446
+ ratio: tuned / baseHz,
228
447
  });
448
+ }
229
449
  }
230
450
  const hold = holdSamples(note, start, context) / sr;
231
- const cents = centsCurve(note, values, track);
451
+ const pluck = bowed ? undefined : noteSpec(values, note, track);
452
+ const bow = bowed ? bowSpec(values, note, track, context) : undefined;
453
+ const cents = bowed
454
+ ? note.performance?.cents
455
+ : centsCurve(note, values, track);
456
+ const section = bowed && unison > 1;
232
457
  strings.forEach((s, i) => {
458
+ // Section players (not the octave string) are seeded as players.
459
+ const player =
460
+ section && i < unison ? sectionPlayer(seed, i, hold) : undefined;
461
+ const onset = player ? Math.round(player.onset * sr) : 0;
462
+ const vibrato = bowed
463
+ ? vibratoCurve(note, values, track, player)
464
+ : undefined;
233
465
  const pan =
234
466
  strings.length > 1 ? (i / (strings.length - 1) - 0.5) * spread : 0;
235
- const string = new PluckString(
236
- spec,
237
- {
238
- hz: s.hz,
239
- velocity,
240
- hold: Math.max(0.01, hold),
241
- seed: seedHash(`${seed}:${i}`),
242
- ...(cents ? { cents } : {}),
243
- },
244
- sr,
245
- MAX_RING,
246
- );
467
+ const common = {
468
+ hz: s.hz,
469
+ hold: Math.max(0.01, hold - onset / sr),
470
+ seed: seedHash(`${seed}:${i}`),
471
+ ...(cents ? { cents } : {}),
472
+ };
473
+ const string = bow
474
+ ? new BowedString(
475
+ bow.spec,
476
+ {
477
+ ...common,
478
+ velocity: bow.velocity,
479
+ ...(vibrato ? { vibrato } : {}),
480
+ ...(bow.control ? { control: bow.control } : {}),
481
+ } satisfies BowNote,
482
+ sr,
483
+ // A bow sustains for as long as the note is held.
484
+ Math.max(MAX_RING, hold + bow.spec.release + 1),
485
+ )
486
+ : new PluckString(
487
+ pluck!.spec,
488
+ { ...common, velocity: pluck!.velocity },
489
+ sr,
490
+ MAX_RING,
491
+ );
247
492
  const gl = R ? s.gain * Math.sqrt(0.5 - pan / 2) * Math.SQRT2 : s.gain;
248
493
  const gr = s.gain * Math.sqrt(0.5 + pan / 2) * Math.SQRT2;
249
494
  voices.push({
250
495
  string,
251
- start,
496
+ ratio: s.ratio,
497
+ start: start + onset,
252
498
  pitch: note.pitch,
253
499
  group: start,
254
500
  gainL: gl,
@@ -24,6 +24,7 @@ export type TestNote = {
24
24
  seconds?: number;
25
25
  velocity?: number;
26
26
  performance?: PerformedNote["performance"];
27
+ articulation?: PerformedNote["articulation"];
27
28
  };
28
29
 
29
30
  /** Renders notes on a string track (mono, before effects). */
@@ -58,6 +59,7 @@ export function render(
58
59
  startTick: Math.round(((n.start ?? 0) * SR) / SPT),
59
60
  durationTicks: Math.round(((n.seconds ?? seconds) * SR) / SPT),
60
61
  ...(n.performance ? { performance: n.performance } : {}),
62
+ ...(n.articulation ? { articulation: n.articulation } : {}),
61
63
  })) as unknown as PerformedNote[];
62
64
  const dry = new Float64Array(samples);
63
65
  renderStrings(dry, undefined, performed, track, context);
package/src/audio/wav.ts CHANGED
@@ -23,7 +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 { engineFor, engineTailSeconds } from "./instruments.ts";
26
+ import {
27
+ engineFor,
28
+ engineTailSeconds,
29
+ RING_OUT_FADE_SECONDS,
30
+ } from "./instruments.ts";
27
31
  import {
28
32
  isStereoVoice,
29
33
  renderSynthNote,
@@ -46,6 +50,8 @@ import {
46
50
  applyStereoChain,
47
51
  delayTailFor,
48
52
  interpolateAutomation,
53
+ needsEffectNotes,
54
+ type EffectNote,
49
55
  reverbActive,
50
56
  reverbImpulse,
51
57
  reverbTailFor,
@@ -97,6 +103,19 @@ export type RenderOptions = WavOptions &
97
103
  * a single pass of the whole song. Zero (the default) changes nothing.
98
104
  */
99
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 }>;
100
119
  }>;
101
120
 
102
121
  /** Interleaved stereo 16-bit PCM plus its frame count. */
@@ -191,12 +210,13 @@ const MAX_ENGINE_TAIL_SECONDS = 30;
191
210
  * The longest 0.6 engine ring-out in `score`, capped at 30 s. One-shot
192
211
  * renders (exports, previews) let engine tails reach their own cap; the
193
212
  * 8 s cap applies only to loop folding. Zero without an engine, so legacy
194
- * renders are unchanged.
213
+ * renders are unchanged. `ringing` counts only ring-out tracks (0.6.1).
195
214
  */
196
- function oneShotEngineTail(score: TrackScore): number {
215
+ function oneShotEngineTail(score: TrackScore, ringing = false): number {
197
216
  let tail = 0;
198
217
  for (const track of score.tracks) {
199
- if (!engineFor(track)) continue;
218
+ const engine = engineFor(track);
219
+ if (!engine || (ringing && engine.ringOut?.(track) !== true)) continue;
200
220
  let lowest: number | undefined;
201
221
  for (const note of score.notes)
202
222
  if (
@@ -219,6 +239,9 @@ export type RenderContext = Readonly<{
219
239
  /** Tempo map in samples (core/tempo.ts); absent: constant tempo. */
220
240
  warp?: SampleWarp;
221
241
  seedTick?: number;
242
+ seedSeconds?: number;
243
+ seedState?: Readonly<Record<string, string>>;
244
+ quantChord?: Readonly<{ score: TrackScore; tick: number }>;
222
245
  }>;
223
246
 
224
247
  /** Exact (fractional) loop length in frames at a sample rate. */
@@ -421,6 +444,9 @@ export class StemRenderer {
421
444
  ...(bank.irs ? { irs: bank.irs } : {}),
422
445
  ...(warp ? { warp } : {}),
423
446
  ...(options.seedTick ? { seedTick: options.seedTick } : {}),
447
+ ...(options.seedSeconds ? { seedSeconds: options.seedSeconds } : {}),
448
+ ...(options.seedState ? { seedState: options.seedState } : {}),
449
+ ...(options.quantChord ? { quantChord: options.quantChord } : {}),
424
450
  };
425
451
  this.renders += 1;
426
452
  const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
@@ -485,7 +511,7 @@ export class StemRenderer {
485
511
  const engineDigests =
486
512
  engine && track
487
513
  ? [
488
- ...(engine.assetDigests?.(track, bank) ?? []),
514
+ ...(engine.assetDigests?.(track, bank, score) ?? []),
489
515
  `key:${score.key ?? ""}`,
490
516
  ]
491
517
  : undefined;
@@ -545,7 +571,7 @@ export class StemRenderer {
545
571
  : { left, right };
546
572
  dry.fill(0);
547
573
  const synthVoice = !engine && !sampler && usesSynthVoice(track);
548
- const stereo = engine
574
+ let stereo = engine
549
575
  ? engine.stereo(track!)
550
576
  : synthVoice && isStereoVoice(track);
551
577
  if (stereo) dryR.fill(0);
@@ -563,7 +589,16 @@ export class StemRenderer {
563
589
  bank,
564
590
  );
565
591
  } else if (sampler) {
566
- if (track) renderSamplerNotes(dry, played, track, context, bank);
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
+ );
567
602
  } else if (synthVoice && track) {
568
603
  const gainAt = (tick: number) => trackGainAt(track, tick);
569
604
  const voice = {
@@ -603,8 +638,13 @@ export class StemRenderer {
603
638
  else renderToneNote(dry, note, track, context, tuning);
604
639
  }
605
640
  }
606
- if (track) applyMonoChain(dry, track, context);
607
- 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);
608
648
  applyPan(
609
649
  dry,
610
650
  target.left,
@@ -620,7 +660,7 @@ export class StemRenderer {
620
660
  target.left,
621
661
  target.right,
622
662
  track,
623
- context,
663
+ chain,
624
664
  true,
625
665
  false,
626
666
  );
@@ -638,7 +678,7 @@ export class StemRenderer {
638
678
  target.left,
639
679
  target.right,
640
680
  track,
641
- context,
681
+ chain,
642
682
  busOrbit === undefined,
643
683
  );
644
684
  stem = {
@@ -694,6 +734,20 @@ export class StemRenderer {
694
734
  if (!groups.has(trackId)) this.evict(trackId);
695
735
  // Linear effects superpose, so folding the tail onto the start yields
696
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
+ );
697
751
  if (samples > frames) foldTail(mixL, mixR, frames, samples);
698
752
  // The song master (src/audio/master.ts); none leaves the mix untouched.
699
753
  const mastered = applyMaster(
@@ -800,6 +854,21 @@ function addStem(
800
854
  }
801
855
 
802
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
+
803
872
  function foldTail(
804
873
  mixL: Float64Array,
805
874
  mixR: Float64Array,
@@ -927,6 +996,10 @@ function stemKey(
927
996
  : []),
928
997
  // Seeded noise follows the window origin of an arranged render.
929
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
+ : []),
930
1003
  ]);
931
1004
  }
932
1005
 
@@ -941,25 +1014,34 @@ function samplerVoiceDigest(track: Track, bank: SampleBank): string[] {
941
1014
  });
942
1015
  }
943
1016
 
944
- /** 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
+ */
945
1022
  function renderSamplerNotes(
946
1023
  target: Float64Array,
947
1024
  notes: readonly Note[],
948
1025
  track: Track,
949
1026
  context: RenderContext,
950
1027
  bank: SampleBank,
951
- ): void {
1028
+ targetRight: Float64Array,
1029
+ ): boolean {
952
1030
  const timing = {
953
1031
  score: context.score,
954
1032
  sampleRate: context.sampleRate,
955
1033
  ...(context.warp ? { warp: context.warp } : {}),
956
1034
  };
957
- renderSamplerVoices(
958
- target,
959
- planSamplerVoices(track, notes, bank, timing),
960
- timing,
961
- (tick) => trackGainAt(track, tick),
962
- );
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;
963
1045
  }
964
1046
 
965
1047
  /** Track volume and volume automation at a score tick (pan is applied in stereo). */
@@ -973,6 +1055,24 @@ function trackGainAt(track: Track | undefined, tick: number): number {
973
1055
  return trackGain * automatedGain;
974
1056
  }
975
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
+
976
1076
  function noteSpan(
977
1077
  note: Note,
978
1078
  context: RenderContext,