@hraness/dawg 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. package/tui/keys.ts +72 -0
package/core/score.ts CHANGED
@@ -20,7 +20,45 @@ import {
20
20
  type FxLane,
21
21
  type TrackFx,
22
22
  } from "./fx.ts";
23
+ import {
24
+ MODAL_INSTRUMENT,
25
+ normalizeModal,
26
+ type TrackModal,
27
+ } from "./resonators.ts";
23
28
  import { normalizeSynth, type TrackSynth } from "./synth.ts";
29
+ import { normalizeString, type TrackString } from "./strings.ts";
30
+ import {
31
+ isGranularInstrument,
32
+ normalizeGranular,
33
+ type TrackGranular,
34
+ } from "./granular.ts";
35
+ import { normalizeKeys, type TrackKeys } from "./keys.ts";
36
+ import {
37
+ checkSongTime,
38
+ normalizeSongTime,
39
+ normalizeTrackTime,
40
+ TimeValidationError,
41
+ type SongTime,
42
+ type TrackTime,
43
+ withMeterChange,
44
+ } from "./tempo.ts";
45
+ import {
46
+ ExpressionValidationError,
47
+ normalizeNoteExpression,
48
+ normalizeTrackPerformance,
49
+ type NoteExpression,
50
+ type NoteExpressionPatch,
51
+ type TrackPerformance,
52
+ } from "./expression.ts";
53
+ import {
54
+ normalizeNoteCents,
55
+ normalizeTuning,
56
+ TuningError,
57
+ type Tuning,
58
+ } from "./tuning.ts";
59
+
60
+ export type { Tuning } from "./tuning.ts";
61
+ import { normalizeMaster, type SongMaster } from "./master.ts";
24
62
 
25
63
  export const SCORE_VERSION = 1 as const;
26
64
  export const DEFAULT_TICKS_PER_BEAT = 480 as const;
@@ -36,6 +74,13 @@ export const SCORE_LIMITS = Object.freeze({
36
74
  maxTempoBpm: 300,
37
75
  minTempoBpm: 20,
38
76
  maxBars: 256,
77
+ /** Song sections (0.5): named bar ranges and the form that orders them. */
78
+ maxSections: 64,
79
+ maxSectionNameLength: 32,
80
+ maxFormEntries: 128,
81
+ maxFormRepeat: 16,
82
+ maxSectionTranspose: 24,
83
+ maxSectionGain: 2,
39
84
  maxBeatsPerBar: 16,
40
85
  maxTicksPerBeat: 4096,
41
86
  maxTick: 1_000_000,
@@ -66,6 +111,11 @@ export const SCORE_LIMITS = Object.freeze({
66
111
  maxSampleAccelerate: 8,
67
112
  /** Sampler `squiz` ratio bound. */
68
113
  maxSampleSquiz: 32,
114
+ /** Sampler `bpm` (source tempo) bounds. */
115
+ minSampleBpm: 20,
116
+ maxSampleBpm: 400,
117
+ /** Sampler `len` (window length in beats) bound. */
118
+ maxSampleLenBeats: 1024,
69
119
  /** Per sample file, enforced by the decoder and the import tool. */
70
120
  maxSampleFileBytes: 50 * 1024 * 1024,
71
121
  maxSampleSeconds: 600,
@@ -328,7 +378,47 @@ export type Track = Readonly<{
328
378
  * `lofi`, … see `core/kits.ts`). Absent plays the default voices.
329
379
  */
330
380
  kit?: string;
331
- }>;
381
+ /**
382
+ * Track time against the song (`core/tempo.ts`): `rate` (tempo ratio),
383
+ * `phase` (ticks the pattern shifts later) and `cycle` (track ticks per
384
+ * repetition) for polytempo, polymeter and phasing. Absent follows the
385
+ * song.
386
+ */
387
+ time?: TrackTime;
388
+ /**
389
+ * Tuning for this track (`core/tuning.ts`): its own table, or just a
390
+ * `ref`/`root` over the song tuning. Absent follows the song tuning.
391
+ */
392
+ tuning?: Tuning;
393
+ /**
394
+ * String engine settings (`core/strings.ts`): a preset plus overrides.
395
+ * Played only when `instrument` is "string"; absent keeps today's voice.
396
+ */
397
+ string?: TrackString;
398
+ /**
399
+ * Granular instrument settings (0.6, `core/granular.ts`). Played when
400
+ * `instrument` is `"granular"`; kept when the instrument changes.
401
+ */
402
+ granular?: TrackGranular;
403
+ /**
404
+ * Optional (0.6): modelled piano settings (`core/keys.ts`), `{ preset?,
405
+ * ...overrides }`. The modelled piano plays only when `instrument` is a
406
+ * piano family (grand upright felt honkytonk prepared) and this is
407
+ * present; `{}` is the family's defaults.
408
+ */
409
+ keys?: TrackKeys;
410
+ /**
411
+ * Optional (0.6): modal percussion (`core/resonators.ts`), a preset plus
412
+ * overrides. Plays only when `instrument` is `"modal"`; any other
413
+ * instrument with this field is rejected. Absent keeps today's tone.
414
+ */
415
+ modal?: TrackModal;
416
+ }> &
417
+ /**
418
+ * Performance (`core/expression.ts`): glide default, sustain pedal
419
+ * events, velocity curve and seeded humanize, all applied at render.
420
+ */
421
+ TrackPerformance;
332
422
 
333
423
  /**
334
424
  * A track's sample voices (Strudel-aligned). In `oneshot` mode every voice is
@@ -399,8 +489,35 @@ export type SampleRef = Readonly<{
399
489
  * (1 is off, up to 32).
400
490
  */
401
491
  squiz?: number;
492
+ /**
493
+ * Optional: the sample's own tempo (20..400). The window then follows
494
+ * the song's tempo map (ramps included): a 174 BPM break in a 128 BPM
495
+ * song plays 128/174 as fast. `fitmode` picks how.
496
+ */
497
+ bpm?: number;
498
+ /**
499
+ * Optional: how a fitted sample changes its time. `repitch` (default)
500
+ * changes speed and pitch together like tape; `beats` cuts the window at
501
+ * its onsets and places each slice on its new time, unstretched (drums,
502
+ * speech); `tones` time-stretches with a phase vocoder and keeps the
503
+ * pitch (pads, loops, vocals). Needs `bpm`, `len` or `fit`.
504
+ */
505
+ fitmode?: SampleFitMode;
506
+ /**
507
+ * Optional: the window's length in beats (0 < len ≤ 1024), followed
508
+ * through the song's tempo map. `fit` wins over `bpm`, `bpm` over `len`.
509
+ */
510
+ len?: number;
402
511
  }>;
403
512
 
513
+ /** Sampler `fitmode`: how a fitted window changes its time. */
514
+ export type SampleFitMode = "repitch" | "beats" | "tones";
515
+ export const SAMPLE_FIT_MODES: readonly SampleFitMode[] = Object.freeze([
516
+ "repitch",
517
+ "beats",
518
+ "tones",
519
+ ]);
520
+
404
521
  /** Sample `unit`: rate, cycles (bars) or seconds. */
405
522
  export type SampleUnit = "r" | "c" | "s";
406
523
  export const SAMPLE_UNITS: readonly SampleUnit[] = Object.freeze([
@@ -627,6 +744,16 @@ export type TrackPatch = Readonly<
627
744
  fxAutomation?: Track["fxAutomation"] | null;
628
745
  synth?: TrackSynth | null;
629
746
  wavetable?: TrackWavetable | null;
747
+ time?: TrackTime | null;
748
+ glide?: Track["glide"] | null;
749
+ pedal?: Track["pedal"] | null;
750
+ velocityCurve?: Track["velocityCurve"] | null;
751
+ humanize?: Track["humanize"] | null;
752
+ tuning?: Tuning | null;
753
+ string?: TrackString | null;
754
+ granular?: TrackGranular | null;
755
+ keys?: TrackKeys | null;
756
+ modal?: TrackModal | null;
630
757
  }
631
758
  >;
632
759
 
@@ -635,7 +762,11 @@ export type AutomationPoint = Readonly<{
635
762
  value: number;
636
763
  }>;
637
764
 
638
- /** A note's start and duration are integer ticks, never floating-point beats. */
765
+ /**
766
+ * A note's start and duration are integer ticks, never floating-point beats.
767
+ * Expression fields (`articulation`, `glide`, `bend`, `vibrato`, see
768
+ * `core/expression.ts`) are optional and absent when unused.
769
+ */
639
770
  export type Note = Readonly<{
640
771
  id: string;
641
772
  trackId: string;
@@ -643,7 +774,10 @@ export type Note = Readonly<{
643
774
  durationTicks: number;
644
775
  pitch: number;
645
776
  velocity: number;
646
- }>;
777
+ /** Static offset from the tuned pitch in cents (±1200); absent is 0. */
778
+ cents?: number;
779
+ }> &
780
+ NoteExpression;
647
781
 
648
782
  export type TrackInput = Readonly<
649
783
  Omit<
@@ -658,6 +792,16 @@ export type TrackInput = Readonly<
658
792
  | "fxAutomation"
659
793
  | "synth"
660
794
  | "wavetable"
795
+ | "time"
796
+ | "glide"
797
+ | "pedal"
798
+ | "velocityCurve"
799
+ | "humanize"
800
+ | "tuning"
801
+ | "string"
802
+ | "granular"
803
+ | "keys"
804
+ | "modal"
661
805
  > &
662
806
  Pick<Track, "id"> & {
663
807
  filter?: TrackFilter | null;
@@ -670,6 +814,16 @@ export type TrackInput = Readonly<
670
814
  fxAutomation?: Track["fxAutomation"] | null;
671
815
  synth?: TrackSynth | null;
672
816
  wavetable?: TrackWavetable | null;
817
+ time?: TrackTime | null;
818
+ glide?: Track["glide"] | number | null;
819
+ pedal?: Track["pedal"] | null;
820
+ velocityCurve?: Track["velocityCurve"] | string | null;
821
+ humanize?: Track["humanize"] | null;
822
+ tuning?: Tuning | null;
823
+ string?: TrackString | null;
824
+ granular?: TrackGranular | null;
825
+ keys?: TrackKeys | null;
826
+ modal?: TrackModal | null;
673
827
  }
674
828
  >;
675
829
 
@@ -686,6 +840,41 @@ export type NoteInput = Readonly<{
686
840
  duration?: number;
687
841
  pitch: number;
688
842
  velocity: number;
843
+ cents?: number;
844
+ }> &
845
+ NoteExpressionPatch;
846
+
847
+ /** A per-track change inside a section: semitones and a velocity gain. */
848
+ export type SectionVariation = Readonly<{
849
+ /** Semitones, -24..24; drum and one-shot sampler tracks ignore it. */
850
+ transpose?: number;
851
+ /** Velocity multiplier 0..2 (1 leaves it alone). */
852
+ gain?: number;
853
+ }>;
854
+
855
+ /**
856
+ * A named bar range of the song (0.5). Sections may overlap: two sections
857
+ * over the same bars with different mutes or variations are two versions
858
+ * of that music. `mute` and `vary` name track ids; unknown ids are ignored.
859
+ */
860
+ export type Section = Readonly<{
861
+ /** Unique (ignoring case), 1..32 characters: `intro`, `chorus 2`, `A`. */
862
+ name: string;
863
+ /** First bar, 0-based. */
864
+ startBar: number;
865
+ /** Length in bars, at least 1. */
866
+ bars: number;
867
+ /** Tracks silent in this section. */
868
+ mute?: readonly string[];
869
+ /** Per-track variations in this section. */
870
+ vary?: Readonly<Record<string, SectionVariation>>;
871
+ }>;
872
+
873
+ /** One step of the song form: a section by name, played `repeat` times. */
874
+ export type FormEntry = Readonly<{
875
+ section: string;
876
+ /** 1..16, default 1. */
877
+ repeat?: number;
689
878
  }>;
690
879
 
691
880
  export type TrackScoreData = Readonly<{
@@ -694,8 +883,26 @@ export type TrackScoreData = Readonly<{
694
883
  bars?: number;
695
884
  ticksPerBeat?: number;
696
885
  key?: string | null;
886
+ /**
887
+ * Tempo map, meter changes and fermatas (`core/tempo.ts`). Absent keeps
888
+ * `tempoBpm` and `beatsPerBar` for the whole song.
889
+ */
890
+ time?: SongTime | null;
891
+ /** Song tuning (`core/tuning.ts`); absent is 12-TET at A4 = 440 Hz. */
892
+ tuning?: Tuning | null;
697
893
  tracks?: readonly TrackInput[];
698
894
  notes?: readonly NoteInput[];
895
+ /** Song master chain and loudness target (core/master.ts); absent is off. */
896
+ master?: SongMaster | null;
897
+ /** Song sections (0.5); absent or empty means none. */
898
+ sections?: readonly Section[];
899
+ /** Song form (0.5): the order sections play in; absent plays the score straight through. */
900
+ form?: readonly FormEntry[];
901
+ /**
902
+ * The section playback loops (0.5), like a DAW's loop brace; absent plays
903
+ * the song (or its form). A name that matches no section is dropped.
904
+ */
905
+ loopSection?: string | null;
699
906
  }>;
700
907
 
701
908
  /** Canonical immutable score. Use `addNote`/`removeNote` to create a revision. */
@@ -706,8 +913,19 @@ export class TrackScore {
706
913
  readonly bars: number;
707
914
  readonly ticksPerBeat: number;
708
915
  readonly key: string | null;
916
+ /** Tempo map, meters and fermatas; absent when the song has none. */
917
+ declare readonly time?: SongTime;
918
+ readonly tuning: Tuning | undefined;
709
919
  readonly tracks: readonly Track[];
710
920
  readonly notes: readonly Note[];
921
+ /** Absent (not undefined-valued) without a master, so 0.4 scores are unchanged. */
922
+ declare readonly master?: SongMaster;
923
+ /** Song sections in bar order; empty when the song has none. */
924
+ readonly sections: readonly Section[];
925
+ /** Song form; empty plays the score straight through. */
926
+ readonly form: readonly FormEntry[];
927
+ /** The section playback loops; undefined plays the song. */
928
+ readonly loopSection: string | undefined;
711
929
 
712
930
  constructor(data: TrackScoreData = {}) {
713
931
  const tempoBpm = data.tempoBpm ?? 120;
@@ -760,15 +978,49 @@ export class TrackScore {
760
978
  "invalid-score",
761
979
  );
762
980
  }
981
+ const time = timeOrThrow(() => {
982
+ const normalized = normalizeSongTime(data.time);
983
+ checkSongTime(normalized, ticksPerBeat, { tempoBpm, beatsPerBar, bars });
984
+ return normalized;
985
+ });
986
+ const tuning = tuningOrThrow(
987
+ () => normalizeTuning(data.tuning, "song tuning"),
988
+ "invalid-score",
989
+ );
763
990
  const tracks = normalizeTracks(data.tracks ?? []);
764
991
  const notes = normalizeNotes(data.notes ?? []);
992
+ this.tuning = tuning;
993
+ let master: SongMaster | undefined;
994
+ try {
995
+ master = normalizeMaster(data.master);
996
+ } catch (error) {
997
+ if (error instanceof FxValidationError)
998
+ throw new ScoreValidationError(error.message, "invalid-score");
999
+ throw error;
1000
+ }
1001
+ if (master) this.master = master;
1002
+ const sections = normalizeSections(data.sections ?? []);
1003
+ const form = normalizeForm(data.form ?? [], sections);
1004
+ // Sections count bars in one meter; meter changes would move them.
1005
+ if (
1006
+ sections.length > 0 &&
1007
+ time?.meter?.some((change, index) => index > 0 || change.bar > 0)
1008
+ )
1009
+ throw new ScoreValidationError(
1010
+ "sections need one meter: remove the meter changes (time.meter) or the sections",
1011
+ "invalid-score",
1012
+ );
765
1013
  this.tempoBpm = tempoBpm;
766
1014
  this.beatsPerBar = beatsPerBar;
767
1015
  this.bars = bars;
768
1016
  this.ticksPerBeat = ticksPerBeat;
769
1017
  this.key = key;
1018
+ if (time) this.time = time;
770
1019
  this.tracks = freezeArray(tracks);
771
1020
  this.notes = freezeArray(notes);
1021
+ this.sections = freezeArray(sections);
1022
+ this.form = freezeArray(form);
1023
+ this.loopSection = normalizeLoopSection(data.loopSection, sections);
772
1024
  Object.freeze(this);
773
1025
  }
774
1026
 
@@ -781,26 +1033,27 @@ export class TrackScore {
781
1033
  }
782
1034
 
783
1035
  withTracks(tracks: readonly TrackInput[]): TrackScore {
784
- return new TrackScore({
785
- tempoBpm: this.tempoBpm,
786
- beatsPerBar: this.beatsPerBar,
787
- bars: this.bars,
788
- ticksPerBeat: this.ticksPerBeat,
789
- key: this.key,
790
- tracks,
791
- notes: this.notes,
792
- });
1036
+ return new TrackScore({ ...this.toJSON(), tracks });
793
1037
  }
794
1038
 
795
1039
  withTempo(tempoBpm: number): TrackScore {
1040
+ return new TrackScore({ ...this.toJSON(), tempoBpm });
1041
+ }
1042
+
1043
+ /**
1044
+ * Replace the sections and the form (both validated together). The loop
1045
+ * section is kept while a section of that name remains.
1046
+ */
1047
+ withSections(
1048
+ sections: readonly Section[],
1049
+ form: readonly FormEntry[] = [],
1050
+ loopSection: string | null | undefined = this.loopSection,
1051
+ ): TrackScore {
796
1052
  return new TrackScore({
797
- tempoBpm,
798
- beatsPerBar: this.beatsPerBar,
799
- bars: this.bars,
800
- ticksPerBeat: this.ticksPerBeat,
801
- key: this.key,
802
- tracks: this.tracks,
803
- notes: this.notes,
1053
+ ...this.toJSON(),
1054
+ sections,
1055
+ form,
1056
+ loopSection: loopSection ?? null,
804
1057
  });
805
1058
  }
806
1059
 
@@ -813,9 +1066,35 @@ export class TrackScore {
813
1066
  return new TrackScore({ ...this.toJSON(), key });
814
1067
  }
815
1068
 
816
- /** Change the meter; ticks are per beat, so notes keep their positions. */
1069
+ /** Set or clear (null) the song tuning. */
1070
+ withTuning(tuning: Tuning | null): TrackScore {
1071
+ return new TrackScore({ ...this.toJSON(), tuning });
1072
+ }
1073
+
1074
+ /**
1075
+ * Change the meter; ticks are per beat, so notes keep their positions.
1076
+ * Beats per bar is the song meter, so a meter change at bar 1
1077
+ * (`time.meter` bar 0) is dropped rather than left overriding it.
1078
+ */
817
1079
  withMeter(beatsPerBar: number): TrackScore {
818
- return new TrackScore({ ...this.toJSON(), beatsPerBar });
1080
+ const time = this.time?.meter?.some((change) => change.bar === 0)
1081
+ ? withMeterChange(this.time, 0, null)
1082
+ : this.time;
1083
+ return new TrackScore({
1084
+ ...this.toJSON(),
1085
+ beatsPerBar,
1086
+ time: time ?? null,
1087
+ });
1088
+ }
1089
+
1090
+ /** Replace the tempo map, meter changes and fermatas; null clears them. */
1091
+ withTime(time: SongTime | null | undefined): TrackScore {
1092
+ return new TrackScore({ ...this.toJSON(), time: time ?? null });
1093
+ }
1094
+
1095
+ /** Replace the song master; `null` removes it (bypass). */
1096
+ withMaster(master: SongMaster | null): TrackScore {
1097
+ return new TrackScore({ ...this.toJSON(), master });
819
1098
  }
820
1099
 
821
1100
  toJSON(): TrackScoreData & { version: typeof SCORE_VERSION } {
@@ -826,8 +1105,16 @@ export class TrackScore {
826
1105
  bars: this.bars,
827
1106
  ticksPerBeat: this.ticksPerBeat,
828
1107
  key: this.key,
1108
+ ...(this.time ? { time: this.time } : {}),
1109
+ ...(this.tuning ? { tuning: this.tuning } : {}),
829
1110
  tracks: this.tracks,
830
1111
  notes: this.notes,
1112
+ ...(this.master ? { master: this.master } : {}),
1113
+ ...(this.sections.length > 0 ? { sections: this.sections } : {}),
1114
+ ...(this.form.length > 0 ? { form: this.form } : {}),
1115
+ ...(this.loopSection === undefined
1116
+ ? {}
1117
+ : { loopSection: this.loopSection }),
831
1118
  };
832
1119
  }
833
1120
  }
@@ -862,15 +1149,7 @@ export function addNote(score: TrackScore, input: NoteInput): TrackScore {
862
1149
  "score-limit",
863
1150
  );
864
1151
  }
865
- return new TrackScore({
866
- tempoBpm: score.tempoBpm,
867
- beatsPerBar: score.beatsPerBar,
868
- bars: score.bars,
869
- ticksPerBeat: score.ticksPerBeat,
870
- key: score.key,
871
- tracks: score.tracks,
872
- notes: [...score.notes, note],
873
- });
1152
+ return new TrackScore({ ...score.toJSON(), notes: [...score.notes, note] });
874
1153
  }
875
1154
 
876
1155
  export function removeNote(score: TrackScore, noteId: string): TrackScore {
@@ -888,32 +1167,31 @@ export function removeNote(score: TrackScore, noteId: string): TrackScore {
888
1167
  }
889
1168
  if (!score.notes.some((note) => note.id === noteId)) return score;
890
1169
  return new TrackScore({
891
- tempoBpm: score.tempoBpm,
892
- beatsPerBar: score.beatsPerBar,
893
- bars: score.bars,
894
- ticksPerBeat: score.ticksPerBeat,
895
- key: score.key,
896
- tracks: score.tracks,
1170
+ ...score.toJSON(),
897
1171
  notes: score.notes.filter((note) => note.id !== noteId),
898
1172
  });
899
1173
  }
900
1174
 
1175
+ /**
1176
+ * A note edit: timing, pitch and velocity, plus the expression fields
1177
+ * (`null` clears one).
1178
+ */
1179
+ export type NotePatch = Readonly<
1180
+ Partial<
1181
+ Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity" | "cents">
1182
+ >
1183
+ > &
1184
+ NoteExpressionPatch;
1185
+
901
1186
  export function updateNote(
902
1187
  score: TrackScore,
903
1188
  noteId: string,
904
- patch: Readonly<
905
- Partial<Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity">>
906
- >,
1189
+ patch: NotePatch,
907
1190
  ): TrackScore {
908
1191
  const current = score.notes.find((note) => note.id === noteId);
909
1192
  if (!current) return score;
910
1193
  return new TrackScore({
911
- tempoBpm: score.tempoBpm,
912
- beatsPerBar: score.beatsPerBar,
913
- bars: score.bars,
914
- ticksPerBeat: score.ticksPerBeat,
915
- key: score.key,
916
- tracks: score.tracks,
1194
+ ...score.toJSON(),
917
1195
  notes: score.notes.map((note) =>
918
1196
  note.id === noteId ? { ...note, ...patch } : note,
919
1197
  ),
@@ -923,12 +1201,7 @@ export function updateNote(
923
1201
  export function clearTrack(score: TrackScore, trackId: string): TrackScore {
924
1202
  if (!score.notes.some((note) => note.trackId === trackId)) return score;
925
1203
  return new TrackScore({
926
- tempoBpm: score.tempoBpm,
927
- beatsPerBar: score.beatsPerBar,
928
- bars: score.bars,
929
- ticksPerBeat: score.ticksPerBeat,
930
- key: score.key,
931
- tracks: score.tracks,
1204
+ ...score.toJSON(),
932
1205
  notes: score.notes.filter((note) => note.trackId !== trackId),
933
1206
  });
934
1207
  }
@@ -940,9 +1213,19 @@ export function updateTrack(
940
1213
  ): TrackScore {
941
1214
  if (!score.tracks.some((track) => track.id === trackId)) return score;
942
1215
  return score.withTracks(
943
- score.tracks.map((track) =>
944
- track.id === trackId ? { ...track, ...patch } : track,
945
- ),
1216
+ score.tracks.map((track) => {
1217
+ if (track.id !== trackId) return track;
1218
+ const next: Record<string, unknown> = { ...track, ...patch };
1219
+ // A modal field only belongs to a modal track: switching the
1220
+ // instrument away drops it unless the patch sets it too.
1221
+ if (
1222
+ patch.instrument !== undefined &&
1223
+ patch.modal === undefined &&
1224
+ patch.instrument !== MODAL_INSTRUMENT
1225
+ )
1226
+ delete next.modal;
1227
+ return next as Track;
1228
+ }),
946
1229
  );
947
1230
  }
948
1231
 
@@ -1076,11 +1359,7 @@ export type ScoreOperation =
1076
1359
  | Readonly<{
1077
1360
  type: "updateNote";
1078
1361
  noteId: string;
1079
- patch: Readonly<
1080
- Partial<
1081
- Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity">
1082
- >
1083
- >;
1362
+ patch: NotePatch;
1084
1363
  }>
1085
1364
  | Readonly<{
1086
1365
  type: "setTempo";
@@ -1121,6 +1400,26 @@ export type ScoreOperation =
1121
1400
  | Readonly<{
1122
1401
  type: "setMeter";
1123
1402
  beatsPerBar: number;
1403
+ }>
1404
+ | Readonly<{
1405
+ /** Replaces the song's tempo map, meter changes and fermatas. */
1406
+ type: "setTime";
1407
+ time: SongTime | null;
1408
+ }>
1409
+ | Readonly<{
1410
+ type: "setTuning";
1411
+ tuning: Tuning | null;
1412
+ }>
1413
+ | Readonly<{
1414
+ type: "setMaster";
1415
+ master: SongMaster | null;
1416
+ }>
1417
+ | Readonly<{
1418
+ type: "setSections";
1419
+ sections: readonly Section[];
1420
+ form: readonly FormEntry[];
1421
+ /** The looped section; absent or null plays the song. */
1422
+ loopSection?: string | null;
1124
1423
  }>;
1125
1424
 
1126
1425
  export function applyScoreOperation(
@@ -1134,6 +1433,15 @@ export function applyScoreOperation(
1134
1433
  if (operation.type === "setKey") return score.withKey(operation.key);
1135
1434
  if (operation.type === "setMeter")
1136
1435
  return score.withMeter(operation.beatsPerBar);
1436
+ if (operation.type === "setTime") return score.withTime(operation.time);
1437
+ if (operation.type === "setTuning") return score.withTuning(operation.tuning);
1438
+ if (operation.type === "setMaster") return score.withMaster(operation.master);
1439
+ if (operation.type === "setSections")
1440
+ return score.withSections(
1441
+ operation.sections,
1442
+ operation.form,
1443
+ operation.loopSection ?? null,
1444
+ );
1137
1445
  if (operation.type === "addTrack") return addTrack(score, operation.track);
1138
1446
  if (operation.type === "addNote") return addNote(score, operation.note);
1139
1447
  if (operation.type === "removeNote")
@@ -1178,8 +1486,14 @@ export function scoreFromJSON(value: unknown): TrackScore {
1178
1486
  bars?: number;
1179
1487
  ticksPerBeat?: number;
1180
1488
  key?: string | null;
1489
+ time?: SongTime | null;
1490
+ tuning?: Tuning | null;
1181
1491
  tracks: readonly TrackInput[];
1182
1492
  notes: readonly NoteInput[];
1493
+ master?: SongMaster;
1494
+ sections?: readonly Section[];
1495
+ form?: readonly FormEntry[];
1496
+ loopSection?: string | null;
1183
1497
  } = {
1184
1498
  tracks: optionalArray(value.tracks).map(parseTrack),
1185
1499
  notes: optionalArray(value.notes).map(parseNote),
@@ -1194,9 +1508,202 @@ export function scoreFromJSON(value: unknown): TrackScore {
1194
1508
  if (bars !== undefined) data.bars = bars;
1195
1509
  if (ticksPerBeat !== undefined) data.ticksPerBeat = ticksPerBeat;
1196
1510
  if (key !== undefined) data.key = key;
1511
+ if (value.time !== undefined && value.time !== null)
1512
+ data.time = value.time as SongTime;
1513
+ if (value.tuning !== undefined) data.tuning = value.tuning as Tuning | null;
1514
+ // Validated by the constructor (core/master.ts normalizeMaster).
1515
+ if (value.master !== undefined && value.master !== null)
1516
+ data.master = value.master as SongMaster;
1517
+ // Shapes are checked by the constructor (normalizeSections, normalizeForm).
1518
+ if (value.sections !== undefined)
1519
+ data.sections = optionalArray(value.sections) as readonly Section[];
1520
+ if (value.form !== undefined)
1521
+ data.form = optionalArray(value.form) as readonly FormEntry[];
1522
+ // The constructor checks the type and drops a name matching no section.
1523
+ if (value.loopSection !== undefined)
1524
+ data.loopSection = value.loopSection as string | null;
1197
1525
  return new TrackScore(data);
1198
1526
  }
1199
1527
 
1528
+ /** Section names: printable text without control characters, trimmed. */
1529
+ const SECTION_NAME = /^[^\u0000-\u001f\u007f]+$/u;
1530
+
1531
+ function normalizeSections(inputs: readonly unknown[]): Section[] {
1532
+ if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxSections)
1533
+ throw new ScoreValidationError(
1534
+ `score cannot contain more than ${SCORE_LIMITS.maxSections} sections`,
1535
+ "score-limit",
1536
+ );
1537
+ const seen = new Set<string>();
1538
+ const sections = inputs.map((input, index): Section => {
1539
+ if (!isRecord(input))
1540
+ throw new ScoreValidationError(`section ${index} must be an object`);
1541
+ const name =
1542
+ typeof input.name === "string"
1543
+ ? input.name.trim().replace(/\s+/gu, " ")
1544
+ : "";
1545
+ if (
1546
+ name.length === 0 ||
1547
+ name.length > SCORE_LIMITS.maxSectionNameLength ||
1548
+ !SECTION_NAME.test(name)
1549
+ )
1550
+ throw new ScoreValidationError(
1551
+ `section ${index} needs a name of 1..${SCORE_LIMITS.maxSectionNameLength} characters`,
1552
+ );
1553
+ const folded = name.toLowerCase();
1554
+ if (seen.has(folded))
1555
+ throw new ScoreValidationError(`duplicate section name: ${name}`);
1556
+ seen.add(folded);
1557
+ const startBar = input.startBar;
1558
+ const bars = input.bars;
1559
+ if (
1560
+ typeof startBar !== "number" ||
1561
+ !Number.isInteger(startBar) ||
1562
+ startBar < 0 ||
1563
+ startBar >= SCORE_LIMITS.maxBars
1564
+ )
1565
+ throw new ScoreValidationError(
1566
+ `section ${name} startBar must be an integer 0..${SCORE_LIMITS.maxBars - 1}`,
1567
+ );
1568
+ if (
1569
+ typeof bars !== "number" ||
1570
+ !Number.isInteger(bars) ||
1571
+ bars < 1 ||
1572
+ startBar + bars > SCORE_LIMITS.maxBars
1573
+ )
1574
+ throw new ScoreValidationError(
1575
+ `section ${name} bars must be an integer 1..${SCORE_LIMITS.maxBars - startBar}`,
1576
+ );
1577
+ const out: { -readonly [K in keyof Section]: Section[K] } = {
1578
+ name,
1579
+ startBar,
1580
+ bars,
1581
+ };
1582
+ if (input.mute !== undefined) {
1583
+ if (
1584
+ !Array.isArray(input.mute) ||
1585
+ input.mute.length > SCORE_LIMITS.maxTracks ||
1586
+ input.mute.some((id) => typeof id !== "string" || id.length === 0)
1587
+ )
1588
+ throw new ScoreValidationError(
1589
+ `section ${name} mute must be a list of track ids`,
1590
+ );
1591
+ const mute = [...new Set(input.mute as string[])];
1592
+ if (mute.length > 0) out.mute = freezeArray(mute);
1593
+ }
1594
+ if (input.vary !== undefined) {
1595
+ if (!isRecord(input.vary))
1596
+ throw new ScoreValidationError(
1597
+ `section ${name} vary must map track ids to variations`,
1598
+ );
1599
+ const entries = Object.entries(input.vary);
1600
+ if (entries.length > SCORE_LIMITS.maxTracks)
1601
+ throw new ScoreValidationError(
1602
+ `section ${name} vary has too many tracks`,
1603
+ );
1604
+ const vary: Record<string, SectionVariation> = {};
1605
+ for (const [trackId, raw] of entries.sort(([a], [b]) =>
1606
+ a < b ? -1 : a > b ? 1 : 0,
1607
+ )) {
1608
+ if (!isRecord(raw) || trackId.length === 0)
1609
+ throw new ScoreValidationError(
1610
+ `section ${name} vary.${trackId} must be an object`,
1611
+ );
1612
+ const variation: { transpose?: number; gain?: number } = {};
1613
+ if (raw.transpose !== undefined) {
1614
+ const t = raw.transpose;
1615
+ if (
1616
+ typeof t !== "number" ||
1617
+ !Number.isInteger(t) ||
1618
+ Math.abs(t) > SCORE_LIMITS.maxSectionTranspose
1619
+ )
1620
+ throw new ScoreValidationError(
1621
+ `section ${name} vary.${trackId}.transpose must be an integer -${SCORE_LIMITS.maxSectionTranspose}..${SCORE_LIMITS.maxSectionTranspose}`,
1622
+ );
1623
+ if (t !== 0) variation.transpose = t;
1624
+ }
1625
+ if (raw.gain !== undefined) {
1626
+ const g = raw.gain;
1627
+ if (
1628
+ typeof g !== "number" ||
1629
+ !Number.isFinite(g) ||
1630
+ g < 0 ||
1631
+ g > SCORE_LIMITS.maxSectionGain
1632
+ )
1633
+ throw new ScoreValidationError(
1634
+ `section ${name} vary.${trackId}.gain must be 0..${SCORE_LIMITS.maxSectionGain}`,
1635
+ );
1636
+ if (g !== 1) variation.gain = g;
1637
+ }
1638
+ if (Object.keys(variation).length > 0)
1639
+ vary[trackId] = Object.freeze(variation);
1640
+ }
1641
+ if (Object.keys(vary).length > 0) out.vary = Object.freeze(vary);
1642
+ }
1643
+ return Object.freeze(out);
1644
+ });
1645
+ // Bar order; sections that start together keep the order they were given.
1646
+ return sections
1647
+ .map((section, index) => ({ section, index }))
1648
+ .sort(
1649
+ (a, b) => a.section.startBar - b.section.startBar || a.index - b.index,
1650
+ )
1651
+ .map(({ section }) => section);
1652
+ }
1653
+
1654
+ function normalizeLoopSection(
1655
+ value: unknown,
1656
+ sections: readonly Section[],
1657
+ ): string | undefined {
1658
+ if (value === undefined || value === null) return undefined;
1659
+ if (typeof value !== "string")
1660
+ throw new ScoreValidationError(
1661
+ "loopSection must be a section name or null",
1662
+ "invalid-score",
1663
+ );
1664
+ const key = value.trim().replace(/\s+/gu, " ").toLowerCase();
1665
+ return sections.find((section) => section.name.toLowerCase() === key)?.name;
1666
+ }
1667
+
1668
+ function normalizeForm(
1669
+ inputs: readonly unknown[],
1670
+ sections: readonly Section[],
1671
+ ): FormEntry[] {
1672
+ if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxFormEntries)
1673
+ throw new ScoreValidationError(
1674
+ `form cannot contain more than ${SCORE_LIMITS.maxFormEntries} entries`,
1675
+ "score-limit",
1676
+ );
1677
+ const byName = new Map(
1678
+ sections.map((section) => [section.name.toLowerCase(), section] as const),
1679
+ );
1680
+ return inputs.map((input, index): FormEntry => {
1681
+ const record = typeof input === "string" ? { section: input } : input;
1682
+ if (!isRecord(record) || typeof record.section !== "string")
1683
+ throw new ScoreValidationError(`form entry ${index} must name a section`);
1684
+ const section = byName.get(record.section.trim().toLowerCase());
1685
+ if (!section)
1686
+ throw new ScoreValidationError(
1687
+ `form entry ${index} names an unknown section: ${record.section}`,
1688
+ );
1689
+ const repeat = record.repeat ?? 1;
1690
+ if (
1691
+ typeof repeat !== "number" ||
1692
+ !Number.isInteger(repeat) ||
1693
+ repeat < 1 ||
1694
+ repeat > SCORE_LIMITS.maxFormRepeat
1695
+ )
1696
+ throw new ScoreValidationError(
1697
+ `form entry ${index} repeat must be an integer 1..${SCORE_LIMITS.maxFormRepeat}`,
1698
+ );
1699
+ return Object.freeze(
1700
+ repeat === 1
1701
+ ? { section: section.name }
1702
+ : { section: section.name, repeat },
1703
+ );
1704
+ });
1705
+ }
1706
+
1200
1707
  function normalizeTracks(inputs: readonly unknown[]): Track[] {
1201
1708
  if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxTracks) {
1202
1709
  throw new ScoreValidationError(
@@ -1299,9 +1806,44 @@ function normalizeTrack(input: unknown): Track {
1299
1806
  const fx = fxOrThrow(() => normalizeFx(input.fx));
1300
1807
  const fxAutomation = normalizeFxAutomation(input.fxAutomation);
1301
1808
  const synth = fxOrThrow(() => normalizeSynth(input.synth));
1809
+ const keys = fxOrThrow(() => normalizeKeys(input.keys));
1302
1810
  const wavetable = normalizeWavetable(input.wavetable);
1811
+ const modal = fxOrThrow(() => normalizeModal(input.modal));
1812
+ if (modal && instrument !== MODAL_INSTRUMENT)
1813
+ throw new ScoreValidationError(
1814
+ `track ${id} has modal settings but its instrument is "${instrument}"`,
1815
+ "invalid-track",
1816
+ );
1303
1817
  const sampler = normalizeSampler(input.sampler);
1304
1818
  const rhythm = normalizeRhythm(input.rhythm, id);
1819
+ const time = timeOrThrow(
1820
+ () => normalizeTrackTime(input.time, `track ${id} time`),
1821
+ "invalid-track",
1822
+ );
1823
+ let performance: TrackPerformance;
1824
+ try {
1825
+ performance = normalizeTrackPerformance(input, SCORE_LIMITS.maxTick);
1826
+ } catch (error) {
1827
+ if (error instanceof ExpressionValidationError)
1828
+ throw new ScoreValidationError(
1829
+ `track ${id} ${error.message}`,
1830
+ "invalid-track",
1831
+ );
1832
+ throw error;
1833
+ }
1834
+ const tuning = tuningOrThrow(
1835
+ () => normalizeTuning(input.tuning, `track ${id} tuning`),
1836
+ "invalid-track",
1837
+ );
1838
+ const string = fxOrThrow(() => normalizeString(input.string));
1839
+ // A bare `granular` instrument (set_instrument, `instrument granular`)
1840
+ // gets an empty object, so the engine plays its defaults.
1841
+ const granular =
1842
+ fxOrThrow(() =>
1843
+ normalizeGranular(input.granular, (ref) =>
1844
+ normalizeSampleRef(ref, "granular src"),
1845
+ ),
1846
+ ) ?? (isGranularInstrument(instrument) ? Object.freeze({}) : undefined);
1305
1847
  let kit: string | undefined;
1306
1848
  if (input.kit !== undefined && input.kit !== null) {
1307
1849
  const found =
@@ -1323,7 +1865,12 @@ function normalizeTrack(input: unknown): Track {
1323
1865
  `track ${id} instrument "sampler" needs a sampler`,
1324
1866
  "invalid-track",
1325
1867
  );
1326
- if (sampler && !isSamplerInstrument(instrument))
1868
+ // A granular track keeps the sampler it grains (`grain off` goes back).
1869
+ if (
1870
+ sampler &&
1871
+ !isSamplerInstrument(instrument) &&
1872
+ !isGranularInstrument(instrument)
1873
+ )
1327
1874
  throw new ScoreValidationError(
1328
1875
  `track ${id} has a sampler but its instrument is "${instrument}"`,
1329
1876
  "invalid-track",
@@ -1353,6 +1900,13 @@ function normalizeTrack(input: unknown): Track {
1353
1900
  ...(sampler ? { sampler } : {}),
1354
1901
  ...(rhythm ? { rhythm } : {}),
1355
1902
  ...(kit ? { kit } : {}),
1903
+ ...(time ? { time } : {}),
1904
+ ...performance,
1905
+ ...(tuning ? { tuning } : {}),
1906
+ ...(string ? { string } : {}),
1907
+ ...(granular ? { granular } : {}),
1908
+ ...(keys ? { keys } : {}),
1909
+ ...(modal ? { modal } : {}),
1356
1910
  });
1357
1911
  }
1358
1912
 
@@ -1386,6 +1940,29 @@ export function normalizeRhythm(
1386
1940
  return Object.freeze(rows);
1387
1941
  }
1388
1942
 
1943
+ function timeOrThrow<T>(
1944
+ run: () => T,
1945
+ code: ScoreValidationError["code"] = "invalid-score",
1946
+ ): T {
1947
+ try {
1948
+ return run();
1949
+ } catch (error) {
1950
+ if (error instanceof TimeValidationError)
1951
+ throw new ScoreValidationError(error.message, code);
1952
+ throw error;
1953
+ }
1954
+ }
1955
+
1956
+ function tuningOrThrow<T>(run: () => T, code: ScoreValidationError["code"]): T {
1957
+ try {
1958
+ return run();
1959
+ } catch (error) {
1960
+ if (error instanceof TuningError)
1961
+ throw new ScoreValidationError(error.message, code);
1962
+ throw error;
1963
+ }
1964
+ }
1965
+
1389
1966
  function fxOrThrow<T>(run: () => T): T {
1390
1967
  try {
1391
1968
  return run();
@@ -1537,6 +2114,9 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
1537
2114
  fit?: boolean;
1538
2115
  accelerate?: number;
1539
2116
  squiz?: number;
2117
+ bpm?: number;
2118
+ fitmode?: SampleFitMode;
2119
+ len?: number;
1540
2120
  } = { src };
1541
2121
  if (input.sha256 !== undefined) {
1542
2122
  if (typeof input.sha256 !== "string" || !SHA256_HEX.test(input.sha256))
@@ -1700,6 +2280,46 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
1700
2280
  1,
1701
2281
  SCORE_LIMITS.maxSampleSquiz,
1702
2282
  );
2283
+ if (input.bpm !== undefined)
2284
+ ref.bpm = boundedNumber(
2285
+ input.bpm,
2286
+ `${label} bpm`,
2287
+ SCORE_LIMITS.minSampleBpm,
2288
+ SCORE_LIMITS.maxSampleBpm,
2289
+ );
2290
+ if (input.fitmode !== undefined) {
2291
+ if (!SAMPLE_FIT_MODES.includes(input.fitmode as SampleFitMode))
2292
+ throw new ScoreValidationError(
2293
+ `${label} fitmode must be "repitch", "beats" or "tones"`,
2294
+ "invalid-track",
2295
+ );
2296
+ ref.fitmode = input.fitmode as SampleFitMode;
2297
+ }
2298
+ if (input.len !== undefined) {
2299
+ const len = boundedNumber(
2300
+ input.len,
2301
+ `${label} len`,
2302
+ 0,
2303
+ SCORE_LIMITS.maxSampleLenBeats,
2304
+ );
2305
+ if (len === 0)
2306
+ throw new ScoreValidationError(
2307
+ `${label} len must be greater than 0`,
2308
+ "invalid-track",
2309
+ );
2310
+ ref.len = len;
2311
+ }
2312
+ if (
2313
+ ref.fitmode !== undefined &&
2314
+ ref.fitmode !== "repitch" &&
2315
+ ref.bpm === undefined &&
2316
+ ref.len === undefined &&
2317
+ ref.fit !== true
2318
+ )
2319
+ throw new ScoreValidationError(
2320
+ `${label} fitmode ${ref.fitmode} needs bpm, len or fit`,
2321
+ "invalid-track",
2322
+ );
1703
2323
  return Object.freeze(ref);
1704
2324
  }
1705
2325
 
@@ -1940,10 +2560,28 @@ function normalizeAutomation(
1940
2560
  return freezeArray(points);
1941
2561
  }
1942
2562
 
2563
+ /** The note cap `normalizeNotes` enforces; see `withNoteCap`. */
2564
+ let noteCap: number = SCORE_LIMITS.maxNotes;
2565
+
2566
+ /**
2567
+ * Runs `fn` with the note cap raised to `cap`. Stored scores keep
2568
+ * SCORE_LIMITS.maxNotes; the arranged renderer bakes timed tracks (cycle,
2569
+ * rate) into plain notes and may hold more than a stored score allows.
2570
+ */
2571
+ export function withNoteCap<T>(cap: number, fn: () => T): T {
2572
+ const previous = noteCap;
2573
+ noteCap = Math.max(previous, cap);
2574
+ try {
2575
+ return fn();
2576
+ } finally {
2577
+ noteCap = previous;
2578
+ }
2579
+ }
2580
+
1943
2581
  function normalizeNotes(inputs: readonly unknown[]): Note[] {
1944
- if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxNotes) {
2582
+ if (!Array.isArray(inputs) || inputs.length > noteCap) {
1945
2583
  throw new ScoreValidationError(
1946
- `score cannot contain more than ${SCORE_LIMITS.maxNotes} notes`,
2584
+ `score cannot contain more than ${noteCap} notes`,
1947
2585
  "score-limit",
1948
2586
  );
1949
2587
  }
@@ -2023,6 +2661,21 @@ function normalizeNote(input: unknown): Note {
2023
2661
  "invalid-note",
2024
2662
  );
2025
2663
  }
2664
+ let expression: NoteExpression;
2665
+ try {
2666
+ expression = normalizeNoteExpression(input);
2667
+ } catch (error) {
2668
+ if (error instanceof ExpressionValidationError)
2669
+ throw new ScoreValidationError(
2670
+ `note ${id} ${error.message}`,
2671
+ "invalid-note",
2672
+ );
2673
+ throw error;
2674
+ }
2675
+ const cents = tuningOrThrow(
2676
+ () => normalizeNoteCents(input.cents, `note ${id}`),
2677
+ "invalid-note",
2678
+ );
2026
2679
  return Object.freeze({
2027
2680
  id,
2028
2681
  trackId,
@@ -2030,6 +2683,8 @@ function normalizeNote(input: unknown): Note {
2030
2683
  durationTicks,
2031
2684
  pitch,
2032
2685
  velocity: input.velocity,
2686
+ ...expression,
2687
+ ...(cents !== undefined ? { cents } : {}),
2033
2688
  });
2034
2689
  }
2035
2690