@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
@@ -0,0 +1,995 @@
1
+ /**
2
+ * The granular instrument (0.6): a seeded, streaming grain voice and the
3
+ * engine that plays a track's notes with it. Ported from the 0.6 granular
4
+ * prototype (`proto/granular/granular.ts`, round 3) with the review's
5
+ * changes: a per-semitone sinc bank (`dsp/bank.ts`) re-picked per 32-frame
6
+ * block from the step times the note's cents (bend, glide, vibrato), the
7
+ * `shimint` interval, beat-repeat latching (`repeat`, `hold`) and a slow
8
+ * seeded drift of the head (`drift`, `drate`).
9
+ *
10
+ * Synchronous-grid granulator with jittered onsets after Roads,
11
+ * *Microsound* (MIT Press 2001) and Bencina, "Implementing Real-Time
12
+ * Granular Synthesis"; structure similar to Mutable Instruments Clouds.
13
+ * Randomness is counter-based (`unit(seed, grain, slot)`), so changing one
14
+ * parameter does not reshuffle the others' draws.
15
+ */
16
+ import { parseKey, scaleOf } from "../../core/chords.ts";
17
+ import { pedalStateAt, type PerformedNote } from "../../core/expression.ts";
18
+ import {
19
+ DEFAULT_GRANULAR_SOURCE,
20
+ GRAIN_SYNC_BEATS,
21
+ GRANULAR_LANE_PARAMS,
22
+ GRANULAR_SOURCE_VOICE,
23
+ SYNTH_SOURCE_SECONDS,
24
+ granularTailSeconds,
25
+ parseSynthSource,
26
+ resolveGranular,
27
+ type GranularSettings,
28
+ } from "../../core/granular.ts";
29
+ import type { FxLane } from "../../core/fx.ts";
30
+ import type { SampleRef, Track, TrackScore } from "../../core/score.ts";
31
+ import { SYNTH_PRESETS, type TrackSynth } from "../../core/synth.ts";
32
+ import { noteHz } from "../../core/tuning.ts";
33
+ import {
34
+ BANK_CHUNK_FRAMES,
35
+ bankChunk,
36
+ bankLevel,
37
+ type BankSource,
38
+ } from "./dsp/bank.ts";
39
+ import { seedHash, unit } from "./dsp/rng.ts";
40
+ import { WINDOW_TABLE, grainWindow } from "./dsp/window.ts";
41
+ import { interpolateAutomation } from "./effects/common.ts";
42
+ import type { EngineContext, InstrumentEngine } from "./instruments.ts";
43
+ import type { DecodedSample, SampleBank } from "./samples.ts";
44
+ import { renderSynthNote } from "./synth/voice.ts";
45
+ import { warpedSpan } from "./warp.ts";
46
+
47
+ /** Parameters are read, and the bank level re-picked, every 32 frames. */
48
+ export const CONTROL_FRAMES = 32;
49
+ /** Grains sounding at once per voice; later grains are skipped by index. */
50
+ export const MAX_GRAINS = 64;
51
+ /** Most grains overlapping at once (synced or not). */
52
+ const MAX_OVERLAP = 32;
53
+ /**
54
+ * Voices per track; the oldest released voice is stolen first, then the
55
+ * oldest held one, with a 5 ms fade.
56
+ */
57
+ export const MAX_VOICES = 16;
58
+ const STEAL_SECONDS = 0.005;
59
+ const CHUNK_MASK = BANK_CHUNK_FRAMES - 1;
60
+
61
+ /** A grain (a class, so the hot loop sees one shape). */
62
+ class Grain {
63
+ start = 0;
64
+ len = 0;
65
+ /** Read position at level 0 frames (all levels share the frame rate). */
66
+ pos = 0;
67
+ /** Signed source frames per output frame, before the note's cents. */
68
+ step = 0;
69
+ gl = 0;
70
+ gr = 0;
71
+ }
72
+
73
+ export type GranularVoiceInit = Readonly<{
74
+ source: BankSource;
75
+ sr: number;
76
+ settings: GranularSettings;
77
+ /** Note frequency over the source root's frequency (tuning applied). */
78
+ baseRate: number;
79
+ velocity: number;
80
+ gateFrames: number;
81
+ /** NotePerformance.cents: bend, glide and vibrato in cents at t seconds. */
82
+ cents?: (t: number) => number;
83
+ /** Half pedal: the level fades from `from` seconds with `tau`. */
84
+ damp?: Readonly<{ from: number; tau: number }>;
85
+ seed: number;
86
+ /**
87
+ * `grain-<param>` lanes (0.6.1): each value at a voice frame, read every
88
+ * 32 frames; grain-scoped ones latch at each grain's onset.
89
+ */
90
+ lanes?: readonly Readonly<{ name: string; at: (frame: number) => number }>[];
91
+ /** `sync` (0.6.1): the voice frame of grid point `k` on the tempo map. */
92
+ grid?: (k: number) => number;
93
+ /** The synced period at grain `k` (default `grid(k + 1) - grid(k)`). */
94
+ gridPeriod?: (k: number) => number;
95
+ /** `quant` (0.6.1): a grain's semitone offset snapped to allowed pitches. */
96
+ quant?: (semis: number, frame: number) => number;
97
+ /** `pedal` (0.6.1): true while the sustain pedal holds the head. */
98
+ held?: (frame: number) => boolean;
99
+ }>;
100
+
101
+ export type GranularVoice = {
102
+ /** Adds `frames` frames at `offset`; false once silent for good. */
103
+ process(
104
+ outL: Float64Array,
105
+ outR: Float64Array,
106
+ offset: number,
107
+ frames: number,
108
+ ): boolean;
109
+ readonly totalFrames: number;
110
+ };
111
+
112
+ /** A streaming grain voice. */
113
+ export function granularVoice(init: GranularVoiceInit): GranularVoice {
114
+ const { source, sr, seed } = init;
115
+ const lanes = init.lanes ?? [];
116
+ // Automated rows read a mutable copy; without lanes `p` is the settings.
117
+ const p: { -readonly [K in keyof GranularSettings]: GranularSettings[K] } =
118
+ lanes.length > 0 ? { ...init.settings } : init.settings;
119
+ const live = p as unknown as Record<string, number>;
120
+ const data = source.data;
121
+ const srcRate = source.rate;
122
+ const n0 = Math.max(1, data.length);
123
+ const begin = Math.max(0, Math.min(Math.floor(p.begin * n0), n0 - 1));
124
+ const end = Math.max(begin + 1, Math.min(Math.ceil(p.end * n0), n0));
125
+ const region = end - begin;
126
+ const releaseFrames = Math.max(1, Math.round(p.release * sr));
127
+ const attackFrames = Math.max(1, Math.round(p.attack * sr));
128
+ const maxGrainFrames = Math.round(2 * sr);
129
+ const tailFrames =
130
+ releaseFrames + Math.round(Math.min(2, p.grain) * sr * 1.5);
131
+ const total = init.gateFrames + tailFrames;
132
+ const spawnEnd = init.gateFrames + releaseFrames;
133
+ const win = grainWindow(p.window);
134
+ const table = win.table;
135
+ let grainSec = Math.max(0.005, Math.min(2, p.grain));
136
+ let overlap = Math.max(0.05, Math.min(MAX_OVERLAP, p.overlap));
137
+ let period = (grainSec * sr) / overlap;
138
+ let len = Math.max(16, Math.min(maxGrainFrames, Math.round(grainSec * sr)));
139
+ let winGain = Math.min(
140
+ 1,
141
+ 1 / Math.sqrt(Math.max(1e-9, overlap * win.meanSq)),
142
+ );
143
+ const frameStep = srcRate / sr;
144
+ let scanStep = p.freeze ? 0 : p.scan * frameStep;
145
+ const grid = init.grid;
146
+ const gridPeriod =
147
+ init.gridPeriod ?? ((at: number) => grid!(at + 1) - grid!(at));
148
+ /** Grain shape from the current grain, overlap and (synced) period. */
149
+ function shape(): void {
150
+ grainSec = Math.max(0.005, Math.min(2, p.grain));
151
+ if (grid) {
152
+ period = Math.max(1, gridPeriod(k));
153
+ overlap = Math.max(0.05, (grainSec * sr) / period);
154
+ // The unsynced cap: a long grain on a fast grid shortens to 32
155
+ // overlapping grains, so the level matches the grains that sound.
156
+ if (overlap > MAX_OVERLAP) {
157
+ overlap = MAX_OVERLAP;
158
+ grainSec = (MAX_OVERLAP * period) / sr;
159
+ }
160
+ } else {
161
+ overlap = Math.max(0.05, Math.min(MAX_OVERLAP, p.overlap));
162
+ period = (grainSec * sr) / overlap;
163
+ }
164
+ len = Math.max(16, Math.min(maxGrainFrames, Math.round(grainSec * sr)));
165
+ winGain = Math.min(1, 1 / Math.sqrt(Math.max(1e-9, overlap * win.meanSq)));
166
+ }
167
+ const grains: Grain[] = [];
168
+ const pool: Grain[] = [];
169
+ let t = 0;
170
+ let scanned = 0;
171
+ let k = 0;
172
+ let nextGrid = grid ? grid(0) : 0;
173
+ if (grid) shape();
174
+ let latch = 0;
175
+ let latched = 0;
176
+ let lp = 0;
177
+ let lpR = 0;
178
+ const lpA =
179
+ p.veltone > 0
180
+ ? 1 -
181
+ Math.exp(
182
+ (-2 *
183
+ Math.PI *
184
+ Math.min(
185
+ 0.45 * sr,
186
+ 18000 * 2 ** (-6 * p.veltone * (1 - init.velocity)),
187
+ )) /
188
+ sr,
189
+ )
190
+ : 1;
191
+ const amp = init.velocity * p.gain;
192
+ const blockL = new Float64Array(CONTROL_FRAMES);
193
+ const blockR = new Float64Array(CONTROL_FRAMES);
194
+
195
+ function driftAt(sec: number): number {
196
+ const driftDepth = 0.5 * p.drift * region;
197
+ if (driftDepth === 0) return 0;
198
+ const x = sec * p.drate;
199
+ const i = Math.floor(x);
200
+ const f = x - i;
201
+ const a = 2 * unit(seed, i, 7) - 1;
202
+ const b = 2 * unit(seed, i + 1, 7) - 1;
203
+ const s = 0.5 - 0.5 * Math.cos(Math.PI * f);
204
+ return driftDepth * (a + (b - a) * s);
205
+ }
206
+
207
+ function spawn(): void {
208
+ if (grid) shape();
209
+ const sec = nextGrid / sr;
210
+ // Beat repeat: a grid step may latch the head for `hold` steps.
211
+ if (latch > 0) latch -= 1;
212
+ else {
213
+ latched = scanned;
214
+ if (p.repeat > 0 && unit(seed, k, 6) < p.repeat) latch = p.hold - 1;
215
+ }
216
+ const head = latched;
217
+ const onset = nextGrid + p.jitter * unit(seed, k, 0) * period;
218
+ const raw =
219
+ p.pitch +
220
+ p.detune * (unit(seed, k, 2) - 0.5) +
221
+ (unit(seed, k, 5) < p.shimmer ? p.shimint : 0);
222
+ const semis = init.quant ? init.quant(raw, nextGrid) : raw;
223
+ const step0 = init.baseRate * 2 ** (semis / 12) * frameStep;
224
+ let center =
225
+ begin +
226
+ p.pos * region +
227
+ head +
228
+ driftAt(sec) +
229
+ p.spray * srcRate * (2 * unit(seed, k, 1) - 1);
230
+ center = begin + ((((center - begin) % region) + region) % region);
231
+ const span = step0 * len;
232
+ const back = unit(seed, k, 4) < p.reverse;
233
+ const theta = ((1 + p.spread * (2 * unit(seed, k, 3) - 1)) * Math.PI) / 4;
234
+ if (grains.length < MAX_GRAINS) {
235
+ const g = pool.pop() ?? new Grain();
236
+ g.start = Math.round(onset);
237
+ g.len = len;
238
+ g.pos = back ? center + span / 2 : center - span / 2;
239
+ g.step = back ? -step0 : step0;
240
+ g.gl = Math.cos(theta) * Math.SQRT2 * winGain;
241
+ g.gr = Math.sin(theta) * Math.SQRT2 * winGain;
242
+ grains.push(g);
243
+ }
244
+ k += 1;
245
+ nextGrid = grid ? grid(k) : nextGrid + period;
246
+ }
247
+
248
+ function envelope(i: number): number {
249
+ const a = i < attackFrames ? i / attackFrames : 1;
250
+ let e: number;
251
+ if (i < init.gateFrames) e = a;
252
+ else {
253
+ const r = 1 - (i - init.gateFrames) / releaseFrames;
254
+ e = r > 0 ? a * r : 0;
255
+ }
256
+ const damp = init.damp;
257
+ if (damp) {
258
+ const sec = i / sr;
259
+ if (sec > damp.from) e *= Math.exp(-(sec - damp.from) / damp.tau);
260
+ }
261
+ return e;
262
+ }
263
+
264
+ function renderGrains(from: number, n: number, centsMul: number): void {
265
+ blockL.fill(0, 0, n);
266
+ blockR.fill(0, 0, n);
267
+ const to = from + n;
268
+ for (let gi = grains.length - 1; gi >= 0; gi -= 1) {
269
+ const g = grains[gi]!;
270
+ if (g.start >= to) continue;
271
+ const a = g.start > from ? g.start : from;
272
+ const gEnd = g.start + g.len;
273
+ const b = gEnd < to ? gEnd : to;
274
+ const step = g.step * centsMul;
275
+ const level = bankLevel(step);
276
+ let pos = g.pos;
277
+ let ci = Math.floor(pos) >> 12;
278
+ let chunk = bankChunk(source, level, ci);
279
+ const wScale = WINDOW_TABLE / g.len;
280
+ const gl = g.gl;
281
+ const gr = g.gr;
282
+ for (let i = a; i < b; i += 1) {
283
+ const ip = Math.floor(pos);
284
+ const c = ip >> 12;
285
+ if (c !== ci) {
286
+ ci = c;
287
+ chunk = bankChunk(source, level, ci);
288
+ }
289
+ const o = (ip & CHUNK_MASK) + 1;
290
+ const f = pos - ip;
291
+ const xm1 = chunk[o - 1]!;
292
+ const x0 = chunk[o]!;
293
+ const x1 = chunk[o + 1]!;
294
+ const x2 = chunk[o + 2]!;
295
+ const c1 = 0.5 * (x1 - xm1);
296
+ const c2 = xm1 - 2.5 * x0 + 2 * x1 - 0.5 * x2;
297
+ const c3 = 0.5 * (x2 - xm1) + 1.5 * (x0 - x1);
298
+ const sample = ((c3 * f + c2) * f + c1) * f + x0;
299
+ const u = (i - g.start) * wScale;
300
+ const wi = Math.floor(u);
301
+ const w = table[wi]! + (table[wi + 1]! - table[wi]!) * (u - wi);
302
+ const s = sample * w;
303
+ const j = i - from;
304
+ blockL[j] = blockL[j]! + s * gl;
305
+ blockR[j] = blockR[j]! + s * gr;
306
+ pos += step;
307
+ }
308
+ g.pos = pos;
309
+ if (gEnd <= to) {
310
+ const last = grains.pop()!;
311
+ if (last !== g) grains[gi] = last;
312
+ pool.push(g);
313
+ }
314
+ }
315
+ }
316
+
317
+ let pedalDown = false;
318
+ return {
319
+ totalFrames: total,
320
+ process(outL, outR, offset, frames) {
321
+ let done = 0;
322
+ while (done < frames) {
323
+ if (t >= total) return false;
324
+ const n = Math.min(
325
+ CONTROL_FRAMES - (t % CONTROL_FRAMES),
326
+ frames - done,
327
+ total - t,
328
+ );
329
+ // Lanes are read on 32-frame control boundaries only, so any block
330
+ // split renders the same samples.
331
+ if (init.held && t % CONTROL_FRAMES === 0) pedalDown = init.held(t);
332
+ if (lanes.length > 0 && t % CONTROL_FRAMES === 0) {
333
+ for (const lane of lanes) live[lane.name] = lane.at(t);
334
+ if (!grid) shape();
335
+ scanStep = p.freeze ? 0 : p.scan * frameStep;
336
+ }
337
+ // Grid points of this block: the grid keeps running through the
338
+ // release, so the release fades a living cloud (spec: release ~ T60).
339
+ while (nextGrid < t + n && t < spawnEnd) spawn();
340
+ const cents = init.cents ? init.cents(t / sr) : 0;
341
+ const centsMul = cents === 0 ? 1 : 2 ** (cents / 1200);
342
+ renderGrains(t, n, centsMul);
343
+ if (!pedalDown) scanned += scanStep * n;
344
+ for (let i = 0; i < n; i += 1) {
345
+ let l = blockL[i]!;
346
+ let r = blockR[i]!;
347
+ if (lpA < 1) {
348
+ lp += lpA * (l - lp);
349
+ lpR += lpA * (r - lpR);
350
+ l = lp;
351
+ r = lpR;
352
+ }
353
+ const e = envelope(t + i) * amp;
354
+ const at = offset + done + i;
355
+ outL[at] = outL[at]! + l * e;
356
+ outR[at] = outR[at]! + r * e;
357
+ }
358
+ t += n;
359
+ done += n;
360
+ }
361
+ return t < total;
362
+ },
363
+ };
364
+ }
365
+
366
+ // ------------------------------------------------------------- sources
367
+
368
+ const synthSources = new Map<string, BankSource>();
369
+ const MAX_SYNTH_SOURCES = 16;
370
+ /** Envelope hop of the held-loop analysis. */
371
+ const LOOP_HOP_SECONDS = 0.01;
372
+ /** The loop starts once the render is within 6 dB of its loudest hop. */
373
+ const LOOP_START_DB = -6;
374
+ /** ... and ends at the last hop within 30 dB of it. */
375
+ const LOOP_END_DB = -30;
376
+ /** The loop is levelled by at most this much (a decaying bell or pluck). */
377
+ const LOOP_MAX_GAIN_DB = 30;
378
+ const LOOP_FADE_SECONDS = 0.05;
379
+
380
+ /**
381
+ * The built-in source: a deterministic offline render of a synth preset
382
+ * (or a sound with `synth` params) held at `note` for 4 s, made into a
383
+ * held, seamless loop so a long note never wraps into silence or through
384
+ * the attack: the sustained part (from 6 dB under the loudest point to
385
+ * 30 dB under it) is levelled to a flat envelope, crossfaded at its seam
386
+ * and tiled to at least 4 s, peak-normalised to 0.7.
387
+ */
388
+ export function synthSource(
389
+ name: string,
390
+ note: number,
391
+ sampleRate: number,
392
+ synth?: TrackSynth,
393
+ ): BankSource {
394
+ const preset = SYNTH_PRESETS[name];
395
+ const params = preset ? preset.synth : synth;
396
+ const id = `synth:${name}@${note}:${sampleRate}${
397
+ !preset && synth ? `:${JSON.stringify(synth)}` : ""
398
+ }`;
399
+ const cached = synthSources.get(id);
400
+ if (cached) return cached;
401
+ const frames = Math.round(SYNTH_SOURCE_SECONDS * sampleRate);
402
+ const track = {
403
+ id: "granular-source",
404
+ name: "granular source",
405
+ instrument: preset ? preset.instrument : name,
406
+ muted: false,
407
+ volume: 1,
408
+ pan: 0,
409
+ ...(params ? { synth: params } : {}),
410
+ } as unknown as Track;
411
+ const left = new Float64Array(frames);
412
+ const ticksPerBeat = 480;
413
+ renderSynthNote(
414
+ left,
415
+ undefined,
416
+ {
417
+ id: "granular-source",
418
+ trackId: track.id,
419
+ startTick: 0,
420
+ durationTicks: ticksPerBeat * 8,
421
+ pitch: note,
422
+ velocity: 1,
423
+ },
424
+ track,
425
+ 0,
426
+ frames,
427
+ {
428
+ sampleRate,
429
+ samples: frames,
430
+ samplesPerTick: (sampleRate * 0.5) / ticksPerBeat,
431
+ tempoBpm: 120,
432
+ ticksPerBeat,
433
+ },
434
+ () => 1,
435
+ );
436
+ const data = heldLoop(left, sampleRate);
437
+ const source = Object.freeze({ id, rate: sampleRate, data });
438
+ synthSources.set(id, source);
439
+ while (synthSources.size > MAX_SYNTH_SOURCES) {
440
+ const oldest = synthSources.keys().next().value as string;
441
+ synthSources.delete(oldest);
442
+ }
443
+ return source;
444
+ }
445
+
446
+ /** A held render made into a levelled, seamless loop (see synthSource). */
447
+ export function heldLoop(
448
+ render: Float64Array,
449
+ sampleRate: number,
450
+ ): Float32Array {
451
+ const frames = render.length;
452
+ const hop = Math.max(1, Math.round(LOOP_HOP_SECONDS * sampleRate));
453
+ const hops = Math.max(1, Math.floor(frames / hop));
454
+ const rms = new Float64Array(hops);
455
+ let loudest = 0;
456
+ for (let h = 0; h < hops; h += 1) {
457
+ let sum = 0;
458
+ for (let i = h * hop; i < (h + 1) * hop; i += 1)
459
+ sum += render[i]! * render[i]!;
460
+ rms[h] = Math.sqrt(sum / hop);
461
+ if (rms[h]! > loudest) loudest = rms[h]!;
462
+ }
463
+ if (loudest === 0) return new Float32Array(frames);
464
+ const startLevel = loudest * 10 ** (LOOP_START_DB / 20);
465
+ const endLevel = loudest * 10 ** (LOOP_END_DB / 20);
466
+ let first = 0;
467
+ while (first < hops - 1 && rms[first]! < startLevel) first += 1;
468
+ let last = hops - 1;
469
+ while (last > first && rms[last]! < endLevel) last -= 1;
470
+ const begin = first * hop;
471
+ const stop = Math.min(frames, (last + 1) * hop);
472
+ const fade = Math.max(
473
+ 1,
474
+ Math.min(
475
+ Math.round(LOOP_FADE_SECONDS * sampleRate),
476
+ Math.floor((stop - begin) / 4),
477
+ ),
478
+ );
479
+ const loop = Math.max(1, stop - begin - fade);
480
+ // Level the loop: gain = loudest / envelope (hop centres, interpolated).
481
+ const maxGain = 10 ** (LOOP_MAX_GAIN_DB / 20);
482
+ const levelled = new Float64Array(loop + fade);
483
+ for (let i = 0; i < loop + fade; i += 1) {
484
+ const frame = begin + i;
485
+ const x = frame / hop - 0.5;
486
+ const h0 = Math.max(0, Math.min(hops - 1, Math.floor(x)));
487
+ const h1 = Math.min(hops - 1, h0 + 1);
488
+ const f = Math.max(0, Math.min(1, x - h0));
489
+ const env = rms[h0]! + (rms[h1]! - rms[h0]!) * f;
490
+ const gain = env > 0 ? Math.min(maxGain, loudest / env) : maxGain;
491
+ levelled[i] = (render[frame] ?? 0) * gain;
492
+ }
493
+ // Seam: the head of the loop fades in while the frames after its end
494
+ // fade out (equal power), so loop[last] -> loop[0] continues the render.
495
+ const seamless = new Float64Array(loop);
496
+ for (let i = 0; i < loop; i += 1) seamless[i] = levelled[i]!;
497
+ for (let i = 0; i < fade && i < loop; i += 1) {
498
+ const u = (i + 0.5) / fade;
499
+ seamless[i] =
500
+ levelled[i]! * Math.sin((u * Math.PI) / 2) +
501
+ levelled[loop + i]! * Math.cos((u * Math.PI) / 2);
502
+ }
503
+ const copies = Math.max(1, Math.ceil(frames / loop));
504
+ const out = new Float32Array(loop * copies);
505
+ let peak = 0;
506
+ for (let i = 0; i < loop; i += 1)
507
+ peak = Math.max(peak, Math.abs(seamless[i]!));
508
+ const g = peak > 0 ? 0.7 / peak : 0;
509
+ for (let c = 0; c < copies; c += 1)
510
+ for (let i = 0; i < loop; i += 1) out[c * loop + i] = seamless[i]! * g;
511
+ return out;
512
+ }
513
+
514
+ /** The bank key of a granular track's sample source. */
515
+ export function granularSampleKey(trackId: string): string {
516
+ return `${trackId}\u0000${GRANULAR_SOURCE_VOICE}`;
517
+ }
518
+
519
+ type ResolvedSource = Readonly<{ source: BankSource; root: number }>;
520
+
521
+ /** The track's source and the note that plays it at its own pitch. */
522
+ export function granularSource(
523
+ track: Track,
524
+ sampleRate: number,
525
+ bank: SampleBank,
526
+ ): ResolvedSource | undefined {
527
+ const s = resolveGranular(track.granular);
528
+ const src = s.src;
529
+ const stored = track.granular?.root;
530
+ if (typeof src === "string") {
531
+ const parsed =
532
+ parseSynthSource(src) ?? parseSynthSource(DEFAULT_GRANULAR_SOURCE)!;
533
+ // A sound (not a preset) plays with the track's own synth params, so
534
+ // `grain on` grains the track's tuned sound.
535
+ return {
536
+ source: synthSource(parsed.name, parsed.note, sampleRate, track.synth),
537
+ root: stored ?? parsed.note,
538
+ };
539
+ }
540
+ const decoded: DecodedSample | undefined = bank.voices.get(
541
+ granularSampleKey(track.id),
542
+ );
543
+ if (!decoded || decoded.frames === 0) return undefined;
544
+ const ref: SampleRef = src;
545
+ return {
546
+ source: {
547
+ id: decoded.sha256,
548
+ rate: decoded.sampleRate,
549
+ data: decoded.mono,
550
+ },
551
+ root: stored ?? ref.root ?? 60,
552
+ };
553
+ }
554
+
555
+ // --------------------------------------------------------------- engine
556
+
557
+ /**
558
+ * Seeds by (seed, track, pitch, startTick, occurrence). The start is the
559
+ * performed one, so humanize timing (amount or seed) also changes the
560
+ * grain draws; edits elsewhere in the track do not.
561
+ */
562
+ export function granularSeeds(
563
+ notes: readonly PerformedNote[],
564
+ seed: number,
565
+ trackId: string,
566
+ ): number[] {
567
+ const seen = new Map<string, number>();
568
+ return notes.map((note) => {
569
+ const at = `${note.pitch}:${note.startTick}`;
570
+ const occurrence = seen.get(at) ?? 0;
571
+ seen.set(at, occurrence + 1);
572
+ return seedHash(`${seed}:${trackId}:${at}:${occurrence}`);
573
+ });
574
+ }
575
+
576
+ function spanOf(
577
+ note: PerformedNote,
578
+ context: EngineContext,
579
+ ): { start: number; length: number } {
580
+ if (context.warp)
581
+ return warpedSpan(context.warp, note.startTick, note.durationTicks);
582
+ const { tempoBpm, sampleRate } = context;
583
+ const tpb = context.ticksPerBeat;
584
+ return {
585
+ start: Math.max(
586
+ 0,
587
+ Math.floor((((note.startTick / tpb) * 60) / tempoBpm) * sampleRate),
588
+ ),
589
+ length: Math.max(
590
+ 1,
591
+ Math.floor((((note.durationTicks / tpb) * 60) / tempoBpm) * sampleRate),
592
+ ),
593
+ };
594
+ }
595
+
596
+ /** Renders a granular track's performed notes into `left`/`right`. */
597
+ export function renderGranularTrack(
598
+ left: Float64Array,
599
+ right: Float64Array,
600
+ notes: readonly PerformedNote[],
601
+ track: Track,
602
+ context: EngineContext,
603
+ bank: SampleBank,
604
+ ): void {
605
+ const resolved = granularSource(track, context.sampleRate, bank);
606
+ if (!resolved) return;
607
+ const settings = resolveGranular(track.granular);
608
+ const sr = context.sampleRate;
609
+ const seeds = granularSeeds(notes, settings.seed, track.id);
610
+ const rootHz = noteHz(resolved.root, undefined, context.tuning);
611
+ // Plan: spans, then voice stealing (16 per track, oldest first).
612
+ type Plan = {
613
+ note: PerformedNote;
614
+ seed: number;
615
+ start: number;
616
+ length: number;
617
+ end: number;
618
+ cut: number;
619
+ };
620
+ const tail = Math.round(granularTailSeconds(track.granular) * sr);
621
+ let plans: Plan[] = notes.map((note, index) => {
622
+ const { start, length } = spanOf(note, context);
623
+ return {
624
+ note,
625
+ seed: seeds[index]!,
626
+ start,
627
+ length,
628
+ end: start + length + tail,
629
+ cut: Infinity,
630
+ };
631
+ });
632
+ // Mono (0.6.1): notes that overlap the one before (legato) join its
633
+ // voice, which retargets pitch at each note's start and keeps its grain
634
+ // stream; a detached note cuts the previous voice's tail.
635
+ const chains = new Map<Plan, Plan[]>();
636
+ if (settings.mono && plans.length > 1) {
637
+ const sorted = [...plans].sort((a, b) => a.start - b.start);
638
+ const heads: Plan[] = [];
639
+ let head: Plan | undefined;
640
+ for (const plan of sorted) {
641
+ if (head && plan.start < head.start + head.length) {
642
+ chains.get(head)!.push(plan);
643
+ head.length = Math.max(
644
+ head.length,
645
+ plan.start + plan.length - head.start,
646
+ );
647
+ head.end = head.start + head.length + tail;
648
+ continue;
649
+ }
650
+ if (head && head.end > plan.start) head.cut = plan.start;
651
+ head = plan;
652
+ chains.set(plan, [plan]);
653
+ heads.push(plan);
654
+ }
655
+ plans = heads;
656
+ }
657
+ const order = plans
658
+ .map((_, index) => index)
659
+ .sort((a, b) => plans[a]!.start - plans[b]!.start || a - b);
660
+ const active: Plan[] = [];
661
+ for (const index of order) {
662
+ const plan = plans[index]!;
663
+ for (let i = active.length - 1; i >= 0; i -= 1)
664
+ if (Math.min(active[i]!.end, active[i]!.cut) <= plan.start)
665
+ active.splice(i, 1);
666
+ if (active.length >= MAX_VOICES) {
667
+ // Oldest released voice first (active is in start order), else the
668
+ // oldest held one.
669
+ let at = active.findIndex(
670
+ (candidate) => candidate.start + candidate.length <= plan.start,
671
+ );
672
+ if (at < 0) at = 0;
673
+ const victim = active.splice(at, 1)[0]!;
674
+ victim.cut = plan.start;
675
+ }
676
+ active.push(plan);
677
+ }
678
+ const stealFrames = Math.max(1, Math.round(STEAL_SECONDS * sr));
679
+ const block = 4096;
680
+ const outL = new Float64Array(block);
681
+ const outR = new Float64Array(block);
682
+ const volume = Math.max(0, Math.min(1, track.volume ?? 1));
683
+ const lane = track.volumeAutomation ?? [];
684
+ const tickOf = (frame: number): number =>
685
+ context.warp
686
+ ? context.warp.tick(frame)
687
+ : frame / Math.max(1e-9, context.samplesPerTick);
688
+ const frameOf = (tick: number): number =>
689
+ context.warp ? context.warp.sample(tick) : tick * context.samplesPerTick;
690
+ const grainLanes = GRANULAR_LANE_PARAMS.flatMap(({ param }) => {
691
+ const points = track.fxAutomation?.[`grain-${param}` as FxLane];
692
+ return points && points.length > 0 ? [{ param, points }] : [];
693
+ });
694
+ const syncBeats =
695
+ settings.sync === "off" ? undefined : GRAIN_SYNC_BEATS[settings.sync];
696
+ const allowed =
697
+ settings.quant === "off"
698
+ ? undefined
699
+ : quantPitchClasses(context.score, settings.quant, context.quantChord);
700
+ const pedal = settings.pedal ? track.pedal : undefined;
701
+ for (const plan of plans) {
702
+ const { note } = plan;
703
+ if (plan.start >= left.length) continue;
704
+ const hz = noteHz(note.pitch, note.cents, context.tuning);
705
+ const performance = note.performance;
706
+ const chain = chains.get(plan);
707
+ const startTick = note.startTick;
708
+ const startFrame = plan.start;
709
+ let cents = performance?.cents;
710
+ if (chain && chain.length > 1) {
711
+ // Retarget: the cents of each chained note's pitch over the first's,
712
+ // from its start, plus that note's own bend/glide/vibrato.
713
+ const steps = chain.map((item) => ({
714
+ from: (item.start - startFrame) / sr,
715
+ cents:
716
+ 1200 *
717
+ Math.log2(
718
+ noteHz(item.note.pitch, item.note.cents, context.tuning) / hz,
719
+ ),
720
+ perf: item.note.performance?.cents,
721
+ offset: (item.start - startFrame) / sr,
722
+ }));
723
+ cents = (t: number) => {
724
+ let at = 0;
725
+ while (at + 1 < steps.length && steps[at + 1]!.from <= t) at += 1;
726
+ const step = steps[at]!;
727
+ return step.cents + (step.perf ? step.perf(t - step.offset) : 0);
728
+ };
729
+ }
730
+ const lanes = grainLanes.map(({ param, points }) => {
731
+ const fallback = settings[param as keyof typeof settings] as number;
732
+ return {
733
+ name: param,
734
+ at: (frame: number) =>
735
+ interpolateAutomation(points, tickOf(startFrame + frame), fallback),
736
+ };
737
+ });
738
+ let grid: ((k: number) => number) | undefined;
739
+ let gridPeriod: ((k: number) => number) | undefined;
740
+ if (syncBeats !== undefined) {
741
+ const stepTicks = syncBeats * context.ticksPerBeat;
742
+ const at = startTick / stepTicks;
743
+ const k0 = Math.round(at);
744
+ if (Math.abs(at - k0) < 1e-9)
745
+ grid = (k: number) => frameOf((k0 + k) * stepTicks) - startFrame;
746
+ else {
747
+ // Off the grid (humanize, swing, a played note): the first grain
748
+ // sounds at note-on, the rest follow the grid from the next point.
749
+ const first = Math.floor(at) + 1;
750
+ const point = (k: number) =>
751
+ frameOf((first + k) * stepTicks) - startFrame;
752
+ grid = (k: number) => (k === 0 ? 0 : point(k - 1));
753
+ gridPeriod = (k: number) =>
754
+ k === 0 ? point(1) - point(0) : point(k) - point(k - 1);
755
+ }
756
+ }
757
+ const quant = allowed
758
+ ? (semis: number, frame: number) =>
759
+ snapSemis(note.pitch, semis, allowed(tickOf(startFrame + frame)))
760
+ : undefined;
761
+ const held =
762
+ pedal && pedal.length > 0
763
+ ? (frame: number) =>
764
+ pedalStateAt(pedal, tickOf(startFrame + frame)) === "down"
765
+ : undefined;
766
+ const voice = granularVoice({
767
+ source: resolved.source,
768
+ sr,
769
+ settings,
770
+ baseRate: hz / rootHz,
771
+ velocity: Math.max(0, Math.min(1, note.velocity)),
772
+ gateFrames: plan.length,
773
+ ...(cents ? { cents } : {}),
774
+ ...(performance?.damp ? { damp: performance.damp } : {}),
775
+ seed: plan.seed,
776
+ ...(lanes.length > 0 ? { lanes } : {}),
777
+ ...(grid ? { grid } : {}),
778
+ ...(gridPeriod ? { gridPeriod } : {}),
779
+ ...(quant ? { quant } : {}),
780
+ ...(held ? { held } : {}),
781
+ });
782
+ const stop = Math.min(
783
+ left.length,
784
+ plan.start + voice.totalFrames,
785
+ plan.cut === Infinity ? Infinity : plan.cut + stealFrames,
786
+ );
787
+ for (let at = plan.start; at < stop; at += block) {
788
+ const n = Math.min(block, stop - at);
789
+ outL.fill(0, 0, n);
790
+ outR.fill(0, 0, n);
791
+ const alive = voice.process(outL, outR, 0, n);
792
+ for (let i = 0; i < n; i += 1) {
793
+ const frame = at + i;
794
+ const tick = context.warp
795
+ ? context.warp.tick(frame)
796
+ : note.startTick +
797
+ (frame - plan.start) / Math.max(1e-9, context.samplesPerTick);
798
+ let g =
799
+ volume * (lane.length > 0 ? interpolateAutomation(lane, tick, 1) : 1);
800
+ if (frame >= plan.cut)
801
+ g *= Math.max(0, 1 - (frame - plan.cut) / stealFrames);
802
+ left[frame] = left[frame]! + outL[i]! * g;
803
+ right[frame] = right[frame]! + outR[i]! * g;
804
+ }
805
+ if (!alive) break;
806
+ }
807
+ }
808
+ }
809
+
810
+ /**
811
+ * The tracks whose notes make the `quant chord` harmony: pitched and not
812
+ * muted in the song. Granular tracks, kits, one-shot samplers and
813
+ * resampled audio (a ref with `from`, one held note) are left out, so a
814
+ * resampled bus does not add its trigger pitch to every chord. Solo is
815
+ * ignored: soloing a track (or a resample's soloed source) keeps the
816
+ * song's chords.
817
+ */
818
+ function harmonicTrack(track: Track): boolean {
819
+ if (track.muted === true) return false;
820
+ if (
821
+ track.granular !== undefined ||
822
+ track.instrument === "kit" ||
823
+ track.kit !== undefined
824
+ )
825
+ return false;
826
+ if (track.sampler || track.instrument === "sampler") {
827
+ const sampler = track.sampler;
828
+ if (!sampler || sampler.mode !== "keyed") return false;
829
+ if (Object.values(sampler.voices).some((ref) => ref.from !== undefined))
830
+ return false;
831
+ }
832
+ return true;
833
+ }
834
+
835
+ /**
836
+ * Pitch classes sounding on the harmonic tracks, as change points:
837
+ * `sets[i]` sounds from `ticks[i]` to `ticks[i + 1]`.
838
+ */
839
+ export type ChordTimeline = Readonly<{
840
+ ticks: readonly number[];
841
+ sets: readonly (readonly number[])[];
842
+ }>;
843
+
844
+ const timelines = new WeakMap<TrackScore, ChordTimeline>();
845
+
846
+ /** The song's chord timeline (computed once per score object). */
847
+ export function chordTimeline(score: TrackScore): ChordTimeline {
848
+ const known = timelines.get(score);
849
+ if (known) return known;
850
+ const harmonic = new Set(
851
+ score.tracks.filter(harmonicTrack).map((track) => track.id),
852
+ );
853
+ const events = new Map<number, number[]>();
854
+ const add = (tick: number, pc: number, delta: number) => {
855
+ let row = events.get(tick);
856
+ if (!row) events.set(tick, (row = new Array<number>(12).fill(0)));
857
+ row[pc] = row[pc]! + delta;
858
+ };
859
+ for (const note of score.notes) {
860
+ if (!harmonic.has(note.trackId) || note.durationTicks <= 0) continue;
861
+ const pc = ((note.pitch % 12) + 12) % 12;
862
+ add(note.startTick, pc, 1);
863
+ add(note.startTick + note.durationTicks, pc, -1);
864
+ }
865
+ const counts = new Array<number>(12).fill(0);
866
+ const ticks: number[] = [];
867
+ const sets: number[][] = [];
868
+ for (const tick of [...events.keys()].sort((a, b) => a - b)) {
869
+ const row = events.get(tick)!;
870
+ for (let pc = 0; pc < 12; pc += 1) counts[pc] = counts[pc]! + row[pc]!;
871
+ ticks.push(tick);
872
+ sets.push(counts.flatMap((count, pc) => (count > 0 ? [pc] : [])));
873
+ }
874
+ const timeline = Object.freeze({ ticks, sets });
875
+ timelines.set(score, timeline);
876
+ return timeline;
877
+ }
878
+
879
+ /** The classes sounding at `tick` (empty when nothing sounds). */
880
+ export function chordAt(
881
+ timeline: ChordTimeline,
882
+ tick: number,
883
+ ): readonly number[] {
884
+ const { ticks, sets } = timeline;
885
+ let lo = 0;
886
+ let hi = ticks.length - 1;
887
+ let found = -1;
888
+ while (lo <= hi) {
889
+ const mid = (lo + hi) >> 1;
890
+ if (ticks[mid]! <= tick) {
891
+ found = mid;
892
+ lo = mid + 1;
893
+ } else hi = mid - 1;
894
+ }
895
+ return found < 0 ? [] : sets[found]!;
896
+ }
897
+
898
+ /**
899
+ * The chord changes between two ticks, relative to `from`, as a stable
900
+ * string: what a `quant chord` render over that span reads.
901
+ */
902
+ export function chordDigest(
903
+ timeline: ChordTimeline,
904
+ from = -Infinity,
905
+ to = Infinity,
906
+ ): string {
907
+ const parts: string[] = [chordAt(timeline, from).join(".")];
908
+ for (let i = 0; i < timeline.ticks.length; i += 1) {
909
+ const tick = timeline.ticks[i]!;
910
+ if (tick > from && tick < to)
911
+ parts.push(
912
+ `${Number.isFinite(from) ? tick - from : tick}:${timeline.sets[i]!.join(".")}`,
913
+ );
914
+ }
915
+ return `chord:${parts.join(",")}`;
916
+ }
917
+
918
+ /**
919
+ * `quant` (0.6.1): the pitch classes grains may land on at a tick. `scale`
920
+ * is the song key's scale (chromatic without a key); `chord` is the pitch
921
+ * classes sounding on the song's harmonic tracks at the tick (see
922
+ * `harmonicTrack`), falling back to the scale when nothing else sounds
923
+ * (Ableton Granulator and Bitwig's scale-quantised grain pitch work the
924
+ * same way). `chord` reads `source` (the whole song for a live note) at
925
+ * `tick + offset`.
926
+ */
927
+ export function quantPitchClasses(
928
+ score: TrackScore,
929
+ mode: "scale" | "chord",
930
+ source?: Readonly<{ score: TrackScore; tick: number }>,
931
+ ): (tick: number) => readonly number[] {
932
+ const key = parseKey(score.key ?? undefined);
933
+ const scale = key ? scaleOf(key) : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
934
+ if (mode === "scale") return () => scale;
935
+ const timeline = chordTimeline(source?.score ?? score);
936
+ const offset = source?.tick ?? 0;
937
+ return (tick: number) => {
938
+ const set = chordAt(timeline, tick + offset);
939
+ return set.length > 0 ? set : scale;
940
+ };
941
+ }
942
+
943
+ /** A grain's semitone offset over `pitch`, snapped to the nearest class. */
944
+ export function snapSemis(
945
+ pitch: number,
946
+ semis: number,
947
+ classes: readonly number[],
948
+ ): number {
949
+ const target = pitch + semis;
950
+ const base = Math.round(target);
951
+ let best = base;
952
+ let bestDistance = Infinity;
953
+ for (let d = 0; d <= 6; d += 1)
954
+ for (const candidate of d === 0 ? [base] : [base - d, base + d]) {
955
+ if (!classes.includes(((candidate % 12) + 12) % 12)) continue;
956
+ const distance = Math.abs(candidate - target);
957
+ if (distance < bestDistance) {
958
+ best = candidate;
959
+ bestDistance = distance;
960
+ }
961
+ }
962
+ return best - pitch;
963
+ }
964
+
965
+ /** The registered engine (`src/audio/instruments.ts`). */
966
+ export const GRANULAR_ENGINE: InstrumentEngine = Object.freeze({
967
+ id: "granular",
968
+ field: "granular",
969
+ render(dry, dryR, notes, track, context, bank) {
970
+ const right = dryR ?? new Float64Array(dry.length);
971
+ renderGranularTrack(dry, right, notes, track, context, bank);
972
+ if (!dryR)
973
+ for (let i = 0; i < dry.length; i += 1)
974
+ dry[i] = 0.5 * (dry[i]! + right[i]!);
975
+ },
976
+ tailSeconds: (track: Track) => granularTailSeconds(track.granular),
977
+ stereo: () => true,
978
+ assetDigests(
979
+ track: Track,
980
+ bank: SampleBank,
981
+ score?: TrackScore,
982
+ ): readonly string[] {
983
+ const src = track.granular?.src;
984
+ const digests: string[] = [];
985
+ if (src !== undefined && typeof src !== "string") {
986
+ const decoded = bank.voices.get(granularSampleKey(track.id));
987
+ digests.push(decoded ? decoded.sha256 : "missing");
988
+ }
989
+ // `quant chord` reads the other tracks' notes: their chords join the
990
+ // stem key, so a cached stem re-renders when the harmony changes.
991
+ if (score && resolveGranular(track.granular).quant === "chord")
992
+ digests.push(chordDigest(chordTimeline(score)));
993
+ return digests;
994
+ },
995
+ });