@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.
- package/CHANGELOG.md +92 -0
- package/DAWG.md +343 -116
- package/README.md +27 -25
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +182 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +5 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +832 -13
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +276 -3
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +1849 -42
- package/core/sections.ts +292 -26
- package/core/sing.ts +815 -0
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +30 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -7
- package/guides/chords.md +15 -15
- package/guides/effects.md +17 -16
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +12 -6
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +25 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +17 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -13
- package/guides/styles.md +31 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +5 -2
- package/src/agent/agent.ts +126 -14
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +369 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +1 -1
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +12 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +13 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +4 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +108 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +11 -7
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +37 -3
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/doctor.ts +86 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +10 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/effects/chain.ts +3 -1
- package/src/audio/effects/common.ts +43 -0
- package/src/audio/effects/convolution.ts +7 -4
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/engine.ts +160 -28
- package/src/audio/fit.ts +35 -3
- package/src/audio/instrument-check.ts +59 -43
- package/src/audio/instruments.ts +4 -0
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +8 -1
- package/src/audio/keys/engine.ts +13 -1
- package/src/audio/keys/piano.ts +22 -2
- package/src/audio/kits.ts +135 -6
- package/src/audio/live.ts +114 -20
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +30 -2
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +2 -1
- package/src/audio/sampler.ts +85 -4
- package/src/audio/samples.ts +20 -2
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/bow.ts +48 -5
- package/src/audio/strings/engine.ts +8 -2
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +500 -77
- package/src/audio/winds/engine.ts +5 -1
- package/src/audio/winds/trim.ts +28 -4
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +15 -2
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +44 -29
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +1 -1
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +101 -32
- package/src/commands/grammar.ts +558 -0
- package/src/commands/help.ts +610 -380
- package/src/commands/history.ts +18 -0
- package/src/commands/keys.ts +8 -8
- package/src/commands/modal.ts +1 -1
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +130 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sing.ts +478 -0
- package/src/commands/strum.ts +13 -1
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +616 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +2 -2
- package/src/fs/durable.ts +50 -0
- package/src/lang/glossary.ts +493 -0
- package/src/launch-args.ts +163 -0
- package/src/main.ts +1397 -253
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +21 -2
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +20 -1
- package/src/session/daemon.ts +3 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +154 -0
- package/src/session/port.ts +22 -4
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +5 -1
- package/src/session/rebase.ts +18 -5
- package/src/session/receipt.ts +258 -0
- package/src/session/store.ts +65 -43
- package/src/tui/arrange-menu.ts +65 -31
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +18 -13
- package/src/tui/fader.ts +228 -41
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +759 -177
- package/src/tui/modal-menu.ts +6 -6
- package/src/tui/performance-menu.ts +5 -2
- package/src/tui/play-chords.ts +4 -2
- package/src/tui/play-mode.ts +15 -1
- package/src/tui/play-session.ts +47 -7
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +3 -3
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +274 -40
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +144 -0
- package/tui/drawer.ts +70 -22
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +112 -63
- package/tui/guide.ts +42 -4
- package/tui/highway.ts +269 -25
- package/tui/hints.ts +192 -0
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/play-strip.ts +68 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +144 -14
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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` (
|
|
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 (
|
|
1544
|
+
if (
|
|
1545
|
+
from < 0 ||
|
|
1546
|
+
!Number.isInteger(index) ||
|
|
1547
|
+
index < 0 ||
|
|
1548
|
+
index >= score.tracks.length
|
|
1549
|
+
)
|
|
1365
1550
|
throw new ScoreValidationError(
|
|
1366
|
-
|
|
1551
|
+
`moveTrack needs an existing track and an index 0..${score.tracks.length - 1}`,
|
|
1367
1552
|
"invalid-track",
|
|
1368
1553
|
);
|
|
1369
|
-
const to =
|
|
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
|
-
|
|
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
|
|
3334
|
+
compareIds(a.trackId, b.trackId) ||
|
|
3053
3335
|
a.pitch - b.pitch ||
|
|
3054
|
-
a.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
|
+
}
|