@hraness/dawg 0.0.0-stage → 0.3.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 (113) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +327 -0
  3. package/LICENSE +21 -0
  4. package/README.md +213 -2
  5. package/core/diff.ts +249 -0
  6. package/core/drums.ts +102 -0
  7. package/core/key.ts +43 -0
  8. package/core/loop.ts +78 -0
  9. package/core/pitch.ts +60 -0
  10. package/core/score.ts +1388 -0
  11. package/core/sdk/eval-child.ts +113 -0
  12. package/core/sdk/eval.ts +257 -0
  13. package/core/sdk/print.ts +393 -0
  14. package/core/sdk/v1.ts +954 -0
  15. package/core/slug.ts +19 -0
  16. package/package.json +45 -4
  17. package/src/agent/agent.ts +853 -0
  18. package/src/agent/brief.ts +160 -0
  19. package/src/agent/gateway.ts +441 -0
  20. package/src/agent/models.ts +633 -0
  21. package/src/agent/ops.ts +157 -0
  22. package/src/agent/planner.ts +259 -0
  23. package/src/agent/provider.ts +454 -0
  24. package/src/agent/sse.ts +114 -0
  25. package/src/agent/tools.ts +1373 -0
  26. package/src/agent/usage.ts +296 -0
  27. package/src/agent/workspace.ts +683 -0
  28. package/src/agent/xcb-agent.ts +262 -0
  29. package/src/agent/xcb.ts +579 -0
  30. package/src/audio/click.ts +125 -0
  31. package/src/audio/clock.ts +68 -0
  32. package/src/audio/engine.ts +841 -0
  33. package/src/audio/live.ts +152 -0
  34. package/src/audio/lock.ts +57 -0
  35. package/src/audio/player.ts +134 -0
  36. package/src/audio/render-worker.ts +68 -0
  37. package/src/audio/renderer.ts +174 -0
  38. package/src/audio/sampler.ts +292 -0
  39. package/src/audio/samples.ts +683 -0
  40. package/src/audio/wav.ts +861 -0
  41. package/src/auth/cli.ts +231 -0
  42. package/src/auth/credentials.ts +411 -0
  43. package/src/auth/discover.ts +481 -0
  44. package/src/auth/login.ts +1191 -0
  45. package/src/auth/openrouter.ts +206 -0
  46. package/src/auth/picker.ts +282 -0
  47. package/src/auth/runner.ts +207 -0
  48. package/src/auth/tui.ts +107 -0
  49. package/src/commands/edit.ts +170 -0
  50. package/src/commands/help.ts +247 -0
  51. package/src/commands/history.ts +69 -0
  52. package/src/commands/music.ts +461 -0
  53. package/src/commands/sample.ts +302 -0
  54. package/src/daemon.ts +31 -0
  55. package/src/main.ts +2209 -0
  56. package/src/media/analyze.ts +364 -0
  57. package/src/media/backend.ts +253 -0
  58. package/src/media/cli.ts +173 -0
  59. package/src/media/download.ts +281 -0
  60. package/src/media/dsp.ts +281 -0
  61. package/src/media/import.ts +130 -0
  62. package/src/media/lyrics.ts +201 -0
  63. package/src/media/notes.ts +363 -0
  64. package/src/media/paths.ts +168 -0
  65. package/src/media/process.ts +226 -0
  66. package/src/media/registry.ts +9 -0
  67. package/src/media/sidecar.ts +72 -0
  68. package/src/media/stemdeck.ts +254 -0
  69. package/src/media/stems.ts +173 -0
  70. package/src/media/tools.ts +292 -0
  71. package/src/media/types.ts +92 -0
  72. package/src/media/vendor/basic-pitch.ts +261 -0
  73. package/src/media/vendor/drums.ts +817 -0
  74. package/src/media/vendor/grid.ts +203 -0
  75. package/src/media/vendor/util.ts +139 -0
  76. package/src/media/vendor/wav.ts +233 -0
  77. package/src/project/check.ts +80 -0
  78. package/src/project/init.ts +253 -0
  79. package/src/project/sync.ts +432 -0
  80. package/src/project/typecheck.ts +149 -0
  81. package/src/render.ts +121 -0
  82. package/src/session/attach.ts +181 -0
  83. package/src/session/client.ts +498 -0
  84. package/src/session/daemon.ts +740 -0
  85. package/src/session/delta.ts +249 -0
  86. package/src/session/list.ts +180 -0
  87. package/src/session/lock.ts +92 -0
  88. package/src/session/meta.ts +253 -0
  89. package/src/session/naming.ts +430 -0
  90. package/src/session/port.ts +481 -0
  91. package/src/session/presence.ts +159 -0
  92. package/src/session/protocol.ts +618 -0
  93. package/src/session/rebase.ts +168 -0
  94. package/src/session/store.ts +581 -0
  95. package/src/tui/menu.ts +1083 -0
  96. package/src/tui/play-mode.ts +442 -0
  97. package/src/tui/play-session.ts +636 -0
  98. package/src/web/fetch.ts +340 -0
  99. package/src/web/http.ts +137 -0
  100. package/src/web/search.ts +681 -0
  101. package/tui/activity.ts +364 -0
  102. package/tui/app.ts +1372 -0
  103. package/tui/drums.ts +65 -0
  104. package/tui/highway.ts +921 -0
  105. package/tui/input.ts +63 -0
  106. package/tui/keys.ts +102 -0
  107. package/tui/layers.ts +80 -0
  108. package/tui/play-strip.ts +143 -0
  109. package/tui/prompt.ts +609 -0
  110. package/tui/render.ts +124 -0
  111. package/tui/screen.ts +247 -0
  112. package/tui/text.ts +72 -0
  113. package/tui/theme.ts +451 -0
package/core/score.ts ADDED
@@ -0,0 +1,1388 @@
1
+ /**
2
+ * The small, JSON-safe score model shared by the dawg daemon and clients.
3
+ *
4
+ * Score time is expressed in integer ticks. A loop uses `ticksPerBeat` ticks
5
+ * per beat; keeping this unit explicit means a transport can change tempo
6
+ * without rewriting the score.
7
+ */
8
+
9
+ export const SCORE_VERSION = 1 as const;
10
+ export const DEFAULT_TICKS_PER_BEAT = 480 as const;
11
+
12
+ export const SCORE_LIMITS = Object.freeze({
13
+ maxTracks: 64,
14
+ maxNotes: 4096,
15
+ maxIdLength: 64,
16
+ maxNameLength: 96,
17
+ maxInstrumentLength: 64,
18
+ maxAutomationPoints: 256,
19
+ maxVolume: 1,
20
+ maxTempoBpm: 300,
21
+ minTempoBpm: 20,
22
+ maxBars: 256,
23
+ maxBeatsPerBar: 16,
24
+ maxTicksPerBeat: 4096,
25
+ maxTick: 1_000_000,
26
+ minFilterCutoff: 20,
27
+ maxFilterCutoff: 20_000,
28
+ maxFilterResonance: 1,
29
+ minDelayBeats: 0.0625,
30
+ maxDelayBeats: 4,
31
+ maxDelayFeedback: 0.9,
32
+ maxDelayMix: 1,
33
+ maxReverbMix: 1,
34
+ minReverbSize: 0,
35
+ maxReverbSize: 1,
36
+ /** Sampler voices per track (score v2, `sampler` field). */
37
+ maxSamplerVoices: 64,
38
+ maxSamplerVoiceNameLength: 32,
39
+ maxSamplePathLength: 256,
40
+ maxSampleGain: 2,
41
+ /** |speed| bound; negative speeds play in reverse. */
42
+ maxSampleSpeed: 8,
43
+ maxChokeGroupLength: 32,
44
+ /** Per sample file, enforced by the decoder and the import tool. */
45
+ maxSampleFileBytes: 50 * 1024 * 1024,
46
+ maxSampleSeconds: 600,
47
+ /** Decoded PCM cache under `.dawg/assets`, LRU. */
48
+ maxSampleCacheBytes: 512 * 1024 * 1024,
49
+ } as const);
50
+
51
+ /** Instrument name that selects a track's `sampler`. */
52
+ export const SAMPLER_INSTRUMENT = "sampler" as const;
53
+
54
+ /** First pitch slot assigned to one-shot sampler voices (GM kick). */
55
+ export const SAMPLER_FIRST_SLOT = 36;
56
+
57
+ export class ScoreValidationError extends Error {
58
+ readonly code:
59
+ | "invalid-score"
60
+ | "invalid-note"
61
+ | "invalid-track"
62
+ | "duplicate-note"
63
+ | "duplicate-track"
64
+ | "score-limit";
65
+
66
+ constructor(
67
+ message: string,
68
+ code: ScoreValidationError["code"] = "invalid-score",
69
+ ) {
70
+ super(message);
71
+ this.name = "ScoreValidationError";
72
+ this.code = code;
73
+ }
74
+ }
75
+
76
+ export type Track = Readonly<{
77
+ id: string;
78
+ name: string;
79
+ instrument: string;
80
+ muted: boolean;
81
+ volume: number;
82
+ pan: number;
83
+ /** Volume control points in score ticks, sorted by tick. */
84
+ volumeAutomation: readonly AutomationPoint[];
85
+ /** Pan control points in score ticks, sorted by tick. */
86
+ panAutomation: readonly AutomationPoint[];
87
+ /**
88
+ * Optional fields below are omitted when at their default so documents
89
+ * written before they existed encode byte-for-byte the same.
90
+ */
91
+ /** When any track is soloed, only soloed (unmuted) tracks are audible. */
92
+ solo?: boolean;
93
+ /** Low-pass filter applied to the track before its delay send. */
94
+ filter?: TrackFilter;
95
+ /** Tempo-synced feedback delay send, mixed after the filter. */
96
+ delay?: TrackDelay;
97
+ /** Filter cutoff (Hz) control points in score ticks, sorted by tick. */
98
+ filterAutomation?: readonly AutomationPoint[];
99
+ /** Filter resonance (0..1) control points; overrides the static resonance. */
100
+ resonanceAutomation?: readonly AutomationPoint[];
101
+ /** Delay feedback (0..0.9) control points; overrides the static feedback. */
102
+ delayFeedbackAutomation?: readonly AutomationPoint[];
103
+ /** Delay wet mix (0..1) control points; overrides the static mix. */
104
+ delayMixAutomation?: readonly AutomationPoint[];
105
+ /** Algorithmic stereo reverb send, mixed after the delay. */
106
+ reverb?: TrackReverb;
107
+ /**
108
+ * Score v2: sample voices; present exactly when `instrument` is
109
+ * `"sampler"`. Documents without it decode unchanged.
110
+ */
111
+ sampler?: Sampler;
112
+ }>;
113
+
114
+ /**
115
+ * A track's sample voices (Strudel-aligned). In `oneshot` mode every voice is
116
+ * a drum-like hit addressed by the pitch slot `samplerVoiceSlots` assigns
117
+ * (36, 37, … in voice-name order); in `keyed` mode notes are ordinary pitches
118
+ * and a voice is resampled from its `root`.
119
+ */
120
+ export type Sampler = Readonly<{
121
+ voices: Readonly<Record<string, SampleRef>>;
122
+ mode: "oneshot" | "keyed";
123
+ }>;
124
+
125
+ export type SampleRef = Readonly<{
126
+ /** Project-relative path, normally `tracks/<slug>/samples/<file>`. */
127
+ src: string;
128
+ /** Content hash (64 hex) when known; the decoded cache is keyed by it. */
129
+ sha256?: string;
130
+ /** MIDI note the file plays at in keyed mode, default 60. */
131
+ root?: number;
132
+ /** Start fraction 0..1 of the file. */
133
+ begin?: number;
134
+ /** End fraction 0..1 of the file, greater than `begin`. */
135
+ end?: number;
136
+ /** Linear gain 0..2. */
137
+ gain?: number;
138
+ /** Playback rate; negative reverses. |speed| ≤ 8, never 0. */
139
+ speed?: number;
140
+ /** Sustain by looping begin..end. */
141
+ loop?: boolean;
142
+ /** Choke group: a new hit in the group stops the previous one. */
143
+ choke?: string;
144
+ }>;
145
+
146
+ export type TrackReverb = Readonly<{
147
+ /** Wet level added to the dry signal, 0..1. */
148
+ mix: number;
149
+ /** Room size 0..1: longer comb feedback and a darker, longer tail. */
150
+ size: number;
151
+ }>;
152
+
153
+ export type TrackFilter = Readonly<{
154
+ /** Cutoff frequency in Hz, 20..20000. */
155
+ cutoff: number;
156
+ /** Resonance 0..1, mapped to a bounded biquad Q. */
157
+ resonance: number;
158
+ }>;
159
+
160
+ export type TrackDelay = Readonly<{
161
+ /** Delay time in beats, 0.0625..4, so echoes follow the tempo. */
162
+ beats: number;
163
+ /** Fraction of each echo fed back, 0..0.9. */
164
+ feedback: number;
165
+ /** Wet level added to the dry signal, 0..1. */
166
+ mix: number;
167
+ }>;
168
+
169
+ export type AutomationParameter =
170
+ "volume" | "pan" | "filter" | "resonance" | "delay-feedback" | "delay-mix";
171
+
172
+ /** Track field and value range for every automation lane. */
173
+ export const AUTOMATION_LANES: Readonly<
174
+ Record<
175
+ AutomationParameter,
176
+ Readonly<{
177
+ field:
178
+ | "volumeAutomation"
179
+ | "panAutomation"
180
+ | "filterAutomation"
181
+ | "resonanceAutomation"
182
+ | "delayFeedbackAutomation"
183
+ | "delayMixAutomation";
184
+ min: number;
185
+ max: number;
186
+ }>
187
+ >
188
+ > = Object.freeze({
189
+ volume: { field: "volumeAutomation", min: 0, max: SCORE_LIMITS.maxVolume },
190
+ pan: { field: "panAutomation", min: -1, max: 1 },
191
+ filter: {
192
+ field: "filterAutomation",
193
+ min: SCORE_LIMITS.minFilterCutoff,
194
+ max: SCORE_LIMITS.maxFilterCutoff,
195
+ },
196
+ resonance: {
197
+ field: "resonanceAutomation",
198
+ min: 0,
199
+ max: SCORE_LIMITS.maxFilterResonance,
200
+ },
201
+ "delay-feedback": {
202
+ field: "delayFeedbackAutomation",
203
+ min: 0,
204
+ max: SCORE_LIMITS.maxDelayFeedback,
205
+ },
206
+ "delay-mix": {
207
+ field: "delayMixAutomation",
208
+ min: 0,
209
+ max: SCORE_LIMITS.maxDelayMix,
210
+ },
211
+ });
212
+
213
+ export function isAutomationParameter(
214
+ value: unknown,
215
+ ): value is AutomationParameter {
216
+ return (
217
+ typeof value === "string" &&
218
+ Object.prototype.hasOwnProperty.call(AUTOMATION_LANES, value)
219
+ );
220
+ }
221
+
222
+ /** Track fields that score operations may patch; `null` clears an effect. */
223
+ export type TrackPatch = Readonly<
224
+ Partial<
225
+ Pick<
226
+ Track,
227
+ | "name"
228
+ | "instrument"
229
+ | "muted"
230
+ | "volume"
231
+ | "pan"
232
+ | "volumeAutomation"
233
+ | "panAutomation"
234
+ | "solo"
235
+ | "filterAutomation"
236
+ | "resonanceAutomation"
237
+ | "delayFeedbackAutomation"
238
+ | "delayMixAutomation"
239
+ >
240
+ > & {
241
+ filter?: TrackFilter | null;
242
+ delay?: TrackDelay | null;
243
+ reverb?: TrackReverb | null;
244
+ sampler?: Sampler | null;
245
+ }
246
+ >;
247
+
248
+ export type AutomationPoint = Readonly<{
249
+ tick: number;
250
+ value: number;
251
+ }>;
252
+
253
+ /** A note's start and duration are integer ticks, never floating-point beats. */
254
+ export type Note = Readonly<{
255
+ id: string;
256
+ trackId: string;
257
+ startTick: number;
258
+ durationTicks: number;
259
+ pitch: number;
260
+ velocity: number;
261
+ }>;
262
+
263
+ export type TrackInput = Readonly<
264
+ Omit<Partial<Track>, "filter" | "delay" | "reverb" | "sampler"> &
265
+ Pick<Track, "id"> & {
266
+ filter?: TrackFilter | null;
267
+ delay?: TrackDelay | null;
268
+ reverb?: TrackReverb | null;
269
+ sampler?: Sampler | null;
270
+ }
271
+ >;
272
+
273
+ /**
274
+ * Input accepts `start`/`duration` as a convenience for callers that use the
275
+ * short names. Encoded and stored notes always use the explicit tick names.
276
+ */
277
+ export type NoteInput = Readonly<{
278
+ id: string;
279
+ trackId: string;
280
+ startTick?: number;
281
+ durationTicks?: number;
282
+ start?: number;
283
+ duration?: number;
284
+ pitch: number;
285
+ velocity: number;
286
+ }>;
287
+
288
+ export type TrackScoreData = Readonly<{
289
+ tempoBpm?: number;
290
+ beatsPerBar?: number;
291
+ bars?: number;
292
+ ticksPerBeat?: number;
293
+ key?: string | null;
294
+ tracks?: readonly TrackInput[];
295
+ notes?: readonly NoteInput[];
296
+ }>;
297
+
298
+ /** Canonical immutable score. Use `addNote`/`removeNote` to create a revision. */
299
+ export class TrackScore {
300
+ readonly version = SCORE_VERSION;
301
+ readonly tempoBpm: number;
302
+ readonly beatsPerBar: number;
303
+ readonly bars: number;
304
+ readonly ticksPerBeat: number;
305
+ readonly key: string | null;
306
+ readonly tracks: readonly Track[];
307
+ readonly notes: readonly Note[];
308
+
309
+ constructor(data: TrackScoreData = {}) {
310
+ const tempoBpm = data.tempoBpm ?? 120;
311
+ const beatsPerBar = data.beatsPerBar ?? 4;
312
+ const bars = data.bars ?? 4;
313
+ const ticksPerBeat = data.ticksPerBeat ?? DEFAULT_TICKS_PER_BEAT;
314
+ const key = data.key ?? null;
315
+ if (
316
+ !Number.isFinite(tempoBpm) ||
317
+ tempoBpm < SCORE_LIMITS.minTempoBpm ||
318
+ tempoBpm > SCORE_LIMITS.maxTempoBpm
319
+ ) {
320
+ throw new ScoreValidationError(
321
+ `tempoBpm must be between ${SCORE_LIMITS.minTempoBpm} and ${SCORE_LIMITS.maxTempoBpm}`,
322
+ "invalid-score",
323
+ );
324
+ }
325
+ if (
326
+ !Number.isInteger(beatsPerBar) ||
327
+ beatsPerBar < 1 ||
328
+ beatsPerBar > SCORE_LIMITS.maxBeatsPerBar
329
+ ) {
330
+ throw new ScoreValidationError(
331
+ `beatsPerBar must be an integer between 1 and ${SCORE_LIMITS.maxBeatsPerBar}`,
332
+ "invalid-score",
333
+ );
334
+ }
335
+ if (!Number.isInteger(bars) || bars < 1 || bars > SCORE_LIMITS.maxBars) {
336
+ throw new ScoreValidationError(
337
+ `bars must be an integer between 1 and ${SCORE_LIMITS.maxBars}`,
338
+ "invalid-score",
339
+ );
340
+ }
341
+ if (
342
+ !Number.isInteger(ticksPerBeat) ||
343
+ ticksPerBeat < 1 ||
344
+ ticksPerBeat > SCORE_LIMITS.maxTicksPerBeat
345
+ ) {
346
+ throw new ScoreValidationError(
347
+ `ticksPerBeat must be an integer between 1 and ${SCORE_LIMITS.maxTicksPerBeat}`,
348
+ "invalid-score",
349
+ );
350
+ }
351
+ if (
352
+ key !== null &&
353
+ (typeof key !== "string" || key.length > SCORE_LIMITS.maxNameLength)
354
+ ) {
355
+ throw new ScoreValidationError(
356
+ "key must be null or a short string",
357
+ "invalid-score",
358
+ );
359
+ }
360
+ const tracks = normalizeTracks(data.tracks ?? []);
361
+ const notes = normalizeNotes(data.notes ?? []);
362
+ this.tempoBpm = tempoBpm;
363
+ this.beatsPerBar = beatsPerBar;
364
+ this.bars = bars;
365
+ this.ticksPerBeat = ticksPerBeat;
366
+ this.key = key;
367
+ this.tracks = freezeArray(tracks);
368
+ this.notes = freezeArray(notes);
369
+ Object.freeze(this);
370
+ }
371
+
372
+ addNote(note: NoteInput): TrackScore {
373
+ return addNote(this, note);
374
+ }
375
+
376
+ removeNote(noteId: string): TrackScore {
377
+ return removeNote(this, noteId);
378
+ }
379
+
380
+ withTracks(tracks: readonly TrackInput[]): TrackScore {
381
+ return new TrackScore({
382
+ tempoBpm: this.tempoBpm,
383
+ beatsPerBar: this.beatsPerBar,
384
+ bars: this.bars,
385
+ ticksPerBeat: this.ticksPerBeat,
386
+ key: this.key,
387
+ tracks,
388
+ notes: this.notes,
389
+ });
390
+ }
391
+
392
+ withTempo(tempoBpm: number): TrackScore {
393
+ return new TrackScore({
394
+ tempoBpm,
395
+ beatsPerBar: this.beatsPerBar,
396
+ bars: this.bars,
397
+ ticksPerBeat: this.ticksPerBeat,
398
+ key: this.key,
399
+ tracks: this.tracks,
400
+ notes: this.notes,
401
+ });
402
+ }
403
+
404
+ /** Resize the loop without discarding notes or automation outside its bounds. */
405
+ withBars(bars: number): TrackScore {
406
+ return new TrackScore({ ...this.toJSON(), bars });
407
+ }
408
+
409
+ withKey(key: string | null): TrackScore {
410
+ return new TrackScore({ ...this.toJSON(), key });
411
+ }
412
+
413
+ /** Change the meter; ticks are per beat, so notes keep their positions. */
414
+ withMeter(beatsPerBar: number): TrackScore {
415
+ return new TrackScore({ ...this.toJSON(), beatsPerBar });
416
+ }
417
+
418
+ toJSON(): TrackScoreData & { version: typeof SCORE_VERSION } {
419
+ return {
420
+ version: SCORE_VERSION,
421
+ tempoBpm: this.tempoBpm,
422
+ beatsPerBar: this.beatsPerBar,
423
+ bars: this.bars,
424
+ ticksPerBeat: this.ticksPerBeat,
425
+ key: this.key,
426
+ tracks: this.tracks,
427
+ notes: this.notes,
428
+ };
429
+ }
430
+ }
431
+
432
+ export function createScore(data: TrackScoreData = {}): TrackScore {
433
+ return new TrackScore(data);
434
+ }
435
+
436
+ export function emptyScore(): TrackScore {
437
+ return new TrackScore();
438
+ }
439
+
440
+ export function addTrack(score: TrackScore, input: TrackInput): TrackScore {
441
+ if (!(score instanceof TrackScore))
442
+ throw new ScoreValidationError("addTrack requires a TrackScore");
443
+ return score.withTracks([...score.tracks, input]);
444
+ }
445
+
446
+ export function addNote(score: TrackScore, input: NoteInput): TrackScore {
447
+ if (!(score instanceof TrackScore))
448
+ throw new ScoreValidationError("addNote requires a TrackScore");
449
+ const note = normalizeNote(input);
450
+ if (score.notes.some((candidate) => candidate.id === note.id)) {
451
+ throw new ScoreValidationError(
452
+ `note id already exists: ${note.id}`,
453
+ "duplicate-note",
454
+ );
455
+ }
456
+ if (score.notes.length >= SCORE_LIMITS.maxNotes) {
457
+ throw new ScoreValidationError(
458
+ `score cannot contain more than ${SCORE_LIMITS.maxNotes} notes`,
459
+ "score-limit",
460
+ );
461
+ }
462
+ return new TrackScore({
463
+ tempoBpm: score.tempoBpm,
464
+ beatsPerBar: score.beatsPerBar,
465
+ bars: score.bars,
466
+ ticksPerBeat: score.ticksPerBeat,
467
+ key: score.key,
468
+ tracks: score.tracks,
469
+ notes: [...score.notes, note],
470
+ });
471
+ }
472
+
473
+ export function removeNote(score: TrackScore, noteId: string): TrackScore {
474
+ if (!(score instanceof TrackScore))
475
+ throw new ScoreValidationError("removeNote requires a TrackScore");
476
+ if (
477
+ typeof noteId !== "string" ||
478
+ noteId.length === 0 ||
479
+ noteId.length > SCORE_LIMITS.maxIdLength
480
+ ) {
481
+ throw new ScoreValidationError(
482
+ "noteId must be a non-empty short string",
483
+ "invalid-note",
484
+ );
485
+ }
486
+ if (!score.notes.some((note) => note.id === noteId)) return score;
487
+ return new TrackScore({
488
+ tempoBpm: score.tempoBpm,
489
+ beatsPerBar: score.beatsPerBar,
490
+ bars: score.bars,
491
+ ticksPerBeat: score.ticksPerBeat,
492
+ key: score.key,
493
+ tracks: score.tracks,
494
+ notes: score.notes.filter((note) => note.id !== noteId),
495
+ });
496
+ }
497
+
498
+ export function updateNote(
499
+ score: TrackScore,
500
+ noteId: string,
501
+ patch: Readonly<
502
+ Partial<Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity">>
503
+ >,
504
+ ): TrackScore {
505
+ const current = score.notes.find((note) => note.id === noteId);
506
+ if (!current) return score;
507
+ return new TrackScore({
508
+ tempoBpm: score.tempoBpm,
509
+ beatsPerBar: score.beatsPerBar,
510
+ bars: score.bars,
511
+ ticksPerBeat: score.ticksPerBeat,
512
+ key: score.key,
513
+ tracks: score.tracks,
514
+ notes: score.notes.map((note) =>
515
+ note.id === noteId ? { ...note, ...patch } : note,
516
+ ),
517
+ });
518
+ }
519
+
520
+ export function clearTrack(score: TrackScore, trackId: string): TrackScore {
521
+ if (!score.notes.some((note) => note.trackId === trackId)) return score;
522
+ return new TrackScore({
523
+ tempoBpm: score.tempoBpm,
524
+ beatsPerBar: score.beatsPerBar,
525
+ bars: score.bars,
526
+ ticksPerBeat: score.ticksPerBeat,
527
+ key: score.key,
528
+ tracks: score.tracks,
529
+ notes: score.notes.filter((note) => note.trackId !== trackId),
530
+ });
531
+ }
532
+
533
+ export function updateTrack(
534
+ score: TrackScore,
535
+ trackId: string,
536
+ patch: TrackPatch,
537
+ ): TrackScore {
538
+ if (!score.tracks.some((track) => track.id === trackId)) return score;
539
+ return score.withTracks(
540
+ score.tracks.map((track) =>
541
+ track.id === trackId ? { ...track, ...patch } : track,
542
+ ),
543
+ );
544
+ }
545
+
546
+ /** Removes a track and every note on it; unknown ids are a no-op. */
547
+ export function removeTrack(score: TrackScore, trackId: string): TrackScore {
548
+ if (!score.tracks.some((track) => track.id === trackId)) return score;
549
+ return new TrackScore({
550
+ ...score.toJSON(),
551
+ tracks: score.tracks.filter((track) => track.id !== trackId),
552
+ notes: score.notes.filter((note) => note.trackId !== trackId),
553
+ });
554
+ }
555
+
556
+ /** Moves a track to position `index` (clamped) in score order. */
557
+ export function moveTrack(
558
+ score: TrackScore,
559
+ trackId: string,
560
+ index: number,
561
+ ): TrackScore {
562
+ const from = score.tracks.findIndex((track) => track.id === trackId);
563
+ if (from < 0 || !Number.isInteger(index))
564
+ throw new ScoreValidationError(
565
+ "moveTrack needs an existing track and an integer index",
566
+ "invalid-track",
567
+ );
568
+ const to = Math.max(0, Math.min(score.tracks.length - 1, index));
569
+ if (from === to) return score;
570
+ const tracks = [...score.tracks];
571
+ const [moved] = tracks.splice(from, 1);
572
+ tracks.splice(to, 0, moved!);
573
+ return score.withTracks(tracks);
574
+ }
575
+
576
+ export function isSamplerInstrument(instrument: string | undefined): boolean {
577
+ return (
578
+ typeof instrument === "string" &&
579
+ instrument.trim().toLowerCase() === SAMPLER_INSTRUMENT
580
+ );
581
+ }
582
+
583
+ /**
584
+ * One-shot voices are addressed by pitch slots so the drum-lane projection
585
+ * and note tools keep working: voice names sorted by code point, slots from
586
+ * `SAMPLER_FIRST_SLOT` upward. Keyed samplers have no slots.
587
+ */
588
+ export function samplerVoiceSlots(
589
+ sampler: Sampler,
590
+ ): ReadonlyMap<string, number> {
591
+ const slots = new Map<string, number>();
592
+ if (sampler.mode !== "oneshot") return slots;
593
+ const names = Object.keys(sampler.voices).sort(compareCodePoints);
594
+ names.forEach((name, index) => slots.set(name, SAMPLER_FIRST_SLOT + index));
595
+ return slots;
596
+ }
597
+
598
+ function compareCodePoints(a: string, b: string): number {
599
+ return a < b ? -1 : a > b ? 1 : 0;
600
+ }
601
+
602
+ export function setVolumeAutomation(
603
+ score: TrackScore,
604
+ trackId: string,
605
+ points: readonly AutomationPoint[],
606
+ ): TrackScore {
607
+ return updateTrack(score, trackId, { volumeAutomation: points });
608
+ }
609
+
610
+ export function setPanAutomation(
611
+ score: TrackScore,
612
+ trackId: string,
613
+ points: readonly AutomationPoint[],
614
+ ): TrackScore {
615
+ return updateTrack(score, trackId, { panAutomation: points });
616
+ }
617
+
618
+ export function setFilterAutomation(
619
+ score: TrackScore,
620
+ trackId: string,
621
+ points: readonly AutomationPoint[],
622
+ ): TrackScore {
623
+ return updateTrack(score, trackId, { filterAutomation: points });
624
+ }
625
+
626
+ /** Replace any automation lane by parameter name. */
627
+ export function setTrackAutomation(
628
+ score: TrackScore,
629
+ trackId: string,
630
+ parameter: AutomationParameter,
631
+ points: readonly AutomationPoint[],
632
+ ): TrackScore {
633
+ if (!isAutomationParameter(parameter))
634
+ throw new ScoreValidationError(
635
+ `unknown automation parameter: ${String(parameter)}`,
636
+ "invalid-track",
637
+ );
638
+ return updateTrack(score, trackId, {
639
+ [AUTOMATION_LANES[parameter].field]: points,
640
+ });
641
+ }
642
+
643
+ /** Mute always silences a track; any solo silences every unsoloed track. */
644
+ export function isTrackAudible(score: TrackScore, trackId: string): boolean {
645
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
646
+ if (track?.muted) return false;
647
+ const soloing = score.tracks.some((candidate) => candidate.solo === true);
648
+ return !soloing || track?.solo === true;
649
+ }
650
+
651
+ export type ScoreOperation =
652
+ | Readonly<{
653
+ type: "addTrack";
654
+ track: TrackInput;
655
+ }>
656
+ | Readonly<{
657
+ type: "addNote";
658
+ note: NoteInput;
659
+ }>
660
+ | Readonly<{
661
+ type: "removeNote";
662
+ noteId: string;
663
+ }>
664
+ | Readonly<{
665
+ type: "updateNote";
666
+ noteId: string;
667
+ patch: Readonly<
668
+ Partial<
669
+ Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity">
670
+ >
671
+ >;
672
+ }>
673
+ | Readonly<{
674
+ type: "setTempo";
675
+ tempoBpm: number;
676
+ }>
677
+ | Readonly<{
678
+ type: "setBars";
679
+ bars: number;
680
+ }>
681
+ | Readonly<{
682
+ type: "updateTrack";
683
+ trackId: string;
684
+ patch: TrackPatch;
685
+ }>
686
+ | Readonly<{
687
+ type: "setAutomation";
688
+ trackId: string;
689
+ parameter: AutomationParameter;
690
+ points: readonly AutomationPoint[];
691
+ }>
692
+ | Readonly<{
693
+ type: "clearTrack";
694
+ trackId: string;
695
+ }>
696
+ | Readonly<{
697
+ type: "removeTrack";
698
+ trackId: string;
699
+ }>
700
+ | Readonly<{
701
+ type: "moveTrack";
702
+ trackId: string;
703
+ index: number;
704
+ }>
705
+ | Readonly<{
706
+ type: "setKey";
707
+ key: string | null;
708
+ }>
709
+ | Readonly<{
710
+ type: "setMeter";
711
+ beatsPerBar: number;
712
+ }>;
713
+
714
+ export function applyScoreOperation(
715
+ score: TrackScore,
716
+ operation: ScoreOperation,
717
+ ): TrackScore {
718
+ if (operation.type === "removeTrack")
719
+ return removeTrack(score, operation.trackId);
720
+ if (operation.type === "moveTrack")
721
+ return moveTrack(score, operation.trackId, operation.index);
722
+ if (operation.type === "setKey") return score.withKey(operation.key);
723
+ if (operation.type === "setMeter")
724
+ return score.withMeter(operation.beatsPerBar);
725
+ if (operation.type === "addTrack") return addTrack(score, operation.track);
726
+ if (operation.type === "addNote") return addNote(score, operation.note);
727
+ if (operation.type === "removeNote")
728
+ return removeNote(score, operation.noteId);
729
+ if (operation.type === "updateNote")
730
+ return updateNote(score, operation.noteId, operation.patch);
731
+ if (operation.type === "setTempo") return score.withTempo(operation.tempoBpm);
732
+ if (operation.type === "setBars") return score.withBars(operation.bars);
733
+ if (operation.type === "updateTrack")
734
+ return updateTrack(score, operation.trackId, operation.patch);
735
+ if (operation.type === "setAutomation")
736
+ return setTrackAutomation(
737
+ score,
738
+ operation.trackId,
739
+ operation.parameter,
740
+ operation.points,
741
+ );
742
+ if (operation.type === "clearTrack")
743
+ return clearTrack(score, operation.trackId);
744
+ return assertNever(operation);
745
+ }
746
+
747
+ export function scoreFromJSON(value: unknown): TrackScore {
748
+ if (!isRecord(value))
749
+ throw new ScoreValidationError("score must be an object");
750
+ const version = value.version;
751
+ if (version !== undefined && version !== SCORE_VERSION) {
752
+ const displayedVersion =
753
+ typeof version === "string" ||
754
+ typeof version === "number" ||
755
+ typeof version === "boolean" ||
756
+ typeof version === "bigint"
757
+ ? String(version)
758
+ : "[object]";
759
+ throw new ScoreValidationError(
760
+ `unsupported score version: ${displayedVersion}`,
761
+ );
762
+ }
763
+ const data: {
764
+ tempoBpm?: number;
765
+ beatsPerBar?: number;
766
+ bars?: number;
767
+ ticksPerBeat?: number;
768
+ key?: string | null;
769
+ tracks: readonly TrackInput[];
770
+ notes: readonly NoteInput[];
771
+ } = {
772
+ tracks: optionalArray(value.tracks).map(parseTrack),
773
+ notes: optionalArray(value.notes).map(parseNote),
774
+ };
775
+ const tempoBpm = optionalNumber(value.tempoBpm);
776
+ const beatsPerBar = optionalNumber(value.beatsPerBar);
777
+ const bars = optionalNumber(value.bars);
778
+ const ticksPerBeat = optionalNumber(value.ticksPerBeat);
779
+ const key = optionalNullableString(value.key);
780
+ if (tempoBpm !== undefined) data.tempoBpm = tempoBpm;
781
+ if (beatsPerBar !== undefined) data.beatsPerBar = beatsPerBar;
782
+ if (bars !== undefined) data.bars = bars;
783
+ if (ticksPerBeat !== undefined) data.ticksPerBeat = ticksPerBeat;
784
+ if (key !== undefined) data.key = key;
785
+ return new TrackScore(data);
786
+ }
787
+
788
+ function normalizeTracks(inputs: readonly unknown[]): Track[] {
789
+ if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxTracks) {
790
+ throw new ScoreValidationError(
791
+ `score cannot contain more than ${SCORE_LIMITS.maxTracks} tracks`,
792
+ "score-limit",
793
+ );
794
+ }
795
+ const seen = new Set<string>();
796
+ return inputs.map((input) => {
797
+ const track = normalizeTrack(input);
798
+ if (seen.has(track.id))
799
+ throw new ScoreValidationError(
800
+ `track id already exists: ${track.id}`,
801
+ "duplicate-track",
802
+ );
803
+ seen.add(track.id);
804
+ return track;
805
+ });
806
+ }
807
+
808
+ function normalizeTrack(input: unknown): Track {
809
+ if (!isRecord(input))
810
+ throw new ScoreValidationError("track must be an object", "invalid-track");
811
+ const id = boundedString(
812
+ input.id,
813
+ "track id",
814
+ SCORE_LIMITS.maxIdLength,
815
+ "invalid-track",
816
+ );
817
+ const name = boundedString(
818
+ input.name ?? id,
819
+ "track name",
820
+ SCORE_LIMITS.maxNameLength,
821
+ "invalid-track",
822
+ );
823
+ const instrument = boundedString(
824
+ input.instrument ?? "sine",
825
+ "instrument",
826
+ SCORE_LIMITS.maxInstrumentLength,
827
+ "invalid-track",
828
+ );
829
+ const muted = input.muted ?? false;
830
+ if (typeof muted !== "boolean")
831
+ throw new ScoreValidationError(
832
+ "track muted must be boolean",
833
+ "invalid-track",
834
+ );
835
+ const volume = input.volume ?? 1;
836
+ const pan = input.pan ?? 0;
837
+ if (
838
+ typeof volume !== "number" ||
839
+ !Number.isFinite(volume) ||
840
+ volume < 0 ||
841
+ volume > 1
842
+ )
843
+ throw new ScoreValidationError(
844
+ "track volume must be between 0 and 1",
845
+ "invalid-track",
846
+ );
847
+ if (typeof pan !== "number" || !Number.isFinite(pan) || pan < -1 || pan > 1)
848
+ throw new ScoreValidationError(
849
+ "track pan must be between -1 and 1",
850
+ "invalid-track",
851
+ );
852
+ const volumeAutomation = normalizeAutomation(
853
+ input.volumeAutomation,
854
+ "volumeAutomation",
855
+ 0,
856
+ SCORE_LIMITS.maxVolume,
857
+ );
858
+ const panAutomation = normalizeAutomation(
859
+ input.panAutomation,
860
+ "panAutomation",
861
+ -1,
862
+ 1,
863
+ );
864
+ const solo = input.solo ?? false;
865
+ if (typeof solo !== "boolean")
866
+ throw new ScoreValidationError(
867
+ "track solo must be boolean",
868
+ "invalid-track",
869
+ );
870
+ const filter = normalizeFilter(input.filter);
871
+ const delay = normalizeDelay(input.delay);
872
+ const filterAutomation = normalizeAutomation(
873
+ input.filterAutomation,
874
+ "filterAutomation",
875
+ SCORE_LIMITS.minFilterCutoff,
876
+ SCORE_LIMITS.maxFilterCutoff,
877
+ );
878
+ const lane = (parameter: AutomationParameter) => {
879
+ const { field, min, max } = AUTOMATION_LANES[parameter];
880
+ return normalizeAutomation(input[field], field, min, max);
881
+ };
882
+ const resonanceAutomation = lane("resonance");
883
+ const delayFeedbackAutomation = lane("delay-feedback");
884
+ const delayMixAutomation = lane("delay-mix");
885
+ const reverb = normalizeReverb(input.reverb);
886
+ const sampler = normalizeSampler(input.sampler);
887
+ if (isSamplerInstrument(instrument) && !sampler)
888
+ throw new ScoreValidationError(
889
+ `track ${id} instrument "sampler" needs a sampler`,
890
+ "invalid-track",
891
+ );
892
+ if (sampler && !isSamplerInstrument(instrument))
893
+ throw new ScoreValidationError(
894
+ `track ${id} has a sampler but its instrument is "${instrument}"`,
895
+ "invalid-track",
896
+ );
897
+ return Object.freeze({
898
+ id,
899
+ name,
900
+ instrument,
901
+ muted,
902
+ volume,
903
+ pan,
904
+ volumeAutomation,
905
+ panAutomation,
906
+ ...(solo ? { solo } : {}),
907
+ ...(filter ? { filter } : {}),
908
+ ...(delay ? { delay } : {}),
909
+ ...(filterAutomation.length > 0 ? { filterAutomation } : {}),
910
+ ...(resonanceAutomation.length > 0 ? { resonanceAutomation } : {}),
911
+ ...(delayFeedbackAutomation.length > 0 ? { delayFeedbackAutomation } : {}),
912
+ ...(delayMixAutomation.length > 0 ? { delayMixAutomation } : {}),
913
+ ...(reverb ? { reverb } : {}),
914
+ ...(sampler ? { sampler } : {}),
915
+ });
916
+ }
917
+
918
+ const VOICE_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
919
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
920
+
921
+ /** Validates a `Sampler` from unknown; `undefined`/`null` means none. */
922
+ export function normalizeSampler(input: unknown): Sampler | undefined {
923
+ if (input === undefined || input === null) return undefined;
924
+ if (!isRecord(input))
925
+ throw new ScoreValidationError(
926
+ "track sampler must be an object or null",
927
+ "invalid-track",
928
+ );
929
+ const mode = input.mode ?? "oneshot";
930
+ if (mode !== "oneshot" && mode !== "keyed")
931
+ throw new ScoreValidationError(
932
+ 'sampler mode must be "oneshot" or "keyed"',
933
+ "invalid-track",
934
+ );
935
+ if (!isRecord(input.voices))
936
+ throw new ScoreValidationError(
937
+ "sampler voices must be an object of name → sample",
938
+ "invalid-track",
939
+ );
940
+ const names = Object.keys(input.voices);
941
+ if (names.length === 0 || names.length > SCORE_LIMITS.maxSamplerVoices)
942
+ throw new ScoreValidationError(
943
+ `sampler must have between 1 and ${SCORE_LIMITS.maxSamplerVoices} voices`,
944
+ "score-limit",
945
+ );
946
+ const voices: Record<string, SampleRef> = {};
947
+ for (const name of names.sort(compareCodePoints)) {
948
+ if (
949
+ name.length > SCORE_LIMITS.maxSamplerVoiceNameLength ||
950
+ !VOICE_NAME.test(name)
951
+ )
952
+ throw new ScoreValidationError(
953
+ `sampler voice name "${name.slice(0, 40)}" must match ${VOICE_NAME.source} and be at most ${SCORE_LIMITS.maxSamplerVoiceNameLength} characters`,
954
+ "invalid-track",
955
+ );
956
+ voices[name] = normalizeSampleRef(input.voices[name], name);
957
+ }
958
+ return Object.freeze({ voices: Object.freeze(voices), mode });
959
+ }
960
+
961
+ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
962
+ const label = `sampler voice ${name}`;
963
+ if (!isRecord(input))
964
+ throw new ScoreValidationError(
965
+ `${label} must be an object`,
966
+ "invalid-track",
967
+ );
968
+ const src = input.src;
969
+ if (
970
+ typeof src !== "string" ||
971
+ src.length === 0 ||
972
+ src.length > SCORE_LIMITS.maxSamplePathLength ||
973
+ !isSafeRelativePath(src)
974
+ )
975
+ throw new ScoreValidationError(
976
+ `${label} src must be a project-relative path without "..", at most ${SCORE_LIMITS.maxSamplePathLength} characters`,
977
+ "invalid-track",
978
+ );
979
+ const ref: {
980
+ src: string;
981
+ sha256?: string;
982
+ root?: number;
983
+ begin?: number;
984
+ end?: number;
985
+ gain?: number;
986
+ speed?: number;
987
+ loop?: boolean;
988
+ choke?: string;
989
+ } = { src };
990
+ if (input.sha256 !== undefined) {
991
+ if (typeof input.sha256 !== "string" || !SHA256_HEX.test(input.sha256))
992
+ throw new ScoreValidationError(
993
+ `${label} sha256 must be 64 lowercase hex characters`,
994
+ "invalid-track",
995
+ );
996
+ ref.sha256 = input.sha256;
997
+ }
998
+ if (input.root !== undefined) {
999
+ if (
1000
+ typeof input.root !== "number" ||
1001
+ !Number.isInteger(input.root) ||
1002
+ input.root < 0 ||
1003
+ input.root > 127
1004
+ )
1005
+ throw new ScoreValidationError(
1006
+ `${label} root must be a MIDI integer between 0 and 127`,
1007
+ "invalid-track",
1008
+ );
1009
+ ref.root = input.root;
1010
+ }
1011
+ const begin = input.begin === undefined ? 0 : input.begin;
1012
+ const end = input.end === undefined ? 1 : input.end;
1013
+ if (
1014
+ typeof begin !== "number" ||
1015
+ typeof end !== "number" ||
1016
+ !Number.isFinite(begin) ||
1017
+ !Number.isFinite(end) ||
1018
+ begin < 0 ||
1019
+ end > 1 ||
1020
+ begin >= end
1021
+ )
1022
+ throw new ScoreValidationError(
1023
+ `${label} begin/end must be fractions with 0 ≤ begin < end ≤ 1`,
1024
+ "invalid-track",
1025
+ );
1026
+ if (input.begin !== undefined) ref.begin = begin;
1027
+ if (input.end !== undefined) ref.end = end;
1028
+ if (input.gain !== undefined)
1029
+ ref.gain = boundedNumber(
1030
+ input.gain,
1031
+ `${label} gain`,
1032
+ 0,
1033
+ SCORE_LIMITS.maxSampleGain,
1034
+ );
1035
+ if (input.speed !== undefined) {
1036
+ const speed = boundedNumber(
1037
+ input.speed,
1038
+ `${label} speed`,
1039
+ -SCORE_LIMITS.maxSampleSpeed,
1040
+ SCORE_LIMITS.maxSampleSpeed,
1041
+ );
1042
+ if (speed === 0)
1043
+ throw new ScoreValidationError(
1044
+ `${label} speed cannot be 0`,
1045
+ "invalid-track",
1046
+ );
1047
+ ref.speed = speed;
1048
+ }
1049
+ if (input.loop !== undefined) {
1050
+ if (typeof input.loop !== "boolean")
1051
+ throw new ScoreValidationError(
1052
+ `${label} loop must be boolean`,
1053
+ "invalid-track",
1054
+ );
1055
+ ref.loop = input.loop;
1056
+ }
1057
+ if (input.choke !== undefined) {
1058
+ if (
1059
+ typeof input.choke !== "string" ||
1060
+ input.choke.length === 0 ||
1061
+ input.choke.length > SCORE_LIMITS.maxChokeGroupLength ||
1062
+ !VOICE_NAME.test(input.choke)
1063
+ )
1064
+ throw new ScoreValidationError(
1065
+ `${label} choke must be a short group name`,
1066
+ "invalid-track",
1067
+ );
1068
+ ref.choke = input.choke;
1069
+ }
1070
+ return Object.freeze(ref);
1071
+ }
1072
+
1073
+ /** Relative, forward-slash, no empty/dot segments, no control characters. */
1074
+ function isSafeRelativePath(path: string): boolean {
1075
+ if (path.startsWith("/") || /[\\\u0000-\u001f]/.test(path)) return false;
1076
+ if (/^[A-Za-z]:/.test(path)) return false;
1077
+ return path
1078
+ .split("/")
1079
+ .every((segment) => segment !== "" && segment !== "." && segment !== "..");
1080
+ }
1081
+
1082
+ export function normalizeReverb(input: unknown): TrackReverb | undefined {
1083
+ if (input === undefined || input === null) return undefined;
1084
+ if (!isRecord(input))
1085
+ throw new ScoreValidationError(
1086
+ "track reverb must be an object or null",
1087
+ "invalid-track",
1088
+ );
1089
+ const mix = boundedNumber(
1090
+ input.mix,
1091
+ "track reverb mix",
1092
+ 0,
1093
+ SCORE_LIMITS.maxReverbMix,
1094
+ );
1095
+ const size = boundedNumber(
1096
+ input.size ?? 0.5,
1097
+ "track reverb size",
1098
+ SCORE_LIMITS.minReverbSize,
1099
+ SCORE_LIMITS.maxReverbSize,
1100
+ );
1101
+ return Object.freeze({ mix, size });
1102
+ }
1103
+
1104
+ export function normalizeFilter(input: unknown): TrackFilter | undefined {
1105
+ if (input === undefined || input === null) return undefined;
1106
+ if (!isRecord(input))
1107
+ throw new ScoreValidationError(
1108
+ "track filter must be an object or null",
1109
+ "invalid-track",
1110
+ );
1111
+ const cutoff = boundedNumber(
1112
+ input.cutoff,
1113
+ "track filter cutoff",
1114
+ SCORE_LIMITS.minFilterCutoff,
1115
+ SCORE_LIMITS.maxFilterCutoff,
1116
+ );
1117
+ const resonance = boundedNumber(
1118
+ input.resonance ?? 0,
1119
+ "track filter resonance",
1120
+ 0,
1121
+ SCORE_LIMITS.maxFilterResonance,
1122
+ );
1123
+ return Object.freeze({ cutoff, resonance });
1124
+ }
1125
+
1126
+ export function normalizeDelay(input: unknown): TrackDelay | undefined {
1127
+ if (input === undefined || input === null) return undefined;
1128
+ if (!isRecord(input))
1129
+ throw new ScoreValidationError(
1130
+ "track delay must be an object or null",
1131
+ "invalid-track",
1132
+ );
1133
+ const beats = boundedNumber(
1134
+ input.beats,
1135
+ "track delay beats",
1136
+ SCORE_LIMITS.minDelayBeats,
1137
+ SCORE_LIMITS.maxDelayBeats,
1138
+ );
1139
+ const feedback = boundedNumber(
1140
+ input.feedback ?? 0.3,
1141
+ "track delay feedback",
1142
+ 0,
1143
+ SCORE_LIMITS.maxDelayFeedback,
1144
+ );
1145
+ const mix = boundedNumber(
1146
+ input.mix ?? 0.35,
1147
+ "track delay mix",
1148
+ 0,
1149
+ SCORE_LIMITS.maxDelayMix,
1150
+ );
1151
+ return Object.freeze({ beats, feedback, mix });
1152
+ }
1153
+
1154
+ function boundedNumber(
1155
+ value: unknown,
1156
+ label: string,
1157
+ min: number,
1158
+ max: number,
1159
+ ): number {
1160
+ if (
1161
+ typeof value !== "number" ||
1162
+ !Number.isFinite(value) ||
1163
+ value < min ||
1164
+ value > max
1165
+ )
1166
+ throw new ScoreValidationError(
1167
+ `${label} must be between ${min} and ${max}`,
1168
+ "invalid-track",
1169
+ );
1170
+ return value;
1171
+ }
1172
+
1173
+ function normalizeAutomation(
1174
+ input: unknown,
1175
+ label: string,
1176
+ minValue: number,
1177
+ maxValue: number,
1178
+ ): readonly AutomationPoint[] {
1179
+ if (input === undefined) return Object.freeze([]);
1180
+ if (!Array.isArray(input) || input.length > SCORE_LIMITS.maxAutomationPoints)
1181
+ throw new ScoreValidationError(
1182
+ `${label} must contain at most ${SCORE_LIMITS.maxAutomationPoints} points`,
1183
+ "score-limit",
1184
+ );
1185
+ const points = input.map((candidate) => {
1186
+ if (!isRecord(candidate))
1187
+ throw new ScoreValidationError(
1188
+ `${label} point must be an object`,
1189
+ "invalid-track",
1190
+ );
1191
+ const tick = candidate.tick;
1192
+ const value = candidate.value;
1193
+ if (
1194
+ typeof tick !== "number" ||
1195
+ !Number.isInteger(tick) ||
1196
+ tick < 0 ||
1197
+ tick > SCORE_LIMITS.maxTick
1198
+ )
1199
+ throw new ScoreValidationError(
1200
+ `${label} point tick must be an integer between 0 and ${SCORE_LIMITS.maxTick}`,
1201
+ "invalid-track",
1202
+ );
1203
+ if (
1204
+ typeof value !== "number" ||
1205
+ !Number.isFinite(value) ||
1206
+ value < minValue ||
1207
+ value > maxValue
1208
+ )
1209
+ throw new ScoreValidationError(
1210
+ `${label} point value must be between ${minValue} and ${maxValue}`,
1211
+ "invalid-track",
1212
+ );
1213
+ return Object.freeze({ tick, value });
1214
+ });
1215
+ points.sort((a, b) => a.tick - b.tick);
1216
+ for (let index = 1; index < points.length; index += 1) {
1217
+ if (points[index]!.tick === points[index - 1]!.tick)
1218
+ throw new ScoreValidationError(
1219
+ `${label} points cannot share a tick`,
1220
+ "invalid-track",
1221
+ );
1222
+ }
1223
+ return freezeArray(points);
1224
+ }
1225
+
1226
+ function normalizeNotes(inputs: readonly unknown[]): Note[] {
1227
+ if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxNotes) {
1228
+ throw new ScoreValidationError(
1229
+ `score cannot contain more than ${SCORE_LIMITS.maxNotes} notes`,
1230
+ "score-limit",
1231
+ );
1232
+ }
1233
+ const seen = new Set<string>();
1234
+ const notes = inputs.map((input) => {
1235
+ const note = normalizeNote(input);
1236
+ if (seen.has(note.id))
1237
+ throw new ScoreValidationError(
1238
+ `note id already exists: ${note.id}`,
1239
+ "duplicate-note",
1240
+ );
1241
+ seen.add(note.id);
1242
+ return note;
1243
+ });
1244
+ return notes.sort(compareNotes);
1245
+ }
1246
+
1247
+ function normalizeNote(input: unknown): Note {
1248
+ if (!isRecord(input))
1249
+ throw new ScoreValidationError("note must be an object", "invalid-note");
1250
+ const id = boundedString(
1251
+ input.id,
1252
+ "note id",
1253
+ SCORE_LIMITS.maxIdLength,
1254
+ "invalid-note",
1255
+ );
1256
+ const trackId = boundedString(
1257
+ input.trackId,
1258
+ "note trackId",
1259
+ SCORE_LIMITS.maxIdLength,
1260
+ "invalid-note",
1261
+ );
1262
+ const startTick = input.startTick ?? input.start;
1263
+ const durationTicks = input.durationTicks ?? input.duration;
1264
+ if (
1265
+ typeof startTick !== "number" ||
1266
+ !Number.isInteger(startTick) ||
1267
+ startTick < 0 ||
1268
+ startTick > SCORE_LIMITS.maxTick
1269
+ ) {
1270
+ throw new ScoreValidationError(
1271
+ `note ${id} startTick must be an integer between 0 and ${SCORE_LIMITS.maxTick}`,
1272
+ "invalid-note",
1273
+ );
1274
+ }
1275
+ if (
1276
+ typeof durationTicks !== "number" ||
1277
+ !Number.isInteger(durationTicks) ||
1278
+ durationTicks < 1 ||
1279
+ durationTicks > SCORE_LIMITS.maxTick
1280
+ ) {
1281
+ throw new ScoreValidationError(
1282
+ `note ${id} durationTicks must be an integer between 1 and ${SCORE_LIMITS.maxTick}`,
1283
+ "invalid-note",
1284
+ );
1285
+ }
1286
+ const pitch = input.pitch;
1287
+ if (
1288
+ typeof pitch !== "number" ||
1289
+ !Number.isInteger(pitch) ||
1290
+ pitch < 0 ||
1291
+ pitch > 127
1292
+ ) {
1293
+ throw new ScoreValidationError(
1294
+ `note ${id} pitch must be a MIDI integer between 0 and 127`,
1295
+ "invalid-note",
1296
+ );
1297
+ }
1298
+ if (
1299
+ typeof input.velocity !== "number" ||
1300
+ !Number.isFinite(input.velocity) ||
1301
+ input.velocity < 0 ||
1302
+ input.velocity > 1
1303
+ ) {
1304
+ throw new ScoreValidationError(
1305
+ `note ${id} velocity must be between 0 and 1`,
1306
+ "invalid-note",
1307
+ );
1308
+ }
1309
+ return Object.freeze({
1310
+ id,
1311
+ trackId,
1312
+ startTick,
1313
+ durationTicks,
1314
+ pitch,
1315
+ velocity: input.velocity,
1316
+ });
1317
+ }
1318
+
1319
+ function parseTrack(value: unknown): TrackInput {
1320
+ if (!isRecord(value))
1321
+ throw new ScoreValidationError("track must be an object", "invalid-track");
1322
+ return value as unknown as TrackInput;
1323
+ }
1324
+
1325
+ function parseNote(value: unknown): NoteInput {
1326
+ if (!isRecord(value))
1327
+ throw new ScoreValidationError("note must be an object", "invalid-note");
1328
+ return value as unknown as NoteInput;
1329
+ }
1330
+
1331
+ function compareNotes(a: Note, b: Note): number {
1332
+ return (
1333
+ a.startTick - b.startTick ||
1334
+ a.trackId.localeCompare(b.trackId) ||
1335
+ a.pitch - b.pitch ||
1336
+ a.id.localeCompare(b.id)
1337
+ );
1338
+ }
1339
+
1340
+ function boundedString(
1341
+ value: unknown,
1342
+ label: string,
1343
+ max: number,
1344
+ code: ScoreValidationError["code"],
1345
+ ): string {
1346
+ if (typeof value !== "string" || value.length === 0 || value.length > max) {
1347
+ throw new ScoreValidationError(
1348
+ `${label} must be a non-empty string of at most ${max} characters`,
1349
+ code,
1350
+ );
1351
+ }
1352
+ return value;
1353
+ }
1354
+
1355
+ function optionalNumber(value: unknown): number | undefined {
1356
+ return value === undefined
1357
+ ? undefined
1358
+ : typeof value === "number"
1359
+ ? value
1360
+ : Number.NaN;
1361
+ }
1362
+
1363
+ function optionalNullableString(value: unknown): string | null | undefined {
1364
+ if (value === undefined || value === null || typeof value === "string")
1365
+ return value;
1366
+ return "\u0000invalid";
1367
+ }
1368
+
1369
+ function optionalArray(value: unknown): unknown[] {
1370
+ if (value === undefined) return [];
1371
+ if (!Array.isArray(value))
1372
+ throw new ScoreValidationError("array field is malformed");
1373
+ return value;
1374
+ }
1375
+
1376
+ function isRecord(value: unknown): value is Record<string, unknown> {
1377
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1378
+ }
1379
+
1380
+ function freezeArray<T>(items: readonly T[]): readonly T[] {
1381
+ return Object.freeze([...items]);
1382
+ }
1383
+
1384
+ function assertNever(value: never): never {
1385
+ throw new ScoreValidationError(
1386
+ `unsupported score operation: ${String(value)}`,
1387
+ );
1388
+ }