@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
package/src/audio/live.ts CHANGED
@@ -17,11 +17,32 @@ import {
17
17
  type Track,
18
18
  } from "../../core/score.ts";
19
19
  import { bpmAtTick } from "../../core/tempo.ts";
20
- import { sampleKey, type SampleBank } from "./samples.ts";
20
+ import { EMPTY_SAMPLE_BANK, sampleKey, type SampleBank } from "./samples.ts";
21
21
  import { RENDER_CHANNELS, renderScorePcm } from "./wav.ts";
22
+ import { engineFor } from "./instruments.ts";
23
+ import { noteHz, resolveTuning } from "../../core/tuning.ts";
24
+ import { liveFitPending, withLiveFit } from "./fit.ts";
25
+ import type { LiveFullReply, LiveFullRequest } from "./live-worker.ts";
22
26
 
23
27
  /** A rendered live note: interleaved stereo 16-bit PCM. */
24
- export type LiveNotePcm = Readonly<{ pcm: Int16Array; frames: number }>;
28
+ export type LiveNotePcm = Readonly<{
29
+ pcm: Int16Array;
30
+ frames: number;
31
+ /** Release fade on note-off, in seconds; absent is the 10 ms default. */
32
+ releaseSeconds?: number;
33
+ /**
34
+ * A fitted sample window (0.6 `bpm`/`len`/`fitmode`) longer than 8 s is
35
+ * still being computed: the note is silent and not cached; play it again
36
+ * once its background fit (fit.ts `onFitReady`) lands.
37
+ */
38
+ fitting?: true;
39
+ /**
40
+ * True when this is only the first window of a guitar-rig note
41
+ * (`LIVE_RIG_WINDOW_SECONDS`): call `LiveSynth.render` again with
42
+ * `full: true` off the key path and swap the result in by voice id.
43
+ */
44
+ partial?: boolean;
45
+ }>;
25
46
 
26
47
  export type LiveNoteRequest = Readonly<{
27
48
  score: TrackScore;
@@ -35,8 +56,18 @@ export type LiveNoteRequest = Readonly<{
35
56
  samples?: SampleBank;
36
57
  /** Song tick being played: the tempo there sets length and synced effects. */
37
58
  tick?: number;
59
+ /** Render the whole note even through a guitar rig (the background pass). */
60
+ full?: boolean;
38
61
  }>;
39
62
 
63
+ /**
64
+ * A guitar rig (stomp, head, cab) is oversampled and costs 15-25 ms per
65
+ * track-second, so a held note through one first renders only this window,
66
+ * at render quality, and the rest follows in the background. Every rig stage
67
+ * is causal, so the window is the exact prefix of the full note.
68
+ */
69
+ export const LIVE_RIG_WINDOW_SECONDS = 0.75;
70
+
40
71
  /** Longest a single live note is rendered for (a held sustain pedal). */
41
72
  export const MAX_LIVE_NOTE_SECONDS = 8;
42
73
  const CACHE_ENTRIES = 96;
@@ -58,12 +89,24 @@ export class LiveSynth {
58
89
  return this.cache.size;
59
90
  }
60
91
 
61
- public render(request: LiveNoteRequest): LiveNotePcm | undefined {
92
+ /**
93
+ * Stores a whole rig note rendered elsewhere (the background worker) so the
94
+ * next press of that key hits the cache and skips the window.
95
+ */
96
+ public adopt(request: LiveNoteRequest, pcm: LiveNotePcm): void {
97
+ const plan = this.plan(request);
98
+ if (!plan || pcm.partial) return;
99
+ this.cache.set(plan.key, pcm);
100
+ this.trim();
101
+ }
102
+
103
+ private plan(request: LiveNoteRequest) {
62
104
  const track = request.score.tracks.find(
63
105
  (candidate) => candidate.id === request.trackId,
64
106
  );
65
107
  if (!track) return undefined;
66
108
  const { score } = request;
109
+ const liveEngine = engineFor(track);
67
110
  const bpm =
68
111
  request.tick === undefined
69
112
  ? score.tempoBpm
@@ -88,11 +131,43 @@ export class LiveSynth {
88
131
  score.ticksPerBeat,
89
132
  this.sampleRate,
90
133
  samplerDigest(track, request.samples),
134
+ // Assets a 0.6 engine reads (a granular sample source).
135
+ ...engineDigest(track, request.samples),
91
136
  // The song tuning and key (the default tuning root) retune live notes.
92
137
  ...(score.tuning || track.tuning
93
138
  ? [score.tuning ?? null, score.key]
94
139
  : []),
140
+ // A 0.6 engine's assets, and the key it may tune to.
141
+ ...(liveEngine
142
+ ? [
143
+ liveEngine.assetDigests?.(
144
+ track,
145
+ request.samples ?? EMPTY_SAMPLE_BANK,
146
+ ) ?? [],
147
+ score.key ?? null,
148
+ ]
149
+ : []),
95
150
  ]);
151
+ return { track, score, bpm, seconds, durationTicks, velocity, pitch, key };
152
+ }
153
+
154
+ public render(request: LiveNoteRequest): LiveNotePcm | undefined {
155
+ const plan = this.plan(request);
156
+ if (!plan) return undefined;
157
+ const { track, score, bpm, seconds, durationTicks, velocity, pitch, key } =
158
+ plan;
159
+ const liveEngine = engineFor(track);
160
+ // The first window of a rig note: its own cache entry, swapped for the
161
+ // whole note once the background pass has rendered it.
162
+ const windowed =
163
+ request.full !== true &&
164
+ hasRig(track) &&
165
+ seconds > LIVE_RIG_WINDOW_SECONDS &&
166
+ !this.cache.has(key);
167
+ if (windowed) {
168
+ const first = this.cache.get(`${key}#window`);
169
+ if (first) return first;
170
+ }
96
171
  const hit = this.cache.get(key);
97
172
  if (hit) {
98
173
  // Refresh LRU order.
@@ -112,7 +187,7 @@ export class LiveSynth {
112
187
  ticksPerBeat: score.ticksPerBeat,
113
188
  // The song tuning, and the key whose tonic is the default root.
114
189
  ...(score.tuning ? { tuning: score.tuning } : {}),
115
- ...(score.tuning || track.tuning ? { key: score.key } : {}),
190
+ ...(score.tuning || track.tuning || liveEngine ? { key: score.key } : {}),
116
191
  tracks: [liveTrack(track)],
117
192
  notes: [
118
193
  {
@@ -125,23 +200,64 @@ export class LiveSynth {
125
200
  },
126
201
  ],
127
202
  });
128
- const audio = renderScorePcm(single, {
129
- sampleRate: this.sampleRate,
130
- maxSeconds: MAX_LIVE_NOTE_SECONDS + 4,
131
- ...(request.samples ? { samples: request.samples } : {}),
132
- });
133
- const frames = audibleFrames(audio.pcm);
203
+ // A 0.6 engine's ring-out sets the one-note length and the key release.
204
+ const tail = liveEngine
205
+ ? Math.max(0, liveEngine.tailSeconds(track, pitch))
206
+ : 0;
207
+ // Fitted sample windows over 8 s fit in the background (silent until
208
+ // ready, never at the wrong pitch); shorter ones fit synchronously.
209
+ const audio = withLiveFit(() =>
210
+ renderScorePcm(single, {
211
+ sampleRate: this.sampleRate,
212
+ // The renderer's shortest one-shot is 1 s; the window is cut below.
213
+ maxSeconds: windowed
214
+ ? 1
215
+ : Math.max(MAX_LIVE_NOTE_SECONDS + 4, seconds + tail),
216
+ ...(request.samples ? { samples: request.samples } : {}),
217
+ }),
218
+ );
219
+ const frames = windowed
220
+ ? Math.min(
221
+ audibleFrames(audio.pcm),
222
+ Math.round(LIVE_RIG_WINDOW_SECONDS * this.sampleRate),
223
+ )
224
+ : audibleFrames(audio.pcm);
225
+ const release = liveEngine?.releaseSeconds
226
+ ? liveEngine.releaseSeconds(
227
+ track,
228
+ pitch,
229
+ noteHz(
230
+ pitch,
231
+ undefined,
232
+ resolveTuning(score.tuning, track.tuning, score.key),
233
+ ),
234
+ )
235
+ : tail;
134
236
  const rendered: LiveNotePcm = {
135
237
  pcm: audio.pcm.subarray(0, frames * RENDER_CHANNELS),
136
238
  frames,
239
+ ...(liveEngine
240
+ ? {
241
+ releaseSeconds: Math.min(
242
+ Math.max(0, release),
243
+ MAX_LIVE_NOTE_SECONDS,
244
+ ),
245
+ }
246
+ : {}),
247
+ ...(windowed ? { partial: true } : {}),
137
248
  };
138
- this.cache.set(key, rendered);
249
+ if (liveFitPending()) return { ...rendered, fitting: true };
250
+ this.cache.set(windowed ? `${key}#window` : key, rendered);
251
+ this.trim();
252
+ return rendered;
253
+ }
254
+
255
+ private trim(): void {
139
256
  while (this.cache.size > CACHE_ENTRIES) {
140
257
  const oldest = this.cache.keys().next().value;
141
258
  if (oldest === undefined) break;
142
259
  this.cache.delete(oldest);
143
260
  }
144
- return rendered;
145
261
  }
146
262
  }
147
263
 
@@ -163,6 +279,17 @@ function liveTrack(track: Track): Track {
163
279
  return { ...rest, muted: false };
164
280
  }
165
281
 
282
+ /** Whether the track plays through a guitar rig stage. */
283
+ function hasRig(track: Track): boolean {
284
+ return Boolean(track.fx?.stomp || track.fx?.head || track.fx?.cab);
285
+ }
286
+
287
+ function engineDigest(track: Track, bank: SampleBank | undefined): string[] {
288
+ const engine = engineFor(track);
289
+ if (!engine?.assetDigests || !bank) return [];
290
+ return [...engine.assetDigests(track, bank)];
291
+ }
292
+
166
293
  function samplerDigest(track: Track, bank: SampleBank | undefined): string[] {
167
294
  if (!isSamplerInstrument(track.instrument) || !track.sampler || !bank)
168
295
  return [];
@@ -177,3 +304,95 @@ function audibleFrames(pcm: Int16Array): number {
177
304
  while (last >= 0 && pcm[last] === 0) last -= 1;
178
305
  return Math.ceil((last + 1) / RENDER_CHANNELS);
179
306
  }
307
+
308
+ /**
309
+ * Renders whole guitar-rig notes on a worker (`live-worker.ts`), in request
310
+ * order. When a worker cannot start, `full` falls back to rendering on the
311
+ * calling thread in a later tick, as before.
312
+ */
313
+ export class LiveFullRenderer {
314
+ private worker: Bun.Worker | undefined;
315
+ private broken = false;
316
+ private nextId = 1;
317
+ private readonly pending = new Map<
318
+ number,
319
+ (pcm: LiveNotePcm | undefined) => void
320
+ >();
321
+
322
+ public full(
323
+ synth: LiveSynth,
324
+ request: LiveNoteRequest,
325
+ ): Promise<LiveNotePcm | undefined> {
326
+ const worker = this.ensure();
327
+ if (!worker)
328
+ return new Promise((resolve) =>
329
+ setTimeout(() => resolve(synth.render({ ...request, full: true })), 0),
330
+ );
331
+ const id = this.nextId;
332
+ this.nextId += 1;
333
+ return new Promise((resolve) => {
334
+ this.pending.set(id, (pcm) => {
335
+ if (pcm) synth.adopt(request, pcm);
336
+ resolve(pcm);
337
+ });
338
+ const message: LiveFullRequest = {
339
+ id,
340
+ sampleRate: synth.rate,
341
+ score: request.score.toJSON(),
342
+ trackId: request.trackId,
343
+ pitch: request.pitch,
344
+ velocity: request.velocity,
345
+ seconds: request.seconds,
346
+ ...(request.tick === undefined ? {} : { tick: request.tick }),
347
+ ...(request.samples ? { samples: request.samples } : {}),
348
+ };
349
+ try {
350
+ worker.postMessage(message);
351
+ } catch {
352
+ this.pending.delete(id);
353
+ this.fail();
354
+ setTimeout(() => resolve(synth.render({ ...request, full: true })), 0);
355
+ }
356
+ });
357
+ }
358
+
359
+ /** Stops the worker; pending notes resolve without a full pass. */
360
+ public close(): void {
361
+ this.fail();
362
+ }
363
+
364
+ private ensure(): Bun.Worker | undefined {
365
+ if (this.worker) return this.worker;
366
+ if (this.broken) return undefined;
367
+ try {
368
+ // The DOM lib's Worker type lacks Bun's `unref`; the runtime is Bun's.
369
+ const worker = new Worker(
370
+ new URL("./live-worker.ts", import.meta.url).href,
371
+ ) as unknown as Bun.Worker;
372
+ worker.addEventListener("message", (event: Event) => {
373
+ const reply = (event as MessageEvent<LiveFullReply>).data;
374
+ const done = this.pending.get(reply.id);
375
+ if (!done) return;
376
+ this.pending.delete(reply.id);
377
+ done(reply.pcm);
378
+ });
379
+ worker.addEventListener("error", () => this.fail());
380
+ worker.unref();
381
+ this.worker = worker;
382
+ return worker;
383
+ } catch {
384
+ this.broken = true;
385
+ return undefined;
386
+ }
387
+ }
388
+
389
+ private fail(): void {
390
+ this.broken = true;
391
+ const worker = this.worker;
392
+ this.worker = undefined;
393
+ worker?.terminate();
394
+ const waiters = [...this.pending.values()];
395
+ this.pending.clear();
396
+ for (const done of waiters) done(undefined);
397
+ }
398
+ }
@@ -3,7 +3,7 @@
3
3
  * K-weighted mean square, momentary (400 ms) and short-term (3 s) loudness
4
4
  * every 100 ms, gated integrated loudness (absolute -70 LUFS, relative
5
5
  * -10 LU), loudness range per EBU Tech 3342 (short-term values gated at
6
- * -70 LUFS and -20 LU, 10th to 95th percentile) and true peak from a 4x
6
+ * -70 LUFS and -20 LU, 10th to 95th percentile) and true peak from an 8x
7
7
  * oversampled polyphase interpolator (BS.1770-4 Annex 2, with a longer
8
8
  * windowed-sinc filter than its example). Validated against
9
9
  * the synthetic test signals of EBU Tech 3341 and 3342 in loudness.test.ts.
@@ -31,7 +31,7 @@ export type Loudness = Readonly<{
31
31
  shortTermMax: number;
32
32
  /** Loudness range (LRA), LU. */
33
33
  range: number;
34
- /** dBTP: the 4x oversampled peak. */
34
+ /** dBTP: the 8x oversampled peak. */
35
35
  truePeak: number;
36
36
  /** dBFS: the highest sample. */
37
37
  samplePeak: number;
@@ -239,15 +239,18 @@ function besselI0(x: number): number {
239
239
  let phases: Float64Array[] | undefined;
240
240
 
241
241
  /**
242
- * The true-peak interpolator: four Kaiser-windowed sinc phases of 32 taps,
243
- * each in time order over x[n-15] .. x[n+16], estimating the signal at
244
- * n + 1/8, 3/8, 5/8 and 7/8. With the samples themselves they give the 4x
245
- * oversampled true peak. BS.1770-4 Annex 2's 12-tap example filter rolls
242
+ * The true-peak interpolator: Kaiser-windowed sinc phases of 32 taps, each
243
+ * in time order over x[n-15] .. x[n+16], estimating the signal at n + 1/8,
244
+ * 3/8, 5/8 and 7/8, then (0.6) n + 2/8, 4/8 and 6/8. With the samples
245
+ * themselves they give an evenly spaced 8x oversampled true peak; the odd
246
+ * eighths alone left 1/4-sample gaps that read dense material up to 0.2 dB
247
+ * low at a peak near n + 3/4. The odd phases come first and are unchanged,
248
+ * so a peak they already caught reads the same. BS.1770-4 Annex 2's 12-tap example filter rolls
246
249
  * off early and reads dense, bright material (supersaws, distortion) up to
247
250
  * half a dB low; this one stays within a few hundredths of a 64x reference.
248
251
  */
249
252
  export function truePeakPhases(): readonly Float64Array[] {
250
- phases ??= [1, 3, 5, 7].map((eighth) => {
253
+ phases ??= [1, 3, 5, 7, 2, 4, 6].map((eighth) => {
251
254
  const frac = eighth / 8;
252
255
  const taps = new Float64Array(TAPS_PER_PHASE);
253
256
  const before = TAPS_PER_PHASE / 2 - 1;
@@ -266,9 +269,23 @@ export function truePeakPhases(): readonly Float64Array[] {
266
269
  return phases;
267
270
  }
268
271
 
272
+ let interleaved: Float64Array | undefined;
273
+
274
+ /** The seven phases interleaved by tap (`tap * 7 + phase`). */
275
+ function interleavedPhases(): Float64Array {
276
+ if (interleaved) return interleaved;
277
+ const branches = truePeakPhases();
278
+ const flat = new Float64Array(TAPS_PER_PHASE * branches.length);
279
+ branches.forEach((taps, phase) => {
280
+ for (let tap = 0; tap < TAPS_PER_PHASE; tap += 1)
281
+ flat[tap * branches.length + phase] = taps[tap]!;
282
+ });
283
+ return (interleaved = flat);
284
+ }
285
+
269
286
  /**
270
287
  * Largest absolute value between each sample and the next (both samples
271
- * and the four interpolated points), as `out[n]` for n .. n+1.
288
+ * and the seven interpolated points), as `out[n]` for n .. n+1.
272
289
  */
273
290
  export function interSamplePeaks(
274
291
  channel: Float64Array,
@@ -277,6 +294,7 @@ export function interSamplePeaks(
277
294
  ): void {
278
295
  const n = channel.length;
279
296
  const branches = truePeakPhases();
297
+ const flat = interleavedPhases();
280
298
  const before = TAPS_PER_PHASE / 2 - 1;
281
299
  const at = (index: number): number => {
282
300
  if (index >= 0 && index < n) return channel[index]!;
@@ -287,23 +305,50 @@ export function interSamplePeaks(
287
305
  let peak = Math.abs(channel[index]!);
288
306
  const next = Math.abs(at(index + 1));
289
307
  if (next > peak) peak = next;
290
- const inside = index - before >= 0 && index + TAPS_PER_PHASE - before <= n;
291
- for (const taps of branches) {
292
- let sum = 0;
293
- if (inside)
294
- for (let tap = 0; tap < TAPS_PER_PHASE; tap += 1)
295
- sum += channel[index - before + tap]! * taps[tap]!;
296
- else
308
+ const base = index - before;
309
+ if (base >= 0 && base + TAPS_PER_PHASE <= n) {
310
+ // Every phase in one pass over the window (the interior fast path).
311
+ let s0 = 0;
312
+ let s1 = 0;
313
+ let s2 = 0;
314
+ let s3 = 0;
315
+ let s4 = 0;
316
+ let s5 = 0;
317
+ let s6 = 0;
318
+ for (let tap = 0; tap < TAPS_PER_PHASE; tap += 1) {
319
+ const x = channel[base + tap]!;
320
+ const k = tap * 7;
321
+ s0 += x * flat[k]!;
322
+ s1 += x * flat[k + 1]!;
323
+ s2 += x * flat[k + 2]!;
324
+ s3 += x * flat[k + 3]!;
325
+ s4 += x * flat[k + 4]!;
326
+ s5 += x * flat[k + 5]!;
327
+ s6 += x * flat[k + 6]!;
328
+ }
329
+ peak = Math.max(
330
+ peak,
331
+ Math.abs(s0),
332
+ Math.abs(s1),
333
+ Math.abs(s2),
334
+ Math.abs(s3),
335
+ Math.abs(s4),
336
+ Math.abs(s5),
337
+ Math.abs(s6),
338
+ );
339
+ } else
340
+ for (const taps of branches) {
341
+ let sum = 0;
297
342
  for (let tap = 0; tap < TAPS_PER_PHASE; tap += 1)
298
- sum += at(index - before + tap) * taps[tap]!;
299
- const magnitude = Math.abs(sum);
300
- if (magnitude > peak) peak = magnitude;
301
- }
343
+ sum += at(base + tap) * taps[tap]!;
344
+ const magnitude = Math.abs(sum);
345
+ if (magnitude > peak) peak = magnitude;
346
+ }
302
347
  if (peak > out[index]!) out[index] = peak;
303
348
  }
304
349
  }
305
350
 
306
- /** The 4x oversampled true peak of a stereo buffer, linear. */
351
+ /** The 8x oversampled true peak of a stereo buffer, linear. */
307
352
  export function truePeakGain(
308
353
  left: Float64Array,
309
354
  right: Float64Array,
@@ -129,6 +129,8 @@ export function phraseRole(track: Track): PhraseRole {
129
129
  if (isSamplerInstrument(track.instrument))
130
130
  return track.sampler?.mode === "keyed" ? "chord" : "groove";
131
131
  if (isWavetableInstrument(track.instrument)) return "drone";
132
+ // Grain clouds are textures: held notes let them evolve.
133
+ if (track.instrument === "granular") return "drone";
132
134
  const words = `${track.instrument} ${track.name} ${track.id}`.toLowerCase();
133
135
  if (/\b(bass|sub|808)/.test(words)) return "riff";
134
136
  if (/\b(lead|arp|pluck|saw|square|pulse|z_|mono|melody|hook)/.test(words))