@hraness/dawg 0.4.1 → 0.5.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 +70 -0
  2. package/DAWG.md +293 -31
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/common.ts +30 -1
  64. package/src/audio/effects/dynamics.ts +2 -1
  65. package/src/audio/effects/filter.ts +5 -3
  66. package/src/audio/effects/modulation.ts +5 -1
  67. package/src/audio/effects/space.ts +92 -2
  68. package/src/audio/engine.ts +90 -20
  69. package/src/audio/live.ts +33 -6
  70. package/src/audio/loudness.ts +551 -0
  71. package/src/audio/master.ts +660 -0
  72. package/src/audio/measure-worker.ts +45 -0
  73. package/src/audio/measure.ts +110 -0
  74. package/src/audio/player.ts +11 -6
  75. package/src/audio/preview.ts +74 -14
  76. package/src/audio/render-worker.ts +6 -1
  77. package/src/audio/renderer.ts +7 -1
  78. package/src/audio/sampler.ts +108 -19
  79. package/src/audio/synth/voice.ts +60 -13
  80. package/src/audio/synth/zzfx.ts +10 -4
  81. package/src/audio/warp.ts +86 -0
  82. package/src/audio/wav.ts +301 -26
  83. package/src/commands/arrange.ts +949 -0
  84. package/src/commands/expression.ts +934 -0
  85. package/src/commands/help.ts +281 -24
  86. package/src/commands/master.ts +361 -0
  87. package/src/commands/music.ts +6 -1
  88. package/src/commands/synth.ts +11 -1
  89. package/src/commands/time.ts +964 -0
  90. package/src/commands/tuning.ts +490 -0
  91. package/src/main.ts +709 -33
  92. package/src/render.ts +109 -7
  93. package/src/session/daemon.ts +18 -8
  94. package/src/session/naming.ts +8 -1
  95. package/src/session/rebase.ts +21 -0
  96. package/src/tui/arrange-menu.ts +390 -0
  97. package/src/tui/audition.ts +38 -5
  98. package/src/tui/fader.ts +409 -0
  99. package/src/tui/menu-time.ts +401 -0
  100. package/src/tui/menu.ts +635 -14
  101. package/src/tui/performance-menu.ts +235 -0
  102. package/src/tui/play-chords.ts +3 -1
  103. package/src/tui/play-mode.ts +150 -6
  104. package/src/tui/play-session.ts +414 -41
  105. package/tui/app.ts +262 -13
  106. package/tui/arrange-strip.ts +174 -0
  107. package/tui/drawer.ts +478 -0
  108. package/tui/grammar.ts +63 -1
  109. package/tui/guide.ts +351 -0
  110. package/tui/highway.ts +87 -4
  111. package/tui/hits.ts +68 -0
  112. package/tui/input.ts +7 -0
  113. package/tui/keys.ts +72 -0
package/core/score.ts CHANGED
@@ -21,6 +21,32 @@ import {
21
21
  type TrackFx,
22
22
  } from "./fx.ts";
23
23
  import { normalizeSynth, type TrackSynth } from "./synth.ts";
24
+ import {
25
+ checkSongTime,
26
+ normalizeSongTime,
27
+ normalizeTrackTime,
28
+ TimeValidationError,
29
+ type SongTime,
30
+ type TrackTime,
31
+ withMeterChange,
32
+ } from "./tempo.ts";
33
+ import {
34
+ ExpressionValidationError,
35
+ normalizeNoteExpression,
36
+ normalizeTrackPerformance,
37
+ type NoteExpression,
38
+ type NoteExpressionPatch,
39
+ type TrackPerformance,
40
+ } from "./expression.ts";
41
+ import {
42
+ normalizeNoteCents,
43
+ normalizeTuning,
44
+ TuningError,
45
+ type Tuning,
46
+ } from "./tuning.ts";
47
+
48
+ export type { Tuning } from "./tuning.ts";
49
+ import { normalizeMaster, type SongMaster } from "./master.ts";
24
50
 
25
51
  export const SCORE_VERSION = 1 as const;
26
52
  export const DEFAULT_TICKS_PER_BEAT = 480 as const;
@@ -36,6 +62,13 @@ export const SCORE_LIMITS = Object.freeze({
36
62
  maxTempoBpm: 300,
37
63
  minTempoBpm: 20,
38
64
  maxBars: 256,
65
+ /** Song sections (0.5): named bar ranges and the form that orders them. */
66
+ maxSections: 64,
67
+ maxSectionNameLength: 32,
68
+ maxFormEntries: 128,
69
+ maxFormRepeat: 16,
70
+ maxSectionTranspose: 24,
71
+ maxSectionGain: 2,
39
72
  maxBeatsPerBar: 16,
40
73
  maxTicksPerBeat: 4096,
41
74
  maxTick: 1_000_000,
@@ -328,7 +361,24 @@ export type Track = Readonly<{
328
361
  * `lofi`, … see `core/kits.ts`). Absent plays the default voices.
329
362
  */
330
363
  kit?: string;
331
- }>;
364
+ /**
365
+ * Track time against the song (`core/tempo.ts`): `rate` (tempo ratio),
366
+ * `phase` (ticks the pattern shifts later) and `cycle` (track ticks per
367
+ * repetition) for polytempo, polymeter and phasing. Absent follows the
368
+ * song.
369
+ */
370
+ time?: TrackTime;
371
+ /**
372
+ * Tuning for this track (`core/tuning.ts`): its own table, or just a
373
+ * `ref`/`root` over the song tuning. Absent follows the song tuning.
374
+ */
375
+ tuning?: Tuning;
376
+ }> &
377
+ /**
378
+ * Performance (`core/expression.ts`): glide default, sustain pedal
379
+ * events, velocity curve and seeded humanize, all applied at render.
380
+ */
381
+ TrackPerformance;
332
382
 
333
383
  /**
334
384
  * A track's sample voices (Strudel-aligned). In `oneshot` mode every voice is
@@ -627,6 +677,12 @@ export type TrackPatch = Readonly<
627
677
  fxAutomation?: Track["fxAutomation"] | null;
628
678
  synth?: TrackSynth | null;
629
679
  wavetable?: TrackWavetable | null;
680
+ time?: TrackTime | null;
681
+ glide?: Track["glide"] | null;
682
+ pedal?: Track["pedal"] | null;
683
+ velocityCurve?: Track["velocityCurve"] | null;
684
+ humanize?: Track["humanize"] | null;
685
+ tuning?: Tuning | null;
630
686
  }
631
687
  >;
632
688
 
@@ -635,7 +691,11 @@ export type AutomationPoint = Readonly<{
635
691
  value: number;
636
692
  }>;
637
693
 
638
- /** A note's start and duration are integer ticks, never floating-point beats. */
694
+ /**
695
+ * A note's start and duration are integer ticks, never floating-point beats.
696
+ * Expression fields (`articulation`, `glide`, `bend`, `vibrato`, see
697
+ * `core/expression.ts`) are optional and absent when unused.
698
+ */
639
699
  export type Note = Readonly<{
640
700
  id: string;
641
701
  trackId: string;
@@ -643,7 +703,10 @@ export type Note = Readonly<{
643
703
  durationTicks: number;
644
704
  pitch: number;
645
705
  velocity: number;
646
- }>;
706
+ /** Static offset from the tuned pitch in cents (±1200); absent is 0. */
707
+ cents?: number;
708
+ }> &
709
+ NoteExpression;
647
710
 
648
711
  export type TrackInput = Readonly<
649
712
  Omit<
@@ -658,6 +721,12 @@ export type TrackInput = Readonly<
658
721
  | "fxAutomation"
659
722
  | "synth"
660
723
  | "wavetable"
724
+ | "time"
725
+ | "glide"
726
+ | "pedal"
727
+ | "velocityCurve"
728
+ | "humanize"
729
+ | "tuning"
661
730
  > &
662
731
  Pick<Track, "id"> & {
663
732
  filter?: TrackFilter | null;
@@ -670,6 +739,12 @@ export type TrackInput = Readonly<
670
739
  fxAutomation?: Track["fxAutomation"] | null;
671
740
  synth?: TrackSynth | null;
672
741
  wavetable?: TrackWavetable | null;
742
+ time?: TrackTime | null;
743
+ glide?: Track["glide"] | number | null;
744
+ pedal?: Track["pedal"] | null;
745
+ velocityCurve?: Track["velocityCurve"] | string | null;
746
+ humanize?: Track["humanize"] | null;
747
+ tuning?: Tuning | null;
673
748
  }
674
749
  >;
675
750
 
@@ -686,6 +761,41 @@ export type NoteInput = Readonly<{
686
761
  duration?: number;
687
762
  pitch: number;
688
763
  velocity: number;
764
+ cents?: number;
765
+ }> &
766
+ NoteExpressionPatch;
767
+
768
+ /** A per-track change inside a section: semitones and a velocity gain. */
769
+ export type SectionVariation = Readonly<{
770
+ /** Semitones, -24..24; drum and one-shot sampler tracks ignore it. */
771
+ transpose?: number;
772
+ /** Velocity multiplier 0..2 (1 leaves it alone). */
773
+ gain?: number;
774
+ }>;
775
+
776
+ /**
777
+ * A named bar range of the song (0.5). Sections may overlap: two sections
778
+ * over the same bars with different mutes or variations are two versions
779
+ * of that music. `mute` and `vary` name track ids; unknown ids are ignored.
780
+ */
781
+ export type Section = Readonly<{
782
+ /** Unique (ignoring case), 1..32 characters: `intro`, `chorus 2`, `A`. */
783
+ name: string;
784
+ /** First bar, 0-based. */
785
+ startBar: number;
786
+ /** Length in bars, at least 1. */
787
+ bars: number;
788
+ /** Tracks silent in this section. */
789
+ mute?: readonly string[];
790
+ /** Per-track variations in this section. */
791
+ vary?: Readonly<Record<string, SectionVariation>>;
792
+ }>;
793
+
794
+ /** One step of the song form: a section by name, played `repeat` times. */
795
+ export type FormEntry = Readonly<{
796
+ section: string;
797
+ /** 1..16, default 1. */
798
+ repeat?: number;
689
799
  }>;
690
800
 
691
801
  export type TrackScoreData = Readonly<{
@@ -694,8 +804,26 @@ export type TrackScoreData = Readonly<{
694
804
  bars?: number;
695
805
  ticksPerBeat?: number;
696
806
  key?: string | null;
807
+ /**
808
+ * Tempo map, meter changes and fermatas (`core/tempo.ts`). Absent keeps
809
+ * `tempoBpm` and `beatsPerBar` for the whole song.
810
+ */
811
+ time?: SongTime | null;
812
+ /** Song tuning (`core/tuning.ts`); absent is 12-TET at A4 = 440 Hz. */
813
+ tuning?: Tuning | null;
697
814
  tracks?: readonly TrackInput[];
698
815
  notes?: readonly NoteInput[];
816
+ /** Song master chain and loudness target (core/master.ts); absent is off. */
817
+ master?: SongMaster | null;
818
+ /** Song sections (0.5); absent or empty means none. */
819
+ sections?: readonly Section[];
820
+ /** Song form (0.5): the order sections play in; absent plays the score straight through. */
821
+ form?: readonly FormEntry[];
822
+ /**
823
+ * The section playback loops (0.5), like a DAW's loop brace; absent plays
824
+ * the song (or its form). A name that matches no section is dropped.
825
+ */
826
+ loopSection?: string | null;
699
827
  }>;
700
828
 
701
829
  /** Canonical immutable score. Use `addNote`/`removeNote` to create a revision. */
@@ -706,8 +834,19 @@ export class TrackScore {
706
834
  readonly bars: number;
707
835
  readonly ticksPerBeat: number;
708
836
  readonly key: string | null;
837
+ /** Tempo map, meters and fermatas; absent when the song has none. */
838
+ declare readonly time?: SongTime;
839
+ readonly tuning: Tuning | undefined;
709
840
  readonly tracks: readonly Track[];
710
841
  readonly notes: readonly Note[];
842
+ /** Absent (not undefined-valued) without a master, so 0.4 scores are unchanged. */
843
+ declare readonly master?: SongMaster;
844
+ /** Song sections in bar order; empty when the song has none. */
845
+ readonly sections: readonly Section[];
846
+ /** Song form; empty plays the score straight through. */
847
+ readonly form: readonly FormEntry[];
848
+ /** The section playback loops; undefined plays the song. */
849
+ readonly loopSection: string | undefined;
711
850
 
712
851
  constructor(data: TrackScoreData = {}) {
713
852
  const tempoBpm = data.tempoBpm ?? 120;
@@ -760,15 +899,49 @@ export class TrackScore {
760
899
  "invalid-score",
761
900
  );
762
901
  }
902
+ const time = timeOrThrow(() => {
903
+ const normalized = normalizeSongTime(data.time);
904
+ checkSongTime(normalized, ticksPerBeat, { tempoBpm, beatsPerBar, bars });
905
+ return normalized;
906
+ });
907
+ const tuning = tuningOrThrow(
908
+ () => normalizeTuning(data.tuning, "song tuning"),
909
+ "invalid-score",
910
+ );
763
911
  const tracks = normalizeTracks(data.tracks ?? []);
764
912
  const notes = normalizeNotes(data.notes ?? []);
913
+ this.tuning = tuning;
914
+ let master: SongMaster | undefined;
915
+ try {
916
+ master = normalizeMaster(data.master);
917
+ } catch (error) {
918
+ if (error instanceof FxValidationError)
919
+ throw new ScoreValidationError(error.message, "invalid-score");
920
+ throw error;
921
+ }
922
+ if (master) this.master = master;
923
+ const sections = normalizeSections(data.sections ?? []);
924
+ const form = normalizeForm(data.form ?? [], sections);
925
+ // Sections count bars in one meter; meter changes would move them.
926
+ if (
927
+ sections.length > 0 &&
928
+ time?.meter?.some((change, index) => index > 0 || change.bar > 0)
929
+ )
930
+ throw new ScoreValidationError(
931
+ "sections need one meter: remove the meter changes (time.meter) or the sections",
932
+ "invalid-score",
933
+ );
765
934
  this.tempoBpm = tempoBpm;
766
935
  this.beatsPerBar = beatsPerBar;
767
936
  this.bars = bars;
768
937
  this.ticksPerBeat = ticksPerBeat;
769
938
  this.key = key;
939
+ if (time) this.time = time;
770
940
  this.tracks = freezeArray(tracks);
771
941
  this.notes = freezeArray(notes);
942
+ this.sections = freezeArray(sections);
943
+ this.form = freezeArray(form);
944
+ this.loopSection = normalizeLoopSection(data.loopSection, sections);
772
945
  Object.freeze(this);
773
946
  }
774
947
 
@@ -781,26 +954,27 @@ export class TrackScore {
781
954
  }
782
955
 
783
956
  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
- });
957
+ return new TrackScore({ ...this.toJSON(), tracks });
793
958
  }
794
959
 
795
960
  withTempo(tempoBpm: number): TrackScore {
961
+ return new TrackScore({ ...this.toJSON(), tempoBpm });
962
+ }
963
+
964
+ /**
965
+ * Replace the sections and the form (both validated together). The loop
966
+ * section is kept while a section of that name remains.
967
+ */
968
+ withSections(
969
+ sections: readonly Section[],
970
+ form: readonly FormEntry[] = [],
971
+ loopSection: string | null | undefined = this.loopSection,
972
+ ): TrackScore {
796
973
  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,
974
+ ...this.toJSON(),
975
+ sections,
976
+ form,
977
+ loopSection: loopSection ?? null,
804
978
  });
805
979
  }
806
980
 
@@ -813,9 +987,35 @@ export class TrackScore {
813
987
  return new TrackScore({ ...this.toJSON(), key });
814
988
  }
815
989
 
816
- /** Change the meter; ticks are per beat, so notes keep their positions. */
990
+ /** Set or clear (null) the song tuning. */
991
+ withTuning(tuning: Tuning | null): TrackScore {
992
+ return new TrackScore({ ...this.toJSON(), tuning });
993
+ }
994
+
995
+ /**
996
+ * Change the meter; ticks are per beat, so notes keep their positions.
997
+ * Beats per bar is the song meter, so a meter change at bar 1
998
+ * (`time.meter` bar 0) is dropped rather than left overriding it.
999
+ */
817
1000
  withMeter(beatsPerBar: number): TrackScore {
818
- return new TrackScore({ ...this.toJSON(), beatsPerBar });
1001
+ const time = this.time?.meter?.some((change) => change.bar === 0)
1002
+ ? withMeterChange(this.time, 0, null)
1003
+ : this.time;
1004
+ return new TrackScore({
1005
+ ...this.toJSON(),
1006
+ beatsPerBar,
1007
+ time: time ?? null,
1008
+ });
1009
+ }
1010
+
1011
+ /** Replace the tempo map, meter changes and fermatas; null clears them. */
1012
+ withTime(time: SongTime | null | undefined): TrackScore {
1013
+ return new TrackScore({ ...this.toJSON(), time: time ?? null });
1014
+ }
1015
+
1016
+ /** Replace the song master; `null` removes it (bypass). */
1017
+ withMaster(master: SongMaster | null): TrackScore {
1018
+ return new TrackScore({ ...this.toJSON(), master });
819
1019
  }
820
1020
 
821
1021
  toJSON(): TrackScoreData & { version: typeof SCORE_VERSION } {
@@ -826,8 +1026,16 @@ export class TrackScore {
826
1026
  bars: this.bars,
827
1027
  ticksPerBeat: this.ticksPerBeat,
828
1028
  key: this.key,
1029
+ ...(this.time ? { time: this.time } : {}),
1030
+ ...(this.tuning ? { tuning: this.tuning } : {}),
829
1031
  tracks: this.tracks,
830
1032
  notes: this.notes,
1033
+ ...(this.master ? { master: this.master } : {}),
1034
+ ...(this.sections.length > 0 ? { sections: this.sections } : {}),
1035
+ ...(this.form.length > 0 ? { form: this.form } : {}),
1036
+ ...(this.loopSection === undefined
1037
+ ? {}
1038
+ : { loopSection: this.loopSection }),
831
1039
  };
832
1040
  }
833
1041
  }
@@ -862,15 +1070,7 @@ export function addNote(score: TrackScore, input: NoteInput): TrackScore {
862
1070
  "score-limit",
863
1071
  );
864
1072
  }
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
- });
1073
+ return new TrackScore({ ...score.toJSON(), notes: [...score.notes, note] });
874
1074
  }
875
1075
 
876
1076
  export function removeNote(score: TrackScore, noteId: string): TrackScore {
@@ -888,32 +1088,31 @@ export function removeNote(score: TrackScore, noteId: string): TrackScore {
888
1088
  }
889
1089
  if (!score.notes.some((note) => note.id === noteId)) return score;
890
1090
  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,
1091
+ ...score.toJSON(),
897
1092
  notes: score.notes.filter((note) => note.id !== noteId),
898
1093
  });
899
1094
  }
900
1095
 
1096
+ /**
1097
+ * A note edit: timing, pitch and velocity, plus the expression fields
1098
+ * (`null` clears one).
1099
+ */
1100
+ export type NotePatch = Readonly<
1101
+ Partial<
1102
+ Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity" | "cents">
1103
+ >
1104
+ > &
1105
+ NoteExpressionPatch;
1106
+
901
1107
  export function updateNote(
902
1108
  score: TrackScore,
903
1109
  noteId: string,
904
- patch: Readonly<
905
- Partial<Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity">>
906
- >,
1110
+ patch: NotePatch,
907
1111
  ): TrackScore {
908
1112
  const current = score.notes.find((note) => note.id === noteId);
909
1113
  if (!current) return score;
910
1114
  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,
1115
+ ...score.toJSON(),
917
1116
  notes: score.notes.map((note) =>
918
1117
  note.id === noteId ? { ...note, ...patch } : note,
919
1118
  ),
@@ -923,12 +1122,7 @@ export function updateNote(
923
1122
  export function clearTrack(score: TrackScore, trackId: string): TrackScore {
924
1123
  if (!score.notes.some((note) => note.trackId === trackId)) return score;
925
1124
  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,
1125
+ ...score.toJSON(),
932
1126
  notes: score.notes.filter((note) => note.trackId !== trackId),
933
1127
  });
934
1128
  }
@@ -1076,11 +1270,7 @@ export type ScoreOperation =
1076
1270
  | Readonly<{
1077
1271
  type: "updateNote";
1078
1272
  noteId: string;
1079
- patch: Readonly<
1080
- Partial<
1081
- Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity">
1082
- >
1083
- >;
1273
+ patch: NotePatch;
1084
1274
  }>
1085
1275
  | Readonly<{
1086
1276
  type: "setTempo";
@@ -1121,6 +1311,26 @@ export type ScoreOperation =
1121
1311
  | Readonly<{
1122
1312
  type: "setMeter";
1123
1313
  beatsPerBar: number;
1314
+ }>
1315
+ | Readonly<{
1316
+ /** Replaces the song's tempo map, meter changes and fermatas. */
1317
+ type: "setTime";
1318
+ time: SongTime | null;
1319
+ }>
1320
+ | Readonly<{
1321
+ type: "setTuning";
1322
+ tuning: Tuning | null;
1323
+ }>
1324
+ | Readonly<{
1325
+ type: "setMaster";
1326
+ master: SongMaster | null;
1327
+ }>
1328
+ | Readonly<{
1329
+ type: "setSections";
1330
+ sections: readonly Section[];
1331
+ form: readonly FormEntry[];
1332
+ /** The looped section; absent or null plays the song. */
1333
+ loopSection?: string | null;
1124
1334
  }>;
1125
1335
 
1126
1336
  export function applyScoreOperation(
@@ -1134,6 +1344,15 @@ export function applyScoreOperation(
1134
1344
  if (operation.type === "setKey") return score.withKey(operation.key);
1135
1345
  if (operation.type === "setMeter")
1136
1346
  return score.withMeter(operation.beatsPerBar);
1347
+ if (operation.type === "setTime") return score.withTime(operation.time);
1348
+ if (operation.type === "setTuning") return score.withTuning(operation.tuning);
1349
+ if (operation.type === "setMaster") return score.withMaster(operation.master);
1350
+ if (operation.type === "setSections")
1351
+ return score.withSections(
1352
+ operation.sections,
1353
+ operation.form,
1354
+ operation.loopSection ?? null,
1355
+ );
1137
1356
  if (operation.type === "addTrack") return addTrack(score, operation.track);
1138
1357
  if (operation.type === "addNote") return addNote(score, operation.note);
1139
1358
  if (operation.type === "removeNote")
@@ -1178,8 +1397,14 @@ export function scoreFromJSON(value: unknown): TrackScore {
1178
1397
  bars?: number;
1179
1398
  ticksPerBeat?: number;
1180
1399
  key?: string | null;
1400
+ time?: SongTime | null;
1401
+ tuning?: Tuning | null;
1181
1402
  tracks: readonly TrackInput[];
1182
1403
  notes: readonly NoteInput[];
1404
+ master?: SongMaster;
1405
+ sections?: readonly Section[];
1406
+ form?: readonly FormEntry[];
1407
+ loopSection?: string | null;
1183
1408
  } = {
1184
1409
  tracks: optionalArray(value.tracks).map(parseTrack),
1185
1410
  notes: optionalArray(value.notes).map(parseNote),
@@ -1194,9 +1419,202 @@ export function scoreFromJSON(value: unknown): TrackScore {
1194
1419
  if (bars !== undefined) data.bars = bars;
1195
1420
  if (ticksPerBeat !== undefined) data.ticksPerBeat = ticksPerBeat;
1196
1421
  if (key !== undefined) data.key = key;
1422
+ if (value.time !== undefined && value.time !== null)
1423
+ data.time = value.time as SongTime;
1424
+ if (value.tuning !== undefined) data.tuning = value.tuning as Tuning | null;
1425
+ // Validated by the constructor (core/master.ts normalizeMaster).
1426
+ if (value.master !== undefined && value.master !== null)
1427
+ data.master = value.master as SongMaster;
1428
+ // Shapes are checked by the constructor (normalizeSections, normalizeForm).
1429
+ if (value.sections !== undefined)
1430
+ data.sections = optionalArray(value.sections) as readonly Section[];
1431
+ if (value.form !== undefined)
1432
+ data.form = optionalArray(value.form) as readonly FormEntry[];
1433
+ // The constructor checks the type and drops a name matching no section.
1434
+ if (value.loopSection !== undefined)
1435
+ data.loopSection = value.loopSection as string | null;
1197
1436
  return new TrackScore(data);
1198
1437
  }
1199
1438
 
1439
+ /** Section names: printable text without control characters, trimmed. */
1440
+ const SECTION_NAME = /^[^\u0000-\u001f\u007f]+$/u;
1441
+
1442
+ function normalizeSections(inputs: readonly unknown[]): Section[] {
1443
+ if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxSections)
1444
+ throw new ScoreValidationError(
1445
+ `score cannot contain more than ${SCORE_LIMITS.maxSections} sections`,
1446
+ "score-limit",
1447
+ );
1448
+ const seen = new Set<string>();
1449
+ const sections = inputs.map((input, index): Section => {
1450
+ if (!isRecord(input))
1451
+ throw new ScoreValidationError(`section ${index} must be an object`);
1452
+ const name =
1453
+ typeof input.name === "string"
1454
+ ? input.name.trim().replace(/\s+/gu, " ")
1455
+ : "";
1456
+ if (
1457
+ name.length === 0 ||
1458
+ name.length > SCORE_LIMITS.maxSectionNameLength ||
1459
+ !SECTION_NAME.test(name)
1460
+ )
1461
+ throw new ScoreValidationError(
1462
+ `section ${index} needs a name of 1..${SCORE_LIMITS.maxSectionNameLength} characters`,
1463
+ );
1464
+ const folded = name.toLowerCase();
1465
+ if (seen.has(folded))
1466
+ throw new ScoreValidationError(`duplicate section name: ${name}`);
1467
+ seen.add(folded);
1468
+ const startBar = input.startBar;
1469
+ const bars = input.bars;
1470
+ if (
1471
+ typeof startBar !== "number" ||
1472
+ !Number.isInteger(startBar) ||
1473
+ startBar < 0 ||
1474
+ startBar >= SCORE_LIMITS.maxBars
1475
+ )
1476
+ throw new ScoreValidationError(
1477
+ `section ${name} startBar must be an integer 0..${SCORE_LIMITS.maxBars - 1}`,
1478
+ );
1479
+ if (
1480
+ typeof bars !== "number" ||
1481
+ !Number.isInteger(bars) ||
1482
+ bars < 1 ||
1483
+ startBar + bars > SCORE_LIMITS.maxBars
1484
+ )
1485
+ throw new ScoreValidationError(
1486
+ `section ${name} bars must be an integer 1..${SCORE_LIMITS.maxBars - startBar}`,
1487
+ );
1488
+ const out: { -readonly [K in keyof Section]: Section[K] } = {
1489
+ name,
1490
+ startBar,
1491
+ bars,
1492
+ };
1493
+ if (input.mute !== undefined) {
1494
+ if (
1495
+ !Array.isArray(input.mute) ||
1496
+ input.mute.length > SCORE_LIMITS.maxTracks ||
1497
+ input.mute.some((id) => typeof id !== "string" || id.length === 0)
1498
+ )
1499
+ throw new ScoreValidationError(
1500
+ `section ${name} mute must be a list of track ids`,
1501
+ );
1502
+ const mute = [...new Set(input.mute as string[])];
1503
+ if (mute.length > 0) out.mute = freezeArray(mute);
1504
+ }
1505
+ if (input.vary !== undefined) {
1506
+ if (!isRecord(input.vary))
1507
+ throw new ScoreValidationError(
1508
+ `section ${name} vary must map track ids to variations`,
1509
+ );
1510
+ const entries = Object.entries(input.vary);
1511
+ if (entries.length > SCORE_LIMITS.maxTracks)
1512
+ throw new ScoreValidationError(
1513
+ `section ${name} vary has too many tracks`,
1514
+ );
1515
+ const vary: Record<string, SectionVariation> = {};
1516
+ for (const [trackId, raw] of entries.sort(([a], [b]) =>
1517
+ a < b ? -1 : a > b ? 1 : 0,
1518
+ )) {
1519
+ if (!isRecord(raw) || trackId.length === 0)
1520
+ throw new ScoreValidationError(
1521
+ `section ${name} vary.${trackId} must be an object`,
1522
+ );
1523
+ const variation: { transpose?: number; gain?: number } = {};
1524
+ if (raw.transpose !== undefined) {
1525
+ const t = raw.transpose;
1526
+ if (
1527
+ typeof t !== "number" ||
1528
+ !Number.isInteger(t) ||
1529
+ Math.abs(t) > SCORE_LIMITS.maxSectionTranspose
1530
+ )
1531
+ throw new ScoreValidationError(
1532
+ `section ${name} vary.${trackId}.transpose must be an integer -${SCORE_LIMITS.maxSectionTranspose}..${SCORE_LIMITS.maxSectionTranspose}`,
1533
+ );
1534
+ if (t !== 0) variation.transpose = t;
1535
+ }
1536
+ if (raw.gain !== undefined) {
1537
+ const g = raw.gain;
1538
+ if (
1539
+ typeof g !== "number" ||
1540
+ !Number.isFinite(g) ||
1541
+ g < 0 ||
1542
+ g > SCORE_LIMITS.maxSectionGain
1543
+ )
1544
+ throw new ScoreValidationError(
1545
+ `section ${name} vary.${trackId}.gain must be 0..${SCORE_LIMITS.maxSectionGain}`,
1546
+ );
1547
+ if (g !== 1) variation.gain = g;
1548
+ }
1549
+ if (Object.keys(variation).length > 0)
1550
+ vary[trackId] = Object.freeze(variation);
1551
+ }
1552
+ if (Object.keys(vary).length > 0) out.vary = Object.freeze(vary);
1553
+ }
1554
+ return Object.freeze(out);
1555
+ });
1556
+ // Bar order; sections that start together keep the order they were given.
1557
+ return sections
1558
+ .map((section, index) => ({ section, index }))
1559
+ .sort(
1560
+ (a, b) => a.section.startBar - b.section.startBar || a.index - b.index,
1561
+ )
1562
+ .map(({ section }) => section);
1563
+ }
1564
+
1565
+ function normalizeLoopSection(
1566
+ value: unknown,
1567
+ sections: readonly Section[],
1568
+ ): string | undefined {
1569
+ if (value === undefined || value === null) return undefined;
1570
+ if (typeof value !== "string")
1571
+ throw new ScoreValidationError(
1572
+ "loopSection must be a section name or null",
1573
+ "invalid-score",
1574
+ );
1575
+ const key = value.trim().replace(/\s+/gu, " ").toLowerCase();
1576
+ return sections.find((section) => section.name.toLowerCase() === key)?.name;
1577
+ }
1578
+
1579
+ function normalizeForm(
1580
+ inputs: readonly unknown[],
1581
+ sections: readonly Section[],
1582
+ ): FormEntry[] {
1583
+ if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxFormEntries)
1584
+ throw new ScoreValidationError(
1585
+ `form cannot contain more than ${SCORE_LIMITS.maxFormEntries} entries`,
1586
+ "score-limit",
1587
+ );
1588
+ const byName = new Map(
1589
+ sections.map((section) => [section.name.toLowerCase(), section] as const),
1590
+ );
1591
+ return inputs.map((input, index): FormEntry => {
1592
+ const record = typeof input === "string" ? { section: input } : input;
1593
+ if (!isRecord(record) || typeof record.section !== "string")
1594
+ throw new ScoreValidationError(`form entry ${index} must name a section`);
1595
+ const section = byName.get(record.section.trim().toLowerCase());
1596
+ if (!section)
1597
+ throw new ScoreValidationError(
1598
+ `form entry ${index} names an unknown section: ${record.section}`,
1599
+ );
1600
+ const repeat = record.repeat ?? 1;
1601
+ if (
1602
+ typeof repeat !== "number" ||
1603
+ !Number.isInteger(repeat) ||
1604
+ repeat < 1 ||
1605
+ repeat > SCORE_LIMITS.maxFormRepeat
1606
+ )
1607
+ throw new ScoreValidationError(
1608
+ `form entry ${index} repeat must be an integer 1..${SCORE_LIMITS.maxFormRepeat}`,
1609
+ );
1610
+ return Object.freeze(
1611
+ repeat === 1
1612
+ ? { section: section.name }
1613
+ : { section: section.name, repeat },
1614
+ );
1615
+ });
1616
+ }
1617
+
1200
1618
  function normalizeTracks(inputs: readonly unknown[]): Track[] {
1201
1619
  if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxTracks) {
1202
1620
  throw new ScoreValidationError(
@@ -1302,6 +1720,25 @@ function normalizeTrack(input: unknown): Track {
1302
1720
  const wavetable = normalizeWavetable(input.wavetable);
1303
1721
  const sampler = normalizeSampler(input.sampler);
1304
1722
  const rhythm = normalizeRhythm(input.rhythm, id);
1723
+ const time = timeOrThrow(
1724
+ () => normalizeTrackTime(input.time, `track ${id} time`),
1725
+ "invalid-track",
1726
+ );
1727
+ let performance: TrackPerformance;
1728
+ try {
1729
+ performance = normalizeTrackPerformance(input, SCORE_LIMITS.maxTick);
1730
+ } catch (error) {
1731
+ if (error instanceof ExpressionValidationError)
1732
+ throw new ScoreValidationError(
1733
+ `track ${id} ${error.message}`,
1734
+ "invalid-track",
1735
+ );
1736
+ throw error;
1737
+ }
1738
+ const tuning = tuningOrThrow(
1739
+ () => normalizeTuning(input.tuning, `track ${id} tuning`),
1740
+ "invalid-track",
1741
+ );
1305
1742
  let kit: string | undefined;
1306
1743
  if (input.kit !== undefined && input.kit !== null) {
1307
1744
  const found =
@@ -1353,6 +1790,9 @@ function normalizeTrack(input: unknown): Track {
1353
1790
  ...(sampler ? { sampler } : {}),
1354
1791
  ...(rhythm ? { rhythm } : {}),
1355
1792
  ...(kit ? { kit } : {}),
1793
+ ...(time ? { time } : {}),
1794
+ ...performance,
1795
+ ...(tuning ? { tuning } : {}),
1356
1796
  });
1357
1797
  }
1358
1798
 
@@ -1386,6 +1826,29 @@ export function normalizeRhythm(
1386
1826
  return Object.freeze(rows);
1387
1827
  }
1388
1828
 
1829
+ function timeOrThrow<T>(
1830
+ run: () => T,
1831
+ code: ScoreValidationError["code"] = "invalid-score",
1832
+ ): T {
1833
+ try {
1834
+ return run();
1835
+ } catch (error) {
1836
+ if (error instanceof TimeValidationError)
1837
+ throw new ScoreValidationError(error.message, code);
1838
+ throw error;
1839
+ }
1840
+ }
1841
+
1842
+ function tuningOrThrow<T>(run: () => T, code: ScoreValidationError["code"]): T {
1843
+ try {
1844
+ return run();
1845
+ } catch (error) {
1846
+ if (error instanceof TuningError)
1847
+ throw new ScoreValidationError(error.message, code);
1848
+ throw error;
1849
+ }
1850
+ }
1851
+
1389
1852
  function fxOrThrow<T>(run: () => T): T {
1390
1853
  try {
1391
1854
  return run();
@@ -2023,6 +2486,21 @@ function normalizeNote(input: unknown): Note {
2023
2486
  "invalid-note",
2024
2487
  );
2025
2488
  }
2489
+ let expression: NoteExpression;
2490
+ try {
2491
+ expression = normalizeNoteExpression(input);
2492
+ } catch (error) {
2493
+ if (error instanceof ExpressionValidationError)
2494
+ throw new ScoreValidationError(
2495
+ `note ${id} ${error.message}`,
2496
+ "invalid-note",
2497
+ );
2498
+ throw error;
2499
+ }
2500
+ const cents = tuningOrThrow(
2501
+ () => normalizeNoteCents(input.cents, `note ${id}`),
2502
+ "invalid-note",
2503
+ );
2026
2504
  return Object.freeze({
2027
2505
  id,
2028
2506
  trackId,
@@ -2030,6 +2508,8 @@ function normalizeNote(input: unknown): Note {
2030
2508
  durationTicks,
2031
2509
  pitch,
2032
2510
  velocity: input.velocity,
2511
+ ...expression,
2512
+ ...(cents !== undefined ? { cents } : {}),
2033
2513
  });
2034
2514
  }
2035
2515