@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
@@ -32,13 +32,22 @@ import {
32
32
  type TrackScore,
33
33
  } from "../../core/score.ts";
34
34
  import { sampleKey, type DecodedSample, type SampleBank } from "./samples.ts";
35
+ import { seedHash } from "./dsp/rng.ts";
35
36
  import {
36
37
  barAt,
37
38
  loopSecondsOf,
38
39
  performedNotes,
39
40
  secondsAtTick,
40
41
  } from "../../core/tempo.ts";
41
- import { sampleWarpFor, warpedSpan, type SampleWarp } from "./warp.ts";
42
+ import {
43
+ beatFitMap,
44
+ sampleWarpFor,
45
+ spanFitMap,
46
+ warpedSpan,
47
+ type FitMap,
48
+ type SampleWarp,
49
+ } from "./warp.ts";
50
+ import { fittedBuffer, shiftedBuffer, windowAt } from "./fit.ts";
42
51
  import {
43
52
  performanceTimingFor,
44
53
  tunedTiming,
@@ -55,6 +64,8 @@ const ATTACK_SECONDS = 0.001;
55
64
  /** Fade at a natural end, a held release, a choke and a voice steal. */
56
65
  const END_FADE_SECONDS = 0.003;
57
66
  const RELEASE_SECONDS = 0.01;
67
+ /** Extra fitted frames past a note's reach (read interpolation). */
68
+ const REACH_MARGIN_FRAMES = 64;
58
69
  const CHOKE_SECONDS = 0.005;
59
70
  /** Loop seam crossfade (source time). */
60
71
  const LOOP_CROSSFADE_SECONDS = 0.005;
@@ -96,6 +107,10 @@ export type SamplerVoice = {
96
107
  readonly cents?: NotePerformance["cents"];
97
108
  /** Half pedal: the level fades from `from` seconds. */
98
109
  readonly damp?: NotePerformance["damp"];
110
+ /** `fadeInTime` in output frames (0.6.1); absent is the 1 ms anti-click. */
111
+ readonly attack?: number;
112
+ /** A resampled stereo file (0.6.1): plays its own left and right. */
113
+ readonly stereo?: boolean;
99
114
  };
100
115
 
101
116
  /**
@@ -150,38 +165,111 @@ function noteLengthFrames(note: Note, timing: SamplerTiming): number {
150
165
  function voiceResolver(
151
166
  track: Track,
152
167
  tuning?: TuningTable,
153
- ): (pitch: number) => { voice: string; ratio: number } | undefined {
168
+ ): (
169
+ pitch: number,
170
+ velocity: number,
171
+ ) => { voice: string; ratio: number } | undefined {
154
172
  const sampler = track.sampler!;
173
+ const pick = layerPicker(
174
+ track.id,
175
+ sampler.voices,
176
+ sampler.mode === "oneshot",
177
+ );
155
178
  if (sampler.mode === "oneshot") {
156
179
  const byPitch = new Map<number, string>();
157
180
  for (const [voice, slot] of samplerVoiceSlots(sampler))
158
181
  byPitch.set(slot, voice);
159
- return (pitch) => {
160
- const voice = byPitch.get(pitch);
182
+ return (pitch, velocity) => {
183
+ const slotted = byPitch.get(pitch);
184
+ if (slotted === undefined) return undefined;
185
+ const voice = pick(slotted, velocity);
161
186
  return voice === undefined ? undefined : { voice, ratio: 1 };
162
187
  };
163
188
  }
164
189
  // Keyed: the voice whose root is the highest at or below the pitch (a
165
- // multi-sampled instrument), else the lowest root.
190
+ // multi-sampled instrument), else the lowest root. With velocity layers
191
+ // only the voices whose `vel` holds the note's velocity take part.
166
192
  const keyed = Object.entries(sampler.voices)
167
- .map(([voice, ref]) => ({ voice, root: ref.root ?? 60 }))
193
+ .map(([voice, ref]) => ({ voice, root: ref.root ?? 60, vel: ref.vel }))
168
194
  .sort((a, b) => a.root - b.root || (a.voice < b.voice ? -1 : 1));
169
- return (pitch) => {
170
- let chosen = keyed[0];
171
- for (const candidate of keyed)
172
- if (candidate.root <= pitch) chosen = candidate;
195
+ return (pitch, velocity) => {
196
+ let chosen: (typeof keyed)[number] | undefined;
197
+ for (const candidate of keyed) {
198
+ if (!inLayer(candidate.vel, velocity)) continue;
199
+ if (chosen === undefined || candidate.root <= pitch) chosen = candidate;
200
+ }
173
201
  if (!chosen) return undefined;
174
- if (!tuning)
175
- return { voice: chosen.voice, ratio: 2 ** ((pitch - chosen.root) / 12) };
202
+ const voice = pick(chosen.voice, velocity) ?? chosen.voice;
203
+ if (!tuning) return { voice, ratio: 2 ** ((pitch - chosen.root) / 12) };
176
204
  const hz = tuning.hz[pitch]!;
177
205
  if (!(hz > 0)) return undefined;
178
206
  return {
179
- voice: chosen.voice,
207
+ voice,
180
208
  ratio: hz / (440 * 2 ** ((chosen.root - 69) / 12)),
181
209
  };
182
210
  };
183
211
  }
184
212
 
213
+ /** MIDI velocity (0..127) of a note velocity 0..1. */
214
+ export function midiVelocity(velocity: number): number {
215
+ return Math.max(0, Math.min(127, Math.round(velocity * 127)));
216
+ }
217
+
218
+ function inLayer(
219
+ vel: readonly [number, number] | undefined,
220
+ velocity: number,
221
+ ): boolean {
222
+ if (!vel) return true;
223
+ const v = midiVelocity(velocity);
224
+ return v >= vel[0] && v <= vel[1];
225
+ }
226
+
227
+ /**
228
+ * Velocity layers and round robin (0.6.1, SFZ lovel/hivel and seq_length).
229
+ * A voice with `rr` stands for its whole group: of the group's voices that
230
+ * hold the note's velocity (and, keyed, share its root) the next one in
231
+ * name order plays. When no layer of the group holds the velocity, the
232
+ * nearest layer plays rather than nothing. Each group keeps its own
233
+ * counter in note order, starting at a turn seeded by the track id and
234
+ * the group, so a render is deterministic (A B A B) while two tracks
235
+ * with the same samples need not alternate in lockstep. A keyed voice
236
+ * without `rr` plays itself when its `vel` holds the velocity, else
237
+ * nothing; a one-shot voice without `rr` (its own pad) always plays.
238
+ */
239
+ function layerPicker(
240
+ trackId: string,
241
+ voices: Readonly<Record<string, SampleRef>>,
242
+ oneshot: boolean,
243
+ ): (voice: string, velocity: number) => string | undefined {
244
+ const counters = new Map<string, number>();
245
+ const names = Object.keys(voices).sort();
246
+ return (voice, velocity) => {
247
+ const ref = voices[voice]!;
248
+ if (ref.rr === undefined)
249
+ return oneshot || inLayer(ref.vel, velocity) ? voice : undefined;
250
+ const members = names.filter((name) => {
251
+ const other = voices[name]!;
252
+ return other.rr === ref.rr && (other.root ?? 60) === (ref.root ?? 60);
253
+ });
254
+ let group = members.filter((name) => inLayer(voices[name]!.vel, velocity));
255
+ if (group.length === 0) {
256
+ const v = midiVelocity(velocity);
257
+ const gap = (name: string) => {
258
+ const vel = voices[name]!.vel!;
259
+ return v < vel[0] ? vel[0] - v : v - vel[1];
260
+ };
261
+ const nearest = Math.min(...members.map(gap));
262
+ group = members.filter((name) => gap(name) === nearest);
263
+ }
264
+ if (group.length === 0) return undefined;
265
+ const key = `${ref.rr}:${ref.root ?? 60}:${group.join(",")}`;
266
+ const turn =
267
+ counters.get(key) ?? seedHash(`${trackId}:${key}`) % group.length;
268
+ counters.set(key, turn + 1);
269
+ return group[turn % group.length];
270
+ };
271
+ }
272
+
185
273
  /** Plans every voice a sampler track plays, with choke and polyphony applied. */
186
274
  export function planSamplerVoices(
187
275
  track: Track,
@@ -214,35 +302,78 @@ export function planSamplerVoices(
214
302
  voice.fade = Math.min(fade, end - voice.start);
215
303
  };
216
304
  for (const note of ordered) {
217
- const resolved = resolve(note.pitch);
305
+ const resolved = resolve(note.pitch, note.velocity);
218
306
  if (!resolved) continue;
219
307
  // A note's cents repitch every voice, keyed or one-shot.
220
308
  const target = note.cents
221
309
  ? { ...resolved, ratio: resolved.ratio * 2 ** (note.cents / 1200) }
222
310
  : resolved;
223
311
  const ref: SampleRef | undefined = sampler.voices[target.voice];
224
- const sample = bank.voices.get(sampleKey(track.id, target.voice));
225
- if (!ref || !sample) continue;
226
- const speed = ref.speed ?? 1;
227
- const regionStart = (ref.begin ?? 0) * sample.frames;
228
- const regionEnd = (ref.end ?? 1) * sample.frames;
312
+ const decoded = bank.voices.get(sampleKey(track.id, target.voice));
313
+ if (!ref || !decoded) continue;
229
314
  const held = noteLengthFrames(note, timing);
315
+ // 0.6 fit (`bpm`, `len`, or `fit` with fitmode beats|tones): the window
316
+ // is fitted once into a render-rate buffer that plays forward at step 1
317
+ // (times the keyed ratio). Absent fields keep the 0.5 path untouched.
318
+ // Only the frames this note can reach are fitted: a keyed or clipped
319
+ // note (not looping, no accelerate) ends at its held length plus the
320
+ // release, read at the keyed ratio; anything else may play it all.
321
+ const reach =
322
+ ref.loop !== true &&
323
+ (ref.accelerate ?? 0) === 0 &&
324
+ (sampler.mode === "keyed" || ref.clip !== undefined)
325
+ ? Math.ceil(
326
+ ((ref.clip === undefined
327
+ ? held
328
+ : Math.max(1, Math.floor(held * ref.clip))) +
329
+ Math.round(RELEASE_SECONDS * sampleRate)) *
330
+ target.ratio,
331
+ ) + REACH_MARGIN_FRAMES
332
+ : Infinity;
333
+ const fitted = isFitted(ref)
334
+ ? fitVoice(ref, decoded, note.startTick, held, timing, reach)
335
+ : undefined;
336
+ if (fitted === null) continue;
337
+ // 0.6.1 `shift`/`formant`: a cached constant-length pitch shift of the
338
+ // played buffer (after any fit, so it follows fitmode).
339
+ const shifted = isShifted(ref)
340
+ ? shiftVoice(ref, fitted?.sample ?? decoded, fitted ? "fit" : "raw")
341
+ : undefined;
342
+ const sample = shifted ?? fitted?.sample ?? decoded;
343
+ // Live, until the cached shift is ready: a plain repitch at the shifted
344
+ // pitch (the length follows, like tape).
345
+ const repitch =
346
+ shifted === undefined && isShifted(ref)
347
+ ? 2 ** ((ref.shift ?? 0) / 12)
348
+ : 1;
349
+ const speed = fitted ? repitch : (ref.speed ?? 1) * repitch;
350
+ const regionStart = fitted ? 0 : (ref.begin ?? 0) * sample.frames;
351
+ const regionEnd = fitted ? sample.frames : (ref.end ?? 1) * sample.frames;
230
352
  // `fit`, `unit: "c"` and `unit: "s"` give the window a duration; the
231
353
  // keyed ratio still repitches on top.
232
354
  const unit = ref.unit ?? "r";
233
- const seconds = ref.fit
234
- ? held / sampleRate
235
- : unit === "c"
236
- ? cycleSeconds(timing.score, note.startTick) / Math.abs(speed)
237
- : unit === "s"
238
- ? Math.abs(speed)
239
- : undefined;
355
+ const seconds = fitted
356
+ ? undefined
357
+ : ref.fit
358
+ ? held / sampleRate
359
+ : unit === "c"
360
+ ? cycleSeconds(timing.score, note.startTick) / Math.abs(speed)
361
+ : unit === "s"
362
+ ? Math.abs(speed)
363
+ : undefined;
240
364
  const step =
241
365
  seconds === undefined
242
366
  ? ((Math.abs(speed) * sample.sampleRate) / sampleRate) * target.ratio
243
367
  : ((regionEnd - regionStart) / (seconds * sampleRate)) * target.ratio;
244
368
  const natural = Math.max(1, Math.floor((regionEnd - regionStart) / step));
245
- const release = Math.max(1, Math.round(RELEASE_SECONDS * sampleRate));
369
+ const release = Math.max(
370
+ 1,
371
+ Math.round((ref.fadeTime ?? RELEASE_SECONDS) * sampleRate),
372
+ );
373
+ const endFade = Math.max(
374
+ 1,
375
+ Math.round((ref.fadeTime ?? END_FADE_SECONDS) * sampleRate),
376
+ );
246
377
  const loop = ref.loop === true;
247
378
  const clipped =
248
379
  ref.clip === undefined ? held : Math.max(1, Math.floor(held * ref.clip));
@@ -259,7 +390,7 @@ export function planSamplerVoices(
259
390
  fade = release;
260
391
  } else {
261
392
  length = natural;
262
- fade = Math.max(1, Math.round(END_FADE_SECONDS * sampleRate));
393
+ fade = endFade;
263
394
  }
264
395
  // accelerate: the rate ramps by `accelerate`× over the planned length;
265
396
  // a non-looping voice ends where the ramped read runs out (or stops).
@@ -273,13 +404,19 @@ export function planSamplerVoices(
273
404
  const until = Math.max(1, Math.floor(reach));
274
405
  if (until < length) {
275
406
  length = until;
276
- fade = Math.max(1, Math.round(END_FADE_SECONDS * sampleRate));
407
+ fade = endFade;
277
408
  }
278
409
  }
279
- const loopFrom =
280
- ref.loopBegin === undefined ? regionStart : ref.loopBegin * sample.frames;
281
- const loopTo =
282
- ref.loopEnd === undefined ? regionEnd : ref.loopEnd * sample.frames;
410
+ const loopFrom = fitted
411
+ ? fitted.loopFrom
412
+ : ref.loopBegin === undefined
413
+ ? regionStart
414
+ : ref.loopBegin * sample.frames;
415
+ const loopTo = fitted
416
+ ? fitted.loopTo
417
+ : ref.loopEnd === undefined
418
+ ? regionEnd
419
+ : ref.loopEnd * sample.frames;
283
420
  const start = noteStartFrame(note, timing);
284
421
  const performance = (note as PerformedNote).performance;
285
422
  const voice: SamplerVoice = {
@@ -300,6 +437,12 @@ export function planSamplerVoices(
300
437
  loopSpan: loopTo - loopFrom,
301
438
  ramp,
302
439
  squiz: ref.squiz ?? 1,
440
+ ...(ref.fadeInTime !== undefined
441
+ ? { attack: Math.max(1, ref.fadeInTime * sampleRate) }
442
+ : {}),
443
+ ...(ref.from !== undefined && sample.left && sample.right
444
+ ? { stereo: true }
445
+ : {}),
303
446
  ...(performance?.cents ? { cents: performance.cents } : {}),
304
447
  ...(performance?.damp ? { damp: performance.damp } : {}),
305
448
  };
@@ -318,6 +461,153 @@ export function planSamplerVoices(
318
461
  return voices;
319
462
  }
320
463
 
464
+ /** A SampleRef that fits its window to the song (0.6 `bpm`, `len`, fitmode). */
465
+ export function isFitted(ref: SampleRef): boolean {
466
+ return (
467
+ ref.bpm !== undefined ||
468
+ ref.len !== undefined ||
469
+ (ref.fit === true && (ref.fitmode === "beats" || ref.fitmode === "tones"))
470
+ );
471
+ }
472
+
473
+ type FittedVoice = Readonly<{
474
+ sample: DecodedSample;
475
+ loopFrom: number;
476
+ loopTo: number;
477
+ }>;
478
+
479
+ /**
480
+ * The fitted window of one note. Precedence `fit` > `bpm` > `len`:
481
+ * - `fit`: the window fills the note (Strudel `fit`, with an algorithm);
482
+ * - `bpm`: the window advances `|speed|` source beats per song beat,
483
+ * following the tempo map (ramps, steps, fermatas);
484
+ * - `len`: the window spans `len` song beats through the tempo map.
485
+ * A negative `speed` reverses the window; `unit` is ignored once fitted.
486
+ * Returns null while a live fit is still computing (the voice stays
487
+ * silent rather than playing at the wrong pitch).
488
+ */
489
+ function fitVoice(
490
+ ref: SampleRef,
491
+ decoded: DecodedSample,
492
+ startTick: number,
493
+ held: number,
494
+ timing: SamplerTiming,
495
+ reach: number,
496
+ ): FittedVoice | null {
497
+ const { sampleRate, score, warp } = timing;
498
+ const speed = ref.speed ?? 1;
499
+ const reverse = speed < 0;
500
+ const from = (ref.begin ?? 0) * decoded.frames;
501
+ const to = (ref.end ?? 1) * decoded.frames;
502
+ const rate = decoded.sampleRate / sampleRate;
503
+ const window = windowAt(decoded.mono, from, to, rate, reverse);
504
+ const frames = window.length;
505
+ let map: FitMap;
506
+ if (ref.fit === true) map = spanFitMap(frames, held);
507
+ else {
508
+ const perBeat =
509
+ ref.bpm !== undefined
510
+ ? ((sampleRate * 60) / ref.bpm) * Math.abs(speed)
511
+ : frames / ref.len!;
512
+ map = beatFitMap(
513
+ warp,
514
+ {
515
+ tempoBpm: score.tempoBpm,
516
+ ticksPerBeat: score.ticksPerBeat,
517
+ sampleRate,
518
+ },
519
+ startTick,
520
+ frames,
521
+ perBeat,
522
+ );
523
+ }
524
+ const algorithm = ref.fitmode ?? "repitch";
525
+ const windowKey = `${decoded.sha256}:${decoded.sampleRate}:${from}:${to}:${sampleRate}:${reverse ? 1 : 0}`;
526
+ const length = Math.min(map.length, reach);
527
+ const key = `${windowKey}:${algorithm}:${mapPrint(map, frames)}:${Math.round(length)}`;
528
+ const buffer = fittedBuffer(
529
+ key,
530
+ window,
531
+ sampleRate,
532
+ map,
533
+ algorithm,
534
+ windowKey,
535
+ length,
536
+ );
537
+ if (!buffer) return null;
538
+ const sample: DecodedSample = Object.freeze({
539
+ sha256: decoded.sha256,
540
+ sampleRate,
541
+ channels: 1,
542
+ frames: buffer.length,
543
+ mono: buffer,
544
+ });
545
+ // Loop points move with the map (in window frames, then out frames).
546
+ const loopAt = (fraction: number | undefined, fallback: number) => {
547
+ if (fraction === undefined) return fallback;
548
+ const source = fraction * decoded.frames;
549
+ const inWindow = (reverse ? to - source : source - from) / rate;
550
+ return Math.max(0, Math.min(buffer.length, map.outAt(inWindow)));
551
+ };
552
+ const a = loopAt(ref.loopBegin, 0);
553
+ const b = loopAt(ref.loopEnd, buffer.length);
554
+ return Object.freeze({
555
+ sample,
556
+ loopFrom: Math.min(a, b),
557
+ loopTo: Math.max(a, b),
558
+ });
559
+ }
560
+
561
+ /** A SampleRef with a pitch or formant shift (0.6.1). */
562
+ export function isShifted(ref: SampleRef): boolean {
563
+ return (
564
+ (ref.shift !== undefined && ref.shift !== 0) || ref.formant !== undefined
565
+ );
566
+ }
567
+
568
+ /** Buffers already shifted, by the identity of the buffer they shift. */
569
+ const shiftKeys = new WeakMap<Float32Array, string>();
570
+ let shiftSerial = 0;
571
+
572
+ /**
573
+ * The shifted copy of `sample` (whole buffer, same length). Returns null
574
+ * while a live shift is still computing (the voice stays silent).
575
+ */
576
+ function shiftVoice(
577
+ ref: SampleRef,
578
+ sample: DecodedSample,
579
+ kind: "raw" | "fit",
580
+ ): DecodedSample | undefined {
581
+ // Decoded files key by content; fitted buffers (already cached by their
582
+ // own key) by identity.
583
+ let base = shiftKeys.get(sample.mono);
584
+ if (base === undefined) {
585
+ base =
586
+ kind === "raw"
587
+ ? `${sample.sha256}:${sample.sampleRate}:${sample.frames}`
588
+ : `fit#${(shiftSerial += 1)}`;
589
+ shiftKeys.set(sample.mono, base);
590
+ }
591
+ const key = `shift:${base}:${ref.shift ?? 0}:${ref.formant ?? "follow"}`;
592
+ const buffer = shiftedBuffer(
593
+ key,
594
+ sample.mono,
595
+ sample.sampleRate,
596
+ ref.shift ?? 0,
597
+ ref.formant,
598
+ );
599
+ if (!buffer) return undefined;
600
+ return Object.freeze({ ...sample, frames: buffer.length, mono: buffer });
601
+ }
602
+
603
+ /** Fingerprint of a position map: its length and 64 exact samples. */
604
+ function mapPrint(map: FitMap, frames: number): string {
605
+ const parts = [map.length.toString()];
606
+ for (let i = 1; i <= 64; i += 1)
607
+ parts.push(map.outAt((frames * i) / 64).toString());
608
+ return parts.join(",");
609
+ }
610
+
321
611
  /** Linear-interpolated read, clamped to the sample. */
322
612
  function readAt(mono: Float32Array, position: number): number {
323
613
  const last = mono.length - 1;
@@ -337,6 +627,8 @@ export function renderSamplerVoices(
337
627
  voices: readonly SamplerVoice[],
338
628
  timing: SamplerTiming,
339
629
  gainAt: (tick: number) => number,
630
+ /** The channel a stereo voice reads (absent: every voice reads mono). */
631
+ channel?: "left" | "right",
340
632
  ): void {
341
633
  const { sampleRate, score, warp } = timing;
342
634
  const samplesPerTick =
@@ -345,7 +637,8 @@ export function renderSamplerVoices(
345
637
  for (const voice of voices) {
346
638
  const { sample, step, regionStart, regionEnd, reverse, loop } = voice;
347
639
  const { loopStart, loopSpan, ramp } = voice;
348
- const mono = sample.mono;
640
+ const mono =
641
+ channel && voice.stereo ? (sample[channel] ?? sample.mono) : sample.mono;
349
642
  const span = regionEnd - regionStart;
350
643
  const crossfade = loop
351
644
  ? Math.min(LOOP_CROSSFADE_SECONDS * sample.sampleRate, loopSpan / 4)
@@ -403,7 +696,7 @@ export function renderSamplerVoices(
403
696
  const elapsed = index - voice.start;
404
697
  const value = squizzed ? squizzed[elapsed]! : read(elapsed);
405
698
  if (value === undefined) break;
406
- let envelope = Math.min(1, elapsed / attack);
699
+ let envelope = Math.min(1, elapsed / (voice.attack ?? attack));
407
700
  if (index >= fadeFrom) envelope *= (voice.end - index) / voice.fade;
408
701
  const damp = voice.damp;
409
702
  if (damp && elapsed / sampleRate > damp.from)
@@ -32,8 +32,10 @@ import {
32
32
  isLocalTableSrc,
33
33
  isWavetableInstrument,
34
34
  type SampleRef,
35
+ type Track,
35
36
  type TrackScore,
36
37
  } from "../../core/score.ts";
38
+ import { GRANULAR_SOURCE_VOICE } from "../../core/granular.ts";
37
39
  import { PackError, PackStore } from "./packs.ts";
38
40
  import {
39
41
  DEFAULT_MEMORY_CACHE_BYTES,
@@ -56,6 +58,12 @@ export type DecodedSample = Readonly<{
56
58
  channels: number;
57
59
  frames: number;
58
60
  mono: Float32Array;
61
+ /**
62
+ * The two channels of a stereo file (0.6.1): a resampled ref (one with
63
+ * `from`) plays them as stereo. Absent for mono and multichannel files.
64
+ */
65
+ left?: Float32Array;
66
+ right?: Float32Array;
59
67
  }>;
60
68
 
61
69
  export type SampleProblem = Readonly<{
@@ -94,10 +102,35 @@ export function hasSamplerTracks(score: TrackScore): boolean {
94
102
  Object.keys(track.sampler.voices).length > 0) ||
95
103
  packWavetable(track) !== undefined ||
96
104
  localWavetable(track) !== undefined ||
97
- reverbIrSample(track) !== undefined,
105
+ reverbIrSample(track) !== undefined ||
106
+ granularSample(track) !== undefined,
98
107
  );
99
108
  }
100
109
 
110
+ /**
111
+ * A granular track's sample source (0.6): its `granular.src` when that is a
112
+ * SampleRef, loaded and sha256-pinned like a sampler voice under the voice
113
+ * name `granular:src`. A `synth:` source or no source needs no loading.
114
+ */
115
+ export function granularSample(track: Track): SampleRef | undefined {
116
+ if (track.instrument !== "granular") return undefined;
117
+ const src = track.granular?.src;
118
+ return src !== undefined && typeof src !== "string" ? src : undefined;
119
+ }
120
+
121
+ /** The sample voices a track loads: sampler voices, then a granular source. */
122
+ function trackSampleRefs(track: Track): [string, SampleRef][] {
123
+ const refs: [string, SampleRef][] = [];
124
+ if (isSamplerInstrument(track.instrument) && track.sampler) {
125
+ const names = Object.keys(track.sampler.voices).sort();
126
+ for (const voice of names.slice(0, SCORE_LIMITS.maxSamplerVoices))
127
+ refs.push([voice, track.sampler.voices[voice]!]);
128
+ }
129
+ const granular = granularSample(track);
130
+ if (granular) refs.push([GRANULAR_SOURCE_VOICE, granular]);
131
+ return refs;
132
+ }
133
+
101
134
  /** The project WAV a wavetable track plays, if any. */
102
135
  export function localWavetable(
103
136
  track: TrackScore["tracks"][number],
@@ -297,6 +330,20 @@ function checkShape(sampleRate: number, channels: number, frames: number) {
297
330
  }
298
331
 
299
332
  /** Average the channels; the renderer plays sampler voices in mono before pan. */
333
+ /** A stereo file's left and right channels (nothing for other shapes). */
334
+ export function stereoChannels(
335
+ pcm: PcmData,
336
+ ): { left: Float32Array; right: Float32Array } | Record<string, never> {
337
+ if (pcm.channels !== 2) return {};
338
+ const left = new Float32Array(pcm.frames);
339
+ const right = new Float32Array(pcm.frames);
340
+ for (let frame = 0; frame < pcm.frames; frame += 1) {
341
+ left[frame] = pcm.data[frame * 2]!;
342
+ right[frame] = pcm.data[frame * 2 + 1]!;
343
+ }
344
+ return { left, right };
345
+ }
346
+
300
347
  export function monoMix(pcm: PcmData): Float32Array {
301
348
  if (pcm.channels === 1) return pcm.data;
302
349
  const mono = new Float32Array(pcm.frames);
@@ -585,8 +632,7 @@ export class SampleLibrary implements SampleSource {
585
632
  this.inUse = new Set();
586
633
  const urls: string[] = [];
587
634
  for (const track of score.tracks) {
588
- if (!isSamplerInstrument(track.instrument) || !track.sampler) continue;
589
- for (const ref of Object.values(track.sampler.voices)) {
635
+ for (const [, ref] of trackSampleRefs(track)) {
590
636
  if (ref.sha256) this.inUse.add(ref.sha256);
591
637
  if (ref.url) urls.push(ref.url);
592
638
  }
@@ -656,10 +702,7 @@ export class SampleLibrary implements SampleSource {
656
702
  }
657
703
  }
658
704
  for (const track of score.tracks) {
659
- if (!isSamplerInstrument(track.instrument) || !track.sampler) continue;
660
- const names = Object.keys(track.sampler.voices).sort();
661
- for (const voice of names.slice(0, SCORE_LIMITS.maxSamplerVoices)) {
662
- const ref = track.sampler.voices[voice]!;
705
+ for (const [voice, ref] of trackSampleRefs(track)) {
663
706
  let src = ref.src;
664
707
  if (src.startsWith(PACK_PREFIX)) {
665
708
  try {
@@ -873,6 +916,7 @@ export class SampleLibrary implements SampleSource {
873
916
  channels: pcm.channels,
874
917
  frames: pcm.frames,
875
918
  mono: monoMix(pcm),
919
+ ...stereoChannels(pcm),
876
920
  });
877
921
  this.remember(sample);
878
922
  return sample;
@@ -920,6 +964,7 @@ export class SampleLibrary implements SampleSource {
920
964
  channels: pcm.channels,
921
965
  frames: pcm.frames,
922
966
  mono: monoMix(pcm),
967
+ ...stereoChannels(pcm),
923
968
  });
924
969
  this.remember(sample);
925
970
  return sample;
@@ -943,7 +988,10 @@ export class SampleLibrary implements SampleSource {
943
988
  }
944
989
 
945
990
  private remember(sample: DecodedSample): void {
946
- const bytes = sample.mono.byteLength;
991
+ const bytes =
992
+ sample.mono.byteLength +
993
+ (sample.left?.byteLength ?? 0) +
994
+ (sample.right?.byteLength ?? 0);
947
995
  this.memory.set(sample.sha256, { sample, bytes });
948
996
  this.memoryBytes += bytes;
949
997
  for (const [sha, entry] of this.memory) {