@hraness/dawg 0.6.0 → 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 (275) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +655 -141
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +759 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -99
  8. package/core/expression.ts +158 -6
  9. package/core/fx.ts +354 -32
  10. package/core/granular.ts +91 -0
  11. package/core/instruments.ts +180 -0
  12. package/core/keys.ts +573 -4
  13. package/core/loop.ts +5 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/resonators.ts +306 -4
  17. package/core/routing.ts +165 -0
  18. package/core/score.ts +1181 -16
  19. package/core/sdk/eval-child.ts +7 -2
  20. package/core/sdk/eval.ts +35 -6
  21. package/core/sdk/print.ts +354 -6
  22. package/core/sdk/sync-lyrics.ts +49 -0
  23. package/core/sdk/v1.ts +3160 -69
  24. package/core/sections.ts +298 -24
  25. package/core/sing.ts +815 -0
  26. package/core/strings.ts +237 -2
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/core/winds.ts +652 -0
  50. package/guides/agent.md +29 -0
  51. package/guides/arrange.md +30 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -6
  54. package/guides/chords.md +16 -14
  55. package/guides/effects.md +17 -15
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +12 -6
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/performance.md +15 -10
  65. package/guides/play.md +21 -13
  66. package/guides/project.md +25 -8
  67. package/guides/providers.md +20 -14
  68. package/guides/resample.md +32 -0
  69. package/guides/rhythm.md +17 -13
  70. package/guides/sessions.md +17 -7
  71. package/guides/show-me.md +31 -0
  72. package/guides/sound.md +25 -9
  73. package/guides/sounds.md +15 -12
  74. package/guides/styles.md +31 -0
  75. package/guides/tempo.md +15 -10
  76. package/guides/tracks.md +15 -10
  77. package/guides/tuning.md +32 -0
  78. package/guides/voice.md +31 -0
  79. package/guides/web-search.md +18 -8
  80. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  81. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  82. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  83. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  84. package/native/prebuilt/manifest.json +21 -0
  85. package/package.json +5 -2
  86. package/src/agent/agent.ts +131 -13
  87. package/src/agent/calibration-tools.ts +53 -0
  88. package/src/agent/chord-tools.ts +153 -1
  89. package/src/agent/clip-tools.ts +453 -0
  90. package/src/agent/command-agent.ts +369 -0
  91. package/src/agent/drum-tools.ts +2 -2
  92. package/src/agent/expression-tools.ts +91 -0
  93. package/src/agent/gateway.ts +246 -60
  94. package/src/agent/models.ts +53 -12
  95. package/src/agent/ops.ts +15 -1
  96. package/src/agent/pack-tools.ts +1 -1
  97. package/src/agent/planner.ts +13 -0
  98. package/src/agent/portable-schema.ts +80 -0
  99. package/src/agent/preview-tool.ts +5 -1
  100. package/src/agent/provider.ts +22 -8
  101. package/src/agent/resample-tool.ts +131 -0
  102. package/src/agent/rhythm-tools.ts +1 -1
  103. package/src/agent/section-tools.ts +1 -1
  104. package/src/agent/show-me.ts +497 -0
  105. package/src/agent/steer.ts +15 -0
  106. package/src/agent/style-tools.ts +217 -0
  107. package/src/agent/tool-error.ts +12 -0
  108. package/src/agent/tools.ts +307 -37
  109. package/src/agent/usage.ts +2 -2
  110. package/src/agent/voice-tools.ts +925 -0
  111. package/src/agent/xcb-agent.ts +16 -6
  112. package/src/argv.ts +38 -0
  113. package/src/audio/analysis.ts +253 -0
  114. package/src/audio/arrange.ts +58 -3
  115. package/src/audio/autotune-engine.ts +101 -0
  116. package/src/audio/autotune.ts +640 -0
  117. package/src/audio/clips.ts +240 -0
  118. package/src/audio/doctor.ts +86 -0
  119. package/src/audio/dsp/bandbank.ts +138 -0
  120. package/src/audio/dsp/envelope.ts +171 -0
  121. package/src/audio/dsp/follow.ts +120 -0
  122. package/src/audio/dsp/formant.ts +427 -0
  123. package/src/audio/dsp/glottal.ts +243 -0
  124. package/src/audio/dsp/interp.ts +7 -2
  125. package/src/audio/dsp/lpc.ts +50 -0
  126. package/src/audio/dsp/periodicity.ts +59 -0
  127. package/src/audio/dsp/pitch.ts +995 -0
  128. package/src/audio/dsp/psola.ts +199 -0
  129. package/src/audio/dsp/shift.ts +256 -0
  130. package/src/audio/effects/chain.ts +21 -4
  131. package/src/audio/effects/common.ts +57 -0
  132. package/src/audio/effects/convolution.ts +113 -5
  133. package/src/audio/effects/filter.ts +48 -69
  134. package/src/audio/effects/formant.ts +263 -0
  135. package/src/audio/effects/gaze.ts +354 -0
  136. package/src/audio/engine.ts +206 -33
  137. package/src/audio/fit.ts +80 -3
  138. package/src/audio/granular.ts +352 -21
  139. package/src/audio/instrument-check.ts +67 -47
  140. package/src/audio/instruments.ts +36 -3
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +427 -0
  143. package/src/audio/keys/engine.ts +156 -20
  144. package/src/audio/keys/organ.ts +1335 -0
  145. package/src/audio/keys/piano.ts +72 -8
  146. package/src/audio/keys/sympathetic.ts +127 -0
  147. package/src/audio/kits.ts +135 -6
  148. package/src/audio/live-worker.ts +3 -1
  149. package/src/audio/live.ts +247 -26
  150. package/src/audio/native.ts +615 -0
  151. package/src/audio/preview.ts +45 -3
  152. package/src/audio/render-worker.ts +2 -0
  153. package/src/audio/renderer.ts +2 -0
  154. package/src/audio/resample.ts +286 -0
  155. package/src/audio/resonators.ts +10 -2
  156. package/src/audio/sampler.ts +252 -24
  157. package/src/audio/samples.ts +46 -3
  158. package/src/audio/sing/analysis.ts +193 -0
  159. package/src/audio/sing/engine.ts +949 -0
  160. package/src/audio/strings/body.ts +29 -16
  161. package/src/audio/strings/bow.ts +699 -0
  162. package/src/audio/strings/engine.ts +274 -22
  163. package/src/audio/strings/measure.test-helpers.ts +2 -0
  164. package/src/audio/synth/oscillators.ts +31 -21
  165. package/src/audio/synth/voice.ts +34 -1
  166. package/src/audio/vocoder/bank.ts +314 -0
  167. package/src/audio/vocoder/carrier.ts +165 -0
  168. package/src/audio/vocoder/control.ts +68 -0
  169. package/src/audio/vocoder/detect.ts +50 -0
  170. package/src/audio/vocoder/index.ts +304 -0
  171. package/src/audio/vocoder/talkbox.ts +143 -0
  172. package/src/audio/wav.ts +608 -85
  173. package/src/audio/winds/engine.ts +306 -0
  174. package/src/audio/winds/filters.ts +153 -0
  175. package/src/audio/winds/pitch.ts +104 -0
  176. package/src/audio/winds/trim.ts +104 -0
  177. package/src/audio/winds/trims.ts +917 -0
  178. package/src/audio/winds/trims1.ts +297 -0
  179. package/src/audio/winds/voice.ts +492 -0
  180. package/src/auth/cli.ts +38 -36
  181. package/src/auth/credentials.ts +30 -1
  182. package/src/auth/login.ts +15 -9
  183. package/src/auth/tui.ts +19 -10
  184. package/src/commands/arrange.ts +44 -29
  185. package/src/commands/autotune.ts +421 -0
  186. package/src/commands/calibration.ts +74 -0
  187. package/src/commands/clips.ts +887 -0
  188. package/src/commands/drums.ts +3 -2
  189. package/src/commands/edit.ts +11 -4
  190. package/src/commands/expression.ts +104 -23
  191. package/src/commands/formant.ts +221 -0
  192. package/src/commands/fx.ts +105 -33
  193. package/src/commands/grammar.ts +558 -0
  194. package/src/commands/granular.ts +7 -2
  195. package/src/commands/help.ts +652 -339
  196. package/src/commands/history.ts +18 -0
  197. package/src/commands/keys.ts +293 -17
  198. package/src/commands/modal.ts +67 -4
  199. package/src/commands/music.ts +1 -1
  200. package/src/commands/nearest.ts +53 -0
  201. package/src/commands/pack.ts +9 -2
  202. package/src/commands/param-range.ts +56 -0
  203. package/src/commands/parses.ts +130 -0
  204. package/src/commands/progression.ts +170 -0
  205. package/src/commands/resample.ts +281 -0
  206. package/src/commands/rhythm.ts +3 -0
  207. package/src/commands/rig.ts +8 -25
  208. package/src/commands/sample.ts +49 -2
  209. package/src/commands/shift.ts +119 -0
  210. package/src/commands/sing.ts +478 -0
  211. package/src/commands/string.ts +28 -2
  212. package/src/commands/strum.ts +485 -0
  213. package/src/commands/style.ts +415 -0
  214. package/src/commands/time.ts +6 -3
  215. package/src/commands/tuning.ts +3 -3
  216. package/src/commands/vocal-pitch.ts +616 -0
  217. package/src/commands/vocal.ts +147 -0
  218. package/src/commands/vocoder.ts +627 -0
  219. package/src/commands/wind.ts +244 -0
  220. package/src/fs/durable.ts +50 -0
  221. package/src/lang/glossary.ts +493 -0
  222. package/src/launch-args.ts +163 -0
  223. package/src/main.ts +1521 -255
  224. package/src/media/cli.ts +20 -3
  225. package/src/media/import.ts +3 -1
  226. package/src/project/check.ts +26 -3
  227. package/src/project/clip-pins.ts +72 -0
  228. package/src/project/init.ts +23 -8
  229. package/src/project/sync.ts +418 -86
  230. package/src/render.ts +36 -1
  231. package/src/session/daemon.ts +3 -0
  232. package/src/session/meta.ts +14 -0
  233. package/src/session/origin.ts +154 -0
  234. package/src/session/port.ts +22 -4
  235. package/src/session/presence.ts +50 -7
  236. package/src/session/protocol.ts +5 -1
  237. package/src/session/rebase.ts +18 -5
  238. package/src/session/receipt.ts +258 -0
  239. package/src/session/store.ts +65 -43
  240. package/src/tui/arrange-menu.ts +65 -31
  241. package/src/tui/audition.ts +1 -1
  242. package/src/tui/euclid.ts +18 -13
  243. package/src/tui/fader.ts +228 -41
  244. package/src/tui/granular-menu.ts +2 -4
  245. package/src/tui/menu-clips.ts +297 -0
  246. package/src/tui/menu-time.ts +20 -13
  247. package/src/tui/menu-voice.ts +405 -0
  248. package/src/tui/menu.ts +1377 -193
  249. package/src/tui/modal-menu.ts +38 -11
  250. package/src/tui/performance-menu.ts +46 -1
  251. package/src/tui/play-chords.ts +108 -5
  252. package/src/tui/play-mode.ts +16 -1
  253. package/src/tui/play-session.ts +81 -16
  254. package/src/tui/sing-menu.ts +278 -0
  255. package/src/tui/style-menu.ts +104 -0
  256. package/src/tui/vocoder-menu.ts +244 -0
  257. package/src/tui/wind-menu.ts +144 -0
  258. package/src/version.ts +8 -0
  259. package/src/web/fetch.ts +115 -29
  260. package/tui/activity.ts +180 -9
  261. package/tui/app.ts +274 -40
  262. package/tui/clip-row.ts +132 -0
  263. package/tui/delight.ts +144 -0
  264. package/tui/drawer.ts +70 -22
  265. package/tui/frame-gate.ts +76 -0
  266. package/tui/grammar.ts +112 -63
  267. package/tui/guide.ts +42 -4
  268. package/tui/highway.ts +269 -25
  269. package/tui/hints.ts +192 -0
  270. package/tui/input.ts +60 -9
  271. package/tui/keys.ts +1 -1
  272. package/tui/play-strip.ts +68 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +144 -14
  275. package/tui/theme.ts +27 -0
package/core/score.ts CHANGED
@@ -23,30 +23,53 @@ import {
23
23
  import {
24
24
  MODAL_INSTRUMENT,
25
25
  normalizeModal,
26
+ normalizeWind,
27
+ WIND_INSTRUMENT,
26
28
  type TrackModal,
29
+ type TrackWind,
27
30
  } from "./resonators.ts";
28
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";
29
47
  import { normalizeString, type TrackString } from "./strings.ts";
30
48
  import {
31
49
  isGranularInstrument,
32
50
  normalizeGranular,
33
51
  type TrackGranular,
34
52
  } from "./granular.ts";
35
- import { normalizeKeys, type TrackKeys } from "./keys.ts";
53
+ import { isKeysFamily, normalizeKeys, type TrackKeys } from "./keys.ts";
54
+ import { GUITAR_TUNING_NAMES } from "./chords.ts";
36
55
  import {
37
56
  checkSongTime,
38
57
  normalizeSongTime,
39
58
  normalizeTrackTime,
40
59
  TimeValidationError,
41
60
  type SongTime,
61
+ timeWithinSong,
42
62
  type TrackTime,
43
63
  withMeterChange,
44
64
  } from "./tempo.ts";
45
65
  import {
46
66
  ExpressionValidationError,
47
67
  normalizeNoteExpression,
68
+ normalizePedal,
48
69
  normalizeTrackPerformance,
70
+ SOSTENUTO_STATES,
49
71
  type NoteExpression,
72
+ type PedalEvent,
50
73
  type NoteExpressionPatch,
51
74
  type TrackPerformance,
52
75
  } from "./expression.ts";
@@ -59,8 +82,22 @@ import {
59
82
 
60
83
  export type { Tuning } from "./tuning.ts";
61
84
  import { normalizeMaster, type SongMaster } from "./master.ts";
85
+ import {
86
+ normalizeSongStyle,
87
+ SongStyleError,
88
+ type SongStyle,
89
+ } from "./style-provenance.ts";
62
90
 
63
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;
64
101
  export const DEFAULT_TICKS_PER_BEAT = 480 as const;
65
102
 
66
103
  export const SCORE_LIMITS = Object.freeze({
@@ -83,7 +120,11 @@ export const SCORE_LIMITS = Object.freeze({
83
120
  maxSectionGain: 2,
84
121
  maxBeatsPerBar: 16,
85
122
  maxTicksPerBeat: 4096,
86
- 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,
87
128
  minFilterCutoff: 20,
88
129
  maxFilterCutoff: 20_000,
89
130
  maxFilterResonance: 1,
@@ -116,6 +157,12 @@ export const SCORE_LIMITS = Object.freeze({
116
157
  maxSampleBpm: 400,
117
158
  /** Sampler `len` (window length in beats) bound. */
118
159
  maxSampleLenBeats: 1024,
160
+ /** Sampler `shift` and `formant` bound in semitones (0.6.1). */
161
+ maxSampleShift: 24,
162
+ /** Sampler `fadeTime`/`fadeInTime` bound in seconds (0.6.1). */
163
+ maxSampleFadeSeconds: 2,
164
+ /** Longest `resample` render in seconds (0.6.1). */
165
+ maxResampleSeconds: 600,
119
166
  /** Per sample file, enforced by the decoder and the import tool. */
120
167
  maxSampleFileBytes: 50 * 1024 * 1024,
121
168
  maxSampleSeconds: 600,
@@ -125,6 +172,34 @@ export const SCORE_LIMITS = Object.freeze({
125
172
  * `DAWG_ASSETS_CACHE_MAX` / `DAWG_PACKS_CACHE_MAX`.
126
173
  */
127
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,
128
203
  } as const);
129
204
 
130
205
  /** Instrument name that selects a track's `wavetable` oscillator. */
@@ -298,7 +373,8 @@ export class ScoreValidationError extends Error {
298
373
  | "invalid-track"
299
374
  | "duplicate-note"
300
375
  | "duplicate-track"
301
- | "score-limit";
376
+ | "score-limit"
377
+ | "routing-cycle";
302
378
 
303
379
  constructor(
304
380
  message: string,
@@ -413,6 +489,54 @@ export type Track = Readonly<{
413
489
  * instrument with this field is rejected. Absent keeps today's tone.
414
490
  */
415
491
  modal?: TrackModal;
492
+ /**
493
+ * Optional (0.6.1): una corda (soft pedal) events, same shape as `pedal`.
494
+ * Modelled pianos read it at each onset (half is half the shift); other
495
+ * instruments ignore it.
496
+ */
497
+ softPedal?: readonly PedalEvent[];
498
+ /**
499
+ * Optional (0.6.1): sostenuto pedal events (down and up only). Keys held
500
+ * when it goes down ring until it lifts; later keys damp as usual.
501
+ */
502
+ sostenuto?: readonly PedalEvent[];
503
+ /**
504
+ * Optional (0.6.1): how the track is fretted when chords are strummed
505
+ * (`strum`, perform mode `guitar`): tuning, capo, hand stretch, how much
506
+ * open strings ring and a preferred position. Notes only; never read at
507
+ * render, so it changes no sound by itself.
508
+ */
509
+ guitar?: TrackGuitar;
510
+ /**
511
+ * Optional (0.6.1): a blown waveguide (`core/winds.ts`), a preset plus
512
+ * overrides. Plays only when `instrument` is `"wind"`; without it the
513
+ * legacy `wind` tone plays unchanged. Any other instrument with this
514
+ * field is rejected.
515
+ */
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;
416
540
  }> &
417
541
  /**
418
542
  * Performance (`core/expression.ts`): glide default, sustain pedal
@@ -508,6 +632,51 @@ export type SampleRef = Readonly<{
508
632
  * through the song's tempo map. `fit` wins over `bpm`, `bpm` over `len`.
509
633
  */
510
634
  len?: number;
635
+ /**
636
+ * Optional (0.6.1): pitch shift in semitones (−24..24) at constant
637
+ * length, a cached phase-vocoder preprocess (`src/audio/dsp/shift.ts`).
638
+ * Applied after any fit, so it follows `fitmode`.
639
+ */
640
+ shift?: number;
641
+ /**
642
+ * Optional (0.6.1, with `shift`): formant shift in semitones (−24..24).
643
+ * Absent: formants follow the pitch (chipmunk); 0 keeps them in place.
644
+ */
645
+ formant?: number;
646
+ /** Optional (0.6.1, Strudel `fadeTime`): release fade in seconds (0..2). */
647
+ fadeTime?: number;
648
+ /** Optional (0.6.1, Strudel `fadeInTime`): attack fade in seconds (0..2). */
649
+ fadeInTime?: number;
650
+ /**
651
+ * Optional (0.6.1): where `resample` rendered this file from; kept for
652
+ * reference only (never read by the renderer).
653
+ */
654
+ from?: SampleProvenance;
655
+ /**
656
+ * Optional (0.6.1, SFZ `lovel`/`hivel`): the MIDI velocity range
657
+ * [lo, hi] (0..127, lo ≤ hi) this voice plays. Keyed voices with the same
658
+ * root are velocity layers; a one-shot voice outside its range is silent
659
+ * unless its `rr` group has a voice for that velocity.
660
+ */
661
+ vel?: readonly [number, number];
662
+ /**
663
+ * Optional (0.6.1, SFZ `seq_length`): round-robin group name. One-shot
664
+ * voices in a group are one sound (any member's slot plays it); voices
665
+ * that match a note's velocity take turns, A B A B, in note order.
666
+ */
667
+ rr?: string;
668
+ }>;
669
+
670
+ /** `SampleRef.from`: the source of a resampled file (0.6.1). */
671
+ export type SampleProvenance = Readonly<{
672
+ /** `track:<id>`, `orbit:<n>` or `master`. */
673
+ source: string;
674
+ /** Section name the render covered. */
675
+ section?: string;
676
+ /** Bars a..b (1-based, inclusive) the render covered. */
677
+ bars?: readonly [number, number];
678
+ /** sha256 of the canonical score JSON the file was rendered from. */
679
+ score: string;
511
680
  }>;
512
681
 
513
682
  /** Sampler `fitmode`: how a fitted window changes its time. */
@@ -541,14 +710,22 @@ export type TrackReverb = Readonly<{
541
710
  predelay?: number;
542
711
  /**
543
712
  * Optional (Strudel `iresponse`/`ir`): convolve with this impulse instead
544
- * of the algorithmic tail. `builtin:room|hall|plate`, a pinned pack sound
713
+ * of the algorithmic tail. `builtin:room|hall|plate|reverse|gate|spring`, a pinned pack sound
545
714
  * or a project file; `size`, `fade` and `dim` then do nothing.
546
715
  */
547
716
  ir?: SampleRef;
548
717
  }>;
549
718
 
550
719
  /** Generated impulse responses `reverb.ir` can name as `builtin:<name>`. */
551
- export const REVERB_IR_BUILTINS = Object.freeze(["room", "hall", "plate"]);
720
+ export const REVERB_IR_BUILTINS = Object.freeze([
721
+ "room",
722
+ "hall",
723
+ "plate",
724
+ // 0.6.1: a reverse (rising) wash, a gated tail, a spring tank.
725
+ "reverse",
726
+ "gate",
727
+ "spring",
728
+ ]);
552
729
 
553
730
  /**
554
731
  * Validates `reverb.ir`: a string or `{ src, … }`. Bare built-in names and
@@ -754,6 +931,15 @@ export type TrackPatch = Readonly<
754
931
  granular?: TrackGranular | null;
755
932
  keys?: TrackKeys | null;
756
933
  modal?: TrackModal | null;
934
+ softPedal?: Track["softPedal"] | null;
935
+ sostenuto?: Track["sostenuto"] | null;
936
+ guitar?: TrackGuitar | null;
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;
757
943
  }
758
944
  >;
759
945
 
@@ -776,6 +962,10 @@ export type Note = Readonly<{
776
962
  velocity: number;
777
963
  /** Static offset from the tuned pitch in cents (±1200); absent is 0. */
778
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;
779
969
  }> &
780
970
  NoteExpression;
781
971
 
@@ -802,6 +992,15 @@ export type TrackInput = Readonly<
802
992
  | "granular"
803
993
  | "keys"
804
994
  | "modal"
995
+ | "softPedal"
996
+ | "sostenuto"
997
+ | "guitar"
998
+ | "wind"
999
+ | "sing"
1000
+ | "clips"
1001
+ | "takes"
1002
+ | "vocoder"
1003
+ | "autotune"
805
1004
  > &
806
1005
  Pick<Track, "id"> & {
807
1006
  filter?: TrackFilter | null;
@@ -824,6 +1023,15 @@ export type TrackInput = Readonly<
824
1023
  granular?: TrackGranular | null;
825
1024
  keys?: TrackKeys | null;
826
1025
  modal?: TrackModal | null;
1026
+ softPedal?: Track["softPedal"] | null;
1027
+ sostenuto?: Track["sostenuto"] | null;
1028
+ guitar?: TrackGuitar | null;
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;
827
1035
  }
828
1036
  >;
829
1037
 
@@ -841,6 +1049,8 @@ export type NoteInput = Readonly<{
841
1049
  pitch: number;
842
1050
  velocity: number;
843
1051
  cents?: number;
1052
+ lyric?: string | null;
1053
+ vowel?: string | null;
844
1054
  }> &
845
1055
  NoteExpressionPatch;
846
1056
 
@@ -894,6 +1104,8 @@ export type TrackScoreData = Readonly<{
894
1104
  notes?: readonly NoteInput[];
895
1105
  /** Song master chain and loudness target (core/master.ts); absent is off. */
896
1106
  master?: SongMaster | null;
1107
+ /** Which style and seed made the song (core/style-provenance.ts); absent is none. */
1108
+ style?: SongStyle | null;
897
1109
  /** Song sections (0.5); absent or empty means none. */
898
1110
  sections?: readonly Section[];
899
1111
  /** Song form (0.5): the order sections play in; absent plays the score straight through. */
@@ -903,6 +1115,8 @@ export type TrackScoreData = Readonly<{
903
1115
  * the song (or its form). A name that matches no section is dropped.
904
1116
  */
905
1117
  loopSection?: string | null;
1118
+ /** Sound calibration (0.7), 0..CALIBRATION_LATEST; absent or 0 is legacy. */
1119
+ calibration?: number | null;
906
1120
  }>;
907
1121
 
908
1122
  /** Canonical immutable score. Use `addNote`/`removeNote` to create a revision. */
@@ -920,12 +1134,16 @@ export class TrackScore {
920
1134
  readonly notes: readonly Note[];
921
1135
  /** Absent (not undefined-valued) without a master, so 0.4 scores are unchanged. */
922
1136
  declare readonly master?: SongMaster;
1137
+ /** Absent without a style, so older scores are unchanged. */
1138
+ declare readonly style?: SongStyle;
923
1139
  /** Song sections in bar order; empty when the song has none. */
924
1140
  readonly sections: readonly Section[];
925
1141
  /** Song form; empty plays the score straight through. */
926
1142
  readonly form: readonly FormEntry[];
927
1143
  /** The section playback loops; undefined plays the song. */
928
1144
  readonly loopSection: string | undefined;
1145
+ /** Sound calibration revision; absent is 0 (legacy engines). */
1146
+ declare readonly calibration?: number;
929
1147
 
930
1148
  constructor(data: TrackScoreData = {}) {
931
1149
  const tempoBpm = data.tempoBpm ?? 120;
@@ -989,6 +1207,13 @@ export class TrackScore {
989
1207
  );
990
1208
  const tracks = normalizeTracks(data.tracks ?? []);
991
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
+ );
992
1217
  this.tuning = tuning;
993
1218
  let master: SongMaster | undefined;
994
1219
  try {
@@ -999,6 +1224,15 @@ export class TrackScore {
999
1224
  throw error;
1000
1225
  }
1001
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;
1002
1236
  const sections = normalizeSections(data.sections ?? []);
1003
1237
  const form = normalizeForm(data.form ?? [], sections);
1004
1238
  // Sections count bars in one meter; meter changes would move them.
@@ -1021,6 +1255,8 @@ export class TrackScore {
1021
1255
  this.sections = freezeArray(sections);
1022
1256
  this.form = freezeArray(form);
1023
1257
  this.loopSection = normalizeLoopSection(data.loopSection, sections);
1258
+ const calibration = normalizeCalibration(data.calibration);
1259
+ if (calibration) this.calibration = calibration;
1024
1260
  Object.freeze(this);
1025
1261
  }
1026
1262
 
@@ -1032,8 +1268,14 @@ export class TrackScore {
1032
1268
  return removeNote(this, noteId);
1033
1269
  }
1034
1270
 
1271
+ /** The song with `tracks`; notes on a track not listed go with it. */
1035
1272
  withTracks(tracks: readonly TrackInput[]): TrackScore {
1036
- 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
+ });
1037
1279
  }
1038
1280
 
1039
1281
  withTempo(tempoBpm: number): TrackScore {
@@ -1058,8 +1300,19 @@ export class TrackScore {
1058
1300
  }
1059
1301
 
1060
1302
  /** Resize the loop without discarding notes or automation outside its bounds. */
1303
+ /** A shorter song drops the time marks past its new end. */
1061
1304
  withBars(bars: number): TrackScore {
1062
- 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 });
1063
1316
  }
1064
1317
 
1065
1318
  withKey(key: string | null): TrackScore {
@@ -1093,10 +1346,20 @@ export class TrackScore {
1093
1346
  }
1094
1347
 
1095
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
+
1096
1354
  withMaster(master: SongMaster | null): TrackScore {
1097
1355
  return new TrackScore({ ...this.toJSON(), master });
1098
1356
  }
1099
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
+
1100
1363
  toJSON(): TrackScoreData & { version: typeof SCORE_VERSION } {
1101
1364
  return {
1102
1365
  version: SCORE_VERSION,
@@ -1110,11 +1373,13 @@ export class TrackScore {
1110
1373
  tracks: this.tracks,
1111
1374
  notes: this.notes,
1112
1375
  ...(this.master ? { master: this.master } : {}),
1376
+ ...(this.style ? { style: this.style } : {}),
1113
1377
  ...(this.sections.length > 0 ? { sections: this.sections } : {}),
1114
1378
  ...(this.form.length > 0 ? { form: this.form } : {}),
1115
1379
  ...(this.loopSection === undefined
1116
1380
  ? {}
1117
1381
  : { loopSection: this.loopSection }),
1382
+ ...(this.calibration ? { calibration: this.calibration } : {}),
1118
1383
  };
1119
1384
  }
1120
1385
  }
@@ -1181,6 +1446,7 @@ export type NotePatch = Readonly<
1181
1446
  Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity" | "cents">
1182
1447
  >
1183
1448
  > &
1449
+ Readonly<{ lyric?: string | null; vowel?: string | null }> &
1184
1450
  NoteExpressionPatch;
1185
1451
 
1186
1452
  export function updateNote(
@@ -1189,7 +1455,10 @@ export function updateNote(
1189
1455
  patch: NotePatch,
1190
1456
  ): TrackScore {
1191
1457
  const current = score.notes.find((note) => note.id === noteId);
1192
- 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");
1193
1462
  return new TrackScore({
1194
1463
  ...score.toJSON(),
1195
1464
  notes: score.notes.map((note) =>
@@ -1224,6 +1493,30 @@ export function updateTrack(
1224
1493
  patch.instrument !== MODAL_INSTRUMENT
1225
1494
  )
1226
1495
  delete next.modal;
1496
+ // The legacy word `wind` is the legacy tone, so naming any instrument
1497
+ // (`wind` included) without a wind field leaves the wind engine.
1498
+ if (patch.instrument !== undefined && patch.wind === undefined)
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;
1503
+ // Keys settings only belong to a keys family (piano, electric, organ):
1504
+ // switching to another instrument drops them unless the patch sets them.
1505
+ if (
1506
+ patch.instrument !== undefined &&
1507
+ patch.keys === undefined &&
1508
+ !isKeysFamily(patch.instrument)
1509
+ )
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({});
1227
1520
  return next as Track;
1228
1521
  }),
1229
1522
  );
@@ -1234,24 +1527,31 @@ export function removeTrack(score: TrackScore, trackId: string): TrackScore {
1234
1527
  if (!score.tracks.some((track) => track.id === trackId)) return score;
1235
1528
  return new TrackScore({
1236
1529
  ...score.toJSON(),
1237
- tracks: score.tracks.filter((track) => track.id !== trackId),
1530
+ tracks: score.tracks
1531
+ .filter((track) => track.id !== trackId)
1532
+ .map((track) => dropTrackRefs(track, trackId)),
1238
1533
  notes: score.notes.filter((note) => note.trackId !== trackId),
1239
1534
  });
1240
1535
  }
1241
1536
 
1242
- /** Moves a track to position `index` (clamped) in score order. */
1537
+ /** Moves a track to position `index` (0 .. tracks - 1) in score order. */
1243
1538
  export function moveTrack(
1244
1539
  score: TrackScore,
1245
1540
  trackId: string,
1246
1541
  index: number,
1247
1542
  ): TrackScore {
1248
1543
  const from = score.tracks.findIndex((track) => track.id === trackId);
1249
- if (from < 0 || !Number.isInteger(index))
1544
+ if (
1545
+ from < 0 ||
1546
+ !Number.isInteger(index) ||
1547
+ index < 0 ||
1548
+ index >= score.tracks.length
1549
+ )
1250
1550
  throw new ScoreValidationError(
1251
- "moveTrack needs an existing track and an integer index",
1551
+ `moveTrack needs an existing track and an index 0..${score.tracks.length - 1}`,
1252
1552
  "invalid-track",
1253
1553
  );
1254
- const to = Math.max(0, Math.min(score.tracks.length - 1, index));
1554
+ const to = index;
1255
1555
  if (from === to) return score;
1256
1556
  const tracks = [...score.tracks];
1257
1557
  const [moved] = tracks.splice(from, 1);
@@ -1414,6 +1714,22 @@ export type ScoreOperation =
1414
1714
  type: "setMaster";
1415
1715
  master: SongMaster | null;
1416
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
+ }>
1417
1733
  | Readonly<{
1418
1734
  type: "setSections";
1419
1735
  sections: readonly Section[];
@@ -1436,6 +1752,9 @@ export function applyScoreOperation(
1436
1752
  if (operation.type === "setTime") return score.withTime(operation.time);
1437
1753
  if (operation.type === "setTuning") return score.withTuning(operation.tuning);
1438
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);
1439
1758
  if (operation.type === "setSections")
1440
1759
  return score.withSections(
1441
1760
  operation.sections,
@@ -1461,6 +1780,8 @@ export function applyScoreOperation(
1461
1780
  );
1462
1781
  if (operation.type === "clearTrack")
1463
1782
  return clearTrack(score, operation.trackId);
1783
+ if (operation.type === "setClips")
1784
+ return setClips(score, operation.trackId, operation.clips);
1464
1785
  return assertNever(operation);
1465
1786
  }
1466
1787
 
@@ -1491,13 +1812,22 @@ export function scoreFromJSON(value: unknown): TrackScore {
1491
1812
  tracks: readonly TrackInput[];
1492
1813
  notes: readonly NoteInput[];
1493
1814
  master?: SongMaster;
1815
+ style?: SongStyle;
1494
1816
  sections?: readonly Section[];
1495
1817
  form?: readonly FormEntry[];
1496
1818
  loopSection?: string | null;
1819
+ calibration?: number | null;
1497
1820
  } = {
1498
1821
  tracks: optionalArray(value.tracks).map(parseTrack),
1499
1822
  notes: optionalArray(value.notes).map(parseNote),
1500
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
+ );
1501
1831
  const tempoBpm = optionalNumber(value.tempoBpm);
1502
1832
  const beatsPerBar = optionalNumber(value.beatsPerBar);
1503
1833
  const bars = optionalNumber(value.bars);
@@ -1514,6 +1844,9 @@ export function scoreFromJSON(value: unknown): TrackScore {
1514
1844
  // Validated by the constructor (core/master.ts normalizeMaster).
1515
1845
  if (value.master !== undefined && value.master !== null)
1516
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;
1517
1850
  // Shapes are checked by the constructor (normalizeSections, normalizeForm).
1518
1851
  if (value.sections !== undefined)
1519
1852
  data.sections = optionalArray(value.sections) as readonly Section[];
@@ -1522,6 +1855,9 @@ export function scoreFromJSON(value: unknown): TrackScore {
1522
1855
  // The constructor checks the type and drops a name matching no section.
1523
1856
  if (value.loopSection !== undefined)
1524
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;
1525
1861
  return new TrackScore(data);
1526
1862
  }
1527
1863
 
@@ -1651,6 +1987,21 @@ function normalizeSections(inputs: readonly unknown[]): Section[] {
1651
1987
  .map(({ section }) => section);
1652
1988
  }
1653
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
+
1654
2005
  function normalizeLoopSection(
1655
2006
  value: unknown,
1656
2007
  sections: readonly Section[],
@@ -1712,7 +2063,7 @@ function normalizeTracks(inputs: readonly unknown[]): Track[] {
1712
2063
  );
1713
2064
  }
1714
2065
  const seen = new Set<string>();
1715
- return inputs.map((input) => {
2066
+ const tracks = inputs.map((input) => {
1716
2067
  const track = normalizeTrack(input);
1717
2068
  if (seen.has(track.id))
1718
2069
  throw new ScoreValidationError(
@@ -1722,6 +2073,22 @@ function normalizeTracks(inputs: readonly unknown[]): Track[] {
1722
2073
  seen.add(track.id);
1723
2074
  return track;
1724
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;
1725
2092
  }
1726
2093
 
1727
2094
  function normalizeTrack(input: unknown): Track {
@@ -1814,7 +2181,26 @@ function normalizeTrack(input: unknown): Track {
1814
2181
  `track ${id} has modal settings but its instrument is "${instrument}"`,
1815
2182
  "invalid-track",
1816
2183
  );
2184
+ const guitar = normalizeGuitar(input.guitar);
2185
+ const wind = fxOrThrow(() => normalizeWind(input.wind));
2186
+ if (wind && instrument !== WIND_INSTRUMENT)
2187
+ throw new ScoreValidationError(
2188
+ `track ${id} has wind settings but its instrument is "${instrument}"`,
2189
+ "invalid-track",
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
+ );
1817
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
+ );
1818
2204
  const rhythm = normalizeRhythm(input.rhythm, id);
1819
2205
  const time = timeOrThrow(
1820
2206
  () => normalizeTrackTime(input.time, `track ${id} time`),
@@ -1835,6 +2221,28 @@ function normalizeTrack(input: unknown): Track {
1835
2221
  () => normalizeTuning(input.tuning, `track ${id} tuning`),
1836
2222
  "invalid-track",
1837
2223
  );
2224
+ let softPedal: readonly PedalEvent[] | undefined;
2225
+ let sostenuto: readonly PedalEvent[] | undefined;
2226
+ try {
2227
+ softPedal = normalizePedal(
2228
+ input.softPedal,
2229
+ SCORE_LIMITS.maxTick,
2230
+ "softPedal",
2231
+ );
2232
+ sostenuto = normalizePedal(
2233
+ input.sostenuto,
2234
+ SCORE_LIMITS.maxTick,
2235
+ "sostenuto",
2236
+ SOSTENUTO_STATES,
2237
+ );
2238
+ } catch (error) {
2239
+ if (error instanceof ExpressionValidationError)
2240
+ throw new ScoreValidationError(
2241
+ `track ${id} ${error.message}`,
2242
+ "invalid-track",
2243
+ );
2244
+ throw error;
2245
+ }
1838
2246
  const string = fxOrThrow(() => normalizeString(input.string));
1839
2247
  // A bare `granular` instrument (set_instrument, `instrument granular`)
1840
2248
  // gets an empty object, so the engine plays its defaults.
@@ -1907,9 +2315,94 @@ function normalizeTrack(input: unknown): Track {
1907
2315
  ...(granular ? { granular } : {}),
1908
2316
  ...(keys ? { keys } : {}),
1909
2317
  ...(modal ? { modal } : {}),
2318
+ ...(softPedal ? { softPedal } : {}),
2319
+ ...(sostenuto ? { sostenuto } : {}),
2320
+ ...(guitar ? { guitar } : {}),
2321
+ ...(wind ? { wind } : {}),
2322
+ ...(sing ? { sing } : {}),
2323
+ ...(clips ? { clips } : {}),
2324
+ ...(takes ? { takes } : {}),
2325
+ ...(vocoder ? { vocoder } : {}),
2326
+ ...(autotune ? { autotune } : {}),
1910
2327
  });
1911
2328
  }
1912
2329
 
2330
+ /** Track.guitar (0.6.1): a tuning name or open-string pitches plus fretting. */
2331
+ export type TrackGuitar = Readonly<{
2332
+ /** A GUITAR_TUNINGS name or 3..12 open-string MIDI pitches, low to high. */
2333
+ tune?: string | readonly number[];
2334
+ /** Capo fret 0..12. */
2335
+ capo?: number;
2336
+ /** Hand stretch in frets 3..6 (default 4). */
2337
+ hand?: number;
2338
+ /** 0 closed shapes .. 1 ringing open strings (default 0.5). */
2339
+ ring?: number;
2340
+ /** Preferred fret position 0..12. */
2341
+ position?: number;
2342
+ }>;
2343
+
2344
+ /** Validates Track.guitar; `{}` and null normalize to absent. */
2345
+ export function normalizeGuitar(input: unknown): TrackGuitar | undefined {
2346
+ if (input === undefined || input === null) return undefined;
2347
+ const fail = (message: string): never => {
2348
+ throw new ScoreValidationError(`track guitar ${message}`, "invalid-track");
2349
+ };
2350
+ if (typeof input !== "object" || Array.isArray(input))
2351
+ fail("must be an object or null");
2352
+ const out: Record<string, unknown> = {};
2353
+ const record = input as Record<string, unknown>;
2354
+ for (const key of Object.keys(record))
2355
+ if (!["tune", "capo", "hand", "ring", "position"].includes(key))
2356
+ fail(`has no field "${key.slice(0, 32)}"`);
2357
+ const tune = record.tune;
2358
+ if (tune !== undefined && tune !== null) {
2359
+ if (typeof tune === "string") {
2360
+ const name = tune.toLowerCase().replace(/[\s_-]/g, "");
2361
+ if (!(GUITAR_TUNING_NAMES as readonly string[]).includes(name))
2362
+ fail(
2363
+ `tune must be one of ${GUITAR_TUNING_NAMES.join(", ")} or a list of pitches`,
2364
+ );
2365
+ out.tune = name;
2366
+ } else if (
2367
+ Array.isArray(tune) &&
2368
+ tune.length >= 3 &&
2369
+ tune.length <= 12 &&
2370
+ tune.every((p) => Number.isInteger(p) && p >= 0 && p <= 127)
2371
+ )
2372
+ out.tune = Object.freeze([...(tune as number[])]);
2373
+ else fail("tune must be a tuning name or 3..12 MIDI pitches");
2374
+ }
2375
+ const int = (key: string, min: number, max: number) => {
2376
+ const value = record[key];
2377
+ if (value === undefined || value === null) return;
2378
+ if (
2379
+ typeof value !== "number" ||
2380
+ !Number.isInteger(value) ||
2381
+ value < min ||
2382
+ value > max
2383
+ )
2384
+ fail(`${key} must be an integer ${min}..${max}`);
2385
+ out[key] = value;
2386
+ };
2387
+ int("capo", 0, 12);
2388
+ int("hand", 3, 6);
2389
+ const ring = record.ring;
2390
+ if (ring !== undefined && ring !== null) {
2391
+ if (
2392
+ typeof ring !== "number" ||
2393
+ !Number.isFinite(ring) ||
2394
+ ring < 0 ||
2395
+ ring > 1
2396
+ )
2397
+ fail("ring must be 0..1");
2398
+ out.ring = Math.round((ring as number) * 1000) / 1000;
2399
+ }
2400
+ int("position", 0, 12);
2401
+ return Object.keys(out).length > 0
2402
+ ? (Object.freeze(out) as TrackGuitar)
2403
+ : undefined;
2404
+ }
2405
+
1913
2406
  export function normalizeRhythm(
1914
2407
  input: unknown,
1915
2408
  trackId: string,
@@ -2032,6 +2525,8 @@ function trackEffectExtras(
2032
2525
  }
2033
2526
 
2034
2527
  const VOICE_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
2528
+ /** A sampler round-robin group name (`SampleRef.rr`). */
2529
+ const SAMPLE_RR_GROUP = /^[A-Za-z0-9_]{1,32}$/;
2035
2530
  const SHA256_HEX = /^[0-9a-f]{64}$/;
2036
2531
 
2037
2532
  /** Validates a `Sampler` from unknown; `undefined`/`null` means none. */
@@ -2117,6 +2612,13 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
2117
2612
  bpm?: number;
2118
2613
  fitmode?: SampleFitMode;
2119
2614
  len?: number;
2615
+ shift?: number;
2616
+ formant?: number;
2617
+ fadeTime?: number;
2618
+ fadeInTime?: number;
2619
+ from?: SampleProvenance;
2620
+ vel?: readonly [number, number];
2621
+ rr?: string;
2120
2622
  } = { src };
2121
2623
  if (input.sha256 !== undefined) {
2122
2624
  if (typeof input.sha256 !== "string" || !SHA256_HEX.test(input.sha256))
@@ -2309,6 +2811,28 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
2309
2811
  );
2310
2812
  ref.len = len;
2311
2813
  }
2814
+ if (input.vel !== undefined) {
2815
+ const vel = input.vel;
2816
+ if (
2817
+ !Array.isArray(vel) ||
2818
+ vel.length !== 2 ||
2819
+ !vel.every((v) => Number.isInteger(v) && v >= 0 && v <= 127) ||
2820
+ vel[0] > vel[1]
2821
+ )
2822
+ throw new ScoreValidationError(
2823
+ `${label} vel must be [lo, hi], MIDI velocities 0..127 with lo ≤ hi`,
2824
+ "invalid-track",
2825
+ );
2826
+ ref.vel = Object.freeze([vel[0] as number, vel[1] as number] as const);
2827
+ }
2828
+ if (input.rr !== undefined) {
2829
+ if (typeof input.rr !== "string" || !SAMPLE_RR_GROUP.test(input.rr))
2830
+ throw new ScoreValidationError(
2831
+ `${label} rr must be a round-robin group name (letters, digits, _; at most 32)`,
2832
+ "invalid-track",
2833
+ );
2834
+ ref.rr = input.rr;
2835
+ }
2312
2836
  if (
2313
2837
  ref.fitmode !== undefined &&
2314
2838
  ref.fitmode !== "repitch" &&
@@ -2320,9 +2844,100 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
2320
2844
  `${label} fitmode ${ref.fitmode} needs bpm, len or fit`,
2321
2845
  "invalid-track",
2322
2846
  );
2847
+ if (input.shift !== undefined)
2848
+ ref.shift = boundedNumber(
2849
+ input.shift,
2850
+ `${label} shift`,
2851
+ -SCORE_LIMITS.maxSampleShift,
2852
+ SCORE_LIMITS.maxSampleShift,
2853
+ );
2854
+ if (input.formant !== undefined)
2855
+ ref.formant = boundedNumber(
2856
+ input.formant,
2857
+ `${label} formant`,
2858
+ -SCORE_LIMITS.maxSampleShift,
2859
+ SCORE_LIMITS.maxSampleShift,
2860
+ );
2861
+ if (input.fadeTime !== undefined)
2862
+ ref.fadeTime = boundedNumber(
2863
+ input.fadeTime,
2864
+ `${label} fadeTime`,
2865
+ 0,
2866
+ SCORE_LIMITS.maxSampleFadeSeconds,
2867
+ );
2868
+ if (input.fadeInTime !== undefined)
2869
+ ref.fadeInTime = boundedNumber(
2870
+ input.fadeInTime,
2871
+ `${label} fadeInTime`,
2872
+ 0,
2873
+ SCORE_LIMITS.maxSampleFadeSeconds,
2874
+ );
2875
+ if (input.from !== undefined)
2876
+ ref.from = normalizeProvenance(input.from, `${label} from`);
2323
2877
  return Object.freeze(ref);
2324
2878
  }
2325
2879
 
2880
+ const PROVENANCE_SOURCE =
2881
+ /^(?:track:[A-Za-z0-9_-]{1,64}|orbit:[0-9]{1,2}|master)$/;
2882
+
2883
+ function normalizeProvenance(input: unknown, label: string): SampleProvenance {
2884
+ if (
2885
+ !isRecord(input) ||
2886
+ typeof input.source !== "string" ||
2887
+ !PROVENANCE_SOURCE.test(input.source) ||
2888
+ typeof input.score !== "string" ||
2889
+ !SHA256_HEX.test(input.score)
2890
+ )
2891
+ throw new ScoreValidationError(
2892
+ `${label} must be { source: track:<id>|orbit:<n>|master, score: <sha256> }`,
2893
+ "invalid-track",
2894
+ );
2895
+ const from: {
2896
+ source: string;
2897
+ section?: string;
2898
+ bars?: [number, number];
2899
+ score: string;
2900
+ } = {
2901
+ source: input.source,
2902
+ score: input.score,
2903
+ };
2904
+ if (input.section !== undefined) {
2905
+ if (
2906
+ typeof input.section !== "string" ||
2907
+ input.section.length === 0 ||
2908
+ input.section.length > 64
2909
+ )
2910
+ throw new ScoreValidationError(
2911
+ `${label} section must be a section name`,
2912
+ "invalid-track",
2913
+ );
2914
+ from.section = input.section;
2915
+ }
2916
+ if (input.bars !== undefined) {
2917
+ const bars = input.bars;
2918
+ if (
2919
+ !Array.isArray(bars) ||
2920
+ bars.length !== 2 ||
2921
+ !bars.every((b) => Number.isInteger(b) && b >= 1 && b <= 100_000) ||
2922
+ bars[0] > bars[1]
2923
+ )
2924
+ throw new ScoreValidationError(
2925
+ `${label} bars must be [a, b] with 1 ≤ a ≤ b`,
2926
+ "invalid-track",
2927
+ );
2928
+ from.bars = [bars[0], bars[1]];
2929
+ }
2930
+ // Key order is fixed so the printed JSON is stable.
2931
+ return Object.freeze({
2932
+ source: from.source,
2933
+ ...(from.section !== undefined ? { section: from.section } : {}),
2934
+ ...(from.bars !== undefined
2935
+ ? { bars: Object.freeze(from.bars) as readonly [number, number] }
2936
+ : {}),
2937
+ score: from.score,
2938
+ });
2939
+ }
2940
+
2326
2941
  /** Prefix of a pack sound reference in `SampleRef.src`. */
2327
2942
  export const PACK_PREFIX = "pack:";
2328
2943
 
@@ -2638,6 +3253,12 @@ function normalizeNote(input: unknown): Note {
2638
3253
  "invalid-note",
2639
3254
  );
2640
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
+ }
2641
3262
  const pitch = input.pitch;
2642
3263
  if (
2643
3264
  typeof pitch !== "number" ||
@@ -2676,6 +3297,11 @@ function normalizeNote(input: unknown): Note {
2676
3297
  () => normalizeNoteCents(input.cents, `note ${id}`),
2677
3298
  "invalid-note",
2678
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);
2679
3305
  return Object.freeze({
2680
3306
  id,
2681
3307
  trackId,
@@ -2685,6 +3311,8 @@ function normalizeNote(input: unknown): Note {
2685
3311
  velocity: input.velocity,
2686
3312
  ...expression,
2687
3313
  ...(cents !== undefined ? { cents } : {}),
3314
+ ...(lyric !== undefined ? { lyric } : {}),
3315
+ ...(vowel !== undefined ? { vowel } : {}),
2688
3316
  });
2689
3317
  }
2690
3318
 
@@ -2703,12 +3331,49 @@ function parseNote(value: unknown): NoteInput {
2703
3331
  function compareNotes(a: Note, b: Note): number {
2704
3332
  return (
2705
3333
  a.startTick - b.startTick ||
2706
- a.trackId.localeCompare(b.trackId) ||
3334
+ compareIds(a.trackId, b.trackId) ||
2707
3335
  a.pitch - b.pitch ||
2708
- a.id.localeCompare(b.id)
3336
+ compareIds(a.id, b.id)
2709
3337
  );
2710
3338
  }
2711
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
+
2712
3377
  function boundedString(
2713
3378
  value: unknown,
2714
3379
  label: string,
@@ -2758,3 +3423,503 @@ function assertNever(value: never): never {
2758
3423
  `unsupported score operation: ${String(value)}`,
2759
3424
  );
2760
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
+ }