@hraness/dawg 0.2.0 → 0.4.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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
package/core/score.ts CHANGED
@@ -6,6 +6,22 @@
6
6
  * without rewriting the score.
7
7
  */
8
8
 
9
+ import { normalizeRhythmRow, RHYTHM_LIMITS, type RhythmRow } from "./euclid.ts";
10
+ import { isDrumInstrument } from "./drums.ts";
11
+ import { synthKit, SYNTH_KIT_NAMES } from "./kits.ts";
12
+
13
+ export type { RhythmRow } from "./euclid.ts";
14
+ import {
15
+ FX_LANES,
16
+ FxValidationError,
17
+ normalizeFx,
18
+ normalizeParams,
19
+ TRACK_EFFECT_SPECS,
20
+ type FxLane,
21
+ type TrackFx,
22
+ } from "./fx.ts";
23
+ import { normalizeSynth, type TrackSynth } from "./synth.ts";
24
+
9
25
  export const SCORE_VERSION = 1 as const;
10
26
  export const DEFAULT_TICKS_PER_BEAT = 480 as const;
11
27
 
@@ -33,8 +49,198 @@ export const SCORE_LIMITS = Object.freeze({
33
49
  maxReverbMix: 1,
34
50
  minReverbSize: 0,
35
51
  maxReverbSize: 1,
52
+ /** Automation lanes kept in `fxAutomation` per track. */
53
+ maxFxLanes: 64,
54
+ /** Sampler voices per track (score v2, `sampler` field). */
55
+ maxSamplerVoices: 64,
56
+ maxSamplerVoiceNameLength: 32,
57
+ maxSamplePathLength: 256,
58
+ maxSampleUrlLength: 1024,
59
+ maxSampleGain: 2,
60
+ /** |speed| bound; negative speeds play in reverse. */
61
+ maxSampleSpeed: 8,
62
+ maxChokeGroupLength: 32,
63
+ /** Sampler `clip`/`legato` factor bound. */
64
+ maxSampleClip: 16,
65
+ /** |accelerate| bound. */
66
+ maxSampleAccelerate: 8,
67
+ /** Sampler `squiz` ratio bound. */
68
+ maxSampleSquiz: 32,
69
+ /** Per sample file, enforced by the decoder and the import tool. */
70
+ maxSampleFileBytes: 50 * 1024 * 1024,
71
+ maxSampleSeconds: 600,
72
+ /**
73
+ * In-memory decoded-sample budget. The disk caps (`.dawg/assets`, pack
74
+ * downloads) live in `src/audio/cache.ts` and follow
75
+ * `DAWG_ASSETS_CACHE_MAX` / `DAWG_PACKS_CACHE_MAX`.
76
+ */
77
+ maxSampleCacheBytes: 512 * 1024 * 1024,
36
78
  } as const);
37
79
 
80
+ /** Instrument name that selects a track's `wavetable` oscillator. */
81
+ export const WAVETABLE_INSTRUMENT = "wavetable" as const;
82
+
83
+ /**
84
+ * Warp modes (Strudel's documented `warpmode` names). Each bends the read
85
+ * phase of the table; see `src/audio/wavetable.ts` for the exact maps.
86
+ */
87
+ export const WARP_MODES = Object.freeze([
88
+ "none",
89
+ "asym",
90
+ "bendp",
91
+ "bendm",
92
+ "bendmp",
93
+ "sync",
94
+ "quant",
95
+ ] as const);
96
+ export type WarpMode = (typeof WARP_MODES)[number];
97
+
98
+ /** Prefix of a table generated in code (`builtin:basic`). */
99
+ export const BUILTIN_TABLE_PREFIX = "builtin:" as const;
100
+
101
+ /**
102
+ * A track's wavetable oscillator, with Strudel's parameter names. Every
103
+ * field but `table` is omitted at its default.
104
+ */
105
+ export type TrackWavetable = Readonly<{
106
+ /**
107
+ * `builtin:<name>`, a pinned pack sound (`pack:uzu-wavetables/wt_digital:1`)
108
+ * or a project WAV (`tracks/<slug>/wavetables/vox.wav`, sha256-pinned).
109
+ */
110
+ table: SampleRef;
111
+ /** Position in the table, 0..1 (default 0). */
112
+ wt?: number;
113
+ /** Position envelope amount, -1..1 (default 0 = off). */
114
+ wtenv?: number;
115
+ /** Position envelope times in seconds and sustain level 0..1. */
116
+ wtattack?: number;
117
+ wtdecay?: number;
118
+ wtsustain?: number;
119
+ wtrelease?: number;
120
+ /** Position LFO rate in Hz (0..50) and depth 0..1 (default 0 = off). */
121
+ wtrate?: number;
122
+ wtdepth?: number;
123
+ /** Warp amount 0..1 and mode (default none). */
124
+ warp?: number;
125
+ warpmode?: WarpMode;
126
+ /** Randomness of each note's start phase, 0..1 (seeded by the note). */
127
+ wtphaserand?: number;
128
+ }>;
129
+
130
+ /** Strudel parameter name → [min, max, default] for wavetable numbers. */
131
+ export const WAVETABLE_PARAMS = Object.freeze({
132
+ wt: [0, 1, 0],
133
+ wtenv: [-1, 1, 0],
134
+ wtattack: [0, 10, 0.01],
135
+ wtdecay: [0, 10, 0.1],
136
+ wtsustain: [0, 1, 1],
137
+ wtrelease: [0, 10, 0.1],
138
+ wtrate: [0, 50, 0],
139
+ wtdepth: [0, 1, 0],
140
+ warp: [0, 1, 0],
141
+ wtphaserand: [0, 1, 0],
142
+ } as const satisfies Record<string, readonly [number, number, number]>);
143
+ export type WavetableParam = keyof typeof WAVETABLE_PARAMS;
144
+
145
+ const BUILTIN_TABLE = /^builtin:[a-z][a-z0-9_-]{0,31}$/;
146
+
147
+ /**
148
+ * A project wavetable file: a project-relative `.wav` path (dawg writes them
149
+ * to `tracks/<slug>/wavetables/<name>.wav`).
150
+ */
151
+ export function isLocalTableSrc(src: string): boolean {
152
+ return (
153
+ !src.startsWith(PACK_PREFIX) &&
154
+ !src.startsWith(BUILTIN_TABLE_PREFIX) &&
155
+ /\.wav$/i.test(src) &&
156
+ isSafeRelativePath(src)
157
+ );
158
+ }
159
+
160
+ export function isWavetableInstrument(instrument: string | undefined): boolean {
161
+ return (
162
+ typeof instrument === "string" &&
163
+ instrument.trim().toLowerCase() === WAVETABLE_INSTRUMENT
164
+ );
165
+ }
166
+
167
+ /** The table a wavetable track plays: its own, else the built-in `basic`. */
168
+ export function wavetableOf(track: Track): TrackWavetable {
169
+ return track.wavetable ?? DEFAULT_WAVETABLE;
170
+ }
171
+
172
+ export const DEFAULT_WAVETABLE: TrackWavetable = Object.freeze({
173
+ table: Object.freeze({ src: `${BUILTIN_TABLE_PREFIX}basic` }),
174
+ });
175
+
176
+ export function normalizeWavetable(input: unknown): TrackWavetable | undefined {
177
+ if (input === undefined || input === null) return undefined;
178
+ if (!isRecord(input))
179
+ throw new ScoreValidationError(
180
+ "track wavetable must be an object or null",
181
+ "invalid-track",
182
+ );
183
+ const table = input.table;
184
+ let ref: SampleRef;
185
+ if (
186
+ isRecord(table) &&
187
+ typeof table.src === "string" &&
188
+ table.src.startsWith(BUILTIN_TABLE_PREFIX)
189
+ ) {
190
+ if (!BUILTIN_TABLE.test(table.src) || Object.keys(table).length !== 1)
191
+ throw new ScoreValidationError(
192
+ "wavetable table builtin:<name> takes no other fields",
193
+ "invalid-track",
194
+ );
195
+ ref = Object.freeze({ src: table.src });
196
+ } else {
197
+ ref = normalizeSampleRef(table, "wavetable table");
198
+ if (!isPackRef(ref.src) && !isLocalTableSrc(ref.src))
199
+ throw new ScoreValidationError(
200
+ "wavetable table must be builtin:<name>, pack:<pack>/<sound>[:<n>] or a project .wav path",
201
+ "invalid-track",
202
+ );
203
+ }
204
+ const out: Record<string, unknown> = { table: ref };
205
+ for (const name of Object.keys(WAVETABLE_PARAMS) as WavetableParam[]) {
206
+ const value = input[name];
207
+ if (value === undefined) continue;
208
+ const [min, max, fallback] = WAVETABLE_PARAMS[name];
209
+ if (typeof value !== "number" || !Number.isFinite(value))
210
+ throw new ScoreValidationError(
211
+ `wavetable ${name} must be a number ${min}..${max}`,
212
+ "invalid-track",
213
+ );
214
+ const clamped = Math.max(min, Math.min(max, value));
215
+ if (clamped !== fallback) out[name] = clamped;
216
+ }
217
+ if (input.warpmode !== undefined) {
218
+ if (!WARP_MODES.includes(input.warpmode as WarpMode))
219
+ throw new ScoreValidationError(
220
+ `wavetable warpmode must be one of ${WARP_MODES.join(", ")}`,
221
+ "invalid-track",
222
+ );
223
+ if (input.warpmode !== "none") out.warpmode = input.warpmode;
224
+ }
225
+ for (const key of Object.keys(input))
226
+ if (
227
+ key !== "table" &&
228
+ key !== "warpmode" &&
229
+ !Object.prototype.hasOwnProperty.call(WAVETABLE_PARAMS, key)
230
+ )
231
+ throw new ScoreValidationError(
232
+ `wavetable has an unknown field ${key.slice(0, 32)}`,
233
+ "invalid-track",
234
+ );
235
+ return Object.freeze(out) as TrackWavetable;
236
+ }
237
+
238
+ /** Instrument name that selects a track's `sampler`. */
239
+ export const SAMPLER_INSTRUMENT = "sampler" as const;
240
+
241
+ /** First pitch slot assigned to one-shot sampler voices (GM kick). */
242
+ export const SAMPLER_FIRST_SLOT = 36;
243
+
38
244
  export class ScoreValidationError extends Error {
39
245
  readonly code:
40
246
  | "invalid-score"
@@ -85,20 +291,179 @@ export type Track = Readonly<{
85
291
  delayMixAutomation?: readonly AutomationPoint[];
86
292
  /** Algorithmic stereo reverb send, mixed after the delay. */
87
293
  reverb?: TrackReverb;
294
+ /**
295
+ * Optional: insert effects by name (`core/fx.ts`), each with every
296
+ * parameter stored. Rendered in `FX_CHAIN` order.
297
+ */
298
+ fx?: TrackFx;
299
+ /** Optional: automation for `fx` parameters, keyed `<effect>-<param>`. */
300
+ fxAutomation?: Readonly<Partial<Record<FxLane, readonly AutomationPoint[]>>>;
301
+ /**
302
+ * Optional: synth voice parameters (`core/synth.ts`), Strudel names.
303
+ * Only the parameters a document sets are stored; lanes are
304
+ * `fxAutomation["synth-<param>"]`, read at note onsets.
305
+ */
306
+ synth?: TrackSynth;
307
+ /**
308
+ * Wavetable oscillator settings (Strudel names). Used when `instrument`
309
+ * is `"wavetable"`; kept when the instrument changes so switching back
310
+ * restores them. Absent means the built-in `basic` table at position 0.
311
+ */
312
+ wavetable?: TrackWavetable;
313
+ /** Wavetable position (`wt`, 0..1) control points; overrides the static `wt`. */
314
+ wtAutomation?: readonly AutomationPoint[];
315
+ /**
316
+ * Score v2: sample voices; present exactly when `instrument` is
317
+ * `"sampler"`. Documents without it decode unchanged.
318
+ */
319
+ sampler?: Sampler;
320
+ /**
321
+ * Generated drum rows (Euclidean or grid, see `core/euclid.ts`). The notes
322
+ * they generate are stored as ordinary notes; the rows are the editable
323
+ * source for those voices. Absent when empty.
324
+ */
325
+ rhythm?: readonly RhythmRow[];
326
+ /**
327
+ * Synthesized drum kit for an `instrument: "kit"` track (`syn808`,
328
+ * `lofi`, … see `core/kits.ts`). Absent plays the default voices.
329
+ */
330
+ kit?: string;
331
+ }>;
332
+
333
+ /**
334
+ * A track's sample voices (Strudel-aligned). In `oneshot` mode every voice is
335
+ * a drum-like hit addressed by the pitch slot `samplerVoiceSlots` assigns
336
+ * (36, 37, … in voice-name order); in `keyed` mode notes are ordinary pitches
337
+ * and a voice is resampled from its `root`.
338
+ */
339
+ export type Sampler = Readonly<{
340
+ voices: Readonly<Record<string, SampleRef>>;
341
+ mode: "oneshot" | "keyed";
342
+ }>;
343
+
344
+ export type SampleRef = Readonly<{
345
+ /**
346
+ * Project-relative path, normally `tracks/<slug>/samples/<file>`, or a
347
+ * pack sound `pack:<pack>/<sound>[:<n>]` (`pack:tidal-drum-machines/RolandTR909_bd:0`).
348
+ */
349
+ src: string;
350
+ /** Content hash (64 hex) when known; the decoded cache is keyed by it. */
351
+ sha256?: string;
352
+ /** Pack sounds: the pinned HTTPS file the sound resolved to. */
353
+ url?: string;
354
+ /** Pack sounds: the pack's license (SPDX id or `none stated`). */
355
+ license?: string;
356
+ /** MIDI note the file plays at in keyed mode, default 60. */
357
+ root?: number;
358
+ /** Start fraction 0..1 of the file. */
359
+ begin?: number;
360
+ /** End fraction 0..1 of the file, greater than `begin`. */
361
+ end?: number;
362
+ /** Linear gain 0..2. */
363
+ gain?: number;
364
+ /** Playback rate; negative reverses. |speed| ≤ 8, never 0. */
365
+ speed?: number;
366
+ /** Sustain by looping begin..end. */
367
+ loop?: boolean;
368
+ /** Choke group: a new hit in the group stops the previous one. */
369
+ choke?: string;
370
+ /**
371
+ * Optional (Strudel `loopBegin`/`loopb`, `loopEnd`/`loope`): the looped
372
+ * part of the window, fractions of the file with
373
+ * begin ≤ loopBegin < loopEnd ≤ end. Playback starts at `begin`.
374
+ */
375
+ loopBegin?: number;
376
+ loopEnd?: number;
377
+ /**
378
+ * Optional (Strudel `clip`/`legato`): the voice lasts the note's length
379
+ * times this, cutting the sample off; a oneshot voice otherwise plays
380
+ * the whole window. 0 < clip ≤ 16.
381
+ */
382
+ clip?: number;
383
+ /**
384
+ * Optional (Strudel/Tidal `unit`): how `speed` reads. `r` (default) is a
385
+ * rate; `c`: the window lasts 1/|speed| cycles (a cycle is one bar);
386
+ * `s`: the window lasts |speed| seconds. Negative still reverses.
387
+ */
388
+ unit?: SampleUnit;
389
+ /** Optional (Strudel `fit`): the window lasts exactly the note's length. */
390
+ fit?: boolean;
391
+ /**
392
+ * Optional (Tidal/Strudel `accelerate`): the rate ramps linearly by this
393
+ * many times the starting rate over the voice (−8..8; −1 slows to a stop).
394
+ */
395
+ accelerate?: number;
396
+ /**
397
+ * Optional (Tidal/Strudel `squiz`): raise the pitch by this ratio inside
398
+ * each zero-crossing cycle, repeating the cycle to keep the length
399
+ * (1 is off, up to 32).
400
+ */
401
+ squiz?: number;
88
402
  }>;
89
403
 
404
+ /** Sample `unit`: rate, cycles (bars) or seconds. */
405
+ export type SampleUnit = "r" | "c" | "s";
406
+ export const SAMPLE_UNITS: readonly SampleUnit[] = Object.freeze([
407
+ "r",
408
+ "c",
409
+ "s",
410
+ ]);
411
+
90
412
  export type TrackReverb = Readonly<{
91
413
  /** Wet level added to the dry signal, 0..1. */
92
414
  mix: number;
93
415
  /** Room size 0..1: longer comb feedback and a darker, longer tail. */
94
416
  size: number;
417
+ /** Optional: decay time to -60 dB in seconds; overrides the size-derived decay. */
418
+ fade?: number;
419
+ /** Optional: low-pass on the reverb input, Hz. */
420
+ lowpass?: number;
421
+ /** Optional: the tail darkens toward this frequency (Hz) as it decays. */
422
+ dim?: number;
423
+ /** Optional: seconds before the tail starts. */
424
+ predelay?: number;
425
+ /**
426
+ * Optional (Strudel `iresponse`/`ir`): convolve with this impulse instead
427
+ * of the algorithmic tail. `builtin:room|hall|plate`, a pinned pack sound
428
+ * or a project file; `size`, `fade` and `dim` then do nothing.
429
+ */
430
+ ir?: SampleRef;
95
431
  }>;
96
432
 
433
+ /** Generated impulse responses `reverb.ir` can name as `builtin:<name>`. */
434
+ export const REVERB_IR_BUILTINS = Object.freeze(["room", "hall", "plate"]);
435
+
436
+ /**
437
+ * Validates `reverb.ir`: a string or `{ src, … }`. Bare built-in names and
438
+ * `builtin:<name>` become `{ src: "builtin:<name>" }`; anything else is a
439
+ * sample reference (pack sound or project-relative file).
440
+ */
441
+ export function normalizeReverbIr(input: unknown): SampleRef {
442
+ const ref = typeof input === "string" ? { src: input } : input;
443
+ if (isRecord(ref) && typeof ref.src === "string") {
444
+ const bare = REVERB_IR_BUILTINS.includes(ref.src);
445
+ if (bare || ref.src.startsWith(BUILTIN_TABLE_PREFIX)) {
446
+ const name = bare ? ref.src : ref.src.slice(BUILTIN_TABLE_PREFIX.length);
447
+ if (!REVERB_IR_BUILTINS.includes(name) || Object.keys(ref).length !== 1)
448
+ throw new ScoreValidationError(
449
+ `track reverb ir builtin must be one of ${REVERB_IR_BUILTINS.map((n) => `builtin:${n}`).join(", ")} with no other fields`,
450
+ "invalid-track",
451
+ );
452
+ return Object.freeze({ src: `${BUILTIN_TABLE_PREFIX}${name}` });
453
+ }
454
+ }
455
+ return normalizeSampleRef(ref, "reverb ir");
456
+ }
457
+
97
458
  export type TrackFilter = Readonly<{
98
459
  /** Cutoff frequency in Hz, 20..20000. */
99
460
  cutoff: number;
100
461
  /** Resonance 0..1, mapped to a bounded biquad Q. */
101
462
  resonance: number;
463
+ /** Optional: filter type; absent is the original low-pass. */
464
+ type?: "lpf" | "hpf" | "bpf";
465
+ /** Optional: slope; absent is the original 12 dB/oct biquad. */
466
+ ftype?: "12db" | "24db" | "ladder";
102
467
  }>;
103
468
 
104
469
  export type TrackDelay = Readonly<{
@@ -108,15 +473,31 @@ export type TrackDelay = Readonly<{
108
473
  feedback: number;
109
474
  /** Wet level added to the dry signal, 0..1. */
110
475
  mix: number;
476
+ /** Optional: delay time in seconds; 0/absent follows `beats`. */
477
+ time?: number;
478
+ /** Optional: repeats alternate left/right; absent is the original cross-fed stereo. */
479
+ pingpong?: boolean;
480
+ /** Optional: low-pass (Hz) inside the feedback loop. */
481
+ highcut?: number;
111
482
  }>;
112
483
 
113
- export type AutomationParameter =
114
- "volume" | "pan" | "filter" | "resonance" | "delay-feedback" | "delay-mix";
484
+ /** The lanes stored in their own track fields (`AUTOMATION_LANES`). */
485
+ export type TrackAutomationParameter =
486
+ | "volume"
487
+ | "pan"
488
+ | "filter"
489
+ | "resonance"
490
+ | "delay-feedback"
491
+ | "delay-mix"
492
+ | "wt";
493
+
494
+ /** Every automation lane: the track-field lanes plus `fx` lanes. */
495
+ export type AutomationParameter = TrackAutomationParameter | FxLane;
115
496
 
116
497
  /** Track field and value range for every automation lane. */
117
498
  export const AUTOMATION_LANES: Readonly<
118
499
  Record<
119
- AutomationParameter,
500
+ TrackAutomationParameter,
120
501
  Readonly<{
121
502
  field:
122
503
  | "volumeAutomation"
@@ -124,7 +505,8 @@ export const AUTOMATION_LANES: Readonly<
124
505
  | "filterAutomation"
125
506
  | "resonanceAutomation"
126
507
  | "delayFeedbackAutomation"
127
- | "delayMixAutomation";
508
+ | "delayMixAutomation"
509
+ | "wtAutomation";
128
510
  min: number;
129
511
  max: number;
130
512
  }>
@@ -152,17 +534,69 @@ export const AUTOMATION_LANES: Readonly<
152
534
  min: 0,
153
535
  max: SCORE_LIMITS.maxDelayMix,
154
536
  },
537
+ wt: { field: "wtAutomation", min: 0, max: 1 },
155
538
  });
156
539
 
157
- export function isAutomationParameter(
540
+ const FX_LANE_RANGES: ReadonlyMap<
541
+ string,
542
+ Readonly<{ min: number; max: number }>
543
+ > = new Map(
544
+ FX_LANES.map(({ lane, spec }) => [lane, { min: spec.min, max: spec.max }]),
545
+ );
546
+
547
+ export function isTrackAutomationParameter(
158
548
  value: unknown,
159
- ): value is AutomationParameter {
549
+ ): value is TrackAutomationParameter {
160
550
  return (
161
551
  typeof value === "string" &&
162
552
  Object.prototype.hasOwnProperty.call(AUTOMATION_LANES, value)
163
553
  );
164
554
  }
165
555
 
556
+ export function isAutomationParameter(
557
+ value: unknown,
558
+ ): value is AutomationParameter {
559
+ return (
560
+ isTrackAutomationParameter(value) ||
561
+ (typeof value === "string" && FX_LANE_RANGES.has(value))
562
+ );
563
+ }
564
+
565
+ /** Every automation lane name, track-field lanes first. */
566
+ export const AUTOMATION_PARAMETERS: readonly AutomationParameter[] =
567
+ Object.freeze([
568
+ ...(Object.keys(AUTOMATION_LANES) as TrackAutomationParameter[]),
569
+ ...FX_LANES.map(({ lane }) => lane),
570
+ ]);
571
+
572
+ /** Value range of any lane. */
573
+ export function automationRange(
574
+ parameter: AutomationParameter,
575
+ ): Readonly<{ min: number; max: number }> {
576
+ if (isTrackAutomationParameter(parameter)) {
577
+ const { min, max } = AUTOMATION_LANES[parameter];
578
+ return { min, max };
579
+ }
580
+ const range = FX_LANE_RANGES.get(parameter);
581
+ if (!range)
582
+ throw new ScoreValidationError(
583
+ `unknown automation parameter: ${String(parameter)}`,
584
+ "invalid-track",
585
+ );
586
+ return range;
587
+ }
588
+
589
+ /** A track's points on any lane (empty when it has none). */
590
+ export function automationPoints(
591
+ track: Track | undefined,
592
+ parameter: AutomationParameter,
593
+ ): readonly AutomationPoint[] {
594
+ if (!track) return [];
595
+ if (isTrackAutomationParameter(parameter))
596
+ return track[AUTOMATION_LANES[parameter].field] ?? [];
597
+ return track.fxAutomation?.[parameter as FxLane] ?? [];
598
+ }
599
+
166
600
  /** Track fields that score operations may patch; `null` clears an effect. */
167
601
  export type TrackPatch = Readonly<
168
602
  Partial<
@@ -180,11 +614,19 @@ export type TrackPatch = Readonly<
180
614
  | "resonanceAutomation"
181
615
  | "delayFeedbackAutomation"
182
616
  | "delayMixAutomation"
617
+ | "wtAutomation"
183
618
  >
184
619
  > & {
185
620
  filter?: TrackFilter | null;
186
621
  delay?: TrackDelay | null;
187
622
  reverb?: TrackReverb | null;
623
+ sampler?: Sampler | null;
624
+ rhythm?: readonly RhythmRow[] | null;
625
+ kit?: string | null;
626
+ fx?: TrackFx | null;
627
+ fxAutomation?: Track["fxAutomation"] | null;
628
+ synth?: TrackSynth | null;
629
+ wavetable?: TrackWavetable | null;
188
630
  }
189
631
  >;
190
632
 
@@ -204,11 +646,30 @@ export type Note = Readonly<{
204
646
  }>;
205
647
 
206
648
  export type TrackInput = Readonly<
207
- Omit<Partial<Track>, "filter" | "delay" | "reverb"> &
649
+ Omit<
650
+ Partial<Track>,
651
+ | "filter"
652
+ | "delay"
653
+ | "reverb"
654
+ | "sampler"
655
+ | "rhythm"
656
+ | "kit"
657
+ | "fx"
658
+ | "fxAutomation"
659
+ | "synth"
660
+ | "wavetable"
661
+ > &
208
662
  Pick<Track, "id"> & {
209
663
  filter?: TrackFilter | null;
210
664
  delay?: TrackDelay | null;
211
665
  reverb?: TrackReverb | null;
666
+ sampler?: Sampler | null;
667
+ rhythm?: readonly RhythmRow[] | null;
668
+ kit?: string | null;
669
+ fx?: TrackFx | null;
670
+ fxAutomation?: Track["fxAutomation"] | null;
671
+ synth?: TrackSynth | null;
672
+ wavetable?: TrackWavetable | null;
212
673
  }
213
674
  >;
214
675
 
@@ -348,6 +809,15 @@ export class TrackScore {
348
809
  return new TrackScore({ ...this.toJSON(), bars });
349
810
  }
350
811
 
812
+ withKey(key: string | null): TrackScore {
813
+ return new TrackScore({ ...this.toJSON(), key });
814
+ }
815
+
816
+ /** Change the meter; ticks are per beat, so notes keep their positions. */
817
+ withMeter(beatsPerBar: number): TrackScore {
818
+ return new TrackScore({ ...this.toJSON(), beatsPerBar });
819
+ }
820
+
351
821
  toJSON(): TrackScoreData & { version: typeof SCORE_VERSION } {
352
822
  return {
353
823
  version: SCORE_VERSION,
@@ -476,6 +946,62 @@ export function updateTrack(
476
946
  );
477
947
  }
478
948
 
949
+ /** Removes a track and every note on it; unknown ids are a no-op. */
950
+ export function removeTrack(score: TrackScore, trackId: string): TrackScore {
951
+ if (!score.tracks.some((track) => track.id === trackId)) return score;
952
+ return new TrackScore({
953
+ ...score.toJSON(),
954
+ tracks: score.tracks.filter((track) => track.id !== trackId),
955
+ notes: score.notes.filter((note) => note.trackId !== trackId),
956
+ });
957
+ }
958
+
959
+ /** Moves a track to position `index` (clamped) in score order. */
960
+ export function moveTrack(
961
+ score: TrackScore,
962
+ trackId: string,
963
+ index: number,
964
+ ): TrackScore {
965
+ const from = score.tracks.findIndex((track) => track.id === trackId);
966
+ if (from < 0 || !Number.isInteger(index))
967
+ throw new ScoreValidationError(
968
+ "moveTrack needs an existing track and an integer index",
969
+ "invalid-track",
970
+ );
971
+ const to = Math.max(0, Math.min(score.tracks.length - 1, index));
972
+ if (from === to) return score;
973
+ const tracks = [...score.tracks];
974
+ const [moved] = tracks.splice(from, 1);
975
+ tracks.splice(to, 0, moved!);
976
+ return score.withTracks(tracks);
977
+ }
978
+
979
+ export function isSamplerInstrument(instrument: string | undefined): boolean {
980
+ return (
981
+ typeof instrument === "string" &&
982
+ instrument.trim().toLowerCase() === SAMPLER_INSTRUMENT
983
+ );
984
+ }
985
+
986
+ /**
987
+ * One-shot voices are addressed by pitch slots so the drum-lane projection
988
+ * and note tools keep working: voice names sorted by code point, slots from
989
+ * `SAMPLER_FIRST_SLOT` upward. Keyed samplers have no slots.
990
+ */
991
+ export function samplerVoiceSlots(
992
+ sampler: Sampler,
993
+ ): ReadonlyMap<string, number> {
994
+ const slots = new Map<string, number>();
995
+ if (sampler.mode !== "oneshot") return slots;
996
+ const names = Object.keys(sampler.voices).sort(compareCodePoints);
997
+ names.forEach((name, index) => slots.set(name, SAMPLER_FIRST_SLOT + index));
998
+ return slots;
999
+ }
1000
+
1001
+ function compareCodePoints(a: string, b: string): number {
1002
+ return a < b ? -1 : a > b ? 1 : 0;
1003
+ }
1004
+
479
1005
  export function setVolumeAutomation(
480
1006
  score: TrackScore,
481
1007
  trackId: string,
@@ -512,9 +1038,18 @@ export function setTrackAutomation(
512
1038
  `unknown automation parameter: ${String(parameter)}`,
513
1039
  "invalid-track",
514
1040
  );
515
- return updateTrack(score, trackId, {
516
- [AUTOMATION_LANES[parameter].field]: points,
517
- });
1041
+ if (isTrackAutomationParameter(parameter))
1042
+ return updateTrack(score, trackId, {
1043
+ [AUTOMATION_LANES[parameter].field]: points,
1044
+ });
1045
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
1046
+ if (!track) return score;
1047
+ const lanes: Partial<Record<FxLane, readonly AutomationPoint[]>> = {
1048
+ ...track.fxAutomation,
1049
+ };
1050
+ if (points.length > 0) lanes[parameter] = points;
1051
+ else delete lanes[parameter];
1052
+ return updateTrack(score, trackId, { fxAutomation: lanes });
518
1053
  }
519
1054
 
520
1055
  /** Mute always silences a track; any solo silences every unsoloed track. */
@@ -569,12 +1104,36 @@ export type ScoreOperation =
569
1104
  | Readonly<{
570
1105
  type: "clearTrack";
571
1106
  trackId: string;
1107
+ }>
1108
+ | Readonly<{
1109
+ type: "removeTrack";
1110
+ trackId: string;
1111
+ }>
1112
+ | Readonly<{
1113
+ type: "moveTrack";
1114
+ trackId: string;
1115
+ index: number;
1116
+ }>
1117
+ | Readonly<{
1118
+ type: "setKey";
1119
+ key: string | null;
1120
+ }>
1121
+ | Readonly<{
1122
+ type: "setMeter";
1123
+ beatsPerBar: number;
572
1124
  }>;
573
1125
 
574
1126
  export function applyScoreOperation(
575
1127
  score: TrackScore,
576
1128
  operation: ScoreOperation,
577
1129
  ): TrackScore {
1130
+ if (operation.type === "removeTrack")
1131
+ return removeTrack(score, operation.trackId);
1132
+ if (operation.type === "moveTrack")
1133
+ return moveTrack(score, operation.trackId, operation.index);
1134
+ if (operation.type === "setKey") return score.withKey(operation.key);
1135
+ if (operation.type === "setMeter")
1136
+ return score.withMeter(operation.beatsPerBar);
578
1137
  if (operation.type === "addTrack") return addTrack(score, operation.track);
579
1138
  if (operation.type === "addNote") return addNote(score, operation.note);
580
1139
  if (operation.type === "removeNote")
@@ -728,14 +1287,47 @@ function normalizeTrack(input: unknown): Track {
728
1287
  SCORE_LIMITS.minFilterCutoff,
729
1288
  SCORE_LIMITS.maxFilterCutoff,
730
1289
  );
731
- const lane = (parameter: AutomationParameter) => {
1290
+ const lane = (parameter: TrackAutomationParameter) => {
732
1291
  const { field, min, max } = AUTOMATION_LANES[parameter];
733
1292
  return normalizeAutomation(input[field], field, min, max);
734
1293
  };
735
1294
  const resonanceAutomation = lane("resonance");
736
1295
  const delayFeedbackAutomation = lane("delay-feedback");
737
1296
  const delayMixAutomation = lane("delay-mix");
1297
+ const wtAutomation = lane("wt");
738
1298
  const reverb = normalizeReverb(input.reverb);
1299
+ const fx = fxOrThrow(() => normalizeFx(input.fx));
1300
+ const fxAutomation = normalizeFxAutomation(input.fxAutomation);
1301
+ const synth = fxOrThrow(() => normalizeSynth(input.synth));
1302
+ const wavetable = normalizeWavetable(input.wavetable);
1303
+ const sampler = normalizeSampler(input.sampler);
1304
+ const rhythm = normalizeRhythm(input.rhythm, id);
1305
+ let kit: string | undefined;
1306
+ if (input.kit !== undefined && input.kit !== null) {
1307
+ const found =
1308
+ typeof input.kit === "string" ? synthKit(input.kit) : undefined;
1309
+ if (!found)
1310
+ throw new ScoreValidationError(
1311
+ `track ${id} kit must be one of ${SYNTH_KIT_NAMES.join(", ")}`,
1312
+ "invalid-track",
1313
+ );
1314
+ if (!isDrumInstrument(instrument))
1315
+ throw new ScoreValidationError(
1316
+ `track ${id} has a kit but its instrument is "${instrument}"`,
1317
+ "invalid-track",
1318
+ );
1319
+ kit = found.name;
1320
+ }
1321
+ if (isSamplerInstrument(instrument) && !sampler)
1322
+ throw new ScoreValidationError(
1323
+ `track ${id} instrument "sampler" needs a sampler`,
1324
+ "invalid-track",
1325
+ );
1326
+ if (sampler && !isSamplerInstrument(instrument))
1327
+ throw new ScoreValidationError(
1328
+ `track ${id} has a sampler but its instrument is "${instrument}"`,
1329
+ "invalid-track",
1330
+ );
739
1331
  return Object.freeze({
740
1332
  id,
741
1333
  name,
@@ -753,9 +1345,436 @@ function normalizeTrack(input: unknown): Track {
753
1345
  ...(delayFeedbackAutomation.length > 0 ? { delayFeedbackAutomation } : {}),
754
1346
  ...(delayMixAutomation.length > 0 ? { delayMixAutomation } : {}),
755
1347
  ...(reverb ? { reverb } : {}),
1348
+ ...(fx ? { fx } : {}),
1349
+ ...(fxAutomation ? { fxAutomation } : {}),
1350
+ ...(synth ? { synth } : {}),
1351
+ ...(wavetable ? { wavetable } : {}),
1352
+ ...(wtAutomation.length > 0 ? { wtAutomation } : {}),
1353
+ ...(sampler ? { sampler } : {}),
1354
+ ...(rhythm ? { rhythm } : {}),
1355
+ ...(kit ? { kit } : {}),
756
1356
  });
757
1357
  }
758
1358
 
1359
+ export function normalizeRhythm(
1360
+ input: unknown,
1361
+ trackId: string,
1362
+ ): readonly RhythmRow[] | undefined {
1363
+ if (input === undefined || input === null) return undefined;
1364
+ if (!Array.isArray(input) || input.length > RHYTHM_LIMITS.maxRows)
1365
+ throw new ScoreValidationError(
1366
+ `track ${trackId} rhythm must be an array of at most ${RHYTHM_LIMITS.maxRows} rows`,
1367
+ "invalid-track",
1368
+ );
1369
+ if (input.length === 0) return undefined;
1370
+ const rows = input.map((row: unknown, index) => {
1371
+ try {
1372
+ return normalizeRhythmRow(row, `track ${trackId} rhythm[${index}]`);
1373
+ } catch (error) {
1374
+ throw new ScoreValidationError(
1375
+ error instanceof Error ? error.message : String(error),
1376
+ "invalid-track",
1377
+ );
1378
+ }
1379
+ });
1380
+ const voices = new Set(rows.map((row) => row.voice));
1381
+ if (voices.size !== rows.length)
1382
+ throw new ScoreValidationError(
1383
+ `track ${trackId} rhythm has two rows for one voice`,
1384
+ "invalid-track",
1385
+ );
1386
+ return Object.freeze(rows);
1387
+ }
1388
+
1389
+ function fxOrThrow<T>(run: () => T): T {
1390
+ try {
1391
+ return run();
1392
+ } catch (error) {
1393
+ if (error instanceof FxValidationError)
1394
+ throw new ScoreValidationError(error.message, "invalid-track");
1395
+ throw error;
1396
+ }
1397
+ }
1398
+
1399
+ /** Validates `fxAutomation`; empty lanes are dropped, keys in lane order. */
1400
+ function normalizeFxAutomation(
1401
+ input: unknown,
1402
+ ): Track["fxAutomation"] | undefined {
1403
+ if (input === undefined || input === null) return undefined;
1404
+ if (!isRecord(input))
1405
+ throw new ScoreValidationError(
1406
+ "track fxAutomation must be an object",
1407
+ "invalid-track",
1408
+ );
1409
+ const keys = Object.keys(input);
1410
+ if (keys.length > SCORE_LIMITS.maxFxLanes)
1411
+ throw new ScoreValidationError(
1412
+ `track fxAutomation holds at most ${SCORE_LIMITS.maxFxLanes} lanes`,
1413
+ "score-limit",
1414
+ );
1415
+ for (const key of keys)
1416
+ if (!FX_LANE_RANGES.has(key))
1417
+ throw new ScoreValidationError(
1418
+ `unknown fx automation lane "${key}"`,
1419
+ "invalid-track",
1420
+ );
1421
+ const out: Partial<Record<FxLane, readonly AutomationPoint[]>> = {};
1422
+ for (const { lane, spec } of FX_LANES) {
1423
+ if (input[lane] === undefined) continue;
1424
+ const points = normalizeAutomation(
1425
+ input[lane],
1426
+ `fxAutomation ${lane}`,
1427
+ spec.min,
1428
+ spec.max,
1429
+ );
1430
+ if (points.length > 0) out[lane] = points;
1431
+ }
1432
+ return Object.keys(out).length > 0 ? Object.freeze(out) : undefined;
1433
+ }
1434
+
1435
+ /** The optional parameters of a track effect; absent keys stay absent. */
1436
+ function trackEffectExtras(
1437
+ name: keyof typeof TRACK_EFFECT_SPECS,
1438
+ input: Record<string, unknown>,
1439
+ keys: readonly string[],
1440
+ ): Record<string, unknown> {
1441
+ const params = TRACK_EFFECT_SPECS[name].params as Record<
1442
+ string,
1443
+ Parameters<typeof normalizeParams>[0][string]
1444
+ >;
1445
+ const subset: Record<string, Parameters<typeof normalizeParams>[0][string]> =
1446
+ {};
1447
+ const values: Record<string, unknown> = {};
1448
+ for (const key of keys) {
1449
+ subset[key] = params[key]!;
1450
+ if (input[key] !== undefined) values[key] = input[key];
1451
+ }
1452
+ return {
1453
+ ...fxOrThrow(() => normalizeParams(subset, values, `track ${name}`)),
1454
+ };
1455
+ }
1456
+
1457
+ const VOICE_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
1458
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
1459
+
1460
+ /** Validates a `Sampler` from unknown; `undefined`/`null` means none. */
1461
+ export function normalizeSampler(input: unknown): Sampler | undefined {
1462
+ if (input === undefined || input === null) return undefined;
1463
+ if (!isRecord(input))
1464
+ throw new ScoreValidationError(
1465
+ "track sampler must be an object or null",
1466
+ "invalid-track",
1467
+ );
1468
+ const mode = input.mode ?? "oneshot";
1469
+ if (mode !== "oneshot" && mode !== "keyed")
1470
+ throw new ScoreValidationError(
1471
+ 'sampler mode must be "oneshot" or "keyed"',
1472
+ "invalid-track",
1473
+ );
1474
+ if (!isRecord(input.voices))
1475
+ throw new ScoreValidationError(
1476
+ "sampler voices must be an object of name → sample",
1477
+ "invalid-track",
1478
+ );
1479
+ const names = Object.keys(input.voices);
1480
+ if (names.length === 0 || names.length > SCORE_LIMITS.maxSamplerVoices)
1481
+ throw new ScoreValidationError(
1482
+ `sampler must have between 1 and ${SCORE_LIMITS.maxSamplerVoices} voices`,
1483
+ "score-limit",
1484
+ );
1485
+ const voices: Record<string, SampleRef> = {};
1486
+ for (const name of names.sort(compareCodePoints)) {
1487
+ if (
1488
+ name.length > SCORE_LIMITS.maxSamplerVoiceNameLength ||
1489
+ !VOICE_NAME.test(name)
1490
+ )
1491
+ throw new ScoreValidationError(
1492
+ `sampler voice name "${name.slice(0, 40)}" must match ${VOICE_NAME.source} and be at most ${SCORE_LIMITS.maxSamplerVoiceNameLength} characters`,
1493
+ "invalid-track",
1494
+ );
1495
+ voices[name] = normalizeSampleRef(input.voices[name], name);
1496
+ }
1497
+ return Object.freeze({ voices: Object.freeze(voices), mode });
1498
+ }
1499
+
1500
+ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
1501
+ const label = `sampler voice ${name}`;
1502
+ if (!isRecord(input))
1503
+ throw new ScoreValidationError(
1504
+ `${label} must be an object`,
1505
+ "invalid-track",
1506
+ );
1507
+ const src = input.src;
1508
+ const packRef = typeof src === "string" && src.startsWith(PACK_PREFIX);
1509
+ if (
1510
+ typeof src !== "string" ||
1511
+ src.length === 0 ||
1512
+ src.length > SCORE_LIMITS.maxSamplePathLength ||
1513
+ (packRef ? !isPackRef(src) : !isSafeRelativePath(src))
1514
+ )
1515
+ throw new ScoreValidationError(
1516
+ packRef
1517
+ ? `${label} src must be pack:<pack>/<sound>[:<n>], at most ${SCORE_LIMITS.maxSamplePathLength} characters`
1518
+ : `${label} src must be a project-relative path without "..", at most ${SCORE_LIMITS.maxSamplePathLength} characters`,
1519
+ "invalid-track",
1520
+ );
1521
+ const ref: {
1522
+ src: string;
1523
+ sha256?: string;
1524
+ url?: string;
1525
+ license?: string;
1526
+ root?: number;
1527
+ begin?: number;
1528
+ end?: number;
1529
+ gain?: number;
1530
+ speed?: number;
1531
+ loop?: boolean;
1532
+ choke?: string;
1533
+ loopBegin?: number;
1534
+ loopEnd?: number;
1535
+ clip?: number;
1536
+ unit?: SampleUnit;
1537
+ fit?: boolean;
1538
+ accelerate?: number;
1539
+ squiz?: number;
1540
+ } = { src };
1541
+ if (input.sha256 !== undefined) {
1542
+ if (typeof input.sha256 !== "string" || !SHA256_HEX.test(input.sha256))
1543
+ throw new ScoreValidationError(
1544
+ `${label} sha256 must be 64 lowercase hex characters`,
1545
+ "invalid-track",
1546
+ );
1547
+ ref.sha256 = input.sha256;
1548
+ }
1549
+ if (input.url !== undefined) {
1550
+ if (!packRef || !isPinnedSampleUrl(input.url))
1551
+ throw new ScoreValidationError(
1552
+ `${label} url must be an https URL without credentials, at most ${SCORE_LIMITS.maxSampleUrlLength} characters, on a pack: sound`,
1553
+ "invalid-track",
1554
+ );
1555
+ ref.url = input.url;
1556
+ }
1557
+ if (input.license !== undefined) {
1558
+ if (
1559
+ typeof input.license !== "string" ||
1560
+ !/^[\x20-\x7e]{1,64}$/.test(input.license)
1561
+ )
1562
+ throw new ScoreValidationError(
1563
+ `${label} license must be a short label such as CC0-1.0`,
1564
+ "invalid-track",
1565
+ );
1566
+ ref.license = input.license;
1567
+ }
1568
+ if (input.root !== undefined) {
1569
+ if (
1570
+ typeof input.root !== "number" ||
1571
+ !Number.isInteger(input.root) ||
1572
+ input.root < 0 ||
1573
+ input.root > 127
1574
+ )
1575
+ throw new ScoreValidationError(
1576
+ `${label} root must be a MIDI integer between 0 and 127`,
1577
+ "invalid-track",
1578
+ );
1579
+ ref.root = input.root;
1580
+ }
1581
+ const begin = input.begin === undefined ? 0 : input.begin;
1582
+ const end = input.end === undefined ? 1 : input.end;
1583
+ if (
1584
+ typeof begin !== "number" ||
1585
+ typeof end !== "number" ||
1586
+ !Number.isFinite(begin) ||
1587
+ !Number.isFinite(end) ||
1588
+ begin < 0 ||
1589
+ end > 1 ||
1590
+ begin >= end
1591
+ )
1592
+ throw new ScoreValidationError(
1593
+ `${label} begin/end must be fractions with 0 ≤ begin < end ≤ 1`,
1594
+ "invalid-track",
1595
+ );
1596
+ if (input.begin !== undefined) ref.begin = begin;
1597
+ if (input.end !== undefined) ref.end = end;
1598
+ if (input.gain !== undefined)
1599
+ ref.gain = boundedNumber(
1600
+ input.gain,
1601
+ `${label} gain`,
1602
+ 0,
1603
+ SCORE_LIMITS.maxSampleGain,
1604
+ );
1605
+ if (input.speed !== undefined) {
1606
+ const speed = boundedNumber(
1607
+ input.speed,
1608
+ `${label} speed`,
1609
+ -SCORE_LIMITS.maxSampleSpeed,
1610
+ SCORE_LIMITS.maxSampleSpeed,
1611
+ );
1612
+ if (speed === 0)
1613
+ throw new ScoreValidationError(
1614
+ `${label} speed cannot be 0`,
1615
+ "invalid-track",
1616
+ );
1617
+ ref.speed = speed;
1618
+ }
1619
+ if (input.loop !== undefined) {
1620
+ if (typeof input.loop !== "boolean")
1621
+ throw new ScoreValidationError(
1622
+ `${label} loop must be boolean`,
1623
+ "invalid-track",
1624
+ );
1625
+ ref.loop = input.loop;
1626
+ }
1627
+ if (input.choke !== undefined) {
1628
+ if (
1629
+ typeof input.choke !== "string" ||
1630
+ input.choke.length === 0 ||
1631
+ input.choke.length > SCORE_LIMITS.maxChokeGroupLength ||
1632
+ !VOICE_NAME.test(input.choke)
1633
+ )
1634
+ throw new ScoreValidationError(
1635
+ `${label} choke must be a short group name`,
1636
+ "invalid-track",
1637
+ );
1638
+ ref.choke = input.choke;
1639
+ }
1640
+ if (input.loopBegin !== undefined || input.loopEnd !== undefined) {
1641
+ const loopBegin = input.loopBegin === undefined ? begin : input.loopBegin;
1642
+ const loopEnd = input.loopEnd === undefined ? end : input.loopEnd;
1643
+ if (
1644
+ typeof loopBegin !== "number" ||
1645
+ typeof loopEnd !== "number" ||
1646
+ !Number.isFinite(loopBegin) ||
1647
+ !Number.isFinite(loopEnd) ||
1648
+ loopBegin < begin ||
1649
+ loopEnd > end ||
1650
+ loopBegin >= loopEnd
1651
+ )
1652
+ throw new ScoreValidationError(
1653
+ `${label} loopBegin/loopEnd must be fractions with begin ≤ loopBegin < loopEnd ≤ end`,
1654
+ "invalid-track",
1655
+ );
1656
+ if (input.loopBegin !== undefined) ref.loopBegin = loopBegin;
1657
+ if (input.loopEnd !== undefined) ref.loopEnd = loopEnd;
1658
+ }
1659
+ if (input.clip !== undefined) {
1660
+ const clip = boundedNumber(
1661
+ input.clip,
1662
+ `${label} clip`,
1663
+ 0,
1664
+ SCORE_LIMITS.maxSampleClip,
1665
+ );
1666
+ if (clip === 0)
1667
+ throw new ScoreValidationError(
1668
+ `${label} clip must be greater than 0`,
1669
+ "invalid-track",
1670
+ );
1671
+ ref.clip = clip;
1672
+ }
1673
+ if (input.unit !== undefined) {
1674
+ if (!SAMPLE_UNITS.includes(input.unit as SampleUnit))
1675
+ throw new ScoreValidationError(
1676
+ `${label} unit must be "r", "c" or "s"`,
1677
+ "invalid-track",
1678
+ );
1679
+ ref.unit = input.unit as SampleUnit;
1680
+ }
1681
+ if (input.fit !== undefined) {
1682
+ if (typeof input.fit !== "boolean")
1683
+ throw new ScoreValidationError(
1684
+ `${label} fit must be boolean`,
1685
+ "invalid-track",
1686
+ );
1687
+ ref.fit = input.fit;
1688
+ }
1689
+ if (input.accelerate !== undefined)
1690
+ ref.accelerate = boundedNumber(
1691
+ input.accelerate,
1692
+ `${label} accelerate`,
1693
+ -SCORE_LIMITS.maxSampleAccelerate,
1694
+ SCORE_LIMITS.maxSampleAccelerate,
1695
+ );
1696
+ if (input.squiz !== undefined)
1697
+ ref.squiz = boundedNumber(
1698
+ input.squiz,
1699
+ `${label} squiz`,
1700
+ 1,
1701
+ SCORE_LIMITS.maxSampleSquiz,
1702
+ );
1703
+ return Object.freeze(ref);
1704
+ }
1705
+
1706
+ /** Prefix of a pack sound reference in `SampleRef.src`. */
1707
+ export const PACK_PREFIX = "pack:";
1708
+
1709
+ /**
1710
+ * `pack:<pack>/<sound>[:<n>]`: pack is a lowercase slug, sound a manifest
1711
+ * key, `n` an index (wrapping, like Strudel `s("bd:3")`) or a note name for
1712
+ * pitched packs (`pack:gm/gm_piano:C4`).
1713
+ */
1714
+ const PACK_REF =
1715
+ /^pack:([a-z0-9][a-z0-9._-]{0,63})\/([A-Za-z0-9][A-Za-z0-9_.~+-]{0,127})(?::([0-9]{1,4}|[A-Ga-g](?:#|s|b)?-?[0-9]))?$/;
1716
+
1717
+ export type PackRef = Readonly<{
1718
+ pack: string;
1719
+ sound: string;
1720
+ /** Index into the sound's files (wraps), or a note-zone name. */
1721
+ n: number | string;
1722
+ }>;
1723
+
1724
+ export function isPackRef(src: string): boolean {
1725
+ return PACK_REF.test(src);
1726
+ }
1727
+
1728
+ /** Splits `pack:<pack>/<sound>[:<n>]`; undefined for anything else. */
1729
+ export function parsePackRef(src: string): PackRef | undefined {
1730
+ const match = PACK_REF.exec(src);
1731
+ if (!match) return undefined;
1732
+ const n = match[3];
1733
+ return Object.freeze({
1734
+ pack: match[1]!,
1735
+ sound: match[2]!,
1736
+ n: n === undefined ? 0 : /^[0-9]+$/.test(n) ? Number(n) : n,
1737
+ });
1738
+ }
1739
+
1740
+ export function formatPackRef(ref: PackRef): string {
1741
+ return `${PACK_PREFIX}${ref.pack}/${ref.sound}${ref.n === 0 ? "" : `:${ref.n}`}`;
1742
+ }
1743
+
1744
+ /** HTTPS (or loopback HTTP), no credentials, bounded: the URLs a score may pin. */
1745
+ export function isPinnedSampleUrl(value: unknown): value is string {
1746
+ if (
1747
+ typeof value !== "string" ||
1748
+ value.length === 0 ||
1749
+ value.length > SCORE_LIMITS.maxSampleUrlLength ||
1750
+ /[\u0000-\u0020]/.test(value)
1751
+ )
1752
+ return false;
1753
+ let url: URL;
1754
+ try {
1755
+ url = new URL(value);
1756
+ } catch {
1757
+ return false;
1758
+ }
1759
+ if (url.username !== "" || url.password !== "") return false;
1760
+ // Plain HTTP only to loopback (local fixture servers); fetches still refuse
1761
+ // it unless the pack store allows loopback.
1762
+ return (
1763
+ url.protocol === "https:" ||
1764
+ (url.protocol === "http:" &&
1765
+ ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))
1766
+ );
1767
+ }
1768
+
1769
+ /** Relative, forward-slash, no empty/dot segments, no control characters. */
1770
+ function isSafeRelativePath(path: string): boolean {
1771
+ if (path.startsWith("/") || /[\\\u0000-\u001f]/.test(path)) return false;
1772
+ if (/^[A-Za-z]:/.test(path)) return false;
1773
+ return path
1774
+ .split("/")
1775
+ .every((segment) => segment !== "" && segment !== "." && segment !== "..");
1776
+ }
1777
+
759
1778
  export function normalizeReverb(input: unknown): TrackReverb | undefined {
760
1779
  if (input === undefined || input === null) return undefined;
761
1780
  if (!isRecord(input))
@@ -775,7 +1794,17 @@ export function normalizeReverb(input: unknown): TrackReverb | undefined {
775
1794
  SCORE_LIMITS.minReverbSize,
776
1795
  SCORE_LIMITS.maxReverbSize,
777
1796
  );
778
- return Object.freeze({ mix, size });
1797
+ const extras = trackEffectExtras("reverb", input, [
1798
+ "fade",
1799
+ "lowpass",
1800
+ "dim",
1801
+ "predelay",
1802
+ ]);
1803
+ const ir =
1804
+ input.ir === undefined || input.ir === null
1805
+ ? undefined
1806
+ : normalizeReverbIr(input.ir);
1807
+ return Object.freeze({ mix, size, ...extras, ...(ir ? { ir } : {}) });
779
1808
  }
780
1809
 
781
1810
  export function normalizeFilter(input: unknown): TrackFilter | undefined {
@@ -797,7 +1826,12 @@ export function normalizeFilter(input: unknown): TrackFilter | undefined {
797
1826
  0,
798
1827
  SCORE_LIMITS.maxFilterResonance,
799
1828
  );
800
- return Object.freeze({ cutoff, resonance });
1829
+ const extras = trackEffectExtras("filter", input, ["type", "ftype"]);
1830
+ if (extras.ftype === "12db") delete extras.ftype;
1831
+ // `lpf` is the original filter; store it as absent so v1 documents and
1832
+ // explicit `lpf` encode the same.
1833
+ if (extras.type === "lpf") delete extras.type;
1834
+ return Object.freeze({ cutoff, resonance, ...extras });
801
1835
  }
802
1836
 
803
1837
  export function normalizeDelay(input: unknown): TrackDelay | undefined {
@@ -825,7 +1859,13 @@ export function normalizeDelay(input: unknown): TrackDelay | undefined {
825
1859
  0,
826
1860
  SCORE_LIMITS.maxDelayMix,
827
1861
  );
828
- return Object.freeze({ beats, feedback, mix });
1862
+ const extras = trackEffectExtras("delay", input, [
1863
+ "time",
1864
+ "pingpong",
1865
+ "highcut",
1866
+ ]);
1867
+ if (extras.time === 0) delete extras.time;
1868
+ return Object.freeze({ beats, feedback, mix, ...extras });
829
1869
  }
830
1870
 
831
1871
  function boundedNumber(