@hraness/dawg 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. package/tui/keys.ts +72 -0
@@ -13,7 +13,8 @@
13
13
  * `accelerate` ramps the rate; `squiz` raises pitch per zero-crossing
14
14
  * cycle (described in Tidal/SuperDirt docs; implemented here from that
15
15
  * description).
16
- * - keyed mode repitches from `root` (rate × 2^((pitch − root)/12)) and holds
16
+ * - keyed mode repitches from `root` (rate × 2^((pitch − root)/12), or the
17
+ * tuning's key frequency over the root's 12-TET frequency) and holds
17
18
  * for the note's length; oneshot mode maps voices to pitch slots from 36 in
18
19
  * voice-name order and plays the whole window unless `loop` is set.
19
20
  *
@@ -31,6 +32,29 @@ import {
31
32
  type TrackScore,
32
33
  } from "../../core/score.ts";
33
34
  import { sampleKey, type DecodedSample, type SampleBank } from "./samples.ts";
35
+ import {
36
+ barAt,
37
+ loopSecondsOf,
38
+ performedNotes,
39
+ secondsAtTick,
40
+ } from "../../core/tempo.ts";
41
+ import {
42
+ beatFitMap,
43
+ sampleWarpFor,
44
+ spanFitMap,
45
+ warpedSpan,
46
+ type FitMap,
47
+ type SampleWarp,
48
+ } from "./warp.ts";
49
+ import { fittedBuffer, windowAt } from "./fit.ts";
50
+ import {
51
+ performanceTimingFor,
52
+ tunedTiming,
53
+ performNotes,
54
+ type NotePerformance,
55
+ type PerformedNote,
56
+ } from "../../core/expression.ts";
57
+ import { resolveTuning, type TuningTable } from "../../core/tuning.ts";
34
58
 
35
59
  /** Output level of a full-scale sample at velocity 1 and gain 1. */
36
60
  export const SAMPLE_LEVEL = 0.7;
@@ -39,6 +63,8 @@ const ATTACK_SECONDS = 0.001;
39
63
  /** Fade at a natural end, a held release, a choke and a voice steal. */
40
64
  const END_FADE_SECONDS = 0.003;
41
65
  const RELEASE_SECONDS = 0.01;
66
+ /** Extra fitted frames past a note's reach (read interpolation). */
67
+ const REACH_MARGIN_FRAMES = 64;
42
68
  const CHOKE_SECONDS = 0.005;
43
69
  /** Loop seam crossfade (source time). */
44
70
  const LOOP_CROSSFADE_SECONDS = 0.005;
@@ -46,6 +72,8 @@ const LOOP_CROSSFADE_SECONDS = 0.005;
46
72
  export type SamplerTiming = Readonly<{
47
73
  score: TrackScore;
48
74
  sampleRate: number;
75
+ /** The song's tempo map in samples; absent keeps constant tempo. */
76
+ warp?: SampleWarp;
49
77
  }>;
50
78
 
51
79
  /** One planned voice: when it sounds and how it reads its sample. */
@@ -74,6 +102,10 @@ export type SamplerVoice = {
74
102
  readonly ramp: number;
75
103
  /** `squiz` ratio; 1 is off. */
76
104
  readonly squiz: number;
105
+ /** Note expression: pitch in cents over time (glide, bend, vibrato). */
106
+ readonly cents?: NotePerformance["cents"];
107
+ /** Half pedal: the level fades from `from` seconds. */
108
+ readonly damp?: NotePerformance["damp"];
77
109
  };
78
110
 
79
111
  /**
@@ -96,7 +128,8 @@ function framesToTravel(distance: number, step: number, ramp: number): number {
96
128
  }
97
129
 
98
130
  function noteStartFrame(note: Note, timing: SamplerTiming): number {
99
- const { score, sampleRate } = timing;
131
+ const { score, sampleRate, warp } = timing;
132
+ if (warp) return warpedSpan(warp, note.startTick, note.durationTicks).start;
100
133
  return Math.max(
101
134
  0,
102
135
  Math.floor(
@@ -107,7 +140,8 @@ function noteStartFrame(note: Note, timing: SamplerTiming): number {
107
140
  }
108
141
 
109
142
  function noteLengthFrames(note: Note, timing: SamplerTiming): number {
110
- const { score, sampleRate } = timing;
143
+ const { score, sampleRate, warp } = timing;
144
+ if (warp) return warpedSpan(warp, note.startTick, note.durationTicks).length;
111
145
  return Math.max(
112
146
  1,
113
147
  Math.floor(
@@ -117,9 +151,15 @@ function noteLengthFrames(note: Note, timing: SamplerTiming): number {
117
151
  );
118
152
  }
119
153
 
120
- /** Which voice a note plays and the pitch ratio it plays at. */
154
+ /**
155
+ * Which voice a note plays and the pitch ratio it plays at. A keyed voice
156
+ * repitches to the tuning's key frequency over its root's 12-TET frequency
157
+ * (a sample is recorded at concert pitch); one-shot slots ignore tuning,
158
+ * since their pitch picks a voice rather than a note.
159
+ */
121
160
  function voiceResolver(
122
161
  track: Track,
162
+ tuning?: TuningTable,
123
163
  ): (pitch: number) => { voice: string; ratio: number } | undefined {
124
164
  const sampler = track.sampler!;
125
165
  if (sampler.mode === "oneshot") {
@@ -141,7 +181,14 @@ function voiceResolver(
141
181
  for (const candidate of keyed)
142
182
  if (candidate.root <= pitch) chosen = candidate;
143
183
  if (!chosen) return undefined;
144
- return { voice: chosen.voice, ratio: 2 ** ((pitch - chosen.root) / 12) };
184
+ if (!tuning)
185
+ return { voice: chosen.voice, ratio: 2 ** ((pitch - chosen.root) / 12) };
186
+ const hz = tuning.hz[pitch]!;
187
+ if (!(hz > 0)) return undefined;
188
+ return {
189
+ voice: chosen.voice,
190
+ ratio: hz / (440 * 2 ** ((chosen.root - 69) / 12)),
191
+ };
145
192
  };
146
193
  }
147
194
 
@@ -154,7 +201,10 @@ export function planSamplerVoices(
154
201
  ): SamplerVoice[] {
155
202
  const sampler = track.sampler;
156
203
  if (!sampler) return [];
157
- const resolve = voiceResolver(track);
204
+ const resolve = voiceResolver(
205
+ track,
206
+ resolveTuning(timing.score.tuning, track.tuning, timing.score.key),
207
+ );
158
208
  const ordered = [...notes].sort(
159
209
  (a, b) =>
160
210
  a.startTick - b.startTick ||
@@ -174,27 +224,54 @@ export function planSamplerVoices(
174
224
  voice.fade = Math.min(fade, end - voice.start);
175
225
  };
176
226
  for (const note of ordered) {
177
- const target = resolve(note.pitch);
178
- if (!target) continue;
227
+ const resolved = resolve(note.pitch);
228
+ if (!resolved) continue;
229
+ // A note's cents repitch every voice, keyed or one-shot.
230
+ const target = note.cents
231
+ ? { ...resolved, ratio: resolved.ratio * 2 ** (note.cents / 1200) }
232
+ : resolved;
179
233
  const ref: SampleRef | undefined = sampler.voices[target.voice];
180
- const sample = bank.voices.get(sampleKey(track.id, target.voice));
181
- if (!ref || !sample) continue;
182
- const speed = ref.speed ?? 1;
183
- const regionStart = (ref.begin ?? 0) * sample.frames;
184
- const regionEnd = (ref.end ?? 1) * sample.frames;
234
+ const decoded = bank.voices.get(sampleKey(track.id, target.voice));
235
+ if (!ref || !decoded) continue;
185
236
  const held = noteLengthFrames(note, timing);
237
+ // 0.6 fit (`bpm`, `len`, or `fit` with fitmode beats|tones): the window
238
+ // is fitted once into a render-rate buffer that plays forward at step 1
239
+ // (times the keyed ratio). Absent fields keep the 0.5 path untouched.
240
+ // Only the frames this note can reach are fitted: a keyed or clipped
241
+ // note (not looping, no accelerate) ends at its held length plus the
242
+ // release, read at the keyed ratio; anything else may play it all.
243
+ const reach =
244
+ ref.loop !== true &&
245
+ (ref.accelerate ?? 0) === 0 &&
246
+ (sampler.mode === "keyed" || ref.clip !== undefined)
247
+ ? Math.ceil(
248
+ ((ref.clip === undefined
249
+ ? held
250
+ : Math.max(1, Math.floor(held * ref.clip))) +
251
+ Math.round(RELEASE_SECONDS * sampleRate)) *
252
+ target.ratio,
253
+ ) + REACH_MARGIN_FRAMES
254
+ : Infinity;
255
+ const fitted = isFitted(ref)
256
+ ? fitVoice(ref, decoded, note.startTick, held, timing, reach)
257
+ : undefined;
258
+ if (fitted === null) continue;
259
+ const sample = fitted?.sample ?? decoded;
260
+ const speed = fitted ? 1 : (ref.speed ?? 1);
261
+ const regionStart = fitted ? 0 : (ref.begin ?? 0) * sample.frames;
262
+ const regionEnd = fitted ? sample.frames : (ref.end ?? 1) * sample.frames;
186
263
  // `fit`, `unit: "c"` and `unit: "s"` give the window a duration; the
187
264
  // keyed ratio still repitches on top.
188
265
  const unit = ref.unit ?? "r";
189
- const seconds = ref.fit
190
- ? held / sampleRate
191
- : unit === "c"
192
- ? (timing.score.beatsPerBar * 60) /
193
- timing.score.tempoBpm /
194
- Math.abs(speed)
195
- : unit === "s"
196
- ? Math.abs(speed)
197
- : undefined;
266
+ const seconds = fitted
267
+ ? undefined
268
+ : ref.fit
269
+ ? held / sampleRate
270
+ : unit === "c"
271
+ ? cycleSeconds(timing.score, note.startTick) / Math.abs(speed)
272
+ : unit === "s"
273
+ ? Math.abs(speed)
274
+ : undefined;
198
275
  const step =
199
276
  seconds === undefined
200
277
  ? ((Math.abs(speed) * sample.sampleRate) / sampleRate) * target.ratio
@@ -234,11 +311,18 @@ export function planSamplerVoices(
234
311
  fade = Math.max(1, Math.round(END_FADE_SECONDS * sampleRate));
235
312
  }
236
313
  }
237
- const loopFrom =
238
- ref.loopBegin === undefined ? regionStart : ref.loopBegin * sample.frames;
239
- const loopTo =
240
- ref.loopEnd === undefined ? regionEnd : ref.loopEnd * sample.frames;
314
+ const loopFrom = fitted
315
+ ? fitted.loopFrom
316
+ : ref.loopBegin === undefined
317
+ ? regionStart
318
+ : ref.loopBegin * sample.frames;
319
+ const loopTo = fitted
320
+ ? fitted.loopTo
321
+ : ref.loopEnd === undefined
322
+ ? regionEnd
323
+ : ref.loopEnd * sample.frames;
241
324
  const start = noteStartFrame(note, timing);
325
+ const performance = (note as PerformedNote).performance;
242
326
  const voice: SamplerVoice = {
243
327
  voice: target.voice,
244
328
  sample,
@@ -257,6 +341,8 @@ export function planSamplerVoices(
257
341
  loopSpan: loopTo - loopFrom,
258
342
  ramp,
259
343
  squiz: ref.squiz ?? 1,
344
+ ...(performance?.cents ? { cents: performance.cents } : {}),
345
+ ...(performance?.damp ? { damp: performance.damp } : {}),
260
346
  };
261
347
  if (voice.choke !== undefined) {
262
348
  const previous = lastInGroup.get(voice.choke);
@@ -273,6 +359,111 @@ export function planSamplerVoices(
273
359
  return voices;
274
360
  }
275
361
 
362
+ /** A SampleRef that fits its window to the song (0.6 `bpm`, `len`, fitmode). */
363
+ export function isFitted(ref: SampleRef): boolean {
364
+ return (
365
+ ref.bpm !== undefined ||
366
+ ref.len !== undefined ||
367
+ (ref.fit === true && (ref.fitmode === "beats" || ref.fitmode === "tones"))
368
+ );
369
+ }
370
+
371
+ type FittedVoice = Readonly<{
372
+ sample: DecodedSample;
373
+ loopFrom: number;
374
+ loopTo: number;
375
+ }>;
376
+
377
+ /**
378
+ * The fitted window of one note. Precedence `fit` > `bpm` > `len`:
379
+ * - `fit`: the window fills the note (Strudel `fit`, with an algorithm);
380
+ * - `bpm`: the window advances `|speed|` source beats per song beat,
381
+ * following the tempo map (ramps, steps, fermatas);
382
+ * - `len`: the window spans `len` song beats through the tempo map.
383
+ * A negative `speed` reverses the window; `unit` is ignored once fitted.
384
+ * Returns null while a live fit is still computing (the voice stays
385
+ * silent rather than playing at the wrong pitch).
386
+ */
387
+ function fitVoice(
388
+ ref: SampleRef,
389
+ decoded: DecodedSample,
390
+ startTick: number,
391
+ held: number,
392
+ timing: SamplerTiming,
393
+ reach: number,
394
+ ): FittedVoice | null {
395
+ const { sampleRate, score, warp } = timing;
396
+ const speed = ref.speed ?? 1;
397
+ const reverse = speed < 0;
398
+ const from = (ref.begin ?? 0) * decoded.frames;
399
+ const to = (ref.end ?? 1) * decoded.frames;
400
+ const rate = decoded.sampleRate / sampleRate;
401
+ const window = windowAt(decoded.mono, from, to, rate, reverse);
402
+ const frames = window.length;
403
+ let map: FitMap;
404
+ if (ref.fit === true) map = spanFitMap(frames, held);
405
+ else {
406
+ const perBeat =
407
+ ref.bpm !== undefined
408
+ ? ((sampleRate * 60) / ref.bpm) * Math.abs(speed)
409
+ : frames / ref.len!;
410
+ map = beatFitMap(
411
+ warp,
412
+ {
413
+ tempoBpm: score.tempoBpm,
414
+ ticksPerBeat: score.ticksPerBeat,
415
+ sampleRate,
416
+ },
417
+ startTick,
418
+ frames,
419
+ perBeat,
420
+ );
421
+ }
422
+ const algorithm = ref.fitmode ?? "repitch";
423
+ const windowKey = `${decoded.sha256}:${decoded.sampleRate}:${from}:${to}:${sampleRate}:${reverse ? 1 : 0}`;
424
+ const length = Math.min(map.length, reach);
425
+ const key = `${windowKey}:${algorithm}:${mapPrint(map, frames)}:${Math.round(length)}`;
426
+ const buffer = fittedBuffer(
427
+ key,
428
+ window,
429
+ sampleRate,
430
+ map,
431
+ algorithm,
432
+ windowKey,
433
+ length,
434
+ );
435
+ if (!buffer) return null;
436
+ const sample: DecodedSample = Object.freeze({
437
+ sha256: decoded.sha256,
438
+ sampleRate,
439
+ channels: 1,
440
+ frames: buffer.length,
441
+ mono: buffer,
442
+ });
443
+ // Loop points move with the map (in window frames, then out frames).
444
+ const loopAt = (fraction: number | undefined, fallback: number) => {
445
+ if (fraction === undefined) return fallback;
446
+ const source = fraction * decoded.frames;
447
+ const inWindow = (reverse ? to - source : source - from) / rate;
448
+ return Math.max(0, Math.min(buffer.length, map.outAt(inWindow)));
449
+ };
450
+ const a = loopAt(ref.loopBegin, 0);
451
+ const b = loopAt(ref.loopEnd, buffer.length);
452
+ return Object.freeze({
453
+ sample,
454
+ loopFrom: Math.min(a, b),
455
+ loopTo: Math.max(a, b),
456
+ });
457
+ }
458
+
459
+ /** Fingerprint of a position map: its length and 64 exact samples. */
460
+ function mapPrint(map: FitMap, frames: number): string {
461
+ const parts = [map.length.toString()];
462
+ for (let i = 1; i <= 64; i += 1)
463
+ parts.push(map.outAt((frames * i) / 64).toString());
464
+ return parts.join(",");
465
+ }
466
+
276
467
  /** Linear-interpolated read, clamped to the sample. */
277
468
  function readAt(mono: Float32Array, position: number): number {
278
469
  const last = mono.length - 1;
@@ -293,7 +484,7 @@ export function renderSamplerVoices(
293
484
  timing: SamplerTiming,
294
485
  gainAt: (tick: number) => number,
295
486
  ): void {
296
- const { sampleRate, score } = timing;
487
+ const { sampleRate, score, warp } = timing;
297
488
  const samplesPerTick =
298
489
  (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat);
299
490
  const attack = Math.max(1, ATTACK_SECONDS * sampleRate);
@@ -311,10 +502,31 @@ export function renderSamplerVoices(
311
502
  const level = SAMPLE_LEVEL * voice.gain;
312
503
  const at = (offset: number) =>
313
504
  readAt(mono, reverse ? regionEnd - 1 - offset : regionStart + offset);
505
+ // A bent voice (note expression) integrates its rate frame by frame;
506
+ // reads arrive in order, so the running sum is kept between calls.
507
+ const bent = voice.cents;
508
+ let bentAt = 0;
509
+ let bentTravel = 0;
510
+ const travel = (elapsed: number): number => {
511
+ if (!bent)
512
+ return ramp === 0 ? elapsed * step : travelAt(elapsed, step, ramp);
513
+ if (elapsed < bentAt) {
514
+ bentAt = 0;
515
+ bentTravel = 0;
516
+ }
517
+ while (bentAt < elapsed) {
518
+ const rate =
519
+ step *
520
+ (ramp === 0 ? 1 : Math.max(0, 1 + ramp * bentAt)) *
521
+ 2 ** (bent(bentAt / sampleRate) / 1200);
522
+ bentTravel += rate;
523
+ bentAt += 1;
524
+ }
525
+ return bentTravel;
526
+ };
314
527
  // The raw read at an elapsed frame, or undefined past a one-shot's end.
315
528
  const read = (elapsed: number): number | undefined => {
316
- const travelled =
317
- ramp === 0 ? elapsed * step : travelAt(elapsed, step, ramp);
529
+ const travelled = travel(elapsed);
318
530
  if (!loop) return travelled >= span ? undefined : at(travelled);
319
531
  if (travelled < loopStart) return at(travelled);
320
532
  const into = travelled - loopStart;
@@ -339,11 +551,16 @@ export function renderSamplerVoices(
339
551
  if (value === undefined) break;
340
552
  let envelope = Math.min(1, elapsed / attack);
341
553
  if (index >= fadeFrom) envelope *= (voice.end - index) / voice.fade;
554
+ const damp = voice.damp;
555
+ if (damp && elapsed / sampleRate > damp.from)
556
+ envelope *= Math.exp(-(elapsed / sampleRate - damp.from) / damp.tau);
342
557
  target[index]! +=
343
558
  value *
344
559
  envelope *
345
560
  level *
346
- gainAt(voice.startTick + elapsed / samplesPerTick);
561
+ gainAt(
562
+ warp ? warp.tick(index) : voice.startTick + elapsed / samplesPerTick,
563
+ );
347
564
  }
348
565
  }
349
566
  }
@@ -406,15 +623,33 @@ export function samplerTailSeconds(
406
623
  sampleRate: number,
407
624
  ): number {
408
625
  if (bank.voices.size === 0) return 0;
409
- const loopEnd =
410
- ((score.bars * score.beatsPerBar * 60) / score.tempoBpm) * sampleRate;
411
- const timing = { score, sampleRate };
626
+ const loopEnd = loopSecondsOf(score) * sampleRate;
627
+ const warp = sampleWarpFor(score, sampleRate);
628
+ const timing = { score, sampleRate, ...(warp ? { warp } : {}) };
412
629
  let latest = 0;
630
+ const performance = performanceTimingFor(score);
413
631
  for (const track of score.tracks) {
414
632
  if (!track.sampler) continue;
415
- const notes = score.notes.filter((note) => note.trackId === track.id);
633
+ const notes = performNotes(
634
+ track,
635
+ performedNotes(score).filter((note) => note.trackId === track.id),
636
+ tunedTiming(performance, score, track),
637
+ );
416
638
  for (const voice of planSamplerVoices(track, notes, bank, timing))
417
639
  latest = Math.max(latest, voice.end - loopEnd);
418
640
  }
419
641
  return latest / sampleRate;
420
642
  }
643
+
644
+ /**
645
+ * Seconds of the bar (a `c` cycle) holding `tick`: through the meter and
646
+ * the tempo map when the song has `time`, else the 0.4 arithmetic.
647
+ */
648
+ function cycleSeconds(score: TrackScore, tick: number): number {
649
+ if (!score.time) return (score.beatsPerBar * 60) / score.tempoBpm;
650
+ const bar = barAt(score, tick);
651
+ return (
652
+ secondsAtTick(score, bar.tick + bar.barTicks) -
653
+ secondsAtTick(score, bar.tick)
654
+ );
655
+ }
@@ -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,
@@ -94,10 +96,35 @@ export function hasSamplerTracks(score: TrackScore): boolean {
94
96
  Object.keys(track.sampler.voices).length > 0) ||
95
97
  packWavetable(track) !== undefined ||
96
98
  localWavetable(track) !== undefined ||
97
- reverbIrSample(track) !== undefined,
99
+ reverbIrSample(track) !== undefined ||
100
+ granularSample(track) !== undefined,
98
101
  );
99
102
  }
100
103
 
104
+ /**
105
+ * A granular track's sample source (0.6): its `granular.src` when that is a
106
+ * SampleRef, loaded and sha256-pinned like a sampler voice under the voice
107
+ * name `granular:src`. A `synth:` source or no source needs no loading.
108
+ */
109
+ export function granularSample(track: Track): SampleRef | undefined {
110
+ if (track.instrument !== "granular") return undefined;
111
+ const src = track.granular?.src;
112
+ return src !== undefined && typeof src !== "string" ? src : undefined;
113
+ }
114
+
115
+ /** The sample voices a track loads: sampler voices, then a granular source. */
116
+ function trackSampleRefs(track: Track): [string, SampleRef][] {
117
+ const refs: [string, SampleRef][] = [];
118
+ if (isSamplerInstrument(track.instrument) && track.sampler) {
119
+ const names = Object.keys(track.sampler.voices).sort();
120
+ for (const voice of names.slice(0, SCORE_LIMITS.maxSamplerVoices))
121
+ refs.push([voice, track.sampler.voices[voice]!]);
122
+ }
123
+ const granular = granularSample(track);
124
+ if (granular) refs.push([GRANULAR_SOURCE_VOICE, granular]);
125
+ return refs;
126
+ }
127
+
101
128
  /** The project WAV a wavetable track plays, if any. */
102
129
  export function localWavetable(
103
130
  track: TrackScore["tracks"][number],
@@ -585,8 +612,7 @@ export class SampleLibrary implements SampleSource {
585
612
  this.inUse = new Set();
586
613
  const urls: string[] = [];
587
614
  for (const track of score.tracks) {
588
- if (!isSamplerInstrument(track.instrument) || !track.sampler) continue;
589
- for (const ref of Object.values(track.sampler.voices)) {
615
+ for (const [, ref] of trackSampleRefs(track)) {
590
616
  if (ref.sha256) this.inUse.add(ref.sha256);
591
617
  if (ref.url) urls.push(ref.url);
592
618
  }
@@ -656,10 +682,7 @@ export class SampleLibrary implements SampleSource {
656
682
  }
657
683
  }
658
684
  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]!;
685
+ for (const [voice, ref] of trackSampleRefs(track)) {
663
686
  let src = ref.src;
664
687
  if (src.startsWith(PACK_PREFIX)) {
665
688
  try {