@hraness/dawg 0.6.1 → 0.7.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 (253) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/DAWG.md +343 -116
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/instruments.ts +19 -0
  11. package/core/keys.ts +3 -3
  12. package/core/loop.ts +5 -0
  13. package/core/lyrics.ts +297 -0
  14. package/core/master.ts +3 -3
  15. package/core/resonators.ts +16 -2
  16. package/core/routing.ts +165 -0
  17. package/core/score.ts +832 -13
  18. package/core/sdk/eval-child.ts +7 -2
  19. package/core/sdk/eval.ts +35 -6
  20. package/core/sdk/print.ts +276 -3
  21. package/core/sdk/sync-lyrics.ts +49 -0
  22. package/core/sdk/v1.ts +1849 -42
  23. package/core/sections.ts +292 -26
  24. package/core/sing.ts +815 -0
  25. package/core/style-provenance.ts +80 -0
  26. package/core/styles/africa-mena-southasia.ts +2893 -0
  27. package/core/styles/americas.ts +3810 -0
  28. package/core/styles/art.ts +4993 -0
  29. package/core/styles/base.ts +123 -0
  30. package/core/styles/cycles.ts +106 -0
  31. package/core/styles/electronic.ts +2723 -0
  32. package/core/styles/europe-asia-pacific.ts +2838 -0
  33. package/core/styles/excerpt.ts +29 -0
  34. package/core/styles/gamelan.ts +283 -0
  35. package/core/styles/generate.ts +2199 -0
  36. package/core/styles/index.ts +515 -0
  37. package/core/styles/parts.ts +106 -0
  38. package/core/styles/pop.ts +3189 -0
  39. package/core/styles/rock.ts +2993 -0
  40. package/core/styles/roots.ts +4175 -0
  41. package/core/styles/schema.ts +429 -0
  42. package/core/styles/taxonomy.ts +940 -0
  43. package/core/styles/validate.ts +528 -0
  44. package/core/tempo.ts +32 -2
  45. package/core/tuning.ts +19 -3
  46. package/core/vocoder.ts +524 -0
  47. package/guides/agent.md +29 -0
  48. package/guides/arrange.md +30 -0
  49. package/guides/audition.md +20 -14
  50. package/guides/automation.md +12 -7
  51. package/guides/chords.md +15 -15
  52. package/guides/effects.md +17 -16
  53. package/guides/faders.md +20 -16
  54. package/guides/files.md +13 -9
  55. package/guides/getting-started.md +15 -11
  56. package/guides/keys.md +19 -15
  57. package/guides/media.md +17 -12
  58. package/guides/mix.md +12 -6
  59. package/guides/music.md +23 -8
  60. package/guides/notes.md +15 -9
  61. package/guides/performance.md +15 -13
  62. package/guides/play.md +21 -13
  63. package/guides/project.md +25 -8
  64. package/guides/providers.md +20 -14
  65. package/guides/resample.md +15 -9
  66. package/guides/rhythm.md +17 -13
  67. package/guides/sessions.md +17 -7
  68. package/guides/show-me.md +31 -0
  69. package/guides/sound.md +25 -9
  70. package/guides/sounds.md +15 -13
  71. package/guides/styles.md +31 -0
  72. package/guides/tempo.md +15 -10
  73. package/guides/tracks.md +15 -10
  74. package/guides/tuning.md +32 -0
  75. package/guides/voice.md +31 -0
  76. package/guides/web-search.md +18 -8
  77. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  78. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  79. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  80. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  81. package/native/prebuilt/manifest.json +21 -0
  82. package/package.json +5 -2
  83. package/src/agent/agent.ts +126 -14
  84. package/src/agent/calibration-tools.ts +53 -0
  85. package/src/agent/clip-tools.ts +453 -0
  86. package/src/agent/command-agent.ts +369 -0
  87. package/src/agent/drum-tools.ts +2 -2
  88. package/src/agent/expression-tools.ts +1 -1
  89. package/src/agent/gateway.ts +246 -60
  90. package/src/agent/models.ts +53 -12
  91. package/src/agent/ops.ts +12 -1
  92. package/src/agent/pack-tools.ts +1 -1
  93. package/src/agent/planner.ts +13 -0
  94. package/src/agent/portable-schema.ts +80 -0
  95. package/src/agent/preview-tool.ts +4 -1
  96. package/src/agent/provider.ts +22 -8
  97. package/src/agent/rhythm-tools.ts +1 -1
  98. package/src/agent/section-tools.ts +1 -1
  99. package/src/agent/show-me.ts +497 -0
  100. package/src/agent/steer.ts +15 -0
  101. package/src/agent/style-tools.ts +217 -0
  102. package/src/agent/tool-error.ts +12 -0
  103. package/src/agent/tools.ts +108 -23
  104. package/src/agent/usage.ts +2 -2
  105. package/src/agent/voice-tools.ts +925 -0
  106. package/src/agent/xcb-agent.ts +11 -7
  107. package/src/argv.ts +38 -0
  108. package/src/audio/analysis.ts +253 -0
  109. package/src/audio/arrange.ts +37 -3
  110. package/src/audio/autotune-engine.ts +101 -0
  111. package/src/audio/autotune.ts +640 -0
  112. package/src/audio/clips.ts +240 -0
  113. package/src/audio/doctor.ts +86 -0
  114. package/src/audio/dsp/bandbank.ts +138 -0
  115. package/src/audio/dsp/envelope.ts +10 -0
  116. package/src/audio/dsp/follow.ts +120 -0
  117. package/src/audio/dsp/formant.ts +427 -0
  118. package/src/audio/dsp/glottal.ts +243 -0
  119. package/src/audio/dsp/interp.ts +7 -2
  120. package/src/audio/dsp/lpc.ts +50 -0
  121. package/src/audio/dsp/periodicity.ts +59 -0
  122. package/src/audio/dsp/pitch.ts +995 -0
  123. package/src/audio/dsp/psola.ts +199 -0
  124. package/src/audio/effects/chain.ts +3 -1
  125. package/src/audio/effects/common.ts +43 -0
  126. package/src/audio/effects/convolution.ts +7 -4
  127. package/src/audio/effects/filter.ts +48 -69
  128. package/src/audio/effects/formant.ts +263 -0
  129. package/src/audio/engine.ts +160 -28
  130. package/src/audio/fit.ts +35 -3
  131. package/src/audio/instrument-check.ts +59 -43
  132. package/src/audio/instruments.ts +4 -0
  133. package/src/audio/keys/calibration.ts +56 -0
  134. package/src/audio/keys/electric.ts +8 -1
  135. package/src/audio/keys/engine.ts +13 -1
  136. package/src/audio/keys/piano.ts +22 -2
  137. package/src/audio/kits.ts +135 -6
  138. package/src/audio/live.ts +114 -20
  139. package/src/audio/native.ts +615 -0
  140. package/src/audio/preview.ts +30 -2
  141. package/src/audio/render-worker.ts +2 -0
  142. package/src/audio/renderer.ts +2 -0
  143. package/src/audio/resample.ts +2 -1
  144. package/src/audio/sampler.ts +85 -4
  145. package/src/audio/samples.ts +20 -2
  146. package/src/audio/sing/analysis.ts +193 -0
  147. package/src/audio/sing/engine.ts +949 -0
  148. package/src/audio/strings/bow.ts +48 -5
  149. package/src/audio/strings/engine.ts +8 -2
  150. package/src/audio/synth/oscillators.ts +31 -21
  151. package/src/audio/synth/voice.ts +34 -1
  152. package/src/audio/vocoder/bank.ts +314 -0
  153. package/src/audio/vocoder/carrier.ts +165 -0
  154. package/src/audio/vocoder/control.ts +68 -0
  155. package/src/audio/vocoder/detect.ts +50 -0
  156. package/src/audio/vocoder/index.ts +304 -0
  157. package/src/audio/vocoder/talkbox.ts +143 -0
  158. package/src/audio/wav.ts +500 -77
  159. package/src/audio/winds/engine.ts +5 -1
  160. package/src/audio/winds/trim.ts +28 -4
  161. package/src/audio/winds/trims1.ts +297 -0
  162. package/src/audio/winds/voice.ts +15 -2
  163. package/src/auth/cli.ts +38 -36
  164. package/src/auth/credentials.ts +30 -1
  165. package/src/auth/login.ts +15 -9
  166. package/src/auth/tui.ts +19 -10
  167. package/src/commands/arrange.ts +44 -29
  168. package/src/commands/autotune.ts +421 -0
  169. package/src/commands/calibration.ts +74 -0
  170. package/src/commands/clips.ts +887 -0
  171. package/src/commands/drums.ts +3 -2
  172. package/src/commands/edit.ts +11 -4
  173. package/src/commands/expression.ts +1 -1
  174. package/src/commands/formant.ts +221 -0
  175. package/src/commands/fx.ts +101 -32
  176. package/src/commands/grammar.ts +558 -0
  177. package/src/commands/help.ts +610 -380
  178. package/src/commands/history.ts +18 -0
  179. package/src/commands/keys.ts +8 -8
  180. package/src/commands/modal.ts +1 -1
  181. package/src/commands/music.ts +1 -1
  182. package/src/commands/nearest.ts +53 -0
  183. package/src/commands/pack.ts +9 -2
  184. package/src/commands/param-range.ts +56 -0
  185. package/src/commands/parses.ts +130 -0
  186. package/src/commands/progression.ts +170 -0
  187. package/src/commands/rhythm.ts +3 -0
  188. package/src/commands/rig.ts +3 -24
  189. package/src/commands/sing.ts +478 -0
  190. package/src/commands/strum.ts +13 -1
  191. package/src/commands/style.ts +415 -0
  192. package/src/commands/time.ts +6 -3
  193. package/src/commands/tuning.ts +3 -3
  194. package/src/commands/vocal-pitch.ts +616 -0
  195. package/src/commands/vocal.ts +147 -0
  196. package/src/commands/vocoder.ts +627 -0
  197. package/src/commands/wind.ts +2 -2
  198. package/src/fs/durable.ts +50 -0
  199. package/src/lang/glossary.ts +493 -0
  200. package/src/launch-args.ts +163 -0
  201. package/src/main.ts +1397 -253
  202. package/src/media/cli.ts +20 -3
  203. package/src/media/import.ts +3 -1
  204. package/src/project/check.ts +21 -2
  205. package/src/project/clip-pins.ts +72 -0
  206. package/src/project/init.ts +23 -8
  207. package/src/project/sync.ts +418 -86
  208. package/src/render.ts +20 -1
  209. package/src/session/daemon.ts +3 -0
  210. package/src/session/meta.ts +14 -0
  211. package/src/session/origin.ts +154 -0
  212. package/src/session/port.ts +22 -4
  213. package/src/session/presence.ts +34 -4
  214. package/src/session/protocol.ts +5 -1
  215. package/src/session/rebase.ts +18 -5
  216. package/src/session/receipt.ts +258 -0
  217. package/src/session/store.ts +65 -43
  218. package/src/tui/arrange-menu.ts +65 -31
  219. package/src/tui/audition.ts +1 -1
  220. package/src/tui/euclid.ts +18 -13
  221. package/src/tui/fader.ts +228 -41
  222. package/src/tui/granular-menu.ts +2 -4
  223. package/src/tui/menu-clips.ts +297 -0
  224. package/src/tui/menu-time.ts +20 -13
  225. package/src/tui/menu-voice.ts +405 -0
  226. package/src/tui/menu.ts +759 -177
  227. package/src/tui/modal-menu.ts +6 -6
  228. package/src/tui/performance-menu.ts +5 -2
  229. package/src/tui/play-chords.ts +4 -2
  230. package/src/tui/play-mode.ts +15 -1
  231. package/src/tui/play-session.ts +47 -7
  232. package/src/tui/sing-menu.ts +278 -0
  233. package/src/tui/style-menu.ts +104 -0
  234. package/src/tui/vocoder-menu.ts +244 -0
  235. package/src/tui/wind-menu.ts +3 -3
  236. package/src/version.ts +8 -0
  237. package/src/web/fetch.ts +115 -29
  238. package/tui/activity.ts +180 -9
  239. package/tui/app.ts +274 -40
  240. package/tui/clip-row.ts +132 -0
  241. package/tui/delight.ts +144 -0
  242. package/tui/drawer.ts +70 -22
  243. package/tui/frame-gate.ts +76 -0
  244. package/tui/grammar.ts +112 -63
  245. package/tui/guide.ts +42 -4
  246. package/tui/highway.ts +269 -25
  247. package/tui/hints.ts +192 -0
  248. package/tui/input.ts +60 -9
  249. package/tui/keys.ts +1 -1
  250. package/tui/play-strip.ts +68 -14
  251. package/tui/prompt.ts +1 -1
  252. package/tui/screen.ts +144 -14
  253. package/tui/theme.ts +27 -0
package/core/score.ts CHANGED
@@ -29,6 +29,21 @@ import {
29
29
  type TrackWind,
30
30
  } from "./resonators.ts";
31
31
  import { normalizeSynth, type TrackSynth } from "./synth.ts";
32
+ import {
33
+ normalizeSing,
34
+ normalizeVowel,
35
+ SING_INSTRUMENT,
36
+ type TrackSing,
37
+ } from "./sing.ts";
38
+ import {
39
+ normalizeVocoder,
40
+ vocoderScoreError,
41
+ VOCODER_INSTRUMENT,
42
+ type TrackVocoder,
43
+ } from "./vocoder.ts";
44
+ import { normalizeAutotune, type TrackAutotune } from "./autotune.ts";
45
+ // Registers cross-track reference fields (autotune.from, vocoder.src).
46
+ import "./routing.ts";
32
47
  import { normalizeString, type TrackString } from "./strings.ts";
33
48
  import {
34
49
  isGranularInstrument,
@@ -43,6 +58,7 @@ import {
43
58
  normalizeTrackTime,
44
59
  TimeValidationError,
45
60
  type SongTime,
61
+ timeWithinSong,
46
62
  type TrackTime,
47
63
  withMeterChange,
48
64
  } from "./tempo.ts";
@@ -66,8 +82,22 @@ import {
66
82
 
67
83
  export type { Tuning } from "./tuning.ts";
68
84
  import { normalizeMaster, type SongMaster } from "./master.ts";
85
+ import {
86
+ normalizeSongStyle,
87
+ SongStyleError,
88
+ type SongStyle,
89
+ } from "./style-provenance.ts";
69
90
 
70
91
  export const SCORE_VERSION = 1 as const;
92
+
93
+ /**
94
+ * Sound calibration (0.7): which revision of the released engines' level,
95
+ * pitch and voice fixes a song renders with. Absent (0) keeps every 0.4 to
96
+ * 0.6.1 project byte-identical; new songs start at the latest. Revision 1
97
+ * chokes the open hat, key-tracks toms, adds crash, ride and cowbell,
98
+ * band-limits hat metal, levels keys presets and steadies lip brass.
99
+ */
100
+ export const CALIBRATION_LATEST = 1 as const;
71
101
  export const DEFAULT_TICKS_PER_BEAT = 480 as const;
72
102
 
73
103
  export const SCORE_LIMITS = Object.freeze({
@@ -90,7 +120,11 @@ export const SCORE_LIMITS = Object.freeze({
90
120
  maxSectionGain: 2,
91
121
  maxBeatsPerBar: 16,
92
122
  maxTicksPerBeat: 4096,
93
- maxTick: 1_000_000,
123
+ /**
124
+ * Latest tick anything may sit at or reach: the longest valid song,
125
+ * 256 bars of 16/1 at 4096 ticks per beat (2^26).
126
+ */
127
+ maxTick: 67_108_864,
94
128
  minFilterCutoff: 20,
95
129
  maxFilterCutoff: 20_000,
96
130
  maxFilterResonance: 1,
@@ -138,6 +172,34 @@ export const SCORE_LIMITS = Object.freeze({
138
172
  * `DAWG_ASSETS_CACHE_MAX` / `DAWG_PACKS_CACHE_MAX`.
139
173
  */
140
174
  maxSampleCacheBytes: 512 * 1024 * 1024,
175
+ /** Audio clips, takes and lyrics (0.7, shapes in the contract). */
176
+ maxClipsPerTrack: 256,
177
+ maxTakesPerTrack: 64,
178
+ maxClipGain: 4,
179
+ maxClipFadeSeconds: 2,
180
+ maxClipSeconds: 520,
181
+ maxClipTextLength: 2000,
182
+ maxLyricLength: 32,
183
+ maxTakeNudgeMs: 250,
184
+ maxTtsVoiceLength: 64,
185
+ /** Singers per note in a `sing` ensemble (0.7). */
186
+ singVoices: 8,
187
+ /** Ensemble members one `sing` track sounds at once (0.7). */
188
+ singVoicesPerTrack: 48,
189
+ maxSayCuts: 512,
190
+ maxTakePpm: 1000,
191
+ /** Vocoder (0.7): channel bands, formant shift (st), tracks per song. */
192
+ minVocoderBands: 4,
193
+ maxVocoderBands: 40,
194
+ maxVocoderFormant: 24,
195
+ maxVocoderTracks: 8,
196
+ /** Track.autotune (0.7). */
197
+ maxAutotuneSpeedMs: 400,
198
+ maxAutotuneHoldMs: 1000,
199
+ maxAutotuneGlideSeconds: 0.5,
200
+ maxAutotuneVibHz: 12,
201
+ /** Per-frame correction cap in semitones. */
202
+ maxAutotuneShift: 12,
141
203
  } as const);
142
204
 
143
205
  /** Instrument name that selects a track's `wavetable` oscillator. */
@@ -311,7 +373,8 @@ export class ScoreValidationError extends Error {
311
373
  | "invalid-track"
312
374
  | "duplicate-note"
313
375
  | "duplicate-track"
314
- | "score-limit";
376
+ | "score-limit"
377
+ | "routing-cycle";
315
378
 
316
379
  constructor(
317
380
  message: string,
@@ -451,6 +514,29 @@ export type Track = Readonly<{
451
514
  * field is rejected.
452
515
  */
453
516
  wind?: TrackWind;
517
+ /**
518
+ * Optional (0.7): the built-in singing voice (`core/sing.ts`), a preset
519
+ * plus overrides. Plays only when `instrument` is `"sing"`; any other
520
+ * instrument with this field is rejected.
521
+ */
522
+ sing?: TrackSing;
523
+ /**
524
+ * Optional (0.7): audio clips placed on the timeline. Shapes only in the
525
+ * contract; the renderer ignores them until the clips lane.
526
+ */
527
+ clips?: readonly AudioClip[];
528
+ /** Optional (0.7): recorded or imported takes that clips may reference. */
529
+ takes?: readonly Take[];
530
+ /**
531
+ * Optional (0.7): vocoder on this track (`core/vocoder.ts`); `src` names
532
+ * the modulator track. Absent: today's sound.
533
+ */
534
+ vocoder?: TrackVocoder;
535
+ /**
536
+ * Optional (0.7): pitch correction of every clip and sampler voice on the
537
+ * track (`core/autotune.ts`). Absent keeps the sound untouched.
538
+ */
539
+ autotune?: TrackAutotune;
454
540
  }> &
455
541
  /**
456
542
  * Performance (`core/expression.ts`): glide default, sustain pedal
@@ -849,6 +935,11 @@ export type TrackPatch = Readonly<
849
935
  sostenuto?: Track["sostenuto"] | null;
850
936
  guitar?: TrackGuitar | null;
851
937
  wind?: TrackWind | null;
938
+ sing?: TrackSing | null;
939
+ clips?: readonly AudioClip[] | null;
940
+ takes?: readonly Take[] | null;
941
+ vocoder?: TrackVocoder | null;
942
+ autotune?: TrackAutotune | null;
852
943
  }
853
944
  >;
854
945
 
@@ -871,6 +962,10 @@ export type Note = Readonly<{
871
962
  velocity: number;
872
963
  /** Static offset from the tuned pitch in cents (±1200); absent is 0. */
873
964
  cents?: number;
965
+ /** Optional (0.7): one sung syllable, no whitespace (≤ 32 characters). */
966
+ lyric?: string;
967
+ /** Optional (0.7): the sung vowel, `"a"` or a morph `"a>u"` (sing). */
968
+ vowel?: string;
874
969
  }> &
875
970
  NoteExpression;
876
971
 
@@ -901,6 +996,11 @@ export type TrackInput = Readonly<
901
996
  | "sostenuto"
902
997
  | "guitar"
903
998
  | "wind"
999
+ | "sing"
1000
+ | "clips"
1001
+ | "takes"
1002
+ | "vocoder"
1003
+ | "autotune"
904
1004
  > &
905
1005
  Pick<Track, "id"> & {
906
1006
  filter?: TrackFilter | null;
@@ -927,6 +1027,11 @@ export type TrackInput = Readonly<
927
1027
  sostenuto?: Track["sostenuto"] | null;
928
1028
  guitar?: TrackGuitar | null;
929
1029
  wind?: TrackWind | null;
1030
+ sing?: TrackSing | null;
1031
+ clips?: readonly AudioClip[] | null;
1032
+ takes?: readonly Take[] | null;
1033
+ vocoder?: TrackVocoder | null;
1034
+ autotune?: TrackAutotune | null;
930
1035
  }
931
1036
  >;
932
1037
 
@@ -944,6 +1049,8 @@ export type NoteInput = Readonly<{
944
1049
  pitch: number;
945
1050
  velocity: number;
946
1051
  cents?: number;
1052
+ lyric?: string | null;
1053
+ vowel?: string | null;
947
1054
  }> &
948
1055
  NoteExpressionPatch;
949
1056
 
@@ -997,6 +1104,8 @@ export type TrackScoreData = Readonly<{
997
1104
  notes?: readonly NoteInput[];
998
1105
  /** Song master chain and loudness target (core/master.ts); absent is off. */
999
1106
  master?: SongMaster | null;
1107
+ /** Which style and seed made the song (core/style-provenance.ts); absent is none. */
1108
+ style?: SongStyle | null;
1000
1109
  /** Song sections (0.5); absent or empty means none. */
1001
1110
  sections?: readonly Section[];
1002
1111
  /** Song form (0.5): the order sections play in; absent plays the score straight through. */
@@ -1006,6 +1115,8 @@ export type TrackScoreData = Readonly<{
1006
1115
  * the song (or its form). A name that matches no section is dropped.
1007
1116
  */
1008
1117
  loopSection?: string | null;
1118
+ /** Sound calibration (0.7), 0..CALIBRATION_LATEST; absent or 0 is legacy. */
1119
+ calibration?: number | null;
1009
1120
  }>;
1010
1121
 
1011
1122
  /** Canonical immutable score. Use `addNote`/`removeNote` to create a revision. */
@@ -1023,12 +1134,16 @@ export class TrackScore {
1023
1134
  readonly notes: readonly Note[];
1024
1135
  /** Absent (not undefined-valued) without a master, so 0.4 scores are unchanged. */
1025
1136
  declare readonly master?: SongMaster;
1137
+ /** Absent without a style, so older scores are unchanged. */
1138
+ declare readonly style?: SongStyle;
1026
1139
  /** Song sections in bar order; empty when the song has none. */
1027
1140
  readonly sections: readonly Section[];
1028
1141
  /** Song form; empty plays the score straight through. */
1029
1142
  readonly form: readonly FormEntry[];
1030
1143
  /** The section playback loops; undefined plays the song. */
1031
1144
  readonly loopSection: string | undefined;
1145
+ /** Sound calibration revision; absent is 0 (legacy engines). */
1146
+ declare readonly calibration?: number;
1032
1147
 
1033
1148
  constructor(data: TrackScoreData = {}) {
1034
1149
  const tempoBpm = data.tempoBpm ?? 120;
@@ -1092,6 +1207,13 @@ export class TrackScore {
1092
1207
  );
1093
1208
  const tracks = normalizeTracks(data.tracks ?? []);
1094
1209
  const notes = normalizeNotes(data.notes ?? []);
1210
+ const trackIds = new Set(tracks.map((track) => track.id));
1211
+ const orphan = notes.find((note) => !trackIds.has(note.trackId));
1212
+ if (orphan)
1213
+ throw new ScoreValidationError(
1214
+ `note ${orphan.id} is on track ${orphan.trackId}, which does not exist`,
1215
+ "invalid-note",
1216
+ );
1095
1217
  this.tuning = tuning;
1096
1218
  let master: SongMaster | undefined;
1097
1219
  try {
@@ -1102,6 +1224,15 @@ export class TrackScore {
1102
1224
  throw error;
1103
1225
  }
1104
1226
  if (master) this.master = master;
1227
+ let style: SongStyle | undefined;
1228
+ try {
1229
+ style = normalizeSongStyle(data.style);
1230
+ } catch (error) {
1231
+ if (error instanceof SongStyleError)
1232
+ throw new ScoreValidationError(error.message, "invalid-score");
1233
+ throw error;
1234
+ }
1235
+ if (style) this.style = style;
1105
1236
  const sections = normalizeSections(data.sections ?? []);
1106
1237
  const form = normalizeForm(data.form ?? [], sections);
1107
1238
  // Sections count bars in one meter; meter changes would move them.
@@ -1124,6 +1255,8 @@ export class TrackScore {
1124
1255
  this.sections = freezeArray(sections);
1125
1256
  this.form = freezeArray(form);
1126
1257
  this.loopSection = normalizeLoopSection(data.loopSection, sections);
1258
+ const calibration = normalizeCalibration(data.calibration);
1259
+ if (calibration) this.calibration = calibration;
1127
1260
  Object.freeze(this);
1128
1261
  }
1129
1262
 
@@ -1135,8 +1268,14 @@ export class TrackScore {
1135
1268
  return removeNote(this, noteId);
1136
1269
  }
1137
1270
 
1271
+ /** The song with `tracks`; notes on a track not listed go with it. */
1138
1272
  withTracks(tracks: readonly TrackInput[]): TrackScore {
1139
- return new TrackScore({ ...this.toJSON(), tracks });
1273
+ const ids = new Set(tracks.map((track) => track.id));
1274
+ return new TrackScore({
1275
+ ...this.toJSON(),
1276
+ tracks,
1277
+ notes: this.notes.filter((note) => ids.has(note.trackId)),
1278
+ });
1140
1279
  }
1141
1280
 
1142
1281
  withTempo(tempoBpm: number): TrackScore {
@@ -1161,8 +1300,19 @@ export class TrackScore {
1161
1300
  }
1162
1301
 
1163
1302
  /** Resize the loop without discarding notes or automation outside its bounds. */
1303
+ /** A shorter song drops the time marks past its new end. */
1164
1304
  withBars(bars: number): TrackScore {
1165
- return new TrackScore({ ...this.toJSON(), bars });
1305
+ const time =
1306
+ bars < this.bars
1307
+ ? timeWithinSong({
1308
+ tempoBpm: this.tempoBpm,
1309
+ beatsPerBar: this.beatsPerBar,
1310
+ ticksPerBeat: this.ticksPerBeat,
1311
+ bars,
1312
+ ...(this.time ? { time: this.time } : {}),
1313
+ })
1314
+ : this.time;
1315
+ return new TrackScore({ ...this.toJSON(), bars, time: time ?? null });
1166
1316
  }
1167
1317
 
1168
1318
  withKey(key: string | null): TrackScore {
@@ -1196,10 +1346,20 @@ export class TrackScore {
1196
1346
  }
1197
1347
 
1198
1348
  /** Replace the song master; `null` removes it (bypass). */
1349
+ /** Set the sound calibration revision; 0 or null is legacy. */
1350
+ withCalibration(calibration: number | null): TrackScore {
1351
+ return new TrackScore({ ...this.toJSON(), calibration });
1352
+ }
1353
+
1199
1354
  withMaster(master: SongMaster | null): TrackScore {
1200
1355
  return new TrackScore({ ...this.toJSON(), master });
1201
1356
  }
1202
1357
 
1358
+ /** Record (or clear, `null`) which style and seed made the song. */
1359
+ withStyle(style: SongStyle | null): TrackScore {
1360
+ return new TrackScore({ ...this.toJSON(), style });
1361
+ }
1362
+
1203
1363
  toJSON(): TrackScoreData & { version: typeof SCORE_VERSION } {
1204
1364
  return {
1205
1365
  version: SCORE_VERSION,
@@ -1213,11 +1373,13 @@ export class TrackScore {
1213
1373
  tracks: this.tracks,
1214
1374
  notes: this.notes,
1215
1375
  ...(this.master ? { master: this.master } : {}),
1376
+ ...(this.style ? { style: this.style } : {}),
1216
1377
  ...(this.sections.length > 0 ? { sections: this.sections } : {}),
1217
1378
  ...(this.form.length > 0 ? { form: this.form } : {}),
1218
1379
  ...(this.loopSection === undefined
1219
1380
  ? {}
1220
1381
  : { loopSection: this.loopSection }),
1382
+ ...(this.calibration ? { calibration: this.calibration } : {}),
1221
1383
  };
1222
1384
  }
1223
1385
  }
@@ -1284,6 +1446,7 @@ export type NotePatch = Readonly<
1284
1446
  Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity" | "cents">
1285
1447
  >
1286
1448
  > &
1449
+ Readonly<{ lyric?: string | null; vowel?: string | null }> &
1287
1450
  NoteExpressionPatch;
1288
1451
 
1289
1452
  export function updateNote(
@@ -1292,7 +1455,10 @@ export function updateNote(
1292
1455
  patch: NotePatch,
1293
1456
  ): TrackScore {
1294
1457
  const current = score.notes.find((note) => note.id === noteId);
1295
- if (!current) return score;
1458
+ // A patch for a missing note is a conflict (a stale or diverged score),
1459
+ // never a silent no-op. removeNote stays idempotent: the note is gone.
1460
+ if (!current)
1461
+ throw new ScoreValidationError(`unknown note: ${noteId}`, "invalid-note");
1296
1462
  return new TrackScore({
1297
1463
  ...score.toJSON(),
1298
1464
  notes: score.notes.map((note) =>
@@ -1331,6 +1497,9 @@ export function updateTrack(
1331
1497
  // (`wind` included) without a wind field leaves the wind engine.
1332
1498
  if (patch.instrument !== undefined && patch.wind === undefined)
1333
1499
  delete next.wind;
1500
+ // Likewise a sing field leaves with the singing instrument.
1501
+ if (patch.instrument !== undefined && patch.sing === undefined)
1502
+ delete next.sing;
1334
1503
  // Keys settings only belong to a keys family (piano, electric, organ):
1335
1504
  // switching to another instrument drops them unless the patch sets them.
1336
1505
  if (
@@ -1339,6 +1508,15 @@ export function updateTrack(
1339
1508
  !isKeysFamily(patch.instrument)
1340
1509
  )
1341
1510
  delete next.keys;
1511
+ // f07-vocoder: naming the `vocoder` instrument turns on its built-in
1512
+ // carrier (an empty field keeps every default); a vocoder stage the
1513
+ // track already has (its source) stays.
1514
+ if (
1515
+ patch.instrument === VOCODER_INSTRUMENT &&
1516
+ patch.vocoder === undefined &&
1517
+ next.vocoder === undefined
1518
+ )
1519
+ next.vocoder = Object.freeze({});
1342
1520
  return next as Track;
1343
1521
  }),
1344
1522
  );
@@ -1349,24 +1527,31 @@ export function removeTrack(score: TrackScore, trackId: string): TrackScore {
1349
1527
  if (!score.tracks.some((track) => track.id === trackId)) return score;
1350
1528
  return new TrackScore({
1351
1529
  ...score.toJSON(),
1352
- tracks: score.tracks.filter((track) => track.id !== trackId),
1530
+ tracks: score.tracks
1531
+ .filter((track) => track.id !== trackId)
1532
+ .map((track) => dropTrackRefs(track, trackId)),
1353
1533
  notes: score.notes.filter((note) => note.trackId !== trackId),
1354
1534
  });
1355
1535
  }
1356
1536
 
1357
- /** Moves a track to position `index` (clamped) in score order. */
1537
+ /** Moves a track to position `index` (0 .. tracks - 1) in score order. */
1358
1538
  export function moveTrack(
1359
1539
  score: TrackScore,
1360
1540
  trackId: string,
1361
1541
  index: number,
1362
1542
  ): TrackScore {
1363
1543
  const from = score.tracks.findIndex((track) => track.id === trackId);
1364
- if (from < 0 || !Number.isInteger(index))
1544
+ if (
1545
+ from < 0 ||
1546
+ !Number.isInteger(index) ||
1547
+ index < 0 ||
1548
+ index >= score.tracks.length
1549
+ )
1365
1550
  throw new ScoreValidationError(
1366
- "moveTrack needs an existing track and an integer index",
1551
+ `moveTrack needs an existing track and an index 0..${score.tracks.length - 1}`,
1367
1552
  "invalid-track",
1368
1553
  );
1369
- const to = Math.max(0, Math.min(score.tracks.length - 1, index));
1554
+ const to = index;
1370
1555
  if (from === to) return score;
1371
1556
  const tracks = [...score.tracks];
1372
1557
  const [moved] = tracks.splice(from, 1);
@@ -1529,6 +1714,22 @@ export type ScoreOperation =
1529
1714
  type: "setMaster";
1530
1715
  master: SongMaster | null;
1531
1716
  }>
1717
+ | Readonly<{
1718
+ /** Sound calibration revision (0.7); 0 or null is legacy. */
1719
+ type: "setCalibration";
1720
+ calibration: number | null;
1721
+ }>
1722
+ | Readonly<{
1723
+ /** Which style and seed made the song (quality-08); null clears it. */
1724
+ type: "setStyle";
1725
+ style: SongStyle | null;
1726
+ }>
1727
+ | Readonly<{
1728
+ /** Replaces a track's audio clips (0.7); null clears them. */
1729
+ type: "setClips";
1730
+ trackId: string;
1731
+ clips: readonly AudioClip[] | null;
1732
+ }>
1532
1733
  | Readonly<{
1533
1734
  type: "setSections";
1534
1735
  sections: readonly Section[];
@@ -1551,6 +1752,9 @@ export function applyScoreOperation(
1551
1752
  if (operation.type === "setTime") return score.withTime(operation.time);
1552
1753
  if (operation.type === "setTuning") return score.withTuning(operation.tuning);
1553
1754
  if (operation.type === "setMaster") return score.withMaster(operation.master);
1755
+ if (operation.type === "setCalibration")
1756
+ return score.withCalibration(operation.calibration);
1757
+ if (operation.type === "setStyle") return score.withStyle(operation.style);
1554
1758
  if (operation.type === "setSections")
1555
1759
  return score.withSections(
1556
1760
  operation.sections,
@@ -1576,6 +1780,8 @@ export function applyScoreOperation(
1576
1780
  );
1577
1781
  if (operation.type === "clearTrack")
1578
1782
  return clearTrack(score, operation.trackId);
1783
+ if (operation.type === "setClips")
1784
+ return setClips(score, operation.trackId, operation.clips);
1579
1785
  return assertNever(operation);
1580
1786
  }
1581
1787
 
@@ -1606,13 +1812,22 @@ export function scoreFromJSON(value: unknown): TrackScore {
1606
1812
  tracks: readonly TrackInput[];
1607
1813
  notes: readonly NoteInput[];
1608
1814
  master?: SongMaster;
1815
+ style?: SongStyle;
1609
1816
  sections?: readonly Section[];
1610
1817
  form?: readonly FormEntry[];
1611
1818
  loopSection?: string | null;
1819
+ calibration?: number | null;
1612
1820
  } = {
1613
1821
  tracks: optionalArray(value.tracks).map(parseTrack),
1614
1822
  notes: optionalArray(value.notes).map(parseNote),
1615
1823
  };
1824
+ // Files written before 0.7 could hold notes on a track that no longer
1825
+ // exists. They never sounded and no edit could reach them, so they go.
1826
+ // They are still checked like any note first.
1827
+ const trackIds = new Set(data.tracks.map((track) => track.id));
1828
+ data.notes = normalizeNotes(data.notes).filter((note) =>
1829
+ trackIds.has(note.trackId),
1830
+ );
1616
1831
  const tempoBpm = optionalNumber(value.tempoBpm);
1617
1832
  const beatsPerBar = optionalNumber(value.beatsPerBar);
1618
1833
  const bars = optionalNumber(value.bars);
@@ -1629,6 +1844,9 @@ export function scoreFromJSON(value: unknown): TrackScore {
1629
1844
  // Validated by the constructor (core/master.ts normalizeMaster).
1630
1845
  if (value.master !== undefined && value.master !== null)
1631
1846
  data.master = value.master as SongMaster;
1847
+ // Checked by the constructor (core/style-provenance.ts).
1848
+ if (value.style !== undefined && value.style !== null)
1849
+ data.style = value.style as SongStyle;
1632
1850
  // Shapes are checked by the constructor (normalizeSections, normalizeForm).
1633
1851
  if (value.sections !== undefined)
1634
1852
  data.sections = optionalArray(value.sections) as readonly Section[];
@@ -1637,6 +1855,9 @@ export function scoreFromJSON(value: unknown): TrackScore {
1637
1855
  // The constructor checks the type and drops a name matching no section.
1638
1856
  if (value.loopSection !== undefined)
1639
1857
  data.loopSection = value.loopSection as string | null;
1858
+ // The constructor checks the range.
1859
+ if (value.calibration !== undefined)
1860
+ data.calibration = value.calibration as number | null;
1640
1861
  return new TrackScore(data);
1641
1862
  }
1642
1863
 
@@ -1766,6 +1987,21 @@ function normalizeSections(inputs: readonly unknown[]): Section[] {
1766
1987
  .map(({ section }) => section);
1767
1988
  }
1768
1989
 
1990
+ function normalizeCalibration(value: unknown): number | undefined {
1991
+ if (value === undefined || value === null || value === 0) return undefined;
1992
+ if (
1993
+ typeof value !== "number" ||
1994
+ !Number.isInteger(value) ||
1995
+ value < 0 ||
1996
+ value > CALIBRATION_LATEST
1997
+ )
1998
+ throw new ScoreValidationError(
1999
+ `calibration must be an integer 0..${CALIBRATION_LATEST}`,
2000
+ "invalid-score",
2001
+ );
2002
+ return value;
2003
+ }
2004
+
1769
2005
  function normalizeLoopSection(
1770
2006
  value: unknown,
1771
2007
  sections: readonly Section[],
@@ -1827,7 +2063,7 @@ function normalizeTracks(inputs: readonly unknown[]): Track[] {
1827
2063
  );
1828
2064
  }
1829
2065
  const seen = new Set<string>();
1830
- return inputs.map((input) => {
2066
+ const tracks = inputs.map((input) => {
1831
2067
  const track = normalizeTrack(input);
1832
2068
  if (seen.has(track.id))
1833
2069
  throw new ScoreValidationError(
@@ -1837,6 +2073,22 @@ function normalizeTracks(inputs: readonly unknown[]): Track[] {
1837
2073
  seen.add(track.id);
1838
2074
  return track;
1839
2075
  });
2076
+ const vocoderError = vocoderScoreError(tracks, SCORE_LIMITS.maxVocoderTracks);
2077
+ if (vocoderError)
2078
+ throw new ScoreValidationError(vocoderError, "invalid-track");
2079
+ // Cross-track references (0.7): autotune.from names another track.
2080
+ for (const track of tracks) {
2081
+ const from = track.autotune?.from;
2082
+ if (from === undefined) continue;
2083
+ if (from === track.id || !seen.has(from))
2084
+ throw new ScoreValidationError(
2085
+ from === track.id
2086
+ ? `track ${track.id} autotune.from cannot be the track itself`
2087
+ : `track ${track.id} autotune.from: unknown track "${from}"`,
2088
+ "invalid-track",
2089
+ );
2090
+ }
2091
+ return tracks;
1840
2092
  }
1841
2093
 
1842
2094
  function normalizeTrack(input: unknown): Track {
@@ -1936,7 +2188,19 @@ function normalizeTrack(input: unknown): Track {
1936
2188
  `track ${id} has wind settings but its instrument is "${instrument}"`,
1937
2189
  "invalid-track",
1938
2190
  );
2191
+ const sing = fxOrThrow(() => normalizeSing(input.sing));
2192
+ if (sing && instrument !== SING_INSTRUMENT)
2193
+ throw new ScoreValidationError(
2194
+ `track ${id} has sing settings but its instrument is "${instrument}"`,
2195
+ "invalid-track",
2196
+ );
1939
2197
  const sampler = normalizeSampler(input.sampler);
2198
+ const takes = normalizeTakes(input.takes, id);
2199
+ const vocoder = fxOrThrow(() => normalizeVocoder(input.vocoder));
2200
+ const clips = normalizeClips(input.clips, id, takes);
2201
+ const autotune = fxOrThrow(() =>
2202
+ normalizeAutotune(input.autotune, instrument),
2203
+ );
1940
2204
  const rhythm = normalizeRhythm(input.rhythm, id);
1941
2205
  const time = timeOrThrow(
1942
2206
  () => normalizeTrackTime(input.time, `track ${id} time`),
@@ -2055,6 +2319,11 @@ function normalizeTrack(input: unknown): Track {
2055
2319
  ...(sostenuto ? { sostenuto } : {}),
2056
2320
  ...(guitar ? { guitar } : {}),
2057
2321
  ...(wind ? { wind } : {}),
2322
+ ...(sing ? { sing } : {}),
2323
+ ...(clips ? { clips } : {}),
2324
+ ...(takes ? { takes } : {}),
2325
+ ...(vocoder ? { vocoder } : {}),
2326
+ ...(autotune ? { autotune } : {}),
2058
2327
  });
2059
2328
  }
2060
2329
 
@@ -2984,6 +3253,12 @@ function normalizeNote(input: unknown): Note {
2984
3253
  "invalid-note",
2985
3254
  );
2986
3255
  }
3256
+ if (startTick + durationTicks > SCORE_LIMITS.maxTick) {
3257
+ throw new ScoreValidationError(
3258
+ `note ${id} must end by tick ${SCORE_LIMITS.maxTick}`,
3259
+ "invalid-note",
3260
+ );
3261
+ }
2987
3262
  const pitch = input.pitch;
2988
3263
  if (
2989
3264
  typeof pitch !== "number" ||
@@ -3022,6 +3297,11 @@ function normalizeNote(input: unknown): Note {
3022
3297
  () => normalizeNoteCents(input.cents, `note ${id}`),
3023
3298
  "invalid-note",
3024
3299
  );
3300
+ const lyric = normalizeLyric(input.lyric, id);
3301
+ const vowel =
3302
+ input.vowel === undefined || input.vowel === null
3303
+ ? undefined
3304
+ : vowelOrThrow(input.vowel, id);
3025
3305
  return Object.freeze({
3026
3306
  id,
3027
3307
  trackId,
@@ -3031,6 +3311,8 @@ function normalizeNote(input: unknown): Note {
3031
3311
  velocity: input.velocity,
3032
3312
  ...expression,
3033
3313
  ...(cents !== undefined ? { cents } : {}),
3314
+ ...(lyric !== undefined ? { lyric } : {}),
3315
+ ...(vowel !== undefined ? { vowel } : {}),
3034
3316
  });
3035
3317
  }
3036
3318
 
@@ -3049,12 +3331,49 @@ function parseNote(value: unknown): NoteInput {
3049
3331
  function compareNotes(a: Note, b: Note): number {
3050
3332
  return (
3051
3333
  a.startTick - b.startTick ||
3052
- a.trackId.localeCompare(b.trackId) ||
3334
+ compareIds(a.trackId, b.trackId) ||
3053
3335
  a.pitch - b.pitch ||
3054
- a.id.localeCompare(b.id)
3336
+ compareIds(a.id, b.id)
3055
3337
  );
3056
3338
  }
3057
3339
 
3340
+ /**
3341
+ * Printable ASCII in the order ICU's root collation gives it (uppercase
3342
+ * letters share their lowercase rank and sort after them on a tie).
3343
+ */
3344
+ const ID_COLLATION =
3345
+ " _-,;:!?.'\"()[]{}@*/\\&#%`^+<=>|~$0123456789abcdefghijklmnopqrstuvwxyz";
3346
+ const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
3347
+
3348
+ /**
3349
+ * Canonical id order, independent of locale and ICU version. Printable
3350
+ * ASCII ids keep the order `localeCompare` gave them before 0.7 (so older
3351
+ * projects print and render byte-identically); any other id sorts after
3352
+ * them by UTF-16 code units, so canonically equivalent spellings stay
3353
+ * distinct and the order is total.
3354
+ */
3355
+ export function compareIds(a: string, b: string): number {
3356
+ if (a === b) return 0;
3357
+ const asciiA = PRINTABLE_ASCII.test(a);
3358
+ const asciiB = PRINTABLE_ASCII.test(b);
3359
+ if (asciiA !== asciiB) return asciiA ? -1 : 1;
3360
+ if (!asciiA) return a < b ? -1 : 1;
3361
+ const length = Math.min(a.length, b.length);
3362
+ for (let i = 0; i < length; i += 1) {
3363
+ const rank =
3364
+ ID_COLLATION.indexOf(a[i]!.toLowerCase()) -
3365
+ ID_COLLATION.indexOf(b[i]!.toLowerCase());
3366
+ if (rank !== 0) return rank;
3367
+ }
3368
+ if (a.length !== b.length) return a.length - b.length;
3369
+ for (let i = 0; i < length; i += 1) {
3370
+ if (a[i] === b[i]) continue;
3371
+ // Same letter, different case: lowercase first.
3372
+ return a[i] === a[i]!.toLowerCase() ? -1 : 1;
3373
+ }
3374
+ return 0;
3375
+ }
3376
+
3058
3377
  function boundedString(
3059
3378
  value: unknown,
3060
3379
  label: string,
@@ -3104,3 +3423,503 @@ function assertNever(value: never): never {
3104
3423
  `unsupported score operation: ${String(value)}`,
3105
3424
  );
3106
3425
  }
3426
+
3427
+ // ---------------------------------------------------------------------------
3428
+ // 0.7 Voice contract: audio clips, takes and lyrics (shapes only). The
3429
+ // renderer ignores these fields until the clips lane wires them up.
3430
+ // ---------------------------------------------------------------------------
3431
+
3432
+ /** How a text-to-speech clip was made and where its syllables fall (0.7.1). */
3433
+ export type ClipSay = Readonly<{
3434
+ engine: "say" | "espeak-ng" | "piper";
3435
+ /** Stock voice name, at most 64 characters. */
3436
+ voice: string;
3437
+ /** Words per minute 80..400 (default 180). */
3438
+ rate?: number;
3439
+ /** Licence of the voice output, recorded with the clip. */
3440
+ license: string;
3441
+ /** Syllable starts in seconds, strictly increasing. */
3442
+ cuts: readonly number[];
3443
+ /** Voicing onsets in seconds (= cuts for unvoiced syllables). */
3444
+ vowels: readonly number[];
3445
+ /** Indices of unvoiced syllables. */
3446
+ unvoiced?: readonly number[];
3447
+ /** Octave shift applied to targets, -3..3. */
3448
+ oct?: number;
3449
+ /** 0..1 share of the spoken contour kept. */
3450
+ soft?: number;
3451
+ /** The pinned sung buffer. */
3452
+ sung?: Readonly<{ src: string; sha256: string; key: string }>;
3453
+ }>;
3454
+
3455
+ /** An audio file placed on a track's timeline (0.7). Times in seconds. */
3456
+ export type AudioClip = Readonly<{
3457
+ id: string;
3458
+ src: string;
3459
+ sha256: string;
3460
+ startTick: number;
3461
+ offset?: number;
3462
+ dur?: number;
3463
+ gain?: number;
3464
+ fadeInTime?: number;
3465
+ fadeTime?: number;
3466
+ rev?: boolean;
3467
+ /** Name of the track's take this clip plays from. */
3468
+ take?: string;
3469
+ mute?: boolean;
3470
+ text?: string;
3471
+ say?: ClipSay;
3472
+ }>;
3473
+
3474
+ /** A recorded or imported take (0.7 shape; recording is 0.7.1). */
3475
+ export type Take = Readonly<{
3476
+ name: string;
3477
+ src: string;
3478
+ sha256: string;
3479
+ startTick: number;
3480
+ /** Seconds into the file where the take starts. */
3481
+ offset: number;
3482
+ /** Round-trip latency compensated, in seconds. */
3483
+ latency: number;
3484
+ latencyAssumed?: boolean;
3485
+ /** Clock drift in parts per million (±1000). */
3486
+ ppm?: number;
3487
+ /** 0..1 fit of the alignment. */
3488
+ fit?: number;
3489
+ warn?: string;
3490
+ /** Manual nudge in milliseconds (±250). */
3491
+ nudge?: number;
3492
+ inTick: number;
3493
+ outTick: number;
3494
+ }>;
3495
+
3496
+ const MAX_CLIP_LABEL_LENGTH = 128;
3497
+
3498
+ function clipError(message: string): ScoreValidationError {
3499
+ return new ScoreValidationError(message, "invalid-track");
3500
+ }
3501
+
3502
+ function clipNumber(
3503
+ value: unknown,
3504
+ label: string,
3505
+ min: number,
3506
+ max: number,
3507
+ ): number | undefined {
3508
+ if (value === undefined) return undefined;
3509
+ if (
3510
+ typeof value !== "number" ||
3511
+ !Number.isFinite(value) ||
3512
+ value < min ||
3513
+ value > max
3514
+ )
3515
+ throw clipError(`${label} must be a number ${min}..${max}`);
3516
+ return value;
3517
+ }
3518
+
3519
+ function clipTick(value: unknown, label: string): number {
3520
+ if (
3521
+ typeof value !== "number" ||
3522
+ !Number.isInteger(value) ||
3523
+ value < 0 ||
3524
+ value > SCORE_LIMITS.maxTick
3525
+ )
3526
+ throw clipError(
3527
+ `${label} must be an integer tick 0..${SCORE_LIMITS.maxTick}`,
3528
+ );
3529
+ return value;
3530
+ }
3531
+
3532
+ function clipString(
3533
+ value: unknown,
3534
+ label: string,
3535
+ max: number,
3536
+ ): string | undefined {
3537
+ if (value === undefined) return undefined;
3538
+ if (typeof value !== "string" || value.length === 0 || value.length > max)
3539
+ throw clipError(`${label} must be 1..${max} characters`);
3540
+ return value;
3541
+ }
3542
+
3543
+ function clipBool(value: unknown, label: string): boolean | undefined {
3544
+ if (value === undefined) return undefined;
3545
+ if (typeof value !== "boolean") throw clipError(`${label} must be a boolean`);
3546
+ return value || undefined;
3547
+ }
3548
+
3549
+ function clipSrc(value: unknown, label: string): string {
3550
+ const packRef = typeof value === "string" && value.startsWith(PACK_PREFIX);
3551
+ if (
3552
+ typeof value !== "string" ||
3553
+ value.length === 0 ||
3554
+ value.length > SCORE_LIMITS.maxSamplePathLength ||
3555
+ (packRef ? !isPackRef(value) : !isSafeRelativePath(value))
3556
+ )
3557
+ throw clipError(
3558
+ `${label} src must be a project-relative path without ".." or pack:<pack>/<sound>, at most ${SCORE_LIMITS.maxSamplePathLength} characters`,
3559
+ );
3560
+ return value;
3561
+ }
3562
+
3563
+ function clipSha(value: unknown, label: string): string {
3564
+ if (typeof value !== "string" || !SHA256_HEX.test(value))
3565
+ throw clipError(`${label} sha256 must be 64 lowercase hex characters`);
3566
+ return value;
3567
+ }
3568
+
3569
+ function increasingTimes(value: unknown, label: string): number[] {
3570
+ if (!Array.isArray(value) || value.length > SCORE_LIMITS.maxSayCuts)
3571
+ throw clipError(
3572
+ `${label} must be an array of at most ${SCORE_LIMITS.maxSayCuts} times`,
3573
+ );
3574
+ let previous = -Infinity;
3575
+ return value.map((time: unknown, index) => {
3576
+ if (
3577
+ typeof time !== "number" ||
3578
+ !Number.isFinite(time) ||
3579
+ time < 0 ||
3580
+ time > SCORE_LIMITS.maxClipSeconds
3581
+ )
3582
+ throw clipError(
3583
+ `${label}[${index}] must be seconds 0..${SCORE_LIMITS.maxClipSeconds}`,
3584
+ );
3585
+ if (time <= previous)
3586
+ throw clipError(`${label} must be strictly increasing`);
3587
+ previous = time;
3588
+ return time;
3589
+ });
3590
+ }
3591
+
3592
+ /** Validates ClipSay; absent stays absent. */
3593
+ export function normalizeClipSay(
3594
+ input: unknown,
3595
+ label: string,
3596
+ ): ClipSay | undefined {
3597
+ if (input === undefined || input === null) return undefined;
3598
+ if (!isRecord(input)) throw clipError(`${label} say must be an object`);
3599
+ const engine = input.engine;
3600
+ if (engine !== "say" && engine !== "espeak-ng" && engine !== "piper")
3601
+ throw clipError(`${label} say.engine must be say, espeak-ng or piper`);
3602
+ const voice = clipString(
3603
+ input.voice,
3604
+ `${label} say.voice`,
3605
+ SCORE_LIMITS.maxTtsVoiceLength,
3606
+ );
3607
+ if (voice === undefined) throw clipError(`${label} say.voice is required`);
3608
+ const license = clipString(
3609
+ input.license,
3610
+ `${label} say.license`,
3611
+ MAX_CLIP_LABEL_LENGTH,
3612
+ );
3613
+ if (license === undefined)
3614
+ throw clipError(`${label} say.license is required`);
3615
+ const rate = clipNumber(input.rate, `${label} say.rate`, 80, 400);
3616
+ const cuts = increasingTimes(input.cuts, `${label} say.cuts`);
3617
+ const vowels = increasingTimes(input.vowels, `${label} say.vowels`);
3618
+ if (cuts.length !== vowels.length)
3619
+ throw clipError(`${label} say.cuts and say.vowels must be the same length`);
3620
+ cuts.forEach((cut, index) => {
3621
+ if (cut > vowels[index]!)
3622
+ throw clipError(
3623
+ `${label} say.cuts[${index}] must be <= say.vowels[${index}]`,
3624
+ );
3625
+ });
3626
+ let unvoiced: number[] | undefined;
3627
+ if (input.unvoiced !== undefined) {
3628
+ if (!Array.isArray(input.unvoiced))
3629
+ throw clipError(`${label} say.unvoiced must be an array of indices`);
3630
+ let previous = -1;
3631
+ unvoiced = input.unvoiced.map((index: unknown) => {
3632
+ if (
3633
+ typeof index !== "number" ||
3634
+ !Number.isInteger(index) ||
3635
+ index <= previous ||
3636
+ index >= cuts.length
3637
+ )
3638
+ throw clipError(
3639
+ `${label} say.unvoiced must be increasing indices below ${cuts.length}`,
3640
+ );
3641
+ previous = index;
3642
+ return index;
3643
+ });
3644
+ }
3645
+ const oct = clipNumber(input.oct, `${label} say.oct`, -3, 3);
3646
+ if (oct !== undefined && !Number.isInteger(oct))
3647
+ throw clipError(`${label} say.oct must be an integer -3..3`);
3648
+ const soft = clipNumber(input.soft, `${label} say.soft`, 0, 1);
3649
+ let sung: ClipSay["sung"];
3650
+ if (input.sung !== undefined) {
3651
+ if (!isRecord(input.sung))
3652
+ throw clipError(`${label} say.sung must be { src, sha256, key }`);
3653
+ const key = clipString(
3654
+ input.sung.key,
3655
+ `${label} say.sung.key`,
3656
+ MAX_CLIP_LABEL_LENGTH,
3657
+ );
3658
+ if (key === undefined) throw clipError(`${label} say.sung.key is required`);
3659
+ sung = Object.freeze({
3660
+ src: clipSrc(input.sung.src, `${label} say.sung`),
3661
+ sha256: clipSha(input.sung.sha256, `${label} say.sung`),
3662
+ key,
3663
+ });
3664
+ }
3665
+ return Object.freeze({
3666
+ engine,
3667
+ voice,
3668
+ ...(rate !== undefined ? { rate } : {}),
3669
+ license,
3670
+ cuts: Object.freeze(cuts),
3671
+ vowels: Object.freeze(vowels),
3672
+ ...(unvoiced && unvoiced.length > 0
3673
+ ? { unvoiced: Object.freeze(unvoiced) }
3674
+ : {}),
3675
+ ...(oct !== undefined ? { oct } : {}),
3676
+ ...(soft !== undefined ? { soft } : {}),
3677
+ ...(sung ? { sung } : {}),
3678
+ });
3679
+ }
3680
+
3681
+ /**
3682
+ * Validates Track.takes: unique names, sample path rules, inTick < outTick.
3683
+ * Null, absent and `[]` normalize to absent.
3684
+ */
3685
+ export function normalizeTakes(
3686
+ input: unknown,
3687
+ trackId: string,
3688
+ ): readonly Take[] | undefined {
3689
+ if (input === undefined || input === null) return undefined;
3690
+ if (!Array.isArray(input))
3691
+ throw clipError(`track ${trackId} takes must be an array`);
3692
+ if (input.length > SCORE_LIMITS.maxTakesPerTrack)
3693
+ throw new ScoreValidationError(
3694
+ `track ${trackId} takes holds at most ${SCORE_LIMITS.maxTakesPerTrack} takes`,
3695
+ "score-limit",
3696
+ );
3697
+ const names = new Set<string>();
3698
+ const takes = input.map((value: unknown, index): Take => {
3699
+ if (!isRecord(value))
3700
+ throw clipError(`track ${trackId} takes[${index}] must be an object`);
3701
+ const name = clipString(
3702
+ value.name,
3703
+ `track ${trackId} takes[${index}] name`,
3704
+ SCORE_LIMITS.maxNameLength,
3705
+ );
3706
+ if (name === undefined)
3707
+ throw clipError(`track ${trackId} takes[${index}] name is required`);
3708
+ if (names.has(name))
3709
+ throw clipError(`track ${trackId} take name ${name} is duplicated`);
3710
+ names.add(name);
3711
+ const label = `track ${trackId} take ${name}`;
3712
+ const offset = clipNumber(
3713
+ value.offset,
3714
+ `${label} offset`,
3715
+ 0,
3716
+ SCORE_LIMITS.maxClipSeconds,
3717
+ );
3718
+ if (offset === undefined) throw clipError(`${label} offset is required`);
3719
+ const latency = clipNumber(value.latency, `${label} latency`, -1, 1);
3720
+ if (latency === undefined) throw clipError(`${label} latency is required`);
3721
+ const inTick = clipTick(value.inTick, `${label} inTick`);
3722
+ const outTick = clipTick(value.outTick, `${label} outTick`);
3723
+ if (inTick >= outTick)
3724
+ throw clipError(`${label} inTick must be before outTick`);
3725
+ const latencyAssumed = clipBool(
3726
+ value.latencyAssumed,
3727
+ `${label} latencyAssumed`,
3728
+ );
3729
+ const ppm = clipNumber(
3730
+ value.ppm,
3731
+ `${label} ppm`,
3732
+ -SCORE_LIMITS.maxTakePpm,
3733
+ SCORE_LIMITS.maxTakePpm,
3734
+ );
3735
+ const fit = clipNumber(value.fit, `${label} fit`, 0, 1);
3736
+ const warn = clipString(value.warn, `${label} warn`, MAX_CLIP_LABEL_LENGTH);
3737
+ const nudge = clipNumber(
3738
+ value.nudge,
3739
+ `${label} nudge`,
3740
+ -SCORE_LIMITS.maxTakeNudgeMs,
3741
+ SCORE_LIMITS.maxTakeNudgeMs,
3742
+ );
3743
+ return Object.freeze({
3744
+ name,
3745
+ src: clipSrc(value.src, label),
3746
+ sha256: clipSha(value.sha256, label),
3747
+ startTick: clipTick(value.startTick, `${label} startTick`),
3748
+ offset,
3749
+ latency,
3750
+ ...(latencyAssumed ? { latencyAssumed } : {}),
3751
+ ...(ppm !== undefined ? { ppm } : {}),
3752
+ ...(fit !== undefined ? { fit } : {}),
3753
+ ...(warn !== undefined ? { warn } : {}),
3754
+ ...(nudge !== undefined ? { nudge } : {}),
3755
+ inTick,
3756
+ outTick,
3757
+ });
3758
+ });
3759
+ return takes.length > 0 ? Object.freeze(takes) : undefined;
3760
+ }
3761
+
3762
+ /**
3763
+ * Validates Track.clips: unique ids, sample path rules, sha256 pins,
3764
+ * offset + dur within maxClipSeconds, fades that fit the clip, and takes
3765
+ * that exist on the track. Null, absent and `[]` normalize to absent.
3766
+ */
3767
+ export function normalizeClips(
3768
+ input: unknown,
3769
+ trackId: string,
3770
+ takes: readonly Take[] | undefined,
3771
+ ): readonly AudioClip[] | undefined {
3772
+ if (input === undefined || input === null) return undefined;
3773
+ if (!Array.isArray(input))
3774
+ throw clipError(`track ${trackId} clips must be an array`);
3775
+ if (input.length > SCORE_LIMITS.maxClipsPerTrack)
3776
+ throw new ScoreValidationError(
3777
+ `track ${trackId} clips holds at most ${SCORE_LIMITS.maxClipsPerTrack} clips`,
3778
+ "score-limit",
3779
+ );
3780
+ const takeNames = new Set((takes ?? []).map((take) => take.name));
3781
+ const ids = new Set<string>();
3782
+ const clips = input.map((value: unknown, index): AudioClip => {
3783
+ if (!isRecord(value))
3784
+ throw clipError(`track ${trackId} clips[${index}] must be an object`);
3785
+ const id = clipString(
3786
+ value.id,
3787
+ `track ${trackId} clips[${index}] id`,
3788
+ SCORE_LIMITS.maxIdLength,
3789
+ );
3790
+ if (id === undefined)
3791
+ throw clipError(`track ${trackId} clips[${index}] id is required`);
3792
+ if (ids.has(id))
3793
+ throw clipError(`track ${trackId} clip id ${id} is duplicated`);
3794
+ ids.add(id);
3795
+ const label = `track ${trackId} clip ${id}`;
3796
+ const max = SCORE_LIMITS.maxClipSeconds;
3797
+ const offset = clipNumber(value.offset, `${label} offset`, 0, max);
3798
+ const dur = clipNumber(value.dur, `${label} dur`, 0, max);
3799
+ if (dur === 0) throw clipError(`${label} dur must be above 0`);
3800
+ if ((offset ?? 0) + (dur ?? 0) > max)
3801
+ throw clipError(`${label} offset + dur must be at most ${max} seconds`);
3802
+ const gain = clipNumber(
3803
+ value.gain,
3804
+ `${label} gain`,
3805
+ 0,
3806
+ SCORE_LIMITS.maxClipGain,
3807
+ );
3808
+ const fadeMax = SCORE_LIMITS.maxClipFadeSeconds;
3809
+ const fadeInTime = clipNumber(
3810
+ value.fadeInTime,
3811
+ `${label} fadeInTime`,
3812
+ 0,
3813
+ fadeMax,
3814
+ );
3815
+ const fadeTime = clipNumber(
3816
+ value.fadeTime,
3817
+ `${label} fadeTime`,
3818
+ 0,
3819
+ fadeMax,
3820
+ );
3821
+ if (dur !== undefined && (fadeInTime ?? 0) + (fadeTime ?? 0) > dur)
3822
+ throw clipError(`${label} fadeInTime + fadeTime must fit within dur`);
3823
+ const rev = clipBool(value.rev, `${label} rev`);
3824
+ const take = clipString(
3825
+ value.take,
3826
+ `${label} take`,
3827
+ SCORE_LIMITS.maxNameLength,
3828
+ );
3829
+ if (take !== undefined && !takeNames.has(take))
3830
+ throw clipError(`${label} take ${take} does not exist on the track`);
3831
+ const mute = clipBool(value.mute, `${label} mute`);
3832
+ const text = clipString(
3833
+ value.text,
3834
+ `${label} text`,
3835
+ SCORE_LIMITS.maxClipTextLength,
3836
+ );
3837
+ const say = normalizeClipSay(value.say, label);
3838
+ return Object.freeze({
3839
+ id,
3840
+ src: clipSrc(value.src, label),
3841
+ sha256: clipSha(value.sha256, label),
3842
+ startTick: clipTick(value.startTick, `${label} startTick`),
3843
+ ...(offset !== undefined ? { offset } : {}),
3844
+ ...(dur !== undefined ? { dur } : {}),
3845
+ ...(gain !== undefined ? { gain } : {}),
3846
+ ...(fadeInTime !== undefined ? { fadeInTime } : {}),
3847
+ ...(fadeTime !== undefined ? { fadeTime } : {}),
3848
+ ...(rev ? { rev } : {}),
3849
+ ...(take !== undefined ? { take } : {}),
3850
+ ...(mute ? { mute } : {}),
3851
+ ...(text !== undefined ? { text } : {}),
3852
+ ...(say ? { say } : {}),
3853
+ });
3854
+ });
3855
+ return clips.length > 0 ? Object.freeze(clips) : undefined;
3856
+ }
3857
+
3858
+ /** Validates Note.vowel: `"a"` or a morph `"a>u"`; null clears. */
3859
+ function vowelOrThrow(input: unknown, noteId: string): string {
3860
+ try {
3861
+ return normalizeVowel(input);
3862
+ } catch (error) {
3863
+ if (error instanceof FxValidationError)
3864
+ throw new ScoreValidationError(
3865
+ `note ${noteId} ${error.message}`,
3866
+ "invalid-note",
3867
+ );
3868
+ throw error;
3869
+ }
3870
+ }
3871
+
3872
+ /** Validates Note.lyric: one syllable without whitespace; null clears. */
3873
+ export function normalizeLyric(
3874
+ input: unknown,
3875
+ noteId: string,
3876
+ ): string | undefined {
3877
+ if (input === undefined || input === null) return undefined;
3878
+ if (
3879
+ typeof input !== "string" ||
3880
+ input.length === 0 ||
3881
+ input.length > SCORE_LIMITS.maxLyricLength ||
3882
+ /\s/.test(input)
3883
+ )
3884
+ throw new ScoreValidationError(
3885
+ `note ${noteId} lyric must be 1..${SCORE_LIMITS.maxLyricLength} characters without whitespace`,
3886
+ "invalid-note",
3887
+ );
3888
+ return input;
3889
+ }
3890
+
3891
+ /** Replaces (or with null clears) a track's clips. */
3892
+ export function setClips(
3893
+ score: TrackScore,
3894
+ trackId: string,
3895
+ clips: readonly AudioClip[] | null,
3896
+ ): TrackScore {
3897
+ return updateTrack(score, trackId, { clips });
3898
+ }
3899
+
3900
+ /**
3901
+ * Cross-track references (0.7): fields that name another track register a
3902
+ * drop function here (through `registerTrackRefs` in core/routing.ts) so
3903
+ * removing a track never leaves a dangling reference.
3904
+ */
3905
+ // `var` and a lazily created map: core/routing.ts registers while this
3906
+ // module may still be evaluating (score.ts imports routing.ts for its
3907
+ // side effect), so the map must not sit in a temporal dead zone.
3908
+ // eslint-disable-next-line no-var
3909
+ var trackRefDrops:
3910
+ Map<string, (track: Track, removedId: string) => Track> | undefined;
3911
+
3912
+ /** Registers how `field` forgets a removed track. Used by core/routing.ts. */
3913
+ export function registerTrackRefDrop(
3914
+ field: string,
3915
+ drop: (track: Track, removedId: string) => Track,
3916
+ ): void {
3917
+ (trackRefDrops ??= new Map()).set(field, drop);
3918
+ }
3919
+
3920
+ function dropTrackRefs(track: Track, removedId: string): Track {
3921
+ let next = track;
3922
+ for (const drop of trackRefDrops?.values() ?? [])
3923
+ next = drop(next, removedId);
3924
+ return next;
3925
+ }