@hraness/dawg 0.6.1 → 0.8.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 +118 -0
- package/DAWG.md +454 -140
- package/README.md +30 -26
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +184 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/ids.ts +52 -0
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +8 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/range.ts +581 -0
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +955 -14
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +287 -5
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +2132 -46
- package/core/sections.ts +480 -35
- 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 +31 -0
- package/guides/audio.md +31 -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 +15 -7
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/panes.md +32 -0
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +26 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +18 -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/tape.md +32 -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 +6 -2
- package/src/agent/agent.ts +130 -18
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +378 -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 +27 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +25 -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/range-tools.ts +216 -0
- 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 +110 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +13 -10
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +49 -5
- package/src/audio/audio-command.ts +218 -0
- 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/devices.ts +264 -0
- package/src/audio/doctor.ts +157 -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 +360 -40
- 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 +82 -32
- 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 +560 -0
- package/src/commands/help.ts +774 -366
- package/src/commands/history.ts +139 -10
- package/src/commands/keys.ts +25 -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 +135 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +474 -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 +617 -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/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +511 -0
- package/src/launch-args.ts +267 -0
- package/src/main.ts +2508 -308
- 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/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +199 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +174 -0
- package/src/session/port.ts +109 -14
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +365 -7
- package/src/session/rebase.ts +63 -12
- package/src/session/receipt.ts +265 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +126 -45
- package/src/tui/arrange-menu.ts +225 -23
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +113 -27
- package/src/tui/fader.ts +282 -42
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- 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 +987 -179
- 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 +243 -28
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -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 +491 -84
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +193 -0
- package/tui/drawer.ts +233 -27
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +143 -65
- package/tui/guide.ts +50 -5
- package/tui/highway.ts +309 -30
- package/tui/hints.ts +197 -0
- package/tui/hits.ts +7 -1
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +80 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +151 -18
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +46 -1
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
|
|
@@ -469,6 +555,34 @@ export type Sampler = Readonly<{
|
|
|
469
555
|
mode: "oneshot" | "keyed";
|
|
470
556
|
}>;
|
|
471
557
|
|
|
558
|
+
/**
|
|
559
|
+
* A sample reference as created by capture, import or resample: always
|
|
560
|
+
* content-pinned, so another machine can fetch the exact bytes by hash.
|
|
561
|
+
* Older refs may lack `sha256`; new ones may not.
|
|
562
|
+
*/
|
|
563
|
+
export type PinnedSampleRef = SampleRef & Readonly<{ sha256: string }>;
|
|
564
|
+
|
|
565
|
+
/** Rejects a newly created local sample ref that carries no valid sha256. */
|
|
566
|
+
export function requirePinnedRef(
|
|
567
|
+
ref: SampleRef,
|
|
568
|
+
label: string,
|
|
569
|
+
): PinnedSampleRef {
|
|
570
|
+
if (ref.src.startsWith(PACK_PREFIX)) {
|
|
571
|
+
if (ref.url === undefined && ref.sha256 === undefined)
|
|
572
|
+
throw new ScoreValidationError(
|
|
573
|
+
`${label} pack sound must be pinned by url or sha256`,
|
|
574
|
+
"invalid-track",
|
|
575
|
+
);
|
|
576
|
+
return ref as PinnedSampleRef;
|
|
577
|
+
}
|
|
578
|
+
if (typeof ref.sha256 !== "string" || !SHA256_HEX.test(ref.sha256))
|
|
579
|
+
throw new ScoreValidationError(
|
|
580
|
+
`${label} needs a sha256 content pin`,
|
|
581
|
+
"invalid-track",
|
|
582
|
+
);
|
|
583
|
+
return ref as PinnedSampleRef;
|
|
584
|
+
}
|
|
585
|
+
|
|
472
586
|
export type SampleRef = Readonly<{
|
|
473
587
|
/**
|
|
474
588
|
* Project-relative path, normally `tracks/<slug>/samples/<file>`, or a
|
|
@@ -849,6 +963,11 @@ export type TrackPatch = Readonly<
|
|
|
849
963
|
sostenuto?: Track["sostenuto"] | null;
|
|
850
964
|
guitar?: TrackGuitar | null;
|
|
851
965
|
wind?: TrackWind | null;
|
|
966
|
+
sing?: TrackSing | null;
|
|
967
|
+
clips?: readonly AudioClip[] | null;
|
|
968
|
+
takes?: readonly Take[] | null;
|
|
969
|
+
vocoder?: TrackVocoder | null;
|
|
970
|
+
autotune?: TrackAutotune | null;
|
|
852
971
|
}
|
|
853
972
|
>;
|
|
854
973
|
|
|
@@ -871,6 +990,10 @@ export type Note = Readonly<{
|
|
|
871
990
|
velocity: number;
|
|
872
991
|
/** Static offset from the tuned pitch in cents (±1200); absent is 0. */
|
|
873
992
|
cents?: number;
|
|
993
|
+
/** Optional (0.7): one sung syllable, no whitespace (≤ 32 characters). */
|
|
994
|
+
lyric?: string;
|
|
995
|
+
/** Optional (0.7): the sung vowel, `"a"` or a morph `"a>u"` (sing). */
|
|
996
|
+
vowel?: string;
|
|
874
997
|
}> &
|
|
875
998
|
NoteExpression;
|
|
876
999
|
|
|
@@ -901,6 +1024,11 @@ export type TrackInput = Readonly<
|
|
|
901
1024
|
| "sostenuto"
|
|
902
1025
|
| "guitar"
|
|
903
1026
|
| "wind"
|
|
1027
|
+
| "sing"
|
|
1028
|
+
| "clips"
|
|
1029
|
+
| "takes"
|
|
1030
|
+
| "vocoder"
|
|
1031
|
+
| "autotune"
|
|
904
1032
|
> &
|
|
905
1033
|
Pick<Track, "id"> & {
|
|
906
1034
|
filter?: TrackFilter | null;
|
|
@@ -927,6 +1055,11 @@ export type TrackInput = Readonly<
|
|
|
927
1055
|
sostenuto?: Track["sostenuto"] | null;
|
|
928
1056
|
guitar?: TrackGuitar | null;
|
|
929
1057
|
wind?: TrackWind | null;
|
|
1058
|
+
sing?: TrackSing | null;
|
|
1059
|
+
clips?: readonly AudioClip[] | null;
|
|
1060
|
+
takes?: readonly Take[] | null;
|
|
1061
|
+
vocoder?: TrackVocoder | null;
|
|
1062
|
+
autotune?: TrackAutotune | null;
|
|
930
1063
|
}
|
|
931
1064
|
>;
|
|
932
1065
|
|
|
@@ -944,6 +1077,8 @@ export type NoteInput = Readonly<{
|
|
|
944
1077
|
pitch: number;
|
|
945
1078
|
velocity: number;
|
|
946
1079
|
cents?: number;
|
|
1080
|
+
lyric?: string | null;
|
|
1081
|
+
vowel?: string | null;
|
|
947
1082
|
}> &
|
|
948
1083
|
NoteExpressionPatch;
|
|
949
1084
|
|
|
@@ -997,6 +1132,8 @@ export type TrackScoreData = Readonly<{
|
|
|
997
1132
|
notes?: readonly NoteInput[];
|
|
998
1133
|
/** Song master chain and loudness target (core/master.ts); absent is off. */
|
|
999
1134
|
master?: SongMaster | null;
|
|
1135
|
+
/** Which style and seed made the song (core/style-provenance.ts); absent is none. */
|
|
1136
|
+
style?: SongStyle | null;
|
|
1000
1137
|
/** Song sections (0.5); absent or empty means none. */
|
|
1001
1138
|
sections?: readonly Section[];
|
|
1002
1139
|
/** Song form (0.5): the order sections play in; absent plays the score straight through. */
|
|
@@ -1006,8 +1143,19 @@ export type TrackScoreData = Readonly<{
|
|
|
1006
1143
|
* the song (or its form). A name that matches no section is dropped.
|
|
1007
1144
|
*/
|
|
1008
1145
|
loopSection?: string | null;
|
|
1146
|
+
/**
|
|
1147
|
+
* The loop range (op1-ux): an ad hoc run of bars playback loops, set by
|
|
1148
|
+
* `loop 5-6`. It creates no section; when present it wins over
|
|
1149
|
+
* `loopSection`. Absent or null plays the song.
|
|
1150
|
+
*/
|
|
1151
|
+
loop?: LoopRange | null;
|
|
1152
|
+
/** Sound calibration (0.7), 0..CALIBRATION_LATEST; absent or 0 is legacy. */
|
|
1153
|
+
calibration?: number | null;
|
|
1009
1154
|
}>;
|
|
1010
1155
|
|
|
1156
|
+
/** A loop range: bars `startBar..startBar+bars` (0-based, like sections). */
|
|
1157
|
+
export type LoopRange = Readonly<{ startBar: number; bars: number }>;
|
|
1158
|
+
|
|
1011
1159
|
/** Canonical immutable score. Use `addNote`/`removeNote` to create a revision. */
|
|
1012
1160
|
export class TrackScore {
|
|
1013
1161
|
readonly version = SCORE_VERSION;
|
|
@@ -1023,12 +1171,18 @@ export class TrackScore {
|
|
|
1023
1171
|
readonly notes: readonly Note[];
|
|
1024
1172
|
/** Absent (not undefined-valued) without a master, so 0.4 scores are unchanged. */
|
|
1025
1173
|
declare readonly master?: SongMaster;
|
|
1174
|
+
/** Absent without a style, so older scores are unchanged. */
|
|
1175
|
+
declare readonly style?: SongStyle;
|
|
1026
1176
|
/** Song sections in bar order; empty when the song has none. */
|
|
1027
1177
|
readonly sections: readonly Section[];
|
|
1028
1178
|
/** Song form; empty plays the score straight through. */
|
|
1029
1179
|
readonly form: readonly FormEntry[];
|
|
1030
1180
|
/** The section playback loops; undefined plays the song. */
|
|
1031
1181
|
readonly loopSection: string | undefined;
|
|
1182
|
+
/** The loop range; absent without one (it wins over `loopSection`). */
|
|
1183
|
+
declare readonly loop?: LoopRange;
|
|
1184
|
+
/** Sound calibration revision; absent is 0 (legacy engines). */
|
|
1185
|
+
declare readonly calibration?: number;
|
|
1032
1186
|
|
|
1033
1187
|
constructor(data: TrackScoreData = {}) {
|
|
1034
1188
|
const tempoBpm = data.tempoBpm ?? 120;
|
|
@@ -1092,6 +1246,13 @@ export class TrackScore {
|
|
|
1092
1246
|
);
|
|
1093
1247
|
const tracks = normalizeTracks(data.tracks ?? []);
|
|
1094
1248
|
const notes = normalizeNotes(data.notes ?? []);
|
|
1249
|
+
const trackIds = new Set(tracks.map((track) => track.id));
|
|
1250
|
+
const orphan = notes.find((note) => !trackIds.has(note.trackId));
|
|
1251
|
+
if (orphan)
|
|
1252
|
+
throw new ScoreValidationError(
|
|
1253
|
+
`note ${orphan.id} is on track ${orphan.trackId}, which does not exist`,
|
|
1254
|
+
"invalid-note",
|
|
1255
|
+
);
|
|
1095
1256
|
this.tuning = tuning;
|
|
1096
1257
|
let master: SongMaster | undefined;
|
|
1097
1258
|
try {
|
|
@@ -1102,6 +1263,15 @@ export class TrackScore {
|
|
|
1102
1263
|
throw error;
|
|
1103
1264
|
}
|
|
1104
1265
|
if (master) this.master = master;
|
|
1266
|
+
let style: SongStyle | undefined;
|
|
1267
|
+
try {
|
|
1268
|
+
style = normalizeSongStyle(data.style);
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
if (error instanceof SongStyleError)
|
|
1271
|
+
throw new ScoreValidationError(error.message, "invalid-score");
|
|
1272
|
+
throw error;
|
|
1273
|
+
}
|
|
1274
|
+
if (style) this.style = style;
|
|
1105
1275
|
const sections = normalizeSections(data.sections ?? []);
|
|
1106
1276
|
const form = normalizeForm(data.form ?? [], sections);
|
|
1107
1277
|
// Sections count bars in one meter; meter changes would move them.
|
|
@@ -1123,7 +1293,13 @@ export class TrackScore {
|
|
|
1123
1293
|
this.notes = freezeArray(notes);
|
|
1124
1294
|
this.sections = freezeArray(sections);
|
|
1125
1295
|
this.form = freezeArray(form);
|
|
1126
|
-
|
|
1296
|
+
// One loop at a time: a range wins over a looped section.
|
|
1297
|
+
const loop = normalizeLoopRange(data.loop);
|
|
1298
|
+
const looped = normalizeLoopSection(data.loopSection, sections);
|
|
1299
|
+
this.loopSection = loop ? undefined : looped;
|
|
1300
|
+
if (loop) this.loop = loop;
|
|
1301
|
+
const calibration = normalizeCalibration(data.calibration);
|
|
1302
|
+
if (calibration) this.calibration = calibration;
|
|
1127
1303
|
Object.freeze(this);
|
|
1128
1304
|
}
|
|
1129
1305
|
|
|
@@ -1135,8 +1311,26 @@ export class TrackScore {
|
|
|
1135
1311
|
return removeNote(this, noteId);
|
|
1136
1312
|
}
|
|
1137
1313
|
|
|
1314
|
+
/** The song with `tracks`; notes on a track not listed go with it. */
|
|
1138
1315
|
withTracks(tracks: readonly TrackInput[]): TrackScore {
|
|
1139
|
-
|
|
1316
|
+
const ids = new Set(tracks.map((track) => track.id));
|
|
1317
|
+
return new TrackScore({
|
|
1318
|
+
...this.toJSON(),
|
|
1319
|
+
tracks,
|
|
1320
|
+
notes: this.notes.filter((note) => ids.has(note.trackId)),
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Set the loop range (or clear it with null). A range clears the looped
|
|
1326
|
+
* section, so one loop plays at a time.
|
|
1327
|
+
*/
|
|
1328
|
+
withLoop(loop: LoopRange | null): TrackScore {
|
|
1329
|
+
return new TrackScore({
|
|
1330
|
+
...this.toJSON(),
|
|
1331
|
+
loop,
|
|
1332
|
+
...(loop ? { loopSection: null } : {}),
|
|
1333
|
+
});
|
|
1140
1334
|
}
|
|
1141
1335
|
|
|
1142
1336
|
withTempo(tempoBpm: number): TrackScore {
|
|
@@ -1157,12 +1351,25 @@ export class TrackScore {
|
|
|
1157
1351
|
sections,
|
|
1158
1352
|
form,
|
|
1159
1353
|
loopSection: loopSection ?? null,
|
|
1354
|
+
// Looping a section ends the loop range.
|
|
1355
|
+
...(loopSection ? { loop: null } : {}),
|
|
1160
1356
|
});
|
|
1161
1357
|
}
|
|
1162
1358
|
|
|
1163
1359
|
/** Resize the loop without discarding notes or automation outside its bounds. */
|
|
1360
|
+
/** A shorter song drops the time marks past its new end. */
|
|
1164
1361
|
withBars(bars: number): TrackScore {
|
|
1165
|
-
|
|
1362
|
+
const time =
|
|
1363
|
+
bars < this.bars
|
|
1364
|
+
? timeWithinSong({
|
|
1365
|
+
tempoBpm: this.tempoBpm,
|
|
1366
|
+
beatsPerBar: this.beatsPerBar,
|
|
1367
|
+
ticksPerBeat: this.ticksPerBeat,
|
|
1368
|
+
bars,
|
|
1369
|
+
...(this.time ? { time: this.time } : {}),
|
|
1370
|
+
})
|
|
1371
|
+
: this.time;
|
|
1372
|
+
return new TrackScore({ ...this.toJSON(), bars, time: time ?? null });
|
|
1166
1373
|
}
|
|
1167
1374
|
|
|
1168
1375
|
withKey(key: string | null): TrackScore {
|
|
@@ -1196,10 +1403,20 @@ export class TrackScore {
|
|
|
1196
1403
|
}
|
|
1197
1404
|
|
|
1198
1405
|
/** Replace the song master; `null` removes it (bypass). */
|
|
1406
|
+
/** Set the sound calibration revision; 0 or null is legacy. */
|
|
1407
|
+
withCalibration(calibration: number | null): TrackScore {
|
|
1408
|
+
return new TrackScore({ ...this.toJSON(), calibration });
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1199
1411
|
withMaster(master: SongMaster | null): TrackScore {
|
|
1200
1412
|
return new TrackScore({ ...this.toJSON(), master });
|
|
1201
1413
|
}
|
|
1202
1414
|
|
|
1415
|
+
/** Record (or clear, `null`) which style and seed made the song. */
|
|
1416
|
+
withStyle(style: SongStyle | null): TrackScore {
|
|
1417
|
+
return new TrackScore({ ...this.toJSON(), style });
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1203
1420
|
toJSON(): TrackScoreData & { version: typeof SCORE_VERSION } {
|
|
1204
1421
|
return {
|
|
1205
1422
|
version: SCORE_VERSION,
|
|
@@ -1213,11 +1430,14 @@ export class TrackScore {
|
|
|
1213
1430
|
tracks: this.tracks,
|
|
1214
1431
|
notes: this.notes,
|
|
1215
1432
|
...(this.master ? { master: this.master } : {}),
|
|
1433
|
+
...(this.style ? { style: this.style } : {}),
|
|
1216
1434
|
...(this.sections.length > 0 ? { sections: this.sections } : {}),
|
|
1217
1435
|
...(this.form.length > 0 ? { form: this.form } : {}),
|
|
1218
1436
|
...(this.loopSection === undefined
|
|
1219
1437
|
? {}
|
|
1220
1438
|
: { loopSection: this.loopSection }),
|
|
1439
|
+
...(this.loop ? { loop: this.loop } : {}),
|
|
1440
|
+
...(this.calibration ? { calibration: this.calibration } : {}),
|
|
1221
1441
|
};
|
|
1222
1442
|
}
|
|
1223
1443
|
}
|
|
@@ -1284,6 +1504,7 @@ export type NotePatch = Readonly<
|
|
|
1284
1504
|
Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity" | "cents">
|
|
1285
1505
|
>
|
|
1286
1506
|
> &
|
|
1507
|
+
Readonly<{ lyric?: string | null; vowel?: string | null }> &
|
|
1287
1508
|
NoteExpressionPatch;
|
|
1288
1509
|
|
|
1289
1510
|
export function updateNote(
|
|
@@ -1292,7 +1513,10 @@ export function updateNote(
|
|
|
1292
1513
|
patch: NotePatch,
|
|
1293
1514
|
): TrackScore {
|
|
1294
1515
|
const current = score.notes.find((note) => note.id === noteId);
|
|
1295
|
-
|
|
1516
|
+
// A patch for a missing note is a conflict (a stale or diverged score),
|
|
1517
|
+
// never a silent no-op. removeNote stays idempotent: the note is gone.
|
|
1518
|
+
if (!current)
|
|
1519
|
+
throw new ScoreValidationError(`unknown note: ${noteId}`, "invalid-note");
|
|
1296
1520
|
return new TrackScore({
|
|
1297
1521
|
...score.toJSON(),
|
|
1298
1522
|
notes: score.notes.map((note) =>
|
|
@@ -1331,6 +1555,9 @@ export function updateTrack(
|
|
|
1331
1555
|
// (`wind` included) without a wind field leaves the wind engine.
|
|
1332
1556
|
if (patch.instrument !== undefined && patch.wind === undefined)
|
|
1333
1557
|
delete next.wind;
|
|
1558
|
+
// Likewise a sing field leaves with the singing instrument.
|
|
1559
|
+
if (patch.instrument !== undefined && patch.sing === undefined)
|
|
1560
|
+
delete next.sing;
|
|
1334
1561
|
// Keys settings only belong to a keys family (piano, electric, organ):
|
|
1335
1562
|
// switching to another instrument drops them unless the patch sets them.
|
|
1336
1563
|
if (
|
|
@@ -1339,6 +1566,15 @@ export function updateTrack(
|
|
|
1339
1566
|
!isKeysFamily(patch.instrument)
|
|
1340
1567
|
)
|
|
1341
1568
|
delete next.keys;
|
|
1569
|
+
// f07-vocoder: naming the `vocoder` instrument turns on its built-in
|
|
1570
|
+
// carrier (an empty field keeps every default); a vocoder stage the
|
|
1571
|
+
// track already has (its source) stays.
|
|
1572
|
+
if (
|
|
1573
|
+
patch.instrument === VOCODER_INSTRUMENT &&
|
|
1574
|
+
patch.vocoder === undefined &&
|
|
1575
|
+
next.vocoder === undefined
|
|
1576
|
+
)
|
|
1577
|
+
next.vocoder = Object.freeze({});
|
|
1342
1578
|
return next as Track;
|
|
1343
1579
|
}),
|
|
1344
1580
|
);
|
|
@@ -1349,24 +1585,31 @@ export function removeTrack(score: TrackScore, trackId: string): TrackScore {
|
|
|
1349
1585
|
if (!score.tracks.some((track) => track.id === trackId)) return score;
|
|
1350
1586
|
return new TrackScore({
|
|
1351
1587
|
...score.toJSON(),
|
|
1352
|
-
tracks: score.tracks
|
|
1588
|
+
tracks: score.tracks
|
|
1589
|
+
.filter((track) => track.id !== trackId)
|
|
1590
|
+
.map((track) => dropTrackRefs(track, trackId)),
|
|
1353
1591
|
notes: score.notes.filter((note) => note.trackId !== trackId),
|
|
1354
1592
|
});
|
|
1355
1593
|
}
|
|
1356
1594
|
|
|
1357
|
-
/** Moves a track to position `index` (
|
|
1595
|
+
/** Moves a track to position `index` (0 .. tracks - 1) in score order. */
|
|
1358
1596
|
export function moveTrack(
|
|
1359
1597
|
score: TrackScore,
|
|
1360
1598
|
trackId: string,
|
|
1361
1599
|
index: number,
|
|
1362
1600
|
): TrackScore {
|
|
1363
1601
|
const from = score.tracks.findIndex((track) => track.id === trackId);
|
|
1364
|
-
if (
|
|
1602
|
+
if (
|
|
1603
|
+
from < 0 ||
|
|
1604
|
+
!Number.isInteger(index) ||
|
|
1605
|
+
index < 0 ||
|
|
1606
|
+
index >= score.tracks.length
|
|
1607
|
+
)
|
|
1365
1608
|
throw new ScoreValidationError(
|
|
1366
|
-
|
|
1609
|
+
`moveTrack needs an existing track and an index 0..${score.tracks.length - 1}`,
|
|
1367
1610
|
"invalid-track",
|
|
1368
1611
|
);
|
|
1369
|
-
const to =
|
|
1612
|
+
const to = index;
|
|
1370
1613
|
if (from === to) return score;
|
|
1371
1614
|
const tracks = [...score.tracks];
|
|
1372
1615
|
const [moved] = tracks.splice(from, 1);
|
|
@@ -1529,12 +1772,33 @@ export type ScoreOperation =
|
|
|
1529
1772
|
type: "setMaster";
|
|
1530
1773
|
master: SongMaster | null;
|
|
1531
1774
|
}>
|
|
1775
|
+
| Readonly<{
|
|
1776
|
+
/** Sound calibration revision (0.7); 0 or null is legacy. */
|
|
1777
|
+
type: "setCalibration";
|
|
1778
|
+
calibration: number | null;
|
|
1779
|
+
}>
|
|
1780
|
+
| Readonly<{
|
|
1781
|
+
/** Which style and seed made the song (quality-08); null clears it. */
|
|
1782
|
+
type: "setStyle";
|
|
1783
|
+
style: SongStyle | null;
|
|
1784
|
+
}>
|
|
1785
|
+
| Readonly<{
|
|
1786
|
+
/** Replaces a track's audio clips (0.7); null clears them. */
|
|
1787
|
+
type: "setClips";
|
|
1788
|
+
trackId: string;
|
|
1789
|
+
clips: readonly AudioClip[] | null;
|
|
1790
|
+
}>
|
|
1532
1791
|
| Readonly<{
|
|
1533
1792
|
type: "setSections";
|
|
1534
1793
|
sections: readonly Section[];
|
|
1535
1794
|
form: readonly FormEntry[];
|
|
1536
1795
|
/** The looped section; absent or null plays the song. */
|
|
1537
1796
|
loopSection?: string | null;
|
|
1797
|
+
}>
|
|
1798
|
+
| Readonly<{
|
|
1799
|
+
/** The loop range (op1-ux); null clears it. */
|
|
1800
|
+
type: "setLoop";
|
|
1801
|
+
loop: LoopRange | null;
|
|
1538
1802
|
}>;
|
|
1539
1803
|
|
|
1540
1804
|
export function applyScoreOperation(
|
|
@@ -1551,12 +1815,16 @@ export function applyScoreOperation(
|
|
|
1551
1815
|
if (operation.type === "setTime") return score.withTime(operation.time);
|
|
1552
1816
|
if (operation.type === "setTuning") return score.withTuning(operation.tuning);
|
|
1553
1817
|
if (operation.type === "setMaster") return score.withMaster(operation.master);
|
|
1818
|
+
if (operation.type === "setCalibration")
|
|
1819
|
+
return score.withCalibration(operation.calibration);
|
|
1820
|
+
if (operation.type === "setStyle") return score.withStyle(operation.style);
|
|
1554
1821
|
if (operation.type === "setSections")
|
|
1555
1822
|
return score.withSections(
|
|
1556
1823
|
operation.sections,
|
|
1557
1824
|
operation.form,
|
|
1558
1825
|
operation.loopSection ?? null,
|
|
1559
1826
|
);
|
|
1827
|
+
if (operation.type === "setLoop") return score.withLoop(operation.loop);
|
|
1560
1828
|
if (operation.type === "addTrack") return addTrack(score, operation.track);
|
|
1561
1829
|
if (operation.type === "addNote") return addNote(score, operation.note);
|
|
1562
1830
|
if (operation.type === "removeNote")
|
|
@@ -1576,6 +1844,8 @@ export function applyScoreOperation(
|
|
|
1576
1844
|
);
|
|
1577
1845
|
if (operation.type === "clearTrack")
|
|
1578
1846
|
return clearTrack(score, operation.trackId);
|
|
1847
|
+
if (operation.type === "setClips")
|
|
1848
|
+
return setClips(score, operation.trackId, operation.clips);
|
|
1579
1849
|
return assertNever(operation);
|
|
1580
1850
|
}
|
|
1581
1851
|
|
|
@@ -1606,13 +1876,23 @@ export function scoreFromJSON(value: unknown): TrackScore {
|
|
|
1606
1876
|
tracks: readonly TrackInput[];
|
|
1607
1877
|
notes: readonly NoteInput[];
|
|
1608
1878
|
master?: SongMaster;
|
|
1879
|
+
style?: SongStyle;
|
|
1609
1880
|
sections?: readonly Section[];
|
|
1610
1881
|
form?: readonly FormEntry[];
|
|
1611
1882
|
loopSection?: string | null;
|
|
1883
|
+
loop?: LoopRange | null;
|
|
1884
|
+
calibration?: number | null;
|
|
1612
1885
|
} = {
|
|
1613
1886
|
tracks: optionalArray(value.tracks).map(parseTrack),
|
|
1614
1887
|
notes: optionalArray(value.notes).map(parseNote),
|
|
1615
1888
|
};
|
|
1889
|
+
// Files written before 0.7 could hold notes on a track that no longer
|
|
1890
|
+
// exists. They never sounded and no edit could reach them, so they go.
|
|
1891
|
+
// They are still checked like any note first.
|
|
1892
|
+
const trackIds = new Set(data.tracks.map((track) => track.id));
|
|
1893
|
+
data.notes = normalizeNotes(data.notes).filter((note) =>
|
|
1894
|
+
trackIds.has(note.trackId),
|
|
1895
|
+
);
|
|
1616
1896
|
const tempoBpm = optionalNumber(value.tempoBpm);
|
|
1617
1897
|
const beatsPerBar = optionalNumber(value.beatsPerBar);
|
|
1618
1898
|
const bars = optionalNumber(value.bars);
|
|
@@ -1629,6 +1909,9 @@ export function scoreFromJSON(value: unknown): TrackScore {
|
|
|
1629
1909
|
// Validated by the constructor (core/master.ts normalizeMaster).
|
|
1630
1910
|
if (value.master !== undefined && value.master !== null)
|
|
1631
1911
|
data.master = value.master as SongMaster;
|
|
1912
|
+
// Checked by the constructor (core/style-provenance.ts).
|
|
1913
|
+
if (value.style !== undefined && value.style !== null)
|
|
1914
|
+
data.style = value.style as SongStyle;
|
|
1632
1915
|
// Shapes are checked by the constructor (normalizeSections, normalizeForm).
|
|
1633
1916
|
if (value.sections !== undefined)
|
|
1634
1917
|
data.sections = optionalArray(value.sections) as readonly Section[];
|
|
@@ -1637,6 +1920,11 @@ export function scoreFromJSON(value: unknown): TrackScore {
|
|
|
1637
1920
|
// The constructor checks the type and drops a name matching no section.
|
|
1638
1921
|
if (value.loopSection !== undefined)
|
|
1639
1922
|
data.loopSection = value.loopSection as string | null;
|
|
1923
|
+
if (value.loop !== undefined) data.loop = value.loop as LoopRange | null;
|
|
1924
|
+
migrateHiddenLoop(data);
|
|
1925
|
+
// The constructor checks the range.
|
|
1926
|
+
if (value.calibration !== undefined)
|
|
1927
|
+
data.calibration = value.calibration as number | null;
|
|
1640
1928
|
return new TrackScore(data);
|
|
1641
1929
|
}
|
|
1642
1930
|
|
|
@@ -1766,6 +2054,76 @@ function normalizeSections(inputs: readonly unknown[]): Section[] {
|
|
|
1766
2054
|
.map(({ section }) => section);
|
|
1767
2055
|
}
|
|
1768
2056
|
|
|
2057
|
+
function normalizeCalibration(value: unknown): number | undefined {
|
|
2058
|
+
if (value === undefined || value === null || value === 0) return undefined;
|
|
2059
|
+
if (
|
|
2060
|
+
typeof value !== "number" ||
|
|
2061
|
+
!Number.isInteger(value) ||
|
|
2062
|
+
value < 0 ||
|
|
2063
|
+
value > CALIBRATION_LATEST
|
|
2064
|
+
)
|
|
2065
|
+
throw new ScoreValidationError(
|
|
2066
|
+
`calibration must be an integer 0..${CALIBRATION_LATEST}`,
|
|
2067
|
+
"invalid-score",
|
|
2068
|
+
);
|
|
2069
|
+
return value;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/** A loop range, checked: whole bars from 0, at least one, inside the limit. */
|
|
2073
|
+
function normalizeLoopRange(value: unknown): LoopRange | undefined {
|
|
2074
|
+
if (value === undefined || value === null) return undefined;
|
|
2075
|
+
const range = value as { startBar?: unknown; bars?: unknown };
|
|
2076
|
+
const { startBar, bars } = range;
|
|
2077
|
+
if (
|
|
2078
|
+
typeof value !== "object" ||
|
|
2079
|
+
typeof startBar !== "number" ||
|
|
2080
|
+
typeof bars !== "number" ||
|
|
2081
|
+
!Number.isInteger(startBar) ||
|
|
2082
|
+
!Number.isInteger(bars) ||
|
|
2083
|
+
startBar < 0 ||
|
|
2084
|
+
bars < 1 ||
|
|
2085
|
+
startBar + bars > SCORE_LIMITS.maxBars
|
|
2086
|
+
)
|
|
2087
|
+
throw new ScoreValidationError(
|
|
2088
|
+
`loop must be {startBar, bars}: whole bars from 0, 1..${SCORE_LIMITS.maxBars}`,
|
|
2089
|
+
"invalid-score",
|
|
2090
|
+
);
|
|
2091
|
+
return Object.freeze({ startBar, bars });
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/**
|
|
2095
|
+
* f07 stored `loop 5-6` as a hidden section named `loop`. On load it becomes
|
|
2096
|
+
* the loop range and the section (and its form entries) go.
|
|
2097
|
+
*/
|
|
2098
|
+
function migrateHiddenLoop(data: {
|
|
2099
|
+
sections?: readonly Section[];
|
|
2100
|
+
form?: readonly FormEntry[];
|
|
2101
|
+
loopSection?: string | null;
|
|
2102
|
+
loop?: LoopRange | null;
|
|
2103
|
+
}): void {
|
|
2104
|
+
if (data.loop || typeof data.loopSection !== "string") return;
|
|
2105
|
+
if (data.loopSection.trim().toLowerCase() !== "loop") return;
|
|
2106
|
+
const sections = data.sections ?? [];
|
|
2107
|
+
const hidden = sections.find(
|
|
2108
|
+
(section) =>
|
|
2109
|
+
typeof section?.name === "string" &&
|
|
2110
|
+
section.name.trim().toLowerCase() === "loop",
|
|
2111
|
+
);
|
|
2112
|
+
if (
|
|
2113
|
+
!hidden ||
|
|
2114
|
+
!Number.isInteger(hidden.startBar) ||
|
|
2115
|
+
!Number.isInteger(hidden.bars)
|
|
2116
|
+
)
|
|
2117
|
+
return;
|
|
2118
|
+
data.loop = { startBar: hidden.startBar, bars: hidden.bars };
|
|
2119
|
+
data.loopSection = null;
|
|
2120
|
+
data.sections = sections.filter((section) => section !== hidden);
|
|
2121
|
+
if (data.form)
|
|
2122
|
+
data.form = data.form.filter(
|
|
2123
|
+
(entry) => entry?.section?.trim?.().toLowerCase() !== "loop",
|
|
2124
|
+
);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
1769
2127
|
function normalizeLoopSection(
|
|
1770
2128
|
value: unknown,
|
|
1771
2129
|
sections: readonly Section[],
|
|
@@ -1827,7 +2185,7 @@ function normalizeTracks(inputs: readonly unknown[]): Track[] {
|
|
|
1827
2185
|
);
|
|
1828
2186
|
}
|
|
1829
2187
|
const seen = new Set<string>();
|
|
1830
|
-
|
|
2188
|
+
const tracks = inputs.map((input) => {
|
|
1831
2189
|
const track = normalizeTrack(input);
|
|
1832
2190
|
if (seen.has(track.id))
|
|
1833
2191
|
throw new ScoreValidationError(
|
|
@@ -1837,6 +2195,22 @@ function normalizeTracks(inputs: readonly unknown[]): Track[] {
|
|
|
1837
2195
|
seen.add(track.id);
|
|
1838
2196
|
return track;
|
|
1839
2197
|
});
|
|
2198
|
+
const vocoderError = vocoderScoreError(tracks, SCORE_LIMITS.maxVocoderTracks);
|
|
2199
|
+
if (vocoderError)
|
|
2200
|
+
throw new ScoreValidationError(vocoderError, "invalid-track");
|
|
2201
|
+
// Cross-track references (0.7): autotune.from names another track.
|
|
2202
|
+
for (const track of tracks) {
|
|
2203
|
+
const from = track.autotune?.from;
|
|
2204
|
+
if (from === undefined) continue;
|
|
2205
|
+
if (from === track.id || !seen.has(from))
|
|
2206
|
+
throw new ScoreValidationError(
|
|
2207
|
+
from === track.id
|
|
2208
|
+
? `track ${track.id} autotune.from cannot be the track itself`
|
|
2209
|
+
: `track ${track.id} autotune.from: unknown track "${from}"`,
|
|
2210
|
+
"invalid-track",
|
|
2211
|
+
);
|
|
2212
|
+
}
|
|
2213
|
+
return tracks;
|
|
1840
2214
|
}
|
|
1841
2215
|
|
|
1842
2216
|
function normalizeTrack(input: unknown): Track {
|
|
@@ -1936,7 +2310,19 @@ function normalizeTrack(input: unknown): Track {
|
|
|
1936
2310
|
`track ${id} has wind settings but its instrument is "${instrument}"`,
|
|
1937
2311
|
"invalid-track",
|
|
1938
2312
|
);
|
|
2313
|
+
const sing = fxOrThrow(() => normalizeSing(input.sing));
|
|
2314
|
+
if (sing && instrument !== SING_INSTRUMENT)
|
|
2315
|
+
throw new ScoreValidationError(
|
|
2316
|
+
`track ${id} has sing settings but its instrument is "${instrument}"`,
|
|
2317
|
+
"invalid-track",
|
|
2318
|
+
);
|
|
1939
2319
|
const sampler = normalizeSampler(input.sampler);
|
|
2320
|
+
const takes = normalizeTakes(input.takes, id);
|
|
2321
|
+
const vocoder = fxOrThrow(() => normalizeVocoder(input.vocoder));
|
|
2322
|
+
const clips = normalizeClips(input.clips, id, takes);
|
|
2323
|
+
const autotune = fxOrThrow(() =>
|
|
2324
|
+
normalizeAutotune(input.autotune, instrument),
|
|
2325
|
+
);
|
|
1940
2326
|
const rhythm = normalizeRhythm(input.rhythm, id);
|
|
1941
2327
|
const time = timeOrThrow(
|
|
1942
2328
|
() => normalizeTrackTime(input.time, `track ${id} time`),
|
|
@@ -2055,6 +2441,11 @@ function normalizeTrack(input: unknown): Track {
|
|
|
2055
2441
|
...(sostenuto ? { sostenuto } : {}),
|
|
2056
2442
|
...(guitar ? { guitar } : {}),
|
|
2057
2443
|
...(wind ? { wind } : {}),
|
|
2444
|
+
...(sing ? { sing } : {}),
|
|
2445
|
+
...(clips ? { clips } : {}),
|
|
2446
|
+
...(takes ? { takes } : {}),
|
|
2447
|
+
...(vocoder ? { vocoder } : {}),
|
|
2448
|
+
...(autotune ? { autotune } : {}),
|
|
2058
2449
|
});
|
|
2059
2450
|
}
|
|
2060
2451
|
|
|
@@ -2984,6 +3375,12 @@ function normalizeNote(input: unknown): Note {
|
|
|
2984
3375
|
"invalid-note",
|
|
2985
3376
|
);
|
|
2986
3377
|
}
|
|
3378
|
+
if (startTick + durationTicks > SCORE_LIMITS.maxTick) {
|
|
3379
|
+
throw new ScoreValidationError(
|
|
3380
|
+
`note ${id} must end by tick ${SCORE_LIMITS.maxTick}`,
|
|
3381
|
+
"invalid-note",
|
|
3382
|
+
);
|
|
3383
|
+
}
|
|
2987
3384
|
const pitch = input.pitch;
|
|
2988
3385
|
if (
|
|
2989
3386
|
typeof pitch !== "number" ||
|
|
@@ -3022,6 +3419,11 @@ function normalizeNote(input: unknown): Note {
|
|
|
3022
3419
|
() => normalizeNoteCents(input.cents, `note ${id}`),
|
|
3023
3420
|
"invalid-note",
|
|
3024
3421
|
);
|
|
3422
|
+
const lyric = normalizeLyric(input.lyric, id);
|
|
3423
|
+
const vowel =
|
|
3424
|
+
input.vowel === undefined || input.vowel === null
|
|
3425
|
+
? undefined
|
|
3426
|
+
: vowelOrThrow(input.vowel, id);
|
|
3025
3427
|
return Object.freeze({
|
|
3026
3428
|
id,
|
|
3027
3429
|
trackId,
|
|
@@ -3031,6 +3433,8 @@ function normalizeNote(input: unknown): Note {
|
|
|
3031
3433
|
velocity: input.velocity,
|
|
3032
3434
|
...expression,
|
|
3033
3435
|
...(cents !== undefined ? { cents } : {}),
|
|
3436
|
+
...(lyric !== undefined ? { lyric } : {}),
|
|
3437
|
+
...(vowel !== undefined ? { vowel } : {}),
|
|
3034
3438
|
});
|
|
3035
3439
|
}
|
|
3036
3440
|
|
|
@@ -3049,12 +3453,49 @@ function parseNote(value: unknown): NoteInput {
|
|
|
3049
3453
|
function compareNotes(a: Note, b: Note): number {
|
|
3050
3454
|
return (
|
|
3051
3455
|
a.startTick - b.startTick ||
|
|
3052
|
-
a.trackId
|
|
3456
|
+
compareIds(a.trackId, b.trackId) ||
|
|
3053
3457
|
a.pitch - b.pitch ||
|
|
3054
|
-
a.id
|
|
3458
|
+
compareIds(a.id, b.id)
|
|
3055
3459
|
);
|
|
3056
3460
|
}
|
|
3057
3461
|
|
|
3462
|
+
/**
|
|
3463
|
+
* Printable ASCII in the order ICU's root collation gives it (uppercase
|
|
3464
|
+
* letters share their lowercase rank and sort after them on a tie).
|
|
3465
|
+
*/
|
|
3466
|
+
const ID_COLLATION =
|
|
3467
|
+
" _-,;:!?.'\"()[]{}@*/\\&#%`^+<=>|~$0123456789abcdefghijklmnopqrstuvwxyz";
|
|
3468
|
+
const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
|
|
3469
|
+
|
|
3470
|
+
/**
|
|
3471
|
+
* Canonical id order, independent of locale and ICU version. Printable
|
|
3472
|
+
* ASCII ids keep the order `localeCompare` gave them before 0.7 (so older
|
|
3473
|
+
* projects print and render byte-identically); any other id sorts after
|
|
3474
|
+
* them by UTF-16 code units, so canonically equivalent spellings stay
|
|
3475
|
+
* distinct and the order is total.
|
|
3476
|
+
*/
|
|
3477
|
+
export function compareIds(a: string, b: string): number {
|
|
3478
|
+
if (a === b) return 0;
|
|
3479
|
+
const asciiA = PRINTABLE_ASCII.test(a);
|
|
3480
|
+
const asciiB = PRINTABLE_ASCII.test(b);
|
|
3481
|
+
if (asciiA !== asciiB) return asciiA ? -1 : 1;
|
|
3482
|
+
if (!asciiA) return a < b ? -1 : 1;
|
|
3483
|
+
const length = Math.min(a.length, b.length);
|
|
3484
|
+
for (let i = 0; i < length; i += 1) {
|
|
3485
|
+
const rank =
|
|
3486
|
+
ID_COLLATION.indexOf(a[i]!.toLowerCase()) -
|
|
3487
|
+
ID_COLLATION.indexOf(b[i]!.toLowerCase());
|
|
3488
|
+
if (rank !== 0) return rank;
|
|
3489
|
+
}
|
|
3490
|
+
if (a.length !== b.length) return a.length - b.length;
|
|
3491
|
+
for (let i = 0; i < length; i += 1) {
|
|
3492
|
+
if (a[i] === b[i]) continue;
|
|
3493
|
+
// Same letter, different case: lowercase first.
|
|
3494
|
+
return a[i] === a[i]!.toLowerCase() ? -1 : 1;
|
|
3495
|
+
}
|
|
3496
|
+
return 0;
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3058
3499
|
function boundedString(
|
|
3059
3500
|
value: unknown,
|
|
3060
3501
|
label: string,
|
|
@@ -3104,3 +3545,503 @@ function assertNever(value: never): never {
|
|
|
3104
3545
|
`unsupported score operation: ${String(value)}`,
|
|
3105
3546
|
);
|
|
3106
3547
|
}
|
|
3548
|
+
|
|
3549
|
+
// ---------------------------------------------------------------------------
|
|
3550
|
+
// 0.7 Voice contract: audio clips, takes and lyrics (shapes only). The
|
|
3551
|
+
// renderer ignores these fields until the clips lane wires them up.
|
|
3552
|
+
// ---------------------------------------------------------------------------
|
|
3553
|
+
|
|
3554
|
+
/** How a text-to-speech clip was made and where its syllables fall (0.7.1). */
|
|
3555
|
+
export type ClipSay = Readonly<{
|
|
3556
|
+
engine: "say" | "espeak-ng" | "piper";
|
|
3557
|
+
/** Stock voice name, at most 64 characters. */
|
|
3558
|
+
voice: string;
|
|
3559
|
+
/** Words per minute 80..400 (default 180). */
|
|
3560
|
+
rate?: number;
|
|
3561
|
+
/** Licence of the voice output, recorded with the clip. */
|
|
3562
|
+
license: string;
|
|
3563
|
+
/** Syllable starts in seconds, strictly increasing. */
|
|
3564
|
+
cuts: readonly number[];
|
|
3565
|
+
/** Voicing onsets in seconds (= cuts for unvoiced syllables). */
|
|
3566
|
+
vowels: readonly number[];
|
|
3567
|
+
/** Indices of unvoiced syllables. */
|
|
3568
|
+
unvoiced?: readonly number[];
|
|
3569
|
+
/** Octave shift applied to targets, -3..3. */
|
|
3570
|
+
oct?: number;
|
|
3571
|
+
/** 0..1 share of the spoken contour kept. */
|
|
3572
|
+
soft?: number;
|
|
3573
|
+
/** The pinned sung buffer. */
|
|
3574
|
+
sung?: Readonly<{ src: string; sha256: string; key: string }>;
|
|
3575
|
+
}>;
|
|
3576
|
+
|
|
3577
|
+
/** An audio file placed on a track's timeline (0.7). Times in seconds. */
|
|
3578
|
+
export type AudioClip = Readonly<{
|
|
3579
|
+
id: string;
|
|
3580
|
+
src: string;
|
|
3581
|
+
sha256: string;
|
|
3582
|
+
startTick: number;
|
|
3583
|
+
offset?: number;
|
|
3584
|
+
dur?: number;
|
|
3585
|
+
gain?: number;
|
|
3586
|
+
fadeInTime?: number;
|
|
3587
|
+
fadeTime?: number;
|
|
3588
|
+
rev?: boolean;
|
|
3589
|
+
/** Name of the track's take this clip plays from. */
|
|
3590
|
+
take?: string;
|
|
3591
|
+
mute?: boolean;
|
|
3592
|
+
text?: string;
|
|
3593
|
+
say?: ClipSay;
|
|
3594
|
+
}>;
|
|
3595
|
+
|
|
3596
|
+
/** A recorded or imported take (0.7 shape; recording is 0.7.1). */
|
|
3597
|
+
export type Take = Readonly<{
|
|
3598
|
+
name: string;
|
|
3599
|
+
src: string;
|
|
3600
|
+
sha256: string;
|
|
3601
|
+
startTick: number;
|
|
3602
|
+
/** Seconds into the file where the take starts. */
|
|
3603
|
+
offset: number;
|
|
3604
|
+
/** Round-trip latency compensated, in seconds. */
|
|
3605
|
+
latency: number;
|
|
3606
|
+
latencyAssumed?: boolean;
|
|
3607
|
+
/** Clock drift in parts per million (±1000). */
|
|
3608
|
+
ppm?: number;
|
|
3609
|
+
/** 0..1 fit of the alignment. */
|
|
3610
|
+
fit?: number;
|
|
3611
|
+
warn?: string;
|
|
3612
|
+
/** Manual nudge in milliseconds (±250). */
|
|
3613
|
+
nudge?: number;
|
|
3614
|
+
inTick: number;
|
|
3615
|
+
outTick: number;
|
|
3616
|
+
}>;
|
|
3617
|
+
|
|
3618
|
+
const MAX_CLIP_LABEL_LENGTH = 128;
|
|
3619
|
+
|
|
3620
|
+
function clipError(message: string): ScoreValidationError {
|
|
3621
|
+
return new ScoreValidationError(message, "invalid-track");
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
function clipNumber(
|
|
3625
|
+
value: unknown,
|
|
3626
|
+
label: string,
|
|
3627
|
+
min: number,
|
|
3628
|
+
max: number,
|
|
3629
|
+
): number | undefined {
|
|
3630
|
+
if (value === undefined) return undefined;
|
|
3631
|
+
if (
|
|
3632
|
+
typeof value !== "number" ||
|
|
3633
|
+
!Number.isFinite(value) ||
|
|
3634
|
+
value < min ||
|
|
3635
|
+
value > max
|
|
3636
|
+
)
|
|
3637
|
+
throw clipError(`${label} must be a number ${min}..${max}`);
|
|
3638
|
+
return value;
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
function clipTick(value: unknown, label: string): number {
|
|
3642
|
+
if (
|
|
3643
|
+
typeof value !== "number" ||
|
|
3644
|
+
!Number.isInteger(value) ||
|
|
3645
|
+
value < 0 ||
|
|
3646
|
+
value > SCORE_LIMITS.maxTick
|
|
3647
|
+
)
|
|
3648
|
+
throw clipError(
|
|
3649
|
+
`${label} must be an integer tick 0..${SCORE_LIMITS.maxTick}`,
|
|
3650
|
+
);
|
|
3651
|
+
return value;
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
function clipString(
|
|
3655
|
+
value: unknown,
|
|
3656
|
+
label: string,
|
|
3657
|
+
max: number,
|
|
3658
|
+
): string | undefined {
|
|
3659
|
+
if (value === undefined) return undefined;
|
|
3660
|
+
if (typeof value !== "string" || value.length === 0 || value.length > max)
|
|
3661
|
+
throw clipError(`${label} must be 1..${max} characters`);
|
|
3662
|
+
return value;
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
function clipBool(value: unknown, label: string): boolean | undefined {
|
|
3666
|
+
if (value === undefined) return undefined;
|
|
3667
|
+
if (typeof value !== "boolean") throw clipError(`${label} must be a boolean`);
|
|
3668
|
+
return value || undefined;
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
function clipSrc(value: unknown, label: string): string {
|
|
3672
|
+
const packRef = typeof value === "string" && value.startsWith(PACK_PREFIX);
|
|
3673
|
+
if (
|
|
3674
|
+
typeof value !== "string" ||
|
|
3675
|
+
value.length === 0 ||
|
|
3676
|
+
value.length > SCORE_LIMITS.maxSamplePathLength ||
|
|
3677
|
+
(packRef ? !isPackRef(value) : !isSafeRelativePath(value))
|
|
3678
|
+
)
|
|
3679
|
+
throw clipError(
|
|
3680
|
+
`${label} src must be a project-relative path without ".." or pack:<pack>/<sound>, at most ${SCORE_LIMITS.maxSamplePathLength} characters`,
|
|
3681
|
+
);
|
|
3682
|
+
return value;
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
function clipSha(value: unknown, label: string): string {
|
|
3686
|
+
if (typeof value !== "string" || !SHA256_HEX.test(value))
|
|
3687
|
+
throw clipError(`${label} sha256 must be 64 lowercase hex characters`);
|
|
3688
|
+
return value;
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
function increasingTimes(value: unknown, label: string): number[] {
|
|
3692
|
+
if (!Array.isArray(value) || value.length > SCORE_LIMITS.maxSayCuts)
|
|
3693
|
+
throw clipError(
|
|
3694
|
+
`${label} must be an array of at most ${SCORE_LIMITS.maxSayCuts} times`,
|
|
3695
|
+
);
|
|
3696
|
+
let previous = -Infinity;
|
|
3697
|
+
return value.map((time: unknown, index) => {
|
|
3698
|
+
if (
|
|
3699
|
+
typeof time !== "number" ||
|
|
3700
|
+
!Number.isFinite(time) ||
|
|
3701
|
+
time < 0 ||
|
|
3702
|
+
time > SCORE_LIMITS.maxClipSeconds
|
|
3703
|
+
)
|
|
3704
|
+
throw clipError(
|
|
3705
|
+
`${label}[${index}] must be seconds 0..${SCORE_LIMITS.maxClipSeconds}`,
|
|
3706
|
+
);
|
|
3707
|
+
if (time <= previous)
|
|
3708
|
+
throw clipError(`${label} must be strictly increasing`);
|
|
3709
|
+
previous = time;
|
|
3710
|
+
return time;
|
|
3711
|
+
});
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
/** Validates ClipSay; absent stays absent. */
|
|
3715
|
+
export function normalizeClipSay(
|
|
3716
|
+
input: unknown,
|
|
3717
|
+
label: string,
|
|
3718
|
+
): ClipSay | undefined {
|
|
3719
|
+
if (input === undefined || input === null) return undefined;
|
|
3720
|
+
if (!isRecord(input)) throw clipError(`${label} say must be an object`);
|
|
3721
|
+
const engine = input.engine;
|
|
3722
|
+
if (engine !== "say" && engine !== "espeak-ng" && engine !== "piper")
|
|
3723
|
+
throw clipError(`${label} say.engine must be say, espeak-ng or piper`);
|
|
3724
|
+
const voice = clipString(
|
|
3725
|
+
input.voice,
|
|
3726
|
+
`${label} say.voice`,
|
|
3727
|
+
SCORE_LIMITS.maxTtsVoiceLength,
|
|
3728
|
+
);
|
|
3729
|
+
if (voice === undefined) throw clipError(`${label} say.voice is required`);
|
|
3730
|
+
const license = clipString(
|
|
3731
|
+
input.license,
|
|
3732
|
+
`${label} say.license`,
|
|
3733
|
+
MAX_CLIP_LABEL_LENGTH,
|
|
3734
|
+
);
|
|
3735
|
+
if (license === undefined)
|
|
3736
|
+
throw clipError(`${label} say.license is required`);
|
|
3737
|
+
const rate = clipNumber(input.rate, `${label} say.rate`, 80, 400);
|
|
3738
|
+
const cuts = increasingTimes(input.cuts, `${label} say.cuts`);
|
|
3739
|
+
const vowels = increasingTimes(input.vowels, `${label} say.vowels`);
|
|
3740
|
+
if (cuts.length !== vowels.length)
|
|
3741
|
+
throw clipError(`${label} say.cuts and say.vowels must be the same length`);
|
|
3742
|
+
cuts.forEach((cut, index) => {
|
|
3743
|
+
if (cut > vowels[index]!)
|
|
3744
|
+
throw clipError(
|
|
3745
|
+
`${label} say.cuts[${index}] must be <= say.vowels[${index}]`,
|
|
3746
|
+
);
|
|
3747
|
+
});
|
|
3748
|
+
let unvoiced: number[] | undefined;
|
|
3749
|
+
if (input.unvoiced !== undefined) {
|
|
3750
|
+
if (!Array.isArray(input.unvoiced))
|
|
3751
|
+
throw clipError(`${label} say.unvoiced must be an array of indices`);
|
|
3752
|
+
let previous = -1;
|
|
3753
|
+
unvoiced = input.unvoiced.map((index: unknown) => {
|
|
3754
|
+
if (
|
|
3755
|
+
typeof index !== "number" ||
|
|
3756
|
+
!Number.isInteger(index) ||
|
|
3757
|
+
index <= previous ||
|
|
3758
|
+
index >= cuts.length
|
|
3759
|
+
)
|
|
3760
|
+
throw clipError(
|
|
3761
|
+
`${label} say.unvoiced must be increasing indices below ${cuts.length}`,
|
|
3762
|
+
);
|
|
3763
|
+
previous = index;
|
|
3764
|
+
return index;
|
|
3765
|
+
});
|
|
3766
|
+
}
|
|
3767
|
+
const oct = clipNumber(input.oct, `${label} say.oct`, -3, 3);
|
|
3768
|
+
if (oct !== undefined && !Number.isInteger(oct))
|
|
3769
|
+
throw clipError(`${label} say.oct must be an integer -3..3`);
|
|
3770
|
+
const soft = clipNumber(input.soft, `${label} say.soft`, 0, 1);
|
|
3771
|
+
let sung: ClipSay["sung"];
|
|
3772
|
+
if (input.sung !== undefined) {
|
|
3773
|
+
if (!isRecord(input.sung))
|
|
3774
|
+
throw clipError(`${label} say.sung must be { src, sha256, key }`);
|
|
3775
|
+
const key = clipString(
|
|
3776
|
+
input.sung.key,
|
|
3777
|
+
`${label} say.sung.key`,
|
|
3778
|
+
MAX_CLIP_LABEL_LENGTH,
|
|
3779
|
+
);
|
|
3780
|
+
if (key === undefined) throw clipError(`${label} say.sung.key is required`);
|
|
3781
|
+
sung = Object.freeze({
|
|
3782
|
+
src: clipSrc(input.sung.src, `${label} say.sung`),
|
|
3783
|
+
sha256: clipSha(input.sung.sha256, `${label} say.sung`),
|
|
3784
|
+
key,
|
|
3785
|
+
});
|
|
3786
|
+
}
|
|
3787
|
+
return Object.freeze({
|
|
3788
|
+
engine,
|
|
3789
|
+
voice,
|
|
3790
|
+
...(rate !== undefined ? { rate } : {}),
|
|
3791
|
+
license,
|
|
3792
|
+
cuts: Object.freeze(cuts),
|
|
3793
|
+
vowels: Object.freeze(vowels),
|
|
3794
|
+
...(unvoiced && unvoiced.length > 0
|
|
3795
|
+
? { unvoiced: Object.freeze(unvoiced) }
|
|
3796
|
+
: {}),
|
|
3797
|
+
...(oct !== undefined ? { oct } : {}),
|
|
3798
|
+
...(soft !== undefined ? { soft } : {}),
|
|
3799
|
+
...(sung ? { sung } : {}),
|
|
3800
|
+
});
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
/**
|
|
3804
|
+
* Validates Track.takes: unique names, sample path rules, inTick < outTick.
|
|
3805
|
+
* Null, absent and `[]` normalize to absent.
|
|
3806
|
+
*/
|
|
3807
|
+
export function normalizeTakes(
|
|
3808
|
+
input: unknown,
|
|
3809
|
+
trackId: string,
|
|
3810
|
+
): readonly Take[] | undefined {
|
|
3811
|
+
if (input === undefined || input === null) return undefined;
|
|
3812
|
+
if (!Array.isArray(input))
|
|
3813
|
+
throw clipError(`track ${trackId} takes must be an array`);
|
|
3814
|
+
if (input.length > SCORE_LIMITS.maxTakesPerTrack)
|
|
3815
|
+
throw new ScoreValidationError(
|
|
3816
|
+
`track ${trackId} takes holds at most ${SCORE_LIMITS.maxTakesPerTrack} takes`,
|
|
3817
|
+
"score-limit",
|
|
3818
|
+
);
|
|
3819
|
+
const names = new Set<string>();
|
|
3820
|
+
const takes = input.map((value: unknown, index): Take => {
|
|
3821
|
+
if (!isRecord(value))
|
|
3822
|
+
throw clipError(`track ${trackId} takes[${index}] must be an object`);
|
|
3823
|
+
const name = clipString(
|
|
3824
|
+
value.name,
|
|
3825
|
+
`track ${trackId} takes[${index}] name`,
|
|
3826
|
+
SCORE_LIMITS.maxNameLength,
|
|
3827
|
+
);
|
|
3828
|
+
if (name === undefined)
|
|
3829
|
+
throw clipError(`track ${trackId} takes[${index}] name is required`);
|
|
3830
|
+
if (names.has(name))
|
|
3831
|
+
throw clipError(`track ${trackId} take name ${name} is duplicated`);
|
|
3832
|
+
names.add(name);
|
|
3833
|
+
const label = `track ${trackId} take ${name}`;
|
|
3834
|
+
const offset = clipNumber(
|
|
3835
|
+
value.offset,
|
|
3836
|
+
`${label} offset`,
|
|
3837
|
+
0,
|
|
3838
|
+
SCORE_LIMITS.maxClipSeconds,
|
|
3839
|
+
);
|
|
3840
|
+
if (offset === undefined) throw clipError(`${label} offset is required`);
|
|
3841
|
+
const latency = clipNumber(value.latency, `${label} latency`, -1, 1);
|
|
3842
|
+
if (latency === undefined) throw clipError(`${label} latency is required`);
|
|
3843
|
+
const inTick = clipTick(value.inTick, `${label} inTick`);
|
|
3844
|
+
const outTick = clipTick(value.outTick, `${label} outTick`);
|
|
3845
|
+
if (inTick >= outTick)
|
|
3846
|
+
throw clipError(`${label} inTick must be before outTick`);
|
|
3847
|
+
const latencyAssumed = clipBool(
|
|
3848
|
+
value.latencyAssumed,
|
|
3849
|
+
`${label} latencyAssumed`,
|
|
3850
|
+
);
|
|
3851
|
+
const ppm = clipNumber(
|
|
3852
|
+
value.ppm,
|
|
3853
|
+
`${label} ppm`,
|
|
3854
|
+
-SCORE_LIMITS.maxTakePpm,
|
|
3855
|
+
SCORE_LIMITS.maxTakePpm,
|
|
3856
|
+
);
|
|
3857
|
+
const fit = clipNumber(value.fit, `${label} fit`, 0, 1);
|
|
3858
|
+
const warn = clipString(value.warn, `${label} warn`, MAX_CLIP_LABEL_LENGTH);
|
|
3859
|
+
const nudge = clipNumber(
|
|
3860
|
+
value.nudge,
|
|
3861
|
+
`${label} nudge`,
|
|
3862
|
+
-SCORE_LIMITS.maxTakeNudgeMs,
|
|
3863
|
+
SCORE_LIMITS.maxTakeNudgeMs,
|
|
3864
|
+
);
|
|
3865
|
+
return Object.freeze({
|
|
3866
|
+
name,
|
|
3867
|
+
src: clipSrc(value.src, label),
|
|
3868
|
+
sha256: clipSha(value.sha256, label),
|
|
3869
|
+
startTick: clipTick(value.startTick, `${label} startTick`),
|
|
3870
|
+
offset,
|
|
3871
|
+
latency,
|
|
3872
|
+
...(latencyAssumed ? { latencyAssumed } : {}),
|
|
3873
|
+
...(ppm !== undefined ? { ppm } : {}),
|
|
3874
|
+
...(fit !== undefined ? { fit } : {}),
|
|
3875
|
+
...(warn !== undefined ? { warn } : {}),
|
|
3876
|
+
...(nudge !== undefined ? { nudge } : {}),
|
|
3877
|
+
inTick,
|
|
3878
|
+
outTick,
|
|
3879
|
+
});
|
|
3880
|
+
});
|
|
3881
|
+
return takes.length > 0 ? Object.freeze(takes) : undefined;
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
/**
|
|
3885
|
+
* Validates Track.clips: unique ids, sample path rules, sha256 pins,
|
|
3886
|
+
* offset + dur within maxClipSeconds, fades that fit the clip, and takes
|
|
3887
|
+
* that exist on the track. Null, absent and `[]` normalize to absent.
|
|
3888
|
+
*/
|
|
3889
|
+
export function normalizeClips(
|
|
3890
|
+
input: unknown,
|
|
3891
|
+
trackId: string,
|
|
3892
|
+
takes: readonly Take[] | undefined,
|
|
3893
|
+
): readonly AudioClip[] | undefined {
|
|
3894
|
+
if (input === undefined || input === null) return undefined;
|
|
3895
|
+
if (!Array.isArray(input))
|
|
3896
|
+
throw clipError(`track ${trackId} clips must be an array`);
|
|
3897
|
+
if (input.length > SCORE_LIMITS.maxClipsPerTrack)
|
|
3898
|
+
throw new ScoreValidationError(
|
|
3899
|
+
`track ${trackId} clips holds at most ${SCORE_LIMITS.maxClipsPerTrack} clips`,
|
|
3900
|
+
"score-limit",
|
|
3901
|
+
);
|
|
3902
|
+
const takeNames = new Set((takes ?? []).map((take) => take.name));
|
|
3903
|
+
const ids = new Set<string>();
|
|
3904
|
+
const clips = input.map((value: unknown, index): AudioClip => {
|
|
3905
|
+
if (!isRecord(value))
|
|
3906
|
+
throw clipError(`track ${trackId} clips[${index}] must be an object`);
|
|
3907
|
+
const id = clipString(
|
|
3908
|
+
value.id,
|
|
3909
|
+
`track ${trackId} clips[${index}] id`,
|
|
3910
|
+
SCORE_LIMITS.maxIdLength,
|
|
3911
|
+
);
|
|
3912
|
+
if (id === undefined)
|
|
3913
|
+
throw clipError(`track ${trackId} clips[${index}] id is required`);
|
|
3914
|
+
if (ids.has(id))
|
|
3915
|
+
throw clipError(`track ${trackId} clip id ${id} is duplicated`);
|
|
3916
|
+
ids.add(id);
|
|
3917
|
+
const label = `track ${trackId} clip ${id}`;
|
|
3918
|
+
const max = SCORE_LIMITS.maxClipSeconds;
|
|
3919
|
+
const offset = clipNumber(value.offset, `${label} offset`, 0, max);
|
|
3920
|
+
const dur = clipNumber(value.dur, `${label} dur`, 0, max);
|
|
3921
|
+
if (dur === 0) throw clipError(`${label} dur must be above 0`);
|
|
3922
|
+
if ((offset ?? 0) + (dur ?? 0) > max)
|
|
3923
|
+
throw clipError(`${label} offset + dur must be at most ${max} seconds`);
|
|
3924
|
+
const gain = clipNumber(
|
|
3925
|
+
value.gain,
|
|
3926
|
+
`${label} gain`,
|
|
3927
|
+
0,
|
|
3928
|
+
SCORE_LIMITS.maxClipGain,
|
|
3929
|
+
);
|
|
3930
|
+
const fadeMax = SCORE_LIMITS.maxClipFadeSeconds;
|
|
3931
|
+
const fadeInTime = clipNumber(
|
|
3932
|
+
value.fadeInTime,
|
|
3933
|
+
`${label} fadeInTime`,
|
|
3934
|
+
0,
|
|
3935
|
+
fadeMax,
|
|
3936
|
+
);
|
|
3937
|
+
const fadeTime = clipNumber(
|
|
3938
|
+
value.fadeTime,
|
|
3939
|
+
`${label} fadeTime`,
|
|
3940
|
+
0,
|
|
3941
|
+
fadeMax,
|
|
3942
|
+
);
|
|
3943
|
+
if (dur !== undefined && (fadeInTime ?? 0) + (fadeTime ?? 0) > dur)
|
|
3944
|
+
throw clipError(`${label} fadeInTime + fadeTime must fit within dur`);
|
|
3945
|
+
const rev = clipBool(value.rev, `${label} rev`);
|
|
3946
|
+
const take = clipString(
|
|
3947
|
+
value.take,
|
|
3948
|
+
`${label} take`,
|
|
3949
|
+
SCORE_LIMITS.maxNameLength,
|
|
3950
|
+
);
|
|
3951
|
+
if (take !== undefined && !takeNames.has(take))
|
|
3952
|
+
throw clipError(`${label} take ${take} does not exist on the track`);
|
|
3953
|
+
const mute = clipBool(value.mute, `${label} mute`);
|
|
3954
|
+
const text = clipString(
|
|
3955
|
+
value.text,
|
|
3956
|
+
`${label} text`,
|
|
3957
|
+
SCORE_LIMITS.maxClipTextLength,
|
|
3958
|
+
);
|
|
3959
|
+
const say = normalizeClipSay(value.say, label);
|
|
3960
|
+
return Object.freeze({
|
|
3961
|
+
id,
|
|
3962
|
+
src: clipSrc(value.src, label),
|
|
3963
|
+
sha256: clipSha(value.sha256, label),
|
|
3964
|
+
startTick: clipTick(value.startTick, `${label} startTick`),
|
|
3965
|
+
...(offset !== undefined ? { offset } : {}),
|
|
3966
|
+
...(dur !== undefined ? { dur } : {}),
|
|
3967
|
+
...(gain !== undefined ? { gain } : {}),
|
|
3968
|
+
...(fadeInTime !== undefined ? { fadeInTime } : {}),
|
|
3969
|
+
...(fadeTime !== undefined ? { fadeTime } : {}),
|
|
3970
|
+
...(rev ? { rev } : {}),
|
|
3971
|
+
...(take !== undefined ? { take } : {}),
|
|
3972
|
+
...(mute ? { mute } : {}),
|
|
3973
|
+
...(text !== undefined ? { text } : {}),
|
|
3974
|
+
...(say ? { say } : {}),
|
|
3975
|
+
});
|
|
3976
|
+
});
|
|
3977
|
+
return clips.length > 0 ? Object.freeze(clips) : undefined;
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3980
|
+
/** Validates Note.vowel: `"a"` or a morph `"a>u"`; null clears. */
|
|
3981
|
+
function vowelOrThrow(input: unknown, noteId: string): string {
|
|
3982
|
+
try {
|
|
3983
|
+
return normalizeVowel(input);
|
|
3984
|
+
} catch (error) {
|
|
3985
|
+
if (error instanceof FxValidationError)
|
|
3986
|
+
throw new ScoreValidationError(
|
|
3987
|
+
`note ${noteId} ${error.message}`,
|
|
3988
|
+
"invalid-note",
|
|
3989
|
+
);
|
|
3990
|
+
throw error;
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3994
|
+
/** Validates Note.lyric: one syllable without whitespace; null clears. */
|
|
3995
|
+
export function normalizeLyric(
|
|
3996
|
+
input: unknown,
|
|
3997
|
+
noteId: string,
|
|
3998
|
+
): string | undefined {
|
|
3999
|
+
if (input === undefined || input === null) return undefined;
|
|
4000
|
+
if (
|
|
4001
|
+
typeof input !== "string" ||
|
|
4002
|
+
input.length === 0 ||
|
|
4003
|
+
input.length > SCORE_LIMITS.maxLyricLength ||
|
|
4004
|
+
/\s/.test(input)
|
|
4005
|
+
)
|
|
4006
|
+
throw new ScoreValidationError(
|
|
4007
|
+
`note ${noteId} lyric must be 1..${SCORE_LIMITS.maxLyricLength} characters without whitespace`,
|
|
4008
|
+
"invalid-note",
|
|
4009
|
+
);
|
|
4010
|
+
return input;
|
|
4011
|
+
}
|
|
4012
|
+
|
|
4013
|
+
/** Replaces (or with null clears) a track's clips. */
|
|
4014
|
+
export function setClips(
|
|
4015
|
+
score: TrackScore,
|
|
4016
|
+
trackId: string,
|
|
4017
|
+
clips: readonly AudioClip[] | null,
|
|
4018
|
+
): TrackScore {
|
|
4019
|
+
return updateTrack(score, trackId, { clips });
|
|
4020
|
+
}
|
|
4021
|
+
|
|
4022
|
+
/**
|
|
4023
|
+
* Cross-track references (0.7): fields that name another track register a
|
|
4024
|
+
* drop function here (through `registerTrackRefs` in core/routing.ts) so
|
|
4025
|
+
* removing a track never leaves a dangling reference.
|
|
4026
|
+
*/
|
|
4027
|
+
// `var` and a lazily created map: core/routing.ts registers while this
|
|
4028
|
+
// module may still be evaluating (score.ts imports routing.ts for its
|
|
4029
|
+
// side effect), so the map must not sit in a temporal dead zone.
|
|
4030
|
+
// eslint-disable-next-line no-var
|
|
4031
|
+
var trackRefDrops:
|
|
4032
|
+
Map<string, (track: Track, removedId: string) => Track> | undefined;
|
|
4033
|
+
|
|
4034
|
+
/** Registers how `field` forgets a removed track. Used by core/routing.ts. */
|
|
4035
|
+
export function registerTrackRefDrop(
|
|
4036
|
+
field: string,
|
|
4037
|
+
drop: (track: Track, removedId: string) => Track,
|
|
4038
|
+
): void {
|
|
4039
|
+
(trackRefDrops ??= new Map()).set(field, drop);
|
|
4040
|
+
}
|
|
4041
|
+
|
|
4042
|
+
function dropTrackRefs(track: Track, removedId: string): Track {
|
|
4043
|
+
let next = track;
|
|
4044
|
+
for (const drop of trackRefDrops?.values() ?? [])
|
|
4045
|
+
next = drop(next, removedId);
|
|
4046
|
+
return next;
|
|
4047
|
+
}
|