@hraness/dawg 0.0.0-stage → 0.2.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 (57) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/DAWG.md +142 -0
  3. package/LICENSE +21 -0
  4. package/README.md +164 -2
  5. package/core/drums.ts +102 -0
  6. package/core/loop.ts +78 -0
  7. package/core/score.ts +1065 -0
  8. package/package.json +42 -4
  9. package/src/agent/agent.ts +587 -0
  10. package/src/agent/brief.ts +140 -0
  11. package/src/agent/gateway.ts +267 -0
  12. package/src/agent/ops.ts +172 -0
  13. package/src/agent/planner.ts +221 -0
  14. package/src/agent/provider.ts +326 -0
  15. package/src/agent/sse.ts +90 -0
  16. package/src/agent/tools.ts +976 -0
  17. package/src/agent/xcb-agent.ts +247 -0
  18. package/src/agent/xcb.ts +367 -0
  19. package/src/audio/clock.ts +68 -0
  20. package/src/audio/engine.ts +467 -0
  21. package/src/audio/lock.ts +57 -0
  22. package/src/audio/player.ts +119 -0
  23. package/src/audio/wav.ts +647 -0
  24. package/src/auth/cli.ts +112 -0
  25. package/src/auth/credentials.ts +285 -0
  26. package/src/auth/login.ts +434 -0
  27. package/src/auth/runner.ts +184 -0
  28. package/src/auth/tui.ts +90 -0
  29. package/src/commands/history.ts +56 -0
  30. package/src/commands/music.ts +444 -0
  31. package/src/daemon.ts +31 -0
  32. package/src/main.ts +1332 -0
  33. package/src/render.ts +99 -0
  34. package/src/session/attach.ts +182 -0
  35. package/src/session/client.ts +498 -0
  36. package/src/session/daemon.ts +723 -0
  37. package/src/session/list.ts +180 -0
  38. package/src/session/lock.ts +92 -0
  39. package/src/session/meta.ts +253 -0
  40. package/src/session/naming.ts +464 -0
  41. package/src/session/port.ts +461 -0
  42. package/src/session/presence.ts +159 -0
  43. package/src/session/protocol.ts +618 -0
  44. package/src/session/rebase.ts +138 -0
  45. package/src/session/store.ts +486 -0
  46. package/tui/activity.ts +325 -0
  47. package/tui/app.ts +1129 -0
  48. package/tui/drums.ts +21 -0
  49. package/tui/highway.ts +905 -0
  50. package/tui/input.ts +63 -0
  51. package/tui/keys.ts +102 -0
  52. package/tui/layers.ts +68 -0
  53. package/tui/prompt.ts +609 -0
  54. package/tui/render.ts +124 -0
  55. package/tui/screen.ts +247 -0
  56. package/tui/text.ts +72 -0
  57. package/tui/theme.ts +451 -0
package/core/score.ts ADDED
@@ -0,0 +1,1065 @@
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
+ } as const);
37
+
38
+ export class ScoreValidationError extends Error {
39
+ readonly code:
40
+ | "invalid-score"
41
+ | "invalid-note"
42
+ | "invalid-track"
43
+ | "duplicate-note"
44
+ | "duplicate-track"
45
+ | "score-limit";
46
+
47
+ constructor(
48
+ message: string,
49
+ code: ScoreValidationError["code"] = "invalid-score",
50
+ ) {
51
+ super(message);
52
+ this.name = "ScoreValidationError";
53
+ this.code = code;
54
+ }
55
+ }
56
+
57
+ export type Track = Readonly<{
58
+ id: string;
59
+ name: string;
60
+ instrument: string;
61
+ muted: boolean;
62
+ volume: number;
63
+ pan: number;
64
+ /** Volume control points in score ticks, sorted by tick. */
65
+ volumeAutomation: readonly AutomationPoint[];
66
+ /** Pan control points in score ticks, sorted by tick. */
67
+ panAutomation: readonly AutomationPoint[];
68
+ /**
69
+ * Optional fields below are omitted when at their default so documents
70
+ * written before they existed encode byte-for-byte the same.
71
+ */
72
+ /** When any track is soloed, only soloed (unmuted) tracks are audible. */
73
+ solo?: boolean;
74
+ /** Low-pass filter applied to the track before its delay send. */
75
+ filter?: TrackFilter;
76
+ /** Tempo-synced feedback delay send, mixed after the filter. */
77
+ delay?: TrackDelay;
78
+ /** Filter cutoff (Hz) control points in score ticks, sorted by tick. */
79
+ filterAutomation?: readonly AutomationPoint[];
80
+ /** Filter resonance (0..1) control points; overrides the static resonance. */
81
+ resonanceAutomation?: readonly AutomationPoint[];
82
+ /** Delay feedback (0..0.9) control points; overrides the static feedback. */
83
+ delayFeedbackAutomation?: readonly AutomationPoint[];
84
+ /** Delay wet mix (0..1) control points; overrides the static mix. */
85
+ delayMixAutomation?: readonly AutomationPoint[];
86
+ /** Algorithmic stereo reverb send, mixed after the delay. */
87
+ reverb?: TrackReverb;
88
+ }>;
89
+
90
+ export type TrackReverb = Readonly<{
91
+ /** Wet level added to the dry signal, 0..1. */
92
+ mix: number;
93
+ /** Room size 0..1: longer comb feedback and a darker, longer tail. */
94
+ size: number;
95
+ }>;
96
+
97
+ export type TrackFilter = Readonly<{
98
+ /** Cutoff frequency in Hz, 20..20000. */
99
+ cutoff: number;
100
+ /** Resonance 0..1, mapped to a bounded biquad Q. */
101
+ resonance: number;
102
+ }>;
103
+
104
+ export type TrackDelay = Readonly<{
105
+ /** Delay time in beats, 0.0625..4, so echoes follow the tempo. */
106
+ beats: number;
107
+ /** Fraction of each echo fed back, 0..0.9. */
108
+ feedback: number;
109
+ /** Wet level added to the dry signal, 0..1. */
110
+ mix: number;
111
+ }>;
112
+
113
+ export type AutomationParameter =
114
+ "volume" | "pan" | "filter" | "resonance" | "delay-feedback" | "delay-mix";
115
+
116
+ /** Track field and value range for every automation lane. */
117
+ export const AUTOMATION_LANES: Readonly<
118
+ Record<
119
+ AutomationParameter,
120
+ Readonly<{
121
+ field:
122
+ | "volumeAutomation"
123
+ | "panAutomation"
124
+ | "filterAutomation"
125
+ | "resonanceAutomation"
126
+ | "delayFeedbackAutomation"
127
+ | "delayMixAutomation";
128
+ min: number;
129
+ max: number;
130
+ }>
131
+ >
132
+ > = Object.freeze({
133
+ volume: { field: "volumeAutomation", min: 0, max: SCORE_LIMITS.maxVolume },
134
+ pan: { field: "panAutomation", min: -1, max: 1 },
135
+ filter: {
136
+ field: "filterAutomation",
137
+ min: SCORE_LIMITS.minFilterCutoff,
138
+ max: SCORE_LIMITS.maxFilterCutoff,
139
+ },
140
+ resonance: {
141
+ field: "resonanceAutomation",
142
+ min: 0,
143
+ max: SCORE_LIMITS.maxFilterResonance,
144
+ },
145
+ "delay-feedback": {
146
+ field: "delayFeedbackAutomation",
147
+ min: 0,
148
+ max: SCORE_LIMITS.maxDelayFeedback,
149
+ },
150
+ "delay-mix": {
151
+ field: "delayMixAutomation",
152
+ min: 0,
153
+ max: SCORE_LIMITS.maxDelayMix,
154
+ },
155
+ });
156
+
157
+ export function isAutomationParameter(
158
+ value: unknown,
159
+ ): value is AutomationParameter {
160
+ return (
161
+ typeof value === "string" &&
162
+ Object.prototype.hasOwnProperty.call(AUTOMATION_LANES, value)
163
+ );
164
+ }
165
+
166
+ /** Track fields that score operations may patch; `null` clears an effect. */
167
+ export type TrackPatch = Readonly<
168
+ Partial<
169
+ Pick<
170
+ Track,
171
+ | "name"
172
+ | "instrument"
173
+ | "muted"
174
+ | "volume"
175
+ | "pan"
176
+ | "volumeAutomation"
177
+ | "panAutomation"
178
+ | "solo"
179
+ | "filterAutomation"
180
+ | "resonanceAutomation"
181
+ | "delayFeedbackAutomation"
182
+ | "delayMixAutomation"
183
+ >
184
+ > & {
185
+ filter?: TrackFilter | null;
186
+ delay?: TrackDelay | null;
187
+ reverb?: TrackReverb | null;
188
+ }
189
+ >;
190
+
191
+ export type AutomationPoint = Readonly<{
192
+ tick: number;
193
+ value: number;
194
+ }>;
195
+
196
+ /** A note's start and duration are integer ticks, never floating-point beats. */
197
+ export type Note = Readonly<{
198
+ id: string;
199
+ trackId: string;
200
+ startTick: number;
201
+ durationTicks: number;
202
+ pitch: number;
203
+ velocity: number;
204
+ }>;
205
+
206
+ export type TrackInput = Readonly<
207
+ Omit<Partial<Track>, "filter" | "delay" | "reverb"> &
208
+ Pick<Track, "id"> & {
209
+ filter?: TrackFilter | null;
210
+ delay?: TrackDelay | null;
211
+ reverb?: TrackReverb | null;
212
+ }
213
+ >;
214
+
215
+ /**
216
+ * Input accepts `start`/`duration` as a convenience for callers that use the
217
+ * short names. Encoded and stored notes always use the explicit tick names.
218
+ */
219
+ export type NoteInput = Readonly<{
220
+ id: string;
221
+ trackId: string;
222
+ startTick?: number;
223
+ durationTicks?: number;
224
+ start?: number;
225
+ duration?: number;
226
+ pitch: number;
227
+ velocity: number;
228
+ }>;
229
+
230
+ export type TrackScoreData = Readonly<{
231
+ tempoBpm?: number;
232
+ beatsPerBar?: number;
233
+ bars?: number;
234
+ ticksPerBeat?: number;
235
+ key?: string | null;
236
+ tracks?: readonly TrackInput[];
237
+ notes?: readonly NoteInput[];
238
+ }>;
239
+
240
+ /** Canonical immutable score. Use `addNote`/`removeNote` to create a revision. */
241
+ export class TrackScore {
242
+ readonly version = SCORE_VERSION;
243
+ readonly tempoBpm: number;
244
+ readonly beatsPerBar: number;
245
+ readonly bars: number;
246
+ readonly ticksPerBeat: number;
247
+ readonly key: string | null;
248
+ readonly tracks: readonly Track[];
249
+ readonly notes: readonly Note[];
250
+
251
+ constructor(data: TrackScoreData = {}) {
252
+ const tempoBpm = data.tempoBpm ?? 120;
253
+ const beatsPerBar = data.beatsPerBar ?? 4;
254
+ const bars = data.bars ?? 4;
255
+ const ticksPerBeat = data.ticksPerBeat ?? DEFAULT_TICKS_PER_BEAT;
256
+ const key = data.key ?? null;
257
+ if (
258
+ !Number.isFinite(tempoBpm) ||
259
+ tempoBpm < SCORE_LIMITS.minTempoBpm ||
260
+ tempoBpm > SCORE_LIMITS.maxTempoBpm
261
+ ) {
262
+ throw new ScoreValidationError(
263
+ `tempoBpm must be between ${SCORE_LIMITS.minTempoBpm} and ${SCORE_LIMITS.maxTempoBpm}`,
264
+ "invalid-score",
265
+ );
266
+ }
267
+ if (
268
+ !Number.isInteger(beatsPerBar) ||
269
+ beatsPerBar < 1 ||
270
+ beatsPerBar > SCORE_LIMITS.maxBeatsPerBar
271
+ ) {
272
+ throw new ScoreValidationError(
273
+ `beatsPerBar must be an integer between 1 and ${SCORE_LIMITS.maxBeatsPerBar}`,
274
+ "invalid-score",
275
+ );
276
+ }
277
+ if (!Number.isInteger(bars) || bars < 1 || bars > SCORE_LIMITS.maxBars) {
278
+ throw new ScoreValidationError(
279
+ `bars must be an integer between 1 and ${SCORE_LIMITS.maxBars}`,
280
+ "invalid-score",
281
+ );
282
+ }
283
+ if (
284
+ !Number.isInteger(ticksPerBeat) ||
285
+ ticksPerBeat < 1 ||
286
+ ticksPerBeat > SCORE_LIMITS.maxTicksPerBeat
287
+ ) {
288
+ throw new ScoreValidationError(
289
+ `ticksPerBeat must be an integer between 1 and ${SCORE_LIMITS.maxTicksPerBeat}`,
290
+ "invalid-score",
291
+ );
292
+ }
293
+ if (
294
+ key !== null &&
295
+ (typeof key !== "string" || key.length > SCORE_LIMITS.maxNameLength)
296
+ ) {
297
+ throw new ScoreValidationError(
298
+ "key must be null or a short string",
299
+ "invalid-score",
300
+ );
301
+ }
302
+ const tracks = normalizeTracks(data.tracks ?? []);
303
+ const notes = normalizeNotes(data.notes ?? []);
304
+ this.tempoBpm = tempoBpm;
305
+ this.beatsPerBar = beatsPerBar;
306
+ this.bars = bars;
307
+ this.ticksPerBeat = ticksPerBeat;
308
+ this.key = key;
309
+ this.tracks = freezeArray(tracks);
310
+ this.notes = freezeArray(notes);
311
+ Object.freeze(this);
312
+ }
313
+
314
+ addNote(note: NoteInput): TrackScore {
315
+ return addNote(this, note);
316
+ }
317
+
318
+ removeNote(noteId: string): TrackScore {
319
+ return removeNote(this, noteId);
320
+ }
321
+
322
+ withTracks(tracks: readonly TrackInput[]): TrackScore {
323
+ return new TrackScore({
324
+ tempoBpm: this.tempoBpm,
325
+ beatsPerBar: this.beatsPerBar,
326
+ bars: this.bars,
327
+ ticksPerBeat: this.ticksPerBeat,
328
+ key: this.key,
329
+ tracks,
330
+ notes: this.notes,
331
+ });
332
+ }
333
+
334
+ withTempo(tempoBpm: number): TrackScore {
335
+ return new TrackScore({
336
+ tempoBpm,
337
+ beatsPerBar: this.beatsPerBar,
338
+ bars: this.bars,
339
+ ticksPerBeat: this.ticksPerBeat,
340
+ key: this.key,
341
+ tracks: this.tracks,
342
+ notes: this.notes,
343
+ });
344
+ }
345
+
346
+ /** Resize the loop without discarding notes or automation outside its bounds. */
347
+ withBars(bars: number): TrackScore {
348
+ return new TrackScore({ ...this.toJSON(), bars });
349
+ }
350
+
351
+ toJSON(): TrackScoreData & { version: typeof SCORE_VERSION } {
352
+ return {
353
+ version: SCORE_VERSION,
354
+ tempoBpm: this.tempoBpm,
355
+ beatsPerBar: this.beatsPerBar,
356
+ bars: this.bars,
357
+ ticksPerBeat: this.ticksPerBeat,
358
+ key: this.key,
359
+ tracks: this.tracks,
360
+ notes: this.notes,
361
+ };
362
+ }
363
+ }
364
+
365
+ export function createScore(data: TrackScoreData = {}): TrackScore {
366
+ return new TrackScore(data);
367
+ }
368
+
369
+ export function emptyScore(): TrackScore {
370
+ return new TrackScore();
371
+ }
372
+
373
+ export function addTrack(score: TrackScore, input: TrackInput): TrackScore {
374
+ if (!(score instanceof TrackScore))
375
+ throw new ScoreValidationError("addTrack requires a TrackScore");
376
+ return score.withTracks([...score.tracks, input]);
377
+ }
378
+
379
+ export function addNote(score: TrackScore, input: NoteInput): TrackScore {
380
+ if (!(score instanceof TrackScore))
381
+ throw new ScoreValidationError("addNote requires a TrackScore");
382
+ const note = normalizeNote(input);
383
+ if (score.notes.some((candidate) => candidate.id === note.id)) {
384
+ throw new ScoreValidationError(
385
+ `note id already exists: ${note.id}`,
386
+ "duplicate-note",
387
+ );
388
+ }
389
+ if (score.notes.length >= SCORE_LIMITS.maxNotes) {
390
+ throw new ScoreValidationError(
391
+ `score cannot contain more than ${SCORE_LIMITS.maxNotes} notes`,
392
+ "score-limit",
393
+ );
394
+ }
395
+ return new TrackScore({
396
+ tempoBpm: score.tempoBpm,
397
+ beatsPerBar: score.beatsPerBar,
398
+ bars: score.bars,
399
+ ticksPerBeat: score.ticksPerBeat,
400
+ key: score.key,
401
+ tracks: score.tracks,
402
+ notes: [...score.notes, note],
403
+ });
404
+ }
405
+
406
+ export function removeNote(score: TrackScore, noteId: string): TrackScore {
407
+ if (!(score instanceof TrackScore))
408
+ throw new ScoreValidationError("removeNote requires a TrackScore");
409
+ if (
410
+ typeof noteId !== "string" ||
411
+ noteId.length === 0 ||
412
+ noteId.length > SCORE_LIMITS.maxIdLength
413
+ ) {
414
+ throw new ScoreValidationError(
415
+ "noteId must be a non-empty short string",
416
+ "invalid-note",
417
+ );
418
+ }
419
+ if (!score.notes.some((note) => note.id === noteId)) return score;
420
+ return new TrackScore({
421
+ tempoBpm: score.tempoBpm,
422
+ beatsPerBar: score.beatsPerBar,
423
+ bars: score.bars,
424
+ ticksPerBeat: score.ticksPerBeat,
425
+ key: score.key,
426
+ tracks: score.tracks,
427
+ notes: score.notes.filter((note) => note.id !== noteId),
428
+ });
429
+ }
430
+
431
+ export function updateNote(
432
+ score: TrackScore,
433
+ noteId: string,
434
+ patch: Readonly<
435
+ Partial<Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity">>
436
+ >,
437
+ ): TrackScore {
438
+ const current = score.notes.find((note) => note.id === noteId);
439
+ if (!current) return score;
440
+ return new TrackScore({
441
+ tempoBpm: score.tempoBpm,
442
+ beatsPerBar: score.beatsPerBar,
443
+ bars: score.bars,
444
+ ticksPerBeat: score.ticksPerBeat,
445
+ key: score.key,
446
+ tracks: score.tracks,
447
+ notes: score.notes.map((note) =>
448
+ note.id === noteId ? { ...note, ...patch } : note,
449
+ ),
450
+ });
451
+ }
452
+
453
+ export function clearTrack(score: TrackScore, trackId: string): TrackScore {
454
+ if (!score.notes.some((note) => note.trackId === trackId)) return score;
455
+ return new TrackScore({
456
+ tempoBpm: score.tempoBpm,
457
+ beatsPerBar: score.beatsPerBar,
458
+ bars: score.bars,
459
+ ticksPerBeat: score.ticksPerBeat,
460
+ key: score.key,
461
+ tracks: score.tracks,
462
+ notes: score.notes.filter((note) => note.trackId !== trackId),
463
+ });
464
+ }
465
+
466
+ export function updateTrack(
467
+ score: TrackScore,
468
+ trackId: string,
469
+ patch: TrackPatch,
470
+ ): TrackScore {
471
+ if (!score.tracks.some((track) => track.id === trackId)) return score;
472
+ return score.withTracks(
473
+ score.tracks.map((track) =>
474
+ track.id === trackId ? { ...track, ...patch } : track,
475
+ ),
476
+ );
477
+ }
478
+
479
+ export function setVolumeAutomation(
480
+ score: TrackScore,
481
+ trackId: string,
482
+ points: readonly AutomationPoint[],
483
+ ): TrackScore {
484
+ return updateTrack(score, trackId, { volumeAutomation: points });
485
+ }
486
+
487
+ export function setPanAutomation(
488
+ score: TrackScore,
489
+ trackId: string,
490
+ points: readonly AutomationPoint[],
491
+ ): TrackScore {
492
+ return updateTrack(score, trackId, { panAutomation: points });
493
+ }
494
+
495
+ export function setFilterAutomation(
496
+ score: TrackScore,
497
+ trackId: string,
498
+ points: readonly AutomationPoint[],
499
+ ): TrackScore {
500
+ return updateTrack(score, trackId, { filterAutomation: points });
501
+ }
502
+
503
+ /** Replace any automation lane by parameter name. */
504
+ export function setTrackAutomation(
505
+ score: TrackScore,
506
+ trackId: string,
507
+ parameter: AutomationParameter,
508
+ points: readonly AutomationPoint[],
509
+ ): TrackScore {
510
+ if (!isAutomationParameter(parameter))
511
+ throw new ScoreValidationError(
512
+ `unknown automation parameter: ${String(parameter)}`,
513
+ "invalid-track",
514
+ );
515
+ return updateTrack(score, trackId, {
516
+ [AUTOMATION_LANES[parameter].field]: points,
517
+ });
518
+ }
519
+
520
+ /** Mute always silences a track; any solo silences every unsoloed track. */
521
+ export function isTrackAudible(score: TrackScore, trackId: string): boolean {
522
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
523
+ if (track?.muted) return false;
524
+ const soloing = score.tracks.some((candidate) => candidate.solo === true);
525
+ return !soloing || track?.solo === true;
526
+ }
527
+
528
+ export type ScoreOperation =
529
+ | Readonly<{
530
+ type: "addTrack";
531
+ track: TrackInput;
532
+ }>
533
+ | Readonly<{
534
+ type: "addNote";
535
+ note: NoteInput;
536
+ }>
537
+ | Readonly<{
538
+ type: "removeNote";
539
+ noteId: string;
540
+ }>
541
+ | Readonly<{
542
+ type: "updateNote";
543
+ noteId: string;
544
+ patch: Readonly<
545
+ Partial<
546
+ Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity">
547
+ >
548
+ >;
549
+ }>
550
+ | Readonly<{
551
+ type: "setTempo";
552
+ tempoBpm: number;
553
+ }>
554
+ | Readonly<{
555
+ type: "setBars";
556
+ bars: number;
557
+ }>
558
+ | Readonly<{
559
+ type: "updateTrack";
560
+ trackId: string;
561
+ patch: TrackPatch;
562
+ }>
563
+ | Readonly<{
564
+ type: "setAutomation";
565
+ trackId: string;
566
+ parameter: AutomationParameter;
567
+ points: readonly AutomationPoint[];
568
+ }>
569
+ | Readonly<{
570
+ type: "clearTrack";
571
+ trackId: string;
572
+ }>;
573
+
574
+ export function applyScoreOperation(
575
+ score: TrackScore,
576
+ operation: ScoreOperation,
577
+ ): TrackScore {
578
+ if (operation.type === "addTrack") return addTrack(score, operation.track);
579
+ if (operation.type === "addNote") return addNote(score, operation.note);
580
+ if (operation.type === "removeNote")
581
+ return removeNote(score, operation.noteId);
582
+ if (operation.type === "updateNote")
583
+ return updateNote(score, operation.noteId, operation.patch);
584
+ if (operation.type === "setTempo") return score.withTempo(operation.tempoBpm);
585
+ if (operation.type === "setBars") return score.withBars(operation.bars);
586
+ if (operation.type === "updateTrack")
587
+ return updateTrack(score, operation.trackId, operation.patch);
588
+ if (operation.type === "setAutomation")
589
+ return setTrackAutomation(
590
+ score,
591
+ operation.trackId,
592
+ operation.parameter,
593
+ operation.points,
594
+ );
595
+ if (operation.type === "clearTrack")
596
+ return clearTrack(score, operation.trackId);
597
+ return assertNever(operation);
598
+ }
599
+
600
+ export function scoreFromJSON(value: unknown): TrackScore {
601
+ if (!isRecord(value))
602
+ throw new ScoreValidationError("score must be an object");
603
+ const version = value.version;
604
+ if (version !== undefined && version !== SCORE_VERSION) {
605
+ const displayedVersion =
606
+ typeof version === "string" ||
607
+ typeof version === "number" ||
608
+ typeof version === "boolean" ||
609
+ typeof version === "bigint"
610
+ ? String(version)
611
+ : "[object]";
612
+ throw new ScoreValidationError(
613
+ `unsupported score version: ${displayedVersion}`,
614
+ );
615
+ }
616
+ const data: {
617
+ tempoBpm?: number;
618
+ beatsPerBar?: number;
619
+ bars?: number;
620
+ ticksPerBeat?: number;
621
+ key?: string | null;
622
+ tracks: readonly TrackInput[];
623
+ notes: readonly NoteInput[];
624
+ } = {
625
+ tracks: optionalArray(value.tracks).map(parseTrack),
626
+ notes: optionalArray(value.notes).map(parseNote),
627
+ };
628
+ const tempoBpm = optionalNumber(value.tempoBpm);
629
+ const beatsPerBar = optionalNumber(value.beatsPerBar);
630
+ const bars = optionalNumber(value.bars);
631
+ const ticksPerBeat = optionalNumber(value.ticksPerBeat);
632
+ const key = optionalNullableString(value.key);
633
+ if (tempoBpm !== undefined) data.tempoBpm = tempoBpm;
634
+ if (beatsPerBar !== undefined) data.beatsPerBar = beatsPerBar;
635
+ if (bars !== undefined) data.bars = bars;
636
+ if (ticksPerBeat !== undefined) data.ticksPerBeat = ticksPerBeat;
637
+ if (key !== undefined) data.key = key;
638
+ return new TrackScore(data);
639
+ }
640
+
641
+ function normalizeTracks(inputs: readonly unknown[]): Track[] {
642
+ if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxTracks) {
643
+ throw new ScoreValidationError(
644
+ `score cannot contain more than ${SCORE_LIMITS.maxTracks} tracks`,
645
+ "score-limit",
646
+ );
647
+ }
648
+ const seen = new Set<string>();
649
+ return inputs.map((input) => {
650
+ const track = normalizeTrack(input);
651
+ if (seen.has(track.id))
652
+ throw new ScoreValidationError(
653
+ `track id already exists: ${track.id}`,
654
+ "duplicate-track",
655
+ );
656
+ seen.add(track.id);
657
+ return track;
658
+ });
659
+ }
660
+
661
+ function normalizeTrack(input: unknown): Track {
662
+ if (!isRecord(input))
663
+ throw new ScoreValidationError("track must be an object", "invalid-track");
664
+ const id = boundedString(
665
+ input.id,
666
+ "track id",
667
+ SCORE_LIMITS.maxIdLength,
668
+ "invalid-track",
669
+ );
670
+ const name = boundedString(
671
+ input.name ?? id,
672
+ "track name",
673
+ SCORE_LIMITS.maxNameLength,
674
+ "invalid-track",
675
+ );
676
+ const instrument = boundedString(
677
+ input.instrument ?? "sine",
678
+ "instrument",
679
+ SCORE_LIMITS.maxInstrumentLength,
680
+ "invalid-track",
681
+ );
682
+ const muted = input.muted ?? false;
683
+ if (typeof muted !== "boolean")
684
+ throw new ScoreValidationError(
685
+ "track muted must be boolean",
686
+ "invalid-track",
687
+ );
688
+ const volume = input.volume ?? 1;
689
+ const pan = input.pan ?? 0;
690
+ if (
691
+ typeof volume !== "number" ||
692
+ !Number.isFinite(volume) ||
693
+ volume < 0 ||
694
+ volume > 1
695
+ )
696
+ throw new ScoreValidationError(
697
+ "track volume must be between 0 and 1",
698
+ "invalid-track",
699
+ );
700
+ if (typeof pan !== "number" || !Number.isFinite(pan) || pan < -1 || pan > 1)
701
+ throw new ScoreValidationError(
702
+ "track pan must be between -1 and 1",
703
+ "invalid-track",
704
+ );
705
+ const volumeAutomation = normalizeAutomation(
706
+ input.volumeAutomation,
707
+ "volumeAutomation",
708
+ 0,
709
+ SCORE_LIMITS.maxVolume,
710
+ );
711
+ const panAutomation = normalizeAutomation(
712
+ input.panAutomation,
713
+ "panAutomation",
714
+ -1,
715
+ 1,
716
+ );
717
+ const solo = input.solo ?? false;
718
+ if (typeof solo !== "boolean")
719
+ throw new ScoreValidationError(
720
+ "track solo must be boolean",
721
+ "invalid-track",
722
+ );
723
+ const filter = normalizeFilter(input.filter);
724
+ const delay = normalizeDelay(input.delay);
725
+ const filterAutomation = normalizeAutomation(
726
+ input.filterAutomation,
727
+ "filterAutomation",
728
+ SCORE_LIMITS.minFilterCutoff,
729
+ SCORE_LIMITS.maxFilterCutoff,
730
+ );
731
+ const lane = (parameter: AutomationParameter) => {
732
+ const { field, min, max } = AUTOMATION_LANES[parameter];
733
+ return normalizeAutomation(input[field], field, min, max);
734
+ };
735
+ const resonanceAutomation = lane("resonance");
736
+ const delayFeedbackAutomation = lane("delay-feedback");
737
+ const delayMixAutomation = lane("delay-mix");
738
+ const reverb = normalizeReverb(input.reverb);
739
+ return Object.freeze({
740
+ id,
741
+ name,
742
+ instrument,
743
+ muted,
744
+ volume,
745
+ pan,
746
+ volumeAutomation,
747
+ panAutomation,
748
+ ...(solo ? { solo } : {}),
749
+ ...(filter ? { filter } : {}),
750
+ ...(delay ? { delay } : {}),
751
+ ...(filterAutomation.length > 0 ? { filterAutomation } : {}),
752
+ ...(resonanceAutomation.length > 0 ? { resonanceAutomation } : {}),
753
+ ...(delayFeedbackAutomation.length > 0 ? { delayFeedbackAutomation } : {}),
754
+ ...(delayMixAutomation.length > 0 ? { delayMixAutomation } : {}),
755
+ ...(reverb ? { reverb } : {}),
756
+ });
757
+ }
758
+
759
+ export function normalizeReverb(input: unknown): TrackReverb | undefined {
760
+ if (input === undefined || input === null) return undefined;
761
+ if (!isRecord(input))
762
+ throw new ScoreValidationError(
763
+ "track reverb must be an object or null",
764
+ "invalid-track",
765
+ );
766
+ const mix = boundedNumber(
767
+ input.mix,
768
+ "track reverb mix",
769
+ 0,
770
+ SCORE_LIMITS.maxReverbMix,
771
+ );
772
+ const size = boundedNumber(
773
+ input.size ?? 0.5,
774
+ "track reverb size",
775
+ SCORE_LIMITS.minReverbSize,
776
+ SCORE_LIMITS.maxReverbSize,
777
+ );
778
+ return Object.freeze({ mix, size });
779
+ }
780
+
781
+ export function normalizeFilter(input: unknown): TrackFilter | undefined {
782
+ if (input === undefined || input === null) return undefined;
783
+ if (!isRecord(input))
784
+ throw new ScoreValidationError(
785
+ "track filter must be an object or null",
786
+ "invalid-track",
787
+ );
788
+ const cutoff = boundedNumber(
789
+ input.cutoff,
790
+ "track filter cutoff",
791
+ SCORE_LIMITS.minFilterCutoff,
792
+ SCORE_LIMITS.maxFilterCutoff,
793
+ );
794
+ const resonance = boundedNumber(
795
+ input.resonance ?? 0,
796
+ "track filter resonance",
797
+ 0,
798
+ SCORE_LIMITS.maxFilterResonance,
799
+ );
800
+ return Object.freeze({ cutoff, resonance });
801
+ }
802
+
803
+ export function normalizeDelay(input: unknown): TrackDelay | undefined {
804
+ if (input === undefined || input === null) return undefined;
805
+ if (!isRecord(input))
806
+ throw new ScoreValidationError(
807
+ "track delay must be an object or null",
808
+ "invalid-track",
809
+ );
810
+ const beats = boundedNumber(
811
+ input.beats,
812
+ "track delay beats",
813
+ SCORE_LIMITS.minDelayBeats,
814
+ SCORE_LIMITS.maxDelayBeats,
815
+ );
816
+ const feedback = boundedNumber(
817
+ input.feedback ?? 0.3,
818
+ "track delay feedback",
819
+ 0,
820
+ SCORE_LIMITS.maxDelayFeedback,
821
+ );
822
+ const mix = boundedNumber(
823
+ input.mix ?? 0.35,
824
+ "track delay mix",
825
+ 0,
826
+ SCORE_LIMITS.maxDelayMix,
827
+ );
828
+ return Object.freeze({ beats, feedback, mix });
829
+ }
830
+
831
+ function boundedNumber(
832
+ value: unknown,
833
+ label: string,
834
+ min: number,
835
+ max: number,
836
+ ): number {
837
+ if (
838
+ typeof value !== "number" ||
839
+ !Number.isFinite(value) ||
840
+ value < min ||
841
+ value > max
842
+ )
843
+ throw new ScoreValidationError(
844
+ `${label} must be between ${min} and ${max}`,
845
+ "invalid-track",
846
+ );
847
+ return value;
848
+ }
849
+
850
+ function normalizeAutomation(
851
+ input: unknown,
852
+ label: string,
853
+ minValue: number,
854
+ maxValue: number,
855
+ ): readonly AutomationPoint[] {
856
+ if (input === undefined) return Object.freeze([]);
857
+ if (!Array.isArray(input) || input.length > SCORE_LIMITS.maxAutomationPoints)
858
+ throw new ScoreValidationError(
859
+ `${label} must contain at most ${SCORE_LIMITS.maxAutomationPoints} points`,
860
+ "score-limit",
861
+ );
862
+ const points = input.map((candidate) => {
863
+ if (!isRecord(candidate))
864
+ throw new ScoreValidationError(
865
+ `${label} point must be an object`,
866
+ "invalid-track",
867
+ );
868
+ const tick = candidate.tick;
869
+ const value = candidate.value;
870
+ if (
871
+ typeof tick !== "number" ||
872
+ !Number.isInteger(tick) ||
873
+ tick < 0 ||
874
+ tick > SCORE_LIMITS.maxTick
875
+ )
876
+ throw new ScoreValidationError(
877
+ `${label} point tick must be an integer between 0 and ${SCORE_LIMITS.maxTick}`,
878
+ "invalid-track",
879
+ );
880
+ if (
881
+ typeof value !== "number" ||
882
+ !Number.isFinite(value) ||
883
+ value < minValue ||
884
+ value > maxValue
885
+ )
886
+ throw new ScoreValidationError(
887
+ `${label} point value must be between ${minValue} and ${maxValue}`,
888
+ "invalid-track",
889
+ );
890
+ return Object.freeze({ tick, value });
891
+ });
892
+ points.sort((a, b) => a.tick - b.tick);
893
+ for (let index = 1; index < points.length; index += 1) {
894
+ if (points[index]!.tick === points[index - 1]!.tick)
895
+ throw new ScoreValidationError(
896
+ `${label} points cannot share a tick`,
897
+ "invalid-track",
898
+ );
899
+ }
900
+ return freezeArray(points);
901
+ }
902
+
903
+ function normalizeNotes(inputs: readonly unknown[]): Note[] {
904
+ if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxNotes) {
905
+ throw new ScoreValidationError(
906
+ `score cannot contain more than ${SCORE_LIMITS.maxNotes} notes`,
907
+ "score-limit",
908
+ );
909
+ }
910
+ const seen = new Set<string>();
911
+ const notes = inputs.map((input) => {
912
+ const note = normalizeNote(input);
913
+ if (seen.has(note.id))
914
+ throw new ScoreValidationError(
915
+ `note id already exists: ${note.id}`,
916
+ "duplicate-note",
917
+ );
918
+ seen.add(note.id);
919
+ return note;
920
+ });
921
+ return notes.sort(compareNotes);
922
+ }
923
+
924
+ function normalizeNote(input: unknown): Note {
925
+ if (!isRecord(input))
926
+ throw new ScoreValidationError("note must be an object", "invalid-note");
927
+ const id = boundedString(
928
+ input.id,
929
+ "note id",
930
+ SCORE_LIMITS.maxIdLength,
931
+ "invalid-note",
932
+ );
933
+ const trackId = boundedString(
934
+ input.trackId,
935
+ "note trackId",
936
+ SCORE_LIMITS.maxIdLength,
937
+ "invalid-note",
938
+ );
939
+ const startTick = input.startTick ?? input.start;
940
+ const durationTicks = input.durationTicks ?? input.duration;
941
+ if (
942
+ typeof startTick !== "number" ||
943
+ !Number.isInteger(startTick) ||
944
+ startTick < 0 ||
945
+ startTick > SCORE_LIMITS.maxTick
946
+ ) {
947
+ throw new ScoreValidationError(
948
+ `note ${id} startTick must be an integer between 0 and ${SCORE_LIMITS.maxTick}`,
949
+ "invalid-note",
950
+ );
951
+ }
952
+ if (
953
+ typeof durationTicks !== "number" ||
954
+ !Number.isInteger(durationTicks) ||
955
+ durationTicks < 1 ||
956
+ durationTicks > SCORE_LIMITS.maxTick
957
+ ) {
958
+ throw new ScoreValidationError(
959
+ `note ${id} durationTicks must be an integer between 1 and ${SCORE_LIMITS.maxTick}`,
960
+ "invalid-note",
961
+ );
962
+ }
963
+ const pitch = input.pitch;
964
+ if (
965
+ typeof pitch !== "number" ||
966
+ !Number.isInteger(pitch) ||
967
+ pitch < 0 ||
968
+ pitch > 127
969
+ ) {
970
+ throw new ScoreValidationError(
971
+ `note ${id} pitch must be a MIDI integer between 0 and 127`,
972
+ "invalid-note",
973
+ );
974
+ }
975
+ if (
976
+ typeof input.velocity !== "number" ||
977
+ !Number.isFinite(input.velocity) ||
978
+ input.velocity < 0 ||
979
+ input.velocity > 1
980
+ ) {
981
+ throw new ScoreValidationError(
982
+ `note ${id} velocity must be between 0 and 1`,
983
+ "invalid-note",
984
+ );
985
+ }
986
+ return Object.freeze({
987
+ id,
988
+ trackId,
989
+ startTick,
990
+ durationTicks,
991
+ pitch,
992
+ velocity: input.velocity,
993
+ });
994
+ }
995
+
996
+ function parseTrack(value: unknown): TrackInput {
997
+ if (!isRecord(value))
998
+ throw new ScoreValidationError("track must be an object", "invalid-track");
999
+ return value as unknown as TrackInput;
1000
+ }
1001
+
1002
+ function parseNote(value: unknown): NoteInput {
1003
+ if (!isRecord(value))
1004
+ throw new ScoreValidationError("note must be an object", "invalid-note");
1005
+ return value as unknown as NoteInput;
1006
+ }
1007
+
1008
+ function compareNotes(a: Note, b: Note): number {
1009
+ return (
1010
+ a.startTick - b.startTick ||
1011
+ a.trackId.localeCompare(b.trackId) ||
1012
+ a.pitch - b.pitch ||
1013
+ a.id.localeCompare(b.id)
1014
+ );
1015
+ }
1016
+
1017
+ function boundedString(
1018
+ value: unknown,
1019
+ label: string,
1020
+ max: number,
1021
+ code: ScoreValidationError["code"],
1022
+ ): string {
1023
+ if (typeof value !== "string" || value.length === 0 || value.length > max) {
1024
+ throw new ScoreValidationError(
1025
+ `${label} must be a non-empty string of at most ${max} characters`,
1026
+ code,
1027
+ );
1028
+ }
1029
+ return value;
1030
+ }
1031
+
1032
+ function optionalNumber(value: unknown): number | undefined {
1033
+ return value === undefined
1034
+ ? undefined
1035
+ : typeof value === "number"
1036
+ ? value
1037
+ : Number.NaN;
1038
+ }
1039
+
1040
+ function optionalNullableString(value: unknown): string | null | undefined {
1041
+ if (value === undefined || value === null || typeof value === "string")
1042
+ return value;
1043
+ return "\u0000invalid";
1044
+ }
1045
+
1046
+ function optionalArray(value: unknown): unknown[] {
1047
+ if (value === undefined) return [];
1048
+ if (!Array.isArray(value))
1049
+ throw new ScoreValidationError("array field is malformed");
1050
+ return value;
1051
+ }
1052
+
1053
+ function isRecord(value: unknown): value is Record<string, unknown> {
1054
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1055
+ }
1056
+
1057
+ function freezeArray<T>(items: readonly T[]): readonly T[] {
1058
+ return Object.freeze([...items]);
1059
+ }
1060
+
1061
+ function assertNever(value: never): never {
1062
+ throw new ScoreValidationError(
1063
+ `unsupported score operation: ${String(value)}`,
1064
+ );
1065
+ }