@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,619 @@
1
+ /**
2
+ * Granular instrument (0.6): the `Track.granular` field, its parameter
3
+ * table, presets and validation. Pure, no audio imports; the voice lives in
4
+ * `src/audio/granular.ts` and reads `resolveGranular`.
5
+ *
6
+ * Names follow common granular UIs (Mutable Instruments Clouds, Ableton
7
+ * Granulator II) where Strudel has no word: `pos`, `grain`, `overlap`,
8
+ * `spray`, `jitter`, `freeze`; `begin`/`end`/`attack`/`release` are the
9
+ * Strudel names.
10
+ */
11
+ import type { ParamSpec } from "./params.ts";
12
+ import { FxValidationError, isRecord, normalizeParam } from "./params.ts";
13
+ import { SYNTH_PRESETS, SYNTH_SOUNDS } from "./synth.ts";
14
+ import type { SampleRef } from "./score.ts";
15
+ import { pitchToMidi } from "./pitch.ts";
16
+
17
+ /** Instrument name that plays a track's `granular` field. */
18
+ export const GRANULAR_INSTRUMENT = "granular" as const;
19
+
20
+ export function isGranularInstrument(instrument: string | undefined): boolean {
21
+ return instrument === GRANULAR_INSTRUMENT;
22
+ }
23
+
24
+ export const GRAIN_WINDOWS = Object.freeze([
25
+ "hann",
26
+ "tukey",
27
+ "gauss",
28
+ "tri",
29
+ "perc",
30
+ "rperc",
31
+ ] as const);
32
+ export type GrainWindow = (typeof GRAIN_WINDOWS)[number];
33
+
34
+ /** Prefix of a built-in source: a held render of a synth preset or sound. */
35
+ export const SYNTH_SOURCE_PREFIX = "synth:";
36
+ /** The source a granular track plays when `src` is absent. */
37
+ export const DEFAULT_GRANULAR_SOURCE = "synth:pad";
38
+ /** Note and length of a built-in synth source render. */
39
+ /** The sample-bank voice name a granular track's sample source loads under. */
40
+ export const GRANULAR_SOURCE_VOICE = "granular:src";
41
+ export const SYNTH_SOURCE_NOTE = 60;
42
+ export const SYNTH_SOURCE_SECONDS = 4;
43
+
44
+ /**
45
+ * A granular source: a sample (same shape and pinning as a sampler voice)
46
+ * or `synth:<preset|sound>[@<note>]`, a deterministic offline render.
47
+ */
48
+ export type GranularSource = SampleRef | string;
49
+
50
+ /** Every stored field is optional; absent means the default or the preset. */
51
+ export type TrackGranular = Readonly<{
52
+ src?: GranularSource;
53
+ preset?: GranularPresetName;
54
+ seed?: number;
55
+ root?: number;
56
+ begin?: number;
57
+ end?: number;
58
+ pos?: number;
59
+ scan?: number;
60
+ grain?: number;
61
+ overlap?: number;
62
+ jitter?: number;
63
+ spray?: number;
64
+ pitch?: number;
65
+ detune?: number;
66
+ shimmer?: number;
67
+ shimint?: number;
68
+ spread?: number;
69
+ window?: GrainWindow;
70
+ reverse?: number;
71
+ freeze?: boolean;
72
+ repeat?: number;
73
+ hold?: number;
74
+ drift?: number;
75
+ drate?: number;
76
+ attack?: number;
77
+ release?: number;
78
+ veltone?: number;
79
+ gain?: number;
80
+ /** Optional (0.6.1): grain rate as a note value on the tempo map. */
81
+ sync?: GrainSync;
82
+ /** Optional (0.6.1): snap grain pitches to the scale or chord. */
83
+ quant?: GrainQuant;
84
+ /** Optional (0.6.1): one voice; legato notes retarget it. */
85
+ mono?: boolean;
86
+ /** Optional (0.6.1): the sustain pedal freezes the head. */
87
+ pedal?: boolean;
88
+ /**
89
+ * The instrument `grain off` returns to (set when granular turns on;
90
+ * absent: a sampler, a wavetable, or the source's synth).
91
+ */
92
+ from?: string;
93
+ }>;
94
+
95
+ const n = (
96
+ min: number,
97
+ max: number,
98
+ value: number,
99
+ step: number | "log",
100
+ doc: string,
101
+ extra: Partial<{ unit: string; integer: boolean; automate: boolean }> = {},
102
+ ): ParamSpec =>
103
+ Object.freeze({
104
+ kind: "number",
105
+ min,
106
+ max,
107
+ default: value,
108
+ step,
109
+ doc,
110
+ ...extra,
111
+ });
112
+
113
+ export const GRAIN_SYNC_VALUES = Object.freeze([
114
+ "off",
115
+ "1/64",
116
+ "1/32",
117
+ "1/16t",
118
+ "1/16",
119
+ "1/16d",
120
+ "1/8t",
121
+ "1/8",
122
+ "1/8d",
123
+ "1/4t",
124
+ "1/4",
125
+ "1/4d",
126
+ "1/2",
127
+ "1/1",
128
+ ] as const);
129
+ export type GrainSync = (typeof GRAIN_SYNC_VALUES)[number];
130
+ export const GRAIN_QUANT_VALUES = Object.freeze([
131
+ "off",
132
+ "scale",
133
+ "chord",
134
+ ] as const);
135
+ export type GrainQuant = (typeof GRAIN_QUANT_VALUES)[number];
136
+
137
+ /**
138
+ * The granular parameter table: one row per stored number, enum or flag,
139
+ * in stored key order. It drives validation, the printer, the menu, the
140
+ * `grain` command and the `set_granular` tool. `automate` marks the rows a
141
+ * later lane exposes as `grain-<name>` automation lanes.
142
+ */
143
+ export const GRANULAR_PARAMS: Readonly<Record<string, ParamSpec>> =
144
+ Object.freeze({
145
+ seed: n(
146
+ 0,
147
+ 2_147_483_647,
148
+ 0,
149
+ 1,
150
+ "texture variation; same seed, same grains",
151
+ {
152
+ integer: true,
153
+ },
154
+ ),
155
+ root: n(0, 127, 60, 1, "the note that plays the source at its own pitch", {
156
+ integer: true,
157
+ }),
158
+ begin: n(
159
+ 0,
160
+ 1,
161
+ 0,
162
+ 0.01,
163
+ "start of the region the head reads, 0..1 of the file",
164
+ ),
165
+ end: n(0, 1, 1, 0.01, "end of the region the head reads, 0..1 of the file"),
166
+ pos: n(0, 1, 0, 0.01, "where the head starts inside the region", {
167
+ automate: true,
168
+ }),
169
+ scan: n(
170
+ -4,
171
+ 4,
172
+ 1,
173
+ 0.05,
174
+ "head speed: 1 the source's own speed, 0 held, negative backwards",
175
+ {
176
+ unit: "x",
177
+ automate: true,
178
+ },
179
+ ),
180
+ grain: n(0.005, 2, 0.08, "log", "grain length", {
181
+ unit: "s",
182
+ automate: true,
183
+ }),
184
+ overlap: n(
185
+ 0.05,
186
+ 32,
187
+ 4,
188
+ 0.5,
189
+ "grains sounding at once (density = overlap / grain)",
190
+ {
191
+ automate: true,
192
+ },
193
+ ),
194
+ jitter: n(
195
+ 0,
196
+ 1,
197
+ 0.25,
198
+ 0.05,
199
+ "onset randomness, fraction of the grain period",
200
+ {
201
+ automate: true,
202
+ },
203
+ ),
204
+ spray: n(0, 2, 0.01, 0.01, "random offset of each grain's read position", {
205
+ unit: "s",
206
+ automate: true,
207
+ }),
208
+ pitch: n(-48, 48, 0, 1, "semitones on top of the note", {
209
+ unit: "st",
210
+ automate: true,
211
+ }),
212
+ detune: n(0, 24, 0, 0.05, "random per-grain pitch spread (± half)", {
213
+ unit: "st",
214
+ automate: true,
215
+ }),
216
+ shimmer: n(0, 1, 0, 0.05, "chance a grain plays shimint semitones up", {
217
+ automate: true,
218
+ }),
219
+ shimint: n(-24, 24, 12, 1, "the shimmer interval", { unit: "st" }),
220
+ spread: n(0, 1, 0.3, 0.05, "stereo spread of the grains", {
221
+ automate: true,
222
+ }),
223
+ window: Object.freeze({
224
+ kind: "enum",
225
+ values: GRAIN_WINDOWS,
226
+ default: "hann",
227
+ doc: "grain envelope: hann tukey gauss tri perc rperc",
228
+ }),
229
+ reverse: n(0, 1, 0, 0.05, "chance a grain plays backwards", {
230
+ automate: true,
231
+ }),
232
+ freeze: Object.freeze({
233
+ kind: "boolean",
234
+ default: false,
235
+ doc: "hold the read head (the cloud stops moving)",
236
+ }),
237
+ repeat: n(
238
+ 0,
239
+ 1,
240
+ 0,
241
+ 0.05,
242
+ "chance a grain step latches the head (beat repeat)",
243
+ {
244
+ automate: true,
245
+ },
246
+ ),
247
+ hold: n(1, 16, 1, 1, "grain steps a latch lasts", { integer: true }),
248
+ drift: n(0, 1, 0, 0.05, "slow random walk of the head (depth)", {
249
+ automate: true,
250
+ }),
251
+ drate: n(0.01, 10, 0.2, "log", "drift rate", { unit: "Hz" }),
252
+ attack: n(0, 10, 0.01, "log", "voice fade in", { unit: "s" }),
253
+ release: n(0, 20, 0.3, "log", "voice fade out after the note ends", {
254
+ unit: "s",
255
+ }),
256
+ veltone: n(0, 1, 0, 0.05, "soft notes are darker"),
257
+ gain: n(0, 4, 1, 0.05, "voice level"),
258
+ // 0.6.1 grainplay (appended; absent keeps 0.6.0 behaviour).
259
+ sync: Object.freeze({
260
+ kind: "enum",
261
+ values: GRAIN_SYNC_VALUES,
262
+ default: "off",
263
+ doc: "grain rate as a note value on the tempo map (off: grain/overlap)",
264
+ }),
265
+ quant: Object.freeze({
266
+ kind: "enum",
267
+ values: GRAIN_QUANT_VALUES,
268
+ default: "off",
269
+ doc: "snap grain pitches to the song scale or the sounding chord",
270
+ }),
271
+ mono: Object.freeze({
272
+ kind: "boolean",
273
+ default: false,
274
+ doc: "one voice: legato notes retarget its pitch, the cloud keeps going",
275
+ }),
276
+ pedal: Object.freeze({
277
+ kind: "boolean",
278
+ default: false,
279
+ doc: "the sustain pedal freezes the head while it is down",
280
+ }),
281
+ });
282
+
283
+ /** Lanes `grain-<param>` (0.6.1): every number row marked `automate`. */
284
+ export const GRANULAR_LANE_PARAMS: readonly Readonly<{
285
+ param: string;
286
+ spec: Extract<ParamSpec, { kind: "number" }>;
287
+ }>[] = Object.freeze(
288
+ Object.entries(GRANULAR_PARAMS)
289
+ .filter(
290
+ (entry): entry is [string, Extract<ParamSpec, { kind: "number" }>] =>
291
+ entry[1].kind === "number" && entry[1].automate === true,
292
+ )
293
+ .map(([param, spec]) => Object.freeze({ param, spec })),
294
+ );
295
+
296
+ /** Note values `sync` takes, with their length in beats. */
297
+ export const GRAIN_SYNC_BEATS: Readonly<Record<string, number>> = Object.freeze(
298
+ {
299
+ "1/64": 1 / 16,
300
+ "1/32": 1 / 8,
301
+ "1/16t": 1 / 6,
302
+ "1/16": 1 / 4,
303
+ "1/16d": 3 / 8,
304
+ "1/8t": 1 / 3,
305
+ "1/8": 1 / 2,
306
+ "1/8d": 3 / 4,
307
+ "1/4t": 2 / 3,
308
+ "1/4": 1,
309
+ "1/4d": 3 / 2,
310
+ "1/2": 2,
311
+ "1/1": 4,
312
+ },
313
+ );
314
+
315
+ export type GranularParamName = keyof typeof GRANULAR_PARAMS & string;
316
+
317
+ /** Resolved settings the voice reads (presets and defaults applied). */
318
+ export type GranularSettings = Readonly<{
319
+ src: GranularSource;
320
+ seed: number;
321
+ root: number;
322
+ begin: number;
323
+ end: number;
324
+ pos: number;
325
+ scan: number;
326
+ grain: number;
327
+ overlap: number;
328
+ jitter: number;
329
+ spray: number;
330
+ pitch: number;
331
+ detune: number;
332
+ shimmer: number;
333
+ shimint: number;
334
+ spread: number;
335
+ window: GrainWindow;
336
+ reverse: number;
337
+ freeze: boolean;
338
+ repeat: number;
339
+ hold: number;
340
+ drift: number;
341
+ drate: number;
342
+ attack: number;
343
+ release: number;
344
+ veltone: number;
345
+ gain: number;
346
+ sync: GrainSync;
347
+ quant: GrainQuant;
348
+ mono: boolean;
349
+ pedal: boolean;
350
+ }>;
351
+
352
+ /**
353
+ * Presets: partial settings over the defaults (the prototype's values).
354
+ * `microloop`, `sparkle` and `backwards` come into their own on a resampled
355
+ * phrase; on the built-in pad they are textures.
356
+ */
357
+ export const GRANULAR_PRESETS = Object.freeze({
358
+ cloud: {
359
+ doc: "slow-scanning soft cloud, wide",
360
+ params: {
361
+ grain: 0.12,
362
+ overlap: 6,
363
+ jitter: 0.5,
364
+ spray: 0.06,
365
+ scan: 0.25,
366
+ spread: 0.6,
367
+ window: "hann",
368
+ attack: 0.4,
369
+ release: 1.2,
370
+ },
371
+ },
372
+ hold: {
373
+ doc: "held, shimmering sustain of one moment",
374
+ params: {
375
+ grain: 0.2,
376
+ overlap: 8,
377
+ jitter: 0.6,
378
+ spray: 0.03,
379
+ freeze: true,
380
+ spread: 0.7,
381
+ window: "gauss",
382
+ attack: 0.6,
383
+ release: 2,
384
+ },
385
+ },
386
+ sparkle: {
387
+ doc: "octave-and-fifth sparkle over a slow scan (best on a resample)",
388
+ params: {
389
+ grain: 0.15,
390
+ overlap: 8,
391
+ jitter: 0.5,
392
+ spray: 0.05,
393
+ scan: 0.2,
394
+ shimmer: 0.35,
395
+ shimint: 19,
396
+ spread: 0.8,
397
+ attack: 0.5,
398
+ release: 2.5,
399
+ },
400
+ },
401
+ swarm: {
402
+ doc: "dense, detuned, fully wide",
403
+ params: {
404
+ grain: 0.09,
405
+ overlap: 14,
406
+ jitter: 0.8,
407
+ spray: 0.08,
408
+ detune: 0.35,
409
+ spread: 1,
410
+ scan: 0.5,
411
+ attack: 0.2,
412
+ release: 0.8,
413
+ },
414
+ },
415
+ stutter: {
416
+ doc: "dry 45 ms repeats that latch and follow the music",
417
+ params: {
418
+ grain: 0.045,
419
+ overlap: 1,
420
+ jitter: 0,
421
+ spray: 0,
422
+ scan: 1,
423
+ repeat: 0.5,
424
+ hold: 4,
425
+ window: "perc",
426
+ reverse: 0.25,
427
+ spread: 0.2,
428
+ attack: 0.002,
429
+ release: 0.08,
430
+ },
431
+ },
432
+ microloop: {
433
+ doc: "tight looping grains that slowly advance (best on a resample)",
434
+ params: {
435
+ grain: 0.06,
436
+ overlap: 2,
437
+ jitter: 0,
438
+ spray: 0,
439
+ scan: 0.5,
440
+ window: "tukey",
441
+ spread: 0.1,
442
+ attack: 0.005,
443
+ release: 0.15,
444
+ },
445
+ },
446
+ backwards: {
447
+ doc: "backwards swells (best on a resample)",
448
+ params: {
449
+ grain: 0.25,
450
+ overlap: 4,
451
+ jitter: 0.3,
452
+ spray: 0.02,
453
+ reverse: 1,
454
+ window: "rperc",
455
+ spread: 0.5,
456
+ attack: 0.05,
457
+ release: 0.6,
458
+ },
459
+ },
460
+ dust: {
461
+ doc: "sparse random crackles across the source",
462
+ params: {
463
+ grain: 0.02,
464
+ overlap: 0.4,
465
+ jitter: 1,
466
+ spray: 0.4,
467
+ detune: 0.2,
468
+ spread: 1,
469
+ window: "perc",
470
+ attack: 0.01,
471
+ release: 0.5,
472
+ },
473
+ },
474
+ } as const satisfies Record<
475
+ string,
476
+ { doc: string; params: Partial<Record<string, number | string | boolean>> }
477
+ >);
478
+
479
+ export type GranularPresetName = keyof typeof GRANULAR_PRESETS;
480
+ export const GRANULAR_PRESET_NAMES: readonly GranularPresetName[] =
481
+ Object.freeze(Object.keys(GRANULAR_PRESETS) as GranularPresetName[]);
482
+
483
+ export function isGranularPreset(name: string): name is GranularPresetName {
484
+ return Object.prototype.hasOwnProperty.call(GRANULAR_PRESETS, name);
485
+ }
486
+
487
+ /** Synth preset and sound names a `synth:` source accepts. */
488
+ export function synthSourceNames(): readonly string[] {
489
+ return [...Object.keys(SYNTH_PRESETS), ...SYNTH_SOUNDS];
490
+ }
491
+
492
+ /** `synth:<name>[@<note>]` parsed, or undefined when malformed or unknown. */
493
+ export function parseSynthSource(
494
+ src: string,
495
+ ): Readonly<{ name: string; note: number }> | undefined {
496
+ if (!src.startsWith(SYNTH_SOURCE_PREFIX)) return undefined;
497
+ const match =
498
+ /^([a-z][a-z0-9_]{0,31})(?:@(\d{1,3}|[a-gA-G][#b]?-?\d))?$/.exec(
499
+ src.slice(SYNTH_SOURCE_PREFIX.length),
500
+ );
501
+ if (!match) return undefined;
502
+ const name = match[1]!;
503
+ const at = match[2];
504
+ const note =
505
+ at === undefined
506
+ ? SYNTH_SOURCE_NOTE
507
+ : /^\d+$/.test(at)
508
+ ? Number(at)
509
+ : pitchToMidi(at);
510
+ if (!(note <= 127) || !synthSourceNames().includes(name)) return undefined;
511
+ return { name, note };
512
+ }
513
+
514
+ /**
515
+ * Validates `Track.granular`. Fields are kept as given (a value equal to
516
+ * the default still overrides the preset), in table order after `src` and
517
+ * `preset`. `normalizeRef` is the score's sample-ref validator.
518
+ */
519
+ export function normalizeGranular(
520
+ input: unknown,
521
+ normalizeRef: (value: unknown) => SampleRef,
522
+ ): TrackGranular | undefined {
523
+ if (input === undefined || input === null) return undefined;
524
+ if (!isRecord(input))
525
+ throw new FxValidationError("track granular must be an object or null");
526
+ const out: Record<string, unknown> = {};
527
+ if (input.src !== undefined) {
528
+ if (typeof input.src === "string") {
529
+ if (!parseSynthSource(input.src))
530
+ throw new FxValidationError(
531
+ `granular src "${input.src.slice(0, 40)}" must be synth:<preset>[@note] (${Object.keys(SYNTH_PRESETS).join(" ")}) or a sample`,
532
+ );
533
+ // A note name (`synth:pad@C3`) is stored as its MIDI number.
534
+ out.src = /@[a-gA-G]/.test(input.src)
535
+ ? `${SYNTH_SOURCE_PREFIX}${parseSynthSource(input.src)!.name}@${parseSynthSource(input.src)!.note}`
536
+ : input.src;
537
+ } else out.src = normalizeRef(input.src);
538
+ }
539
+ if (input.preset !== undefined) {
540
+ if (typeof input.preset !== "string" || !isGranularPreset(input.preset))
541
+ throw new FxValidationError(
542
+ `granular preset must be one of ${GRANULAR_PRESET_NAMES.join(", ")}`,
543
+ );
544
+ out.preset = input.preset;
545
+ }
546
+ for (const [name, spec] of Object.entries(GRANULAR_PARAMS)) {
547
+ const value = input[name];
548
+ if (value === undefined) continue;
549
+ out[name] = normalizeParam(spec, value, `granular ${name}`);
550
+ }
551
+ if (input.from !== undefined) {
552
+ if (
553
+ typeof input.from !== "string" ||
554
+ !/^[a-z][a-z0-9_-]{0,31}$/.test(input.from) ||
555
+ isGranularInstrument(input.from)
556
+ )
557
+ throw new FxValidationError(
558
+ "granular from must be the instrument name grain off returns to",
559
+ );
560
+ out.from = input.from;
561
+ }
562
+ for (const key of Object.keys(input))
563
+ if (
564
+ key !== "src" &&
565
+ key !== "preset" &&
566
+ key !== "from" &&
567
+ !Object.prototype.hasOwnProperty.call(GRANULAR_PARAMS, key)
568
+ )
569
+ throw new FxValidationError(
570
+ `granular has no parameter "${key.slice(0, 32)}"`,
571
+ );
572
+ const begin = (out.begin as number | undefined) ?? 0;
573
+ const end = (out.end as number | undefined) ?? 1;
574
+ if (end <= begin)
575
+ throw new FxValidationError("granular end must be greater than begin");
576
+ return Object.freeze(out) as TrackGranular;
577
+ }
578
+
579
+ /** Defaults, then the preset, then the stored fields. */
580
+ export function resolveGranular(
581
+ granular: TrackGranular | undefined,
582
+ ): GranularSettings {
583
+ const out: Record<string, unknown> = { src: DEFAULT_GRANULAR_SOURCE };
584
+ for (const [name, spec] of Object.entries(GRANULAR_PARAMS))
585
+ out[name] = spec.default;
586
+ if (granular?.preset)
587
+ Object.assign(out, GRANULAR_PRESETS[granular.preset].params);
588
+ if (granular)
589
+ for (const [key, value] of Object.entries(granular))
590
+ if (key !== "preset" && key !== "from" && value !== undefined)
591
+ out[key] = value;
592
+ return Object.freeze(out) as GranularSettings;
593
+ }
594
+
595
+ /** A settings value as stored, or the preset's, or the default. */
596
+ export function granularValue(
597
+ granular: TrackGranular | undefined,
598
+ name: GranularParamName,
599
+ ): number | string | boolean {
600
+ return resolveGranular(granular)[name as keyof GranularSettings] as
601
+ number | string | boolean;
602
+ }
603
+
604
+ /** Ring-out after a note: release plus 1.5 grains, capped at 10 s. */
605
+ export const GRANULAR_TAIL_SECONDS_MAX = 10;
606
+ export function granularTailSeconds(
607
+ granular: TrackGranular | undefined,
608
+ ): number {
609
+ const s = resolveGranular(granular);
610
+ return Math.min(GRANULAR_TAIL_SECONDS_MAX, s.release + 1.5 * s.grain);
611
+ }
612
+
613
+ /** A short label for the source (`synth:pad`, a file name). */
614
+ export function granularSourceLabel(src: GranularSource | undefined): string {
615
+ const value = src ?? DEFAULT_GRANULAR_SOURCE;
616
+ if (typeof value === "string") return value;
617
+ const base = value.src.split("/").pop() ?? value.src;
618
+ return base;
619
+ }