@hraness/dawg 0.5.0 → 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 (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/DAWG.md +269 -13
  3. package/README.md +4 -4
  4. package/core/diff.ts +4 -0
  5. package/core/fx.ts +437 -2
  6. package/core/granular.ts +528 -0
  7. package/core/instruments.ts +281 -0
  8. package/core/keys.ts +386 -0
  9. package/core/resonators.ts +574 -0
  10. package/core/score.ts +181 -6
  11. package/core/sdk/eval-child.ts +2 -0
  12. package/core/sdk/print.ts +169 -13
  13. package/core/sdk/sync-instruments.ts +58 -0
  14. package/core/sdk/v1.ts +1114 -21
  15. package/core/sections.ts +34 -8
  16. package/core/strings.ts +845 -0
  17. package/guides/effects.md +3 -1
  18. package/guides/sounds.md +4 -0
  19. package/guides/tempo.md +1 -0
  20. package/package.json +1 -1
  21. package/src/agent/agent.ts +7 -0
  22. package/src/agent/granular-tools.ts +138 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/ops.ts +39 -2
  25. package/src/agent/preview-tool.ts +6 -1
  26. package/src/agent/tools.ts +538 -14
  27. package/src/agent/xcb-agent.ts +7 -0
  28. package/src/audio/arrange.ts +21 -3
  29. package/src/audio/dsp/bank.ts +233 -0
  30. package/src/audio/dsp/fft.ts +6 -0
  31. package/src/audio/dsp/filters.ts +57 -0
  32. package/src/audio/dsp/interp.ts +112 -0
  33. package/src/audio/dsp/modal.ts +684 -0
  34. package/src/audio/dsp/onset.ts +197 -0
  35. package/src/audio/dsp/oversample.ts +202 -0
  36. package/src/audio/dsp/rng.ts +37 -0
  37. package/src/audio/dsp/shape.ts +81 -0
  38. package/src/audio/dsp/stft.ts +73 -0
  39. package/src/audio/dsp/window.ts +77 -0
  40. package/src/audio/effects/chain.ts +6 -1
  41. package/src/audio/effects/rig/cab.ts +99 -0
  42. package/src/audio/effects/rig/filters.ts +152 -0
  43. package/src/audio/effects/rig/gate.ts +39 -0
  44. package/src/audio/effects/rig/head.ts +487 -0
  45. package/src/audio/effects/rig/index.ts +170 -0
  46. package/src/audio/effects/rig/section.ts +78 -0
  47. package/src/audio/effects/rig/stomp.ts +238 -0
  48. package/src/audio/engine.ts +10 -1
  49. package/src/audio/fit.ts +402 -0
  50. package/src/audio/granular.ts +664 -0
  51. package/src/audio/instrument-check.ts +133 -0
  52. package/src/audio/instruments.ts +111 -0
  53. package/src/audio/keys/dsp.ts +323 -0
  54. package/src/audio/keys/engine.ts +361 -0
  55. package/src/audio/keys/piano.ts +432 -0
  56. package/src/audio/live-worker.ts +54 -0
  57. package/src/audio/live.ts +231 -12
  58. package/src/audio/loudness.ts +65 -20
  59. package/src/audio/preview.ts +2 -0
  60. package/src/audio/resonators.ts +287 -0
  61. package/src/audio/sampler.ts +163 -17
  62. package/src/audio/samples.ts +30 -7
  63. package/src/audio/strings/body.ts +250 -0
  64. package/src/audio/strings/engine.ts +369 -0
  65. package/src/audio/strings/loop.ts +119 -0
  66. package/src/audio/strings/measure.test-helpers.ts +198 -0
  67. package/src/audio/strings/pluck.ts +354 -0
  68. package/src/audio/warp.ts +61 -0
  69. package/src/audio/wav.ts +78 -6
  70. package/src/commands/expression.ts +9 -2
  71. package/src/commands/fit.ts +135 -0
  72. package/src/commands/fx.ts +31 -0
  73. package/src/commands/granular.ts +427 -0
  74. package/src/commands/help.ts +74 -3
  75. package/src/commands/keys.ts +353 -0
  76. package/src/commands/modal.ts +247 -0
  77. package/src/commands/rig.ts +260 -0
  78. package/src/commands/sample.ts +3 -0
  79. package/src/commands/string.ts +175 -0
  80. package/src/commands/time.ts +4 -1
  81. package/src/main.ts +210 -8
  82. package/src/project/check.ts +13 -0
  83. package/src/render.ts +33 -4
  84. package/src/tui/audition.ts +2 -2
  85. package/src/tui/granular-menu.ts +278 -0
  86. package/src/tui/menu.ts +332 -9
  87. package/src/tui/modal-menu.ts +118 -0
  88. package/src/tui/play-session.ts +45 -2
@@ -0,0 +1,287 @@
1
+ /**
2
+ * The modal percussion engine (0.6, lane f06-modal): renders a track whose
3
+ * instrument is `"modal"` and that carries a `modal` field. Registered in
4
+ * `src/audio/instruments.ts`; without the field the legacy tone voice plays.
5
+ *
6
+ * Per track: notes in onset order, at most `MAX_RESONATOR_VOICES` sounding
7
+ * (the oldest onset is stolen with a 5 ms fade), each a `ModalBank` struck
8
+ * once and rung for up to `MAX_RESONATOR_TAIL` seconds after note-off with
9
+ * early stop on silence. Stealing depends only on earlier onsets, so a
10
+ * render is prefix-stable: adding a later note never changes earlier audio
11
+ * before that note starts.
12
+ *
13
+ * 0.5 hooks: tuning and note cents (`noteHz`), bends and glides
14
+ * (`performance.cents`, faded per tick above 0.45 sr), articulation as
15
+ * timbre only (accent and marcato harden the mallet; staccato damps sooner
16
+ * through the shorter length), sustain pedal (longer lengths) and half
17
+ * pedal (`performance.damp`), velocity curve and humanize (already in the
18
+ * performed notes), and the tempo map (`context.warp`).
19
+ */
20
+ import type { PerformedNote } from "../../core/expression.ts";
21
+ import {
22
+ MAX_RESONATOR_TAIL,
23
+ MAX_RESONATOR_VOICES,
24
+ MODAL_LANE_PARAMS,
25
+ modalSettings,
26
+ modalTailSeconds,
27
+ RESONATOR_TABLE_VERSION,
28
+ type ModalSettings,
29
+ } from "../../core/resonators.ts";
30
+ import type { AutomationPoint, Track } from "../../core/score.ts";
31
+ import { noteHz } from "../../core/tuning.ts";
32
+ import { ModalBank, MODAL_BLOCK } from "./dsp/modal.ts";
33
+ import { logCosh } from "./dsp/shape.ts";
34
+ import { interpolateAutomation } from "./effects/common.ts";
35
+ import type { EngineContext, InstrumentEngine } from "./instruments.ts";
36
+ import { warpedSpan } from "./warp.ts";
37
+
38
+ /**
39
+ * Raised-cosine fade applied to a stolen voice, seconds. At 80 ms the
40
+ * largest per-sample gain step is pi/(2*fade samples): -61 dB at 22.05 kHz,
41
+ * -68 dB at 48 kHz, so stealing a long gong or kempul does not click.
42
+ */
43
+ const STEAL_FADE = 0.08;
44
+ /** Extra mallet hardness for accent and marcato. */
45
+ const ACCENT_HARDNESS = 0.2;
46
+ /**
47
+ * Track-level safety knee: exactly linear up to KNEE, then a tanh shoulder
48
+ * that never passes CEILING. Single notes (about 0.8 at velocity 0.8) and
49
+ * dyads pass bit-exact; only dense velocity-1 chords reach the shoulder. A
50
+ * six-note chord at velocity 1 sums to about 4-5 and lands under the
51
+ * ceiling, which the centre pan and track gain keep below 0 dBFS (spec
52
+ * test 13).
53
+ */
54
+ export const MODAL_KNEE = 1;
55
+ const KNEE = MODAL_KNEE;
56
+ const CEILING = 1.38;
57
+ const SPAN = CEILING - KNEE;
58
+
59
+ function knee(x: number): number {
60
+ const a = Math.abs(x);
61
+ if (a <= KNEE) return x;
62
+ return Math.sign(x) * (KNEE + SPAN * Math.tanh((a - KNEE) / SPAN));
63
+ }
64
+
65
+ /** Antiderivative of `knee` (even), for first-order ADAA. */
66
+ function kneeF(x: number): number {
67
+ const a = Math.abs(x);
68
+ if (a <= KNEE) return 0.5 * x * x;
69
+ return (
70
+ 0.5 * KNEE * KNEE +
71
+ KNEE * (a - KNEE) +
72
+ SPAN * SPAN * logCosh((a - KNEE) / SPAN)
73
+ );
74
+ }
75
+
76
+ /**
77
+ * The knee with first-order ADAA only inside the shoulder: while this and
78
+ * the previous sample are both under KNEE the output is the input itself, so
79
+ * the limiter adds no two-tap low-pass or half-sample delay to normal play.
80
+ */
81
+ export function applyModalKnee(dry: Float64Array, samples: number): void {
82
+ let x1 = 0;
83
+ let F1 = 0;
84
+ for (let index = 0; index < samples; index += 1) {
85
+ const x = dry[index]!;
86
+ const Fx = kneeF(x);
87
+ if (Math.abs(x) > KNEE || Math.abs(x1) > KNEE) {
88
+ const dx = x - x1;
89
+ dry[index] = Math.abs(dx) < 1e-5 ? knee(0.5 * (x + x1)) : (Fx - F1) / dx;
90
+ }
91
+ x1 = x;
92
+ F1 = Fx;
93
+ }
94
+ }
95
+
96
+ type Planned = Readonly<{
97
+ note: PerformedNote;
98
+ start: number;
99
+ length: number;
100
+ }>;
101
+
102
+ function span(
103
+ note: PerformedNote,
104
+ context: EngineContext,
105
+ ): { start: number; length: number } {
106
+ if (context.warp)
107
+ return warpedSpan(context.warp, note.startTick, note.durationTicks);
108
+ const perTick =
109
+ (60 * context.sampleRate) / (context.tempoBpm * context.ticksPerBeat);
110
+ return {
111
+ start: Math.max(0, Math.floor(note.startTick * perTick)),
112
+ length: Math.max(1, Math.floor(note.durationTicks * perTick)),
113
+ };
114
+ }
115
+
116
+ function laneOf(
117
+ track: Track,
118
+ param: string,
119
+ ): readonly AutomationPoint[] | undefined {
120
+ const lanes = track.fxAutomation as
121
+ Readonly<Record<string, readonly AutomationPoint[]>> | undefined;
122
+ const points = lanes?.[`modal-${param}`];
123
+ return points && points.length > 0 ? points : undefined;
124
+ }
125
+
126
+ /** Renders a modal track's performed notes into `dry` (mono). */
127
+ export function renderResonatorTrack(
128
+ dry: Float64Array,
129
+ notes: readonly PerformedNote[],
130
+ track: Track,
131
+ context: EngineContext,
132
+ ): void {
133
+ const { sampleRate, samples } = context;
134
+ const samplesPerTick = context.samplesPerTick;
135
+ const tickAt = (index: number): number =>
136
+ context.warp ? context.warp.tick(index) : index / samplesPerTick;
137
+ const planned: Planned[] = notes
138
+ .map((note) => ({ note, ...span(note, context) }))
139
+ .filter((entry) => entry.start < samples)
140
+ .sort(
141
+ (a, b) =>
142
+ a.start - b.start ||
143
+ a.note.pitch - b.note.pitch ||
144
+ (a.note.id < b.note.id ? -1 : a.note.id > b.note.id ? 1 : 0),
145
+ );
146
+ const lanes = new Map<string, readonly AutomationPoint[]>();
147
+ for (const { param } of MODAL_LANE_PARAMS) {
148
+ const points = laneOf(track, param);
149
+ if (points) lanes.set(param, points);
150
+ }
151
+ const motorLane = lanes.get("motordepth");
152
+ const volumeLane = track.volumeAutomation ?? [];
153
+ const volume = Math.max(0, Math.min(1, track.volume ?? 1));
154
+ const gainAt = (index: number): number =>
155
+ volume *
156
+ (volumeLane.length > 0
157
+ ? interpolateAutomation(volumeLane, tickAt(index), 1)
158
+ : 1);
159
+ const seedTick = context.seedTick ?? 0;
160
+ const base = modalSettings(track.modal);
161
+ const banks: ModalBank[] = [];
162
+ // Conservative end of each planned voice (its ring cap, or a steal).
163
+ const ends: number[] = [];
164
+ const steals: number[] = [];
165
+ const sounding: number[] = [];
166
+ for (let i = 0; i < planned.length; i += 1) {
167
+ const { note, start, length } = planned[i]!;
168
+ const onsetTick = note.startTick;
169
+ const settings =
170
+ lanes.size === 0
171
+ ? base
172
+ : clampLanes(
173
+ modalSettings(track.modal, (param) => {
174
+ const points = lanes.get(param);
175
+ return points
176
+ ? interpolateAutomation(
177
+ points,
178
+ onsetTick,
179
+ base[param as keyof typeof base] as number,
180
+ )
181
+ : undefined;
182
+ }),
183
+ );
184
+ const performance = note.performance;
185
+ const accent =
186
+ performance?.accent || note.articulation === "marcato"
187
+ ? ACCENT_HARDNESS
188
+ : 0;
189
+ const bank = new ModalBank(
190
+ settings,
191
+ {
192
+ hz: noteHz(note.pitch, note.cents, context.tuning),
193
+ velocity: note.velocity,
194
+ start: start / sampleRate,
195
+ duration: length / sampleRate,
196
+ seed: `${note.id}:${note.startTick + seedTick}:modal`,
197
+ ...(accent ? { accent } : {}),
198
+ ...(performance?.cents ? { cents: performance.cents } : {}),
199
+ ...(motorLane
200
+ ? {
201
+ motordepth: (t: number) =>
202
+ interpolateAutomation(
203
+ motorLane,
204
+ tickAt(start + t * sampleRate),
205
+ settings.motordepth,
206
+ ),
207
+ }
208
+ : {}),
209
+ },
210
+ sampleRate,
211
+ MAX_RESONATOR_TAIL,
212
+ );
213
+ banks.push(bank);
214
+ ends.push(start + bank.endAt);
215
+ steals.push(Infinity);
216
+ // Voice stealing in onset order: drop finished voices, then steal the
217
+ // oldest onset while the pool is full.
218
+ for (let k = sounding.length - 1; k >= 0; k -= 1)
219
+ if (ends[sounding[k]!]! <= start) sounding.splice(k, 1);
220
+ while (sounding.length >= MAX_RESONATOR_VOICES) {
221
+ const oldest = sounding.shift()!;
222
+ steals[oldest] = start;
223
+ ends[oldest] = Math.min(
224
+ ends[oldest]!,
225
+ start + Math.round(STEAL_FADE * sampleRate),
226
+ );
227
+ }
228
+ sounding.push(i);
229
+ }
230
+ const scratch = new Float64Array(MODAL_BLOCK);
231
+ const fade = Math.max(1, Math.round(STEAL_FADE * sampleRate));
232
+ for (let i = 0; i < planned.length; i += 1) {
233
+ const { note, start } = planned[i]!;
234
+ const bank = banks[i]!;
235
+ const stop = Math.min(samples, ends[i]!);
236
+ const steal = steals[i]!;
237
+ const damp = note.performance?.damp;
238
+ const dampFrom = damp ? start + Math.round(damp.from * sampleRate) : 0;
239
+ let at = start;
240
+ while (at < stop) {
241
+ const count = Math.min(MODAL_BLOCK, stop - at);
242
+ scratch.fill(0, 0, count);
243
+ const alive = bank.process(scratch, 0, count);
244
+ const gain = gainAt(at);
245
+ for (let j = 0; j < count; j += 1) {
246
+ const index = at + j;
247
+ let level = gain;
248
+ if (index >= steal)
249
+ level *=
250
+ index - steal >= fade
251
+ ? 0
252
+ : 0.5 + 0.5 * Math.cos((Math.PI * (index - steal)) / fade);
253
+ if (damp && index > dampFrom)
254
+ level *= Math.exp(-(index - dampFrom) / (damp.tau * sampleRate));
255
+ dry[index]! += scratch[j]! * level;
256
+ }
257
+ if (!alive) break;
258
+ at += count;
259
+ }
260
+ }
261
+ // Causal and stateless across renders, so the output stays prefix-stable.
262
+ applyModalKnee(dry, samples);
263
+ }
264
+
265
+ /** Lane values land inside each parameter's range. */
266
+ function clampLanes(settings: ModalSettings): ModalSettings {
267
+ const out: Record<string, number | string> = { ...settings };
268
+ for (const { param, spec } of MODAL_LANE_PARAMS) {
269
+ const value = out[param];
270
+ if (typeof value === "number")
271
+ out[param] = Math.max(spec.min, Math.min(spec.max, value));
272
+ }
273
+ return Object.freeze(out) as ModalSettings;
274
+ }
275
+
276
+ /** The modal engine as registered in `src/audio/instruments.ts`. */
277
+ export const MODAL_ENGINE: InstrumentEngine = Object.freeze({
278
+ id: "modal",
279
+ field: "modal",
280
+ render(dry, _dryR, notes, track, context) {
281
+ renderResonatorTrack(dry, notes, track, context);
282
+ },
283
+ tailSeconds: (track: Track, lowestPitch?: number) =>
284
+ modalTailSeconds(track.modal, lowestPitch),
285
+ stereo: () => false,
286
+ assetDigests: () => [`resonators:${RESONATOR_TABLE_VERSION}`],
287
+ });
@@ -38,7 +38,15 @@ import {
38
38
  performedNotes,
39
39
  secondsAtTick,
40
40
  } from "../../core/tempo.ts";
41
- import { sampleWarpFor, warpedSpan, type SampleWarp } from "./warp.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";
42
50
  import {
43
51
  performanceTimingFor,
44
52
  tunedTiming,
@@ -55,6 +63,8 @@ const ATTACK_SECONDS = 0.001;
55
63
  /** Fade at a natural end, a held release, a choke and a voice steal. */
56
64
  const END_FADE_SECONDS = 0.003;
57
65
  const RELEASE_SECONDS = 0.01;
66
+ /** Extra fitted frames past a note's reach (read interpolation). */
67
+ const REACH_MARGIN_FRAMES = 64;
58
68
  const CHOKE_SECONDS = 0.005;
59
69
  /** Loop seam crossfade (source time). */
60
70
  const LOOP_CROSSFADE_SECONDS = 0.005;
@@ -221,22 +231,47 @@ export function planSamplerVoices(
221
231
  ? { ...resolved, ratio: resolved.ratio * 2 ** (note.cents / 1200) }
222
232
  : resolved;
223
233
  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;
234
+ const decoded = bank.voices.get(sampleKey(track.id, target.voice));
235
+ if (!ref || !decoded) continue;
229
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;
230
263
  // `fit`, `unit: "c"` and `unit: "s"` give the window a duration; the
231
264
  // keyed ratio still repitches on top.
232
265
  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;
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;
240
275
  const step =
241
276
  seconds === undefined
242
277
  ? ((Math.abs(speed) * sample.sampleRate) / sampleRate) * target.ratio
@@ -276,10 +311,16 @@ export function planSamplerVoices(
276
311
  fade = Math.max(1, Math.round(END_FADE_SECONDS * sampleRate));
277
312
  }
278
313
  }
279
- const loopFrom =
280
- ref.loopBegin === undefined ? regionStart : ref.loopBegin * sample.frames;
281
- const loopTo =
282
- 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;
283
324
  const start = noteStartFrame(note, timing);
284
325
  const performance = (note as PerformedNote).performance;
285
326
  const voice: SamplerVoice = {
@@ -318,6 +359,111 @@ export function planSamplerVoices(
318
359
  return voices;
319
360
  }
320
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
+
321
467
  /** Linear-interpolated read, clamped to the sample. */
322
468
  function readAt(mono: Float32Array, position: number): number {
323
469
  const last = mono.length - 1;
@@ -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 {