@hraness/dawg 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/DAWG.md +655 -141
- package/README.md +27 -25
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +759 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +182 -99
- package/core/expression.ts +158 -6
- package/core/fx.ts +354 -32
- package/core/granular.ts +91 -0
- package/core/instruments.ts +180 -0
- package/core/keys.ts +573 -4
- package/core/loop.ts +5 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/resonators.ts +306 -4
- package/core/routing.ts +165 -0
- package/core/score.ts +1181 -16
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +354 -6
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +3160 -69
- package/core/sections.ts +298 -24
- package/core/sing.ts +815 -0
- package/core/strings.ts +237 -2
- 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/core/winds.ts +652 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +30 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -6
- package/guides/chords.md +16 -14
- package/guides/effects.md +17 -15
- 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 -10
- package/guides/play.md +21 -13
- package/guides/project.md +25 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +32 -0
- 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 -12
- 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 +131 -13
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/chord-tools.ts +153 -1
- 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 +91 -0
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +15 -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 +5 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/resample-tool.ts +131 -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 +307 -37
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +16 -6
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +58 -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 +171 -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/dsp/shift.ts +256 -0
- package/src/audio/effects/chain.ts +21 -4
- package/src/audio/effects/common.ts +57 -0
- package/src/audio/effects/convolution.ts +113 -5
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/engine.ts +206 -33
- package/src/audio/fit.ts +80 -3
- package/src/audio/granular.ts +352 -21
- package/src/audio/instrument-check.ts +67 -47
- package/src/audio/instruments.ts +36 -3
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +427 -0
- package/src/audio/keys/engine.ts +156 -20
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +72 -8
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/kits.ts +135 -6
- package/src/audio/live-worker.ts +3 -1
- package/src/audio/live.ts +247 -26
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +45 -3
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +286 -0
- package/src/audio/resonators.ts +10 -2
- package/src/audio/sampler.ts +252 -24
- package/src/audio/samples.ts +46 -3
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/body.ts +29 -16
- package/src/audio/strings/bow.ts +699 -0
- package/src/audio/strings/engine.ts +274 -22
- package/src/audio/strings/measure.test-helpers.ts +2 -0
- 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 +608 -85
- package/src/audio/winds/engine.ts +306 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +104 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +492 -0
- 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 +104 -23
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +105 -33
- package/src/commands/grammar.ts +558 -0
- package/src/commands/granular.ts +7 -2
- package/src/commands/help.ts +652 -339
- package/src/commands/history.ts +18 -0
- package/src/commands/keys.ts +293 -17
- package/src/commands/modal.ts +67 -4
- 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/resample.ts +281 -0
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +8 -25
- package/src/commands/sample.ts +49 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/sing.ts +478 -0
- package/src/commands/string.ts +28 -2
- package/src/commands/strum.ts +485 -0
- 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 +244 -0
- 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 +1521 -255
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +26 -3
- 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 +36 -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 +50 -7
- 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 +1377 -193
- package/src/tui/modal-menu.ts +38 -11
- package/src/tui/performance-menu.ts +46 -1
- package/src/tui/play-chords.ts +108 -5
- package/src/tui/play-mode.ts +16 -1
- package/src/tui/play-session.ts +81 -16
- 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 +144 -0
- 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
|
@@ -23,30 +23,53 @@ import {
|
|
|
23
23
|
import {
|
|
24
24
|
MODAL_INSTRUMENT,
|
|
25
25
|
normalizeModal,
|
|
26
|
+
normalizeWind,
|
|
27
|
+
WIND_INSTRUMENT,
|
|
26
28
|
type TrackModal,
|
|
29
|
+
type TrackWind,
|
|
27
30
|
} from "./resonators.ts";
|
|
28
31
|
import { normalizeSynth, type TrackSynth } from "./synth.ts";
|
|
32
|
+
import {
|
|
33
|
+
normalizeSing,
|
|
34
|
+
normalizeVowel,
|
|
35
|
+
SING_INSTRUMENT,
|
|
36
|
+
type TrackSing,
|
|
37
|
+
} from "./sing.ts";
|
|
38
|
+
import {
|
|
39
|
+
normalizeVocoder,
|
|
40
|
+
vocoderScoreError,
|
|
41
|
+
VOCODER_INSTRUMENT,
|
|
42
|
+
type TrackVocoder,
|
|
43
|
+
} from "./vocoder.ts";
|
|
44
|
+
import { normalizeAutotune, type TrackAutotune } from "./autotune.ts";
|
|
45
|
+
// Registers cross-track reference fields (autotune.from, vocoder.src).
|
|
46
|
+
import "./routing.ts";
|
|
29
47
|
import { normalizeString, type TrackString } from "./strings.ts";
|
|
30
48
|
import {
|
|
31
49
|
isGranularInstrument,
|
|
32
50
|
normalizeGranular,
|
|
33
51
|
type TrackGranular,
|
|
34
52
|
} from "./granular.ts";
|
|
35
|
-
import { normalizeKeys, type TrackKeys } from "./keys.ts";
|
|
53
|
+
import { isKeysFamily, normalizeKeys, type TrackKeys } from "./keys.ts";
|
|
54
|
+
import { GUITAR_TUNING_NAMES } from "./chords.ts";
|
|
36
55
|
import {
|
|
37
56
|
checkSongTime,
|
|
38
57
|
normalizeSongTime,
|
|
39
58
|
normalizeTrackTime,
|
|
40
59
|
TimeValidationError,
|
|
41
60
|
type SongTime,
|
|
61
|
+
timeWithinSong,
|
|
42
62
|
type TrackTime,
|
|
43
63
|
withMeterChange,
|
|
44
64
|
} from "./tempo.ts";
|
|
45
65
|
import {
|
|
46
66
|
ExpressionValidationError,
|
|
47
67
|
normalizeNoteExpression,
|
|
68
|
+
normalizePedal,
|
|
48
69
|
normalizeTrackPerformance,
|
|
70
|
+
SOSTENUTO_STATES,
|
|
49
71
|
type NoteExpression,
|
|
72
|
+
type PedalEvent,
|
|
50
73
|
type NoteExpressionPatch,
|
|
51
74
|
type TrackPerformance,
|
|
52
75
|
} from "./expression.ts";
|
|
@@ -59,8 +82,22 @@ import {
|
|
|
59
82
|
|
|
60
83
|
export type { Tuning } from "./tuning.ts";
|
|
61
84
|
import { normalizeMaster, type SongMaster } from "./master.ts";
|
|
85
|
+
import {
|
|
86
|
+
normalizeSongStyle,
|
|
87
|
+
SongStyleError,
|
|
88
|
+
type SongStyle,
|
|
89
|
+
} from "./style-provenance.ts";
|
|
62
90
|
|
|
63
91
|
export const SCORE_VERSION = 1 as const;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Sound calibration (0.7): which revision of the released engines' level,
|
|
95
|
+
* pitch and voice fixes a song renders with. Absent (0) keeps every 0.4 to
|
|
96
|
+
* 0.6.1 project byte-identical; new songs start at the latest. Revision 1
|
|
97
|
+
* chokes the open hat, key-tracks toms, adds crash, ride and cowbell,
|
|
98
|
+
* band-limits hat metal, levels keys presets and steadies lip brass.
|
|
99
|
+
*/
|
|
100
|
+
export const CALIBRATION_LATEST = 1 as const;
|
|
64
101
|
export const DEFAULT_TICKS_PER_BEAT = 480 as const;
|
|
65
102
|
|
|
66
103
|
export const SCORE_LIMITS = Object.freeze({
|
|
@@ -83,7 +120,11 @@ export const SCORE_LIMITS = Object.freeze({
|
|
|
83
120
|
maxSectionGain: 2,
|
|
84
121
|
maxBeatsPerBar: 16,
|
|
85
122
|
maxTicksPerBeat: 4096,
|
|
86
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Latest tick anything may sit at or reach: the longest valid song,
|
|
125
|
+
* 256 bars of 16/1 at 4096 ticks per beat (2^26).
|
|
126
|
+
*/
|
|
127
|
+
maxTick: 67_108_864,
|
|
87
128
|
minFilterCutoff: 20,
|
|
88
129
|
maxFilterCutoff: 20_000,
|
|
89
130
|
maxFilterResonance: 1,
|
|
@@ -116,6 +157,12 @@ export const SCORE_LIMITS = Object.freeze({
|
|
|
116
157
|
maxSampleBpm: 400,
|
|
117
158
|
/** Sampler `len` (window length in beats) bound. */
|
|
118
159
|
maxSampleLenBeats: 1024,
|
|
160
|
+
/** Sampler `shift` and `formant` bound in semitones (0.6.1). */
|
|
161
|
+
maxSampleShift: 24,
|
|
162
|
+
/** Sampler `fadeTime`/`fadeInTime` bound in seconds (0.6.1). */
|
|
163
|
+
maxSampleFadeSeconds: 2,
|
|
164
|
+
/** Longest `resample` render in seconds (0.6.1). */
|
|
165
|
+
maxResampleSeconds: 600,
|
|
119
166
|
/** Per sample file, enforced by the decoder and the import tool. */
|
|
120
167
|
maxSampleFileBytes: 50 * 1024 * 1024,
|
|
121
168
|
maxSampleSeconds: 600,
|
|
@@ -125,6 +172,34 @@ export const SCORE_LIMITS = Object.freeze({
|
|
|
125
172
|
* `DAWG_ASSETS_CACHE_MAX` / `DAWG_PACKS_CACHE_MAX`.
|
|
126
173
|
*/
|
|
127
174
|
maxSampleCacheBytes: 512 * 1024 * 1024,
|
|
175
|
+
/** Audio clips, takes and lyrics (0.7, shapes in the contract). */
|
|
176
|
+
maxClipsPerTrack: 256,
|
|
177
|
+
maxTakesPerTrack: 64,
|
|
178
|
+
maxClipGain: 4,
|
|
179
|
+
maxClipFadeSeconds: 2,
|
|
180
|
+
maxClipSeconds: 520,
|
|
181
|
+
maxClipTextLength: 2000,
|
|
182
|
+
maxLyricLength: 32,
|
|
183
|
+
maxTakeNudgeMs: 250,
|
|
184
|
+
maxTtsVoiceLength: 64,
|
|
185
|
+
/** Singers per note in a `sing` ensemble (0.7). */
|
|
186
|
+
singVoices: 8,
|
|
187
|
+
/** Ensemble members one `sing` track sounds at once (0.7). */
|
|
188
|
+
singVoicesPerTrack: 48,
|
|
189
|
+
maxSayCuts: 512,
|
|
190
|
+
maxTakePpm: 1000,
|
|
191
|
+
/** Vocoder (0.7): channel bands, formant shift (st), tracks per song. */
|
|
192
|
+
minVocoderBands: 4,
|
|
193
|
+
maxVocoderBands: 40,
|
|
194
|
+
maxVocoderFormant: 24,
|
|
195
|
+
maxVocoderTracks: 8,
|
|
196
|
+
/** Track.autotune (0.7). */
|
|
197
|
+
maxAutotuneSpeedMs: 400,
|
|
198
|
+
maxAutotuneHoldMs: 1000,
|
|
199
|
+
maxAutotuneGlideSeconds: 0.5,
|
|
200
|
+
maxAutotuneVibHz: 12,
|
|
201
|
+
/** Per-frame correction cap in semitones. */
|
|
202
|
+
maxAutotuneShift: 12,
|
|
128
203
|
} as const);
|
|
129
204
|
|
|
130
205
|
/** Instrument name that selects a track's `wavetable` oscillator. */
|
|
@@ -298,7 +373,8 @@ export class ScoreValidationError extends Error {
|
|
|
298
373
|
| "invalid-track"
|
|
299
374
|
| "duplicate-note"
|
|
300
375
|
| "duplicate-track"
|
|
301
|
-
| "score-limit"
|
|
376
|
+
| "score-limit"
|
|
377
|
+
| "routing-cycle";
|
|
302
378
|
|
|
303
379
|
constructor(
|
|
304
380
|
message: string,
|
|
@@ -413,6 +489,54 @@ export type Track = Readonly<{
|
|
|
413
489
|
* instrument with this field is rejected. Absent keeps today's tone.
|
|
414
490
|
*/
|
|
415
491
|
modal?: TrackModal;
|
|
492
|
+
/**
|
|
493
|
+
* Optional (0.6.1): una corda (soft pedal) events, same shape as `pedal`.
|
|
494
|
+
* Modelled pianos read it at each onset (half is half the shift); other
|
|
495
|
+
* instruments ignore it.
|
|
496
|
+
*/
|
|
497
|
+
softPedal?: readonly PedalEvent[];
|
|
498
|
+
/**
|
|
499
|
+
* Optional (0.6.1): sostenuto pedal events (down and up only). Keys held
|
|
500
|
+
* when it goes down ring until it lifts; later keys damp as usual.
|
|
501
|
+
*/
|
|
502
|
+
sostenuto?: readonly PedalEvent[];
|
|
503
|
+
/**
|
|
504
|
+
* Optional (0.6.1): how the track is fretted when chords are strummed
|
|
505
|
+
* (`strum`, perform mode `guitar`): tuning, capo, hand stretch, how much
|
|
506
|
+
* open strings ring and a preferred position. Notes only; never read at
|
|
507
|
+
* render, so it changes no sound by itself.
|
|
508
|
+
*/
|
|
509
|
+
guitar?: TrackGuitar;
|
|
510
|
+
/**
|
|
511
|
+
* Optional (0.6.1): a blown waveguide (`core/winds.ts`), a preset plus
|
|
512
|
+
* overrides. Plays only when `instrument` is `"wind"`; without it the
|
|
513
|
+
* legacy `wind` tone plays unchanged. Any other instrument with this
|
|
514
|
+
* field is rejected.
|
|
515
|
+
*/
|
|
516
|
+
wind?: TrackWind;
|
|
517
|
+
/**
|
|
518
|
+
* Optional (0.7): the built-in singing voice (`core/sing.ts`), a preset
|
|
519
|
+
* plus overrides. Plays only when `instrument` is `"sing"`; any other
|
|
520
|
+
* instrument with this field is rejected.
|
|
521
|
+
*/
|
|
522
|
+
sing?: TrackSing;
|
|
523
|
+
/**
|
|
524
|
+
* Optional (0.7): audio clips placed on the timeline. Shapes only in the
|
|
525
|
+
* contract; the renderer ignores them until the clips lane.
|
|
526
|
+
*/
|
|
527
|
+
clips?: readonly AudioClip[];
|
|
528
|
+
/** Optional (0.7): recorded or imported takes that clips may reference. */
|
|
529
|
+
takes?: readonly Take[];
|
|
530
|
+
/**
|
|
531
|
+
* Optional (0.7): vocoder on this track (`core/vocoder.ts`); `src` names
|
|
532
|
+
* the modulator track. Absent: today's sound.
|
|
533
|
+
*/
|
|
534
|
+
vocoder?: TrackVocoder;
|
|
535
|
+
/**
|
|
536
|
+
* Optional (0.7): pitch correction of every clip and sampler voice on the
|
|
537
|
+
* track (`core/autotune.ts`). Absent keeps the sound untouched.
|
|
538
|
+
*/
|
|
539
|
+
autotune?: TrackAutotune;
|
|
416
540
|
}> &
|
|
417
541
|
/**
|
|
418
542
|
* Performance (`core/expression.ts`): glide default, sustain pedal
|
|
@@ -508,6 +632,51 @@ export type SampleRef = Readonly<{
|
|
|
508
632
|
* through the song's tempo map. `fit` wins over `bpm`, `bpm` over `len`.
|
|
509
633
|
*/
|
|
510
634
|
len?: number;
|
|
635
|
+
/**
|
|
636
|
+
* Optional (0.6.1): pitch shift in semitones (−24..24) at constant
|
|
637
|
+
* length, a cached phase-vocoder preprocess (`src/audio/dsp/shift.ts`).
|
|
638
|
+
* Applied after any fit, so it follows `fitmode`.
|
|
639
|
+
*/
|
|
640
|
+
shift?: number;
|
|
641
|
+
/**
|
|
642
|
+
* Optional (0.6.1, with `shift`): formant shift in semitones (−24..24).
|
|
643
|
+
* Absent: formants follow the pitch (chipmunk); 0 keeps them in place.
|
|
644
|
+
*/
|
|
645
|
+
formant?: number;
|
|
646
|
+
/** Optional (0.6.1, Strudel `fadeTime`): release fade in seconds (0..2). */
|
|
647
|
+
fadeTime?: number;
|
|
648
|
+
/** Optional (0.6.1, Strudel `fadeInTime`): attack fade in seconds (0..2). */
|
|
649
|
+
fadeInTime?: number;
|
|
650
|
+
/**
|
|
651
|
+
* Optional (0.6.1): where `resample` rendered this file from; kept for
|
|
652
|
+
* reference only (never read by the renderer).
|
|
653
|
+
*/
|
|
654
|
+
from?: SampleProvenance;
|
|
655
|
+
/**
|
|
656
|
+
* Optional (0.6.1, SFZ `lovel`/`hivel`): the MIDI velocity range
|
|
657
|
+
* [lo, hi] (0..127, lo ≤ hi) this voice plays. Keyed voices with the same
|
|
658
|
+
* root are velocity layers; a one-shot voice outside its range is silent
|
|
659
|
+
* unless its `rr` group has a voice for that velocity.
|
|
660
|
+
*/
|
|
661
|
+
vel?: readonly [number, number];
|
|
662
|
+
/**
|
|
663
|
+
* Optional (0.6.1, SFZ `seq_length`): round-robin group name. One-shot
|
|
664
|
+
* voices in a group are one sound (any member's slot plays it); voices
|
|
665
|
+
* that match a note's velocity take turns, A B A B, in note order.
|
|
666
|
+
*/
|
|
667
|
+
rr?: string;
|
|
668
|
+
}>;
|
|
669
|
+
|
|
670
|
+
/** `SampleRef.from`: the source of a resampled file (0.6.1). */
|
|
671
|
+
export type SampleProvenance = Readonly<{
|
|
672
|
+
/** `track:<id>`, `orbit:<n>` or `master`. */
|
|
673
|
+
source: string;
|
|
674
|
+
/** Section name the render covered. */
|
|
675
|
+
section?: string;
|
|
676
|
+
/** Bars a..b (1-based, inclusive) the render covered. */
|
|
677
|
+
bars?: readonly [number, number];
|
|
678
|
+
/** sha256 of the canonical score JSON the file was rendered from. */
|
|
679
|
+
score: string;
|
|
511
680
|
}>;
|
|
512
681
|
|
|
513
682
|
/** Sampler `fitmode`: how a fitted window changes its time. */
|
|
@@ -541,14 +710,22 @@ export type TrackReverb = Readonly<{
|
|
|
541
710
|
predelay?: number;
|
|
542
711
|
/**
|
|
543
712
|
* Optional (Strudel `iresponse`/`ir`): convolve with this impulse instead
|
|
544
|
-
* of the algorithmic tail. `builtin:room|hall|plate`, a pinned pack sound
|
|
713
|
+
* of the algorithmic tail. `builtin:room|hall|plate|reverse|gate|spring`, a pinned pack sound
|
|
545
714
|
* or a project file; `size`, `fade` and `dim` then do nothing.
|
|
546
715
|
*/
|
|
547
716
|
ir?: SampleRef;
|
|
548
717
|
}>;
|
|
549
718
|
|
|
550
719
|
/** Generated impulse responses `reverb.ir` can name as `builtin:<name>`. */
|
|
551
|
-
export const REVERB_IR_BUILTINS = Object.freeze([
|
|
720
|
+
export const REVERB_IR_BUILTINS = Object.freeze([
|
|
721
|
+
"room",
|
|
722
|
+
"hall",
|
|
723
|
+
"plate",
|
|
724
|
+
// 0.6.1: a reverse (rising) wash, a gated tail, a spring tank.
|
|
725
|
+
"reverse",
|
|
726
|
+
"gate",
|
|
727
|
+
"spring",
|
|
728
|
+
]);
|
|
552
729
|
|
|
553
730
|
/**
|
|
554
731
|
* Validates `reverb.ir`: a string or `{ src, … }`. Bare built-in names and
|
|
@@ -754,6 +931,15 @@ export type TrackPatch = Readonly<
|
|
|
754
931
|
granular?: TrackGranular | null;
|
|
755
932
|
keys?: TrackKeys | null;
|
|
756
933
|
modal?: TrackModal | null;
|
|
934
|
+
softPedal?: Track["softPedal"] | null;
|
|
935
|
+
sostenuto?: Track["sostenuto"] | null;
|
|
936
|
+
guitar?: TrackGuitar | null;
|
|
937
|
+
wind?: TrackWind | null;
|
|
938
|
+
sing?: TrackSing | null;
|
|
939
|
+
clips?: readonly AudioClip[] | null;
|
|
940
|
+
takes?: readonly Take[] | null;
|
|
941
|
+
vocoder?: TrackVocoder | null;
|
|
942
|
+
autotune?: TrackAutotune | null;
|
|
757
943
|
}
|
|
758
944
|
>;
|
|
759
945
|
|
|
@@ -776,6 +962,10 @@ export type Note = Readonly<{
|
|
|
776
962
|
velocity: number;
|
|
777
963
|
/** Static offset from the tuned pitch in cents (±1200); absent is 0. */
|
|
778
964
|
cents?: number;
|
|
965
|
+
/** Optional (0.7): one sung syllable, no whitespace (≤ 32 characters). */
|
|
966
|
+
lyric?: string;
|
|
967
|
+
/** Optional (0.7): the sung vowel, `"a"` or a morph `"a>u"` (sing). */
|
|
968
|
+
vowel?: string;
|
|
779
969
|
}> &
|
|
780
970
|
NoteExpression;
|
|
781
971
|
|
|
@@ -802,6 +992,15 @@ export type TrackInput = Readonly<
|
|
|
802
992
|
| "granular"
|
|
803
993
|
| "keys"
|
|
804
994
|
| "modal"
|
|
995
|
+
| "softPedal"
|
|
996
|
+
| "sostenuto"
|
|
997
|
+
| "guitar"
|
|
998
|
+
| "wind"
|
|
999
|
+
| "sing"
|
|
1000
|
+
| "clips"
|
|
1001
|
+
| "takes"
|
|
1002
|
+
| "vocoder"
|
|
1003
|
+
| "autotune"
|
|
805
1004
|
> &
|
|
806
1005
|
Pick<Track, "id"> & {
|
|
807
1006
|
filter?: TrackFilter | null;
|
|
@@ -824,6 +1023,15 @@ export type TrackInput = Readonly<
|
|
|
824
1023
|
granular?: TrackGranular | null;
|
|
825
1024
|
keys?: TrackKeys | null;
|
|
826
1025
|
modal?: TrackModal | null;
|
|
1026
|
+
softPedal?: Track["softPedal"] | null;
|
|
1027
|
+
sostenuto?: Track["sostenuto"] | null;
|
|
1028
|
+
guitar?: TrackGuitar | null;
|
|
1029
|
+
wind?: TrackWind | null;
|
|
1030
|
+
sing?: TrackSing | null;
|
|
1031
|
+
clips?: readonly AudioClip[] | null;
|
|
1032
|
+
takes?: readonly Take[] | null;
|
|
1033
|
+
vocoder?: TrackVocoder | null;
|
|
1034
|
+
autotune?: TrackAutotune | null;
|
|
827
1035
|
}
|
|
828
1036
|
>;
|
|
829
1037
|
|
|
@@ -841,6 +1049,8 @@ export type NoteInput = Readonly<{
|
|
|
841
1049
|
pitch: number;
|
|
842
1050
|
velocity: number;
|
|
843
1051
|
cents?: number;
|
|
1052
|
+
lyric?: string | null;
|
|
1053
|
+
vowel?: string | null;
|
|
844
1054
|
}> &
|
|
845
1055
|
NoteExpressionPatch;
|
|
846
1056
|
|
|
@@ -894,6 +1104,8 @@ export type TrackScoreData = Readonly<{
|
|
|
894
1104
|
notes?: readonly NoteInput[];
|
|
895
1105
|
/** Song master chain and loudness target (core/master.ts); absent is off. */
|
|
896
1106
|
master?: SongMaster | null;
|
|
1107
|
+
/** Which style and seed made the song (core/style-provenance.ts); absent is none. */
|
|
1108
|
+
style?: SongStyle | null;
|
|
897
1109
|
/** Song sections (0.5); absent or empty means none. */
|
|
898
1110
|
sections?: readonly Section[];
|
|
899
1111
|
/** Song form (0.5): the order sections play in; absent plays the score straight through. */
|
|
@@ -903,6 +1115,8 @@ export type TrackScoreData = Readonly<{
|
|
|
903
1115
|
* the song (or its form). A name that matches no section is dropped.
|
|
904
1116
|
*/
|
|
905
1117
|
loopSection?: string | null;
|
|
1118
|
+
/** Sound calibration (0.7), 0..CALIBRATION_LATEST; absent or 0 is legacy. */
|
|
1119
|
+
calibration?: number | null;
|
|
906
1120
|
}>;
|
|
907
1121
|
|
|
908
1122
|
/** Canonical immutable score. Use `addNote`/`removeNote` to create a revision. */
|
|
@@ -920,12 +1134,16 @@ export class TrackScore {
|
|
|
920
1134
|
readonly notes: readonly Note[];
|
|
921
1135
|
/** Absent (not undefined-valued) without a master, so 0.4 scores are unchanged. */
|
|
922
1136
|
declare readonly master?: SongMaster;
|
|
1137
|
+
/** Absent without a style, so older scores are unchanged. */
|
|
1138
|
+
declare readonly style?: SongStyle;
|
|
923
1139
|
/** Song sections in bar order; empty when the song has none. */
|
|
924
1140
|
readonly sections: readonly Section[];
|
|
925
1141
|
/** Song form; empty plays the score straight through. */
|
|
926
1142
|
readonly form: readonly FormEntry[];
|
|
927
1143
|
/** The section playback loops; undefined plays the song. */
|
|
928
1144
|
readonly loopSection: string | undefined;
|
|
1145
|
+
/** Sound calibration revision; absent is 0 (legacy engines). */
|
|
1146
|
+
declare readonly calibration?: number;
|
|
929
1147
|
|
|
930
1148
|
constructor(data: TrackScoreData = {}) {
|
|
931
1149
|
const tempoBpm = data.tempoBpm ?? 120;
|
|
@@ -989,6 +1207,13 @@ export class TrackScore {
|
|
|
989
1207
|
);
|
|
990
1208
|
const tracks = normalizeTracks(data.tracks ?? []);
|
|
991
1209
|
const notes = normalizeNotes(data.notes ?? []);
|
|
1210
|
+
const trackIds = new Set(tracks.map((track) => track.id));
|
|
1211
|
+
const orphan = notes.find((note) => !trackIds.has(note.trackId));
|
|
1212
|
+
if (orphan)
|
|
1213
|
+
throw new ScoreValidationError(
|
|
1214
|
+
`note ${orphan.id} is on track ${orphan.trackId}, which does not exist`,
|
|
1215
|
+
"invalid-note",
|
|
1216
|
+
);
|
|
992
1217
|
this.tuning = tuning;
|
|
993
1218
|
let master: SongMaster | undefined;
|
|
994
1219
|
try {
|
|
@@ -999,6 +1224,15 @@ export class TrackScore {
|
|
|
999
1224
|
throw error;
|
|
1000
1225
|
}
|
|
1001
1226
|
if (master) this.master = master;
|
|
1227
|
+
let style: SongStyle | undefined;
|
|
1228
|
+
try {
|
|
1229
|
+
style = normalizeSongStyle(data.style);
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
if (error instanceof SongStyleError)
|
|
1232
|
+
throw new ScoreValidationError(error.message, "invalid-score");
|
|
1233
|
+
throw error;
|
|
1234
|
+
}
|
|
1235
|
+
if (style) this.style = style;
|
|
1002
1236
|
const sections = normalizeSections(data.sections ?? []);
|
|
1003
1237
|
const form = normalizeForm(data.form ?? [], sections);
|
|
1004
1238
|
// Sections count bars in one meter; meter changes would move them.
|
|
@@ -1021,6 +1255,8 @@ export class TrackScore {
|
|
|
1021
1255
|
this.sections = freezeArray(sections);
|
|
1022
1256
|
this.form = freezeArray(form);
|
|
1023
1257
|
this.loopSection = normalizeLoopSection(data.loopSection, sections);
|
|
1258
|
+
const calibration = normalizeCalibration(data.calibration);
|
|
1259
|
+
if (calibration) this.calibration = calibration;
|
|
1024
1260
|
Object.freeze(this);
|
|
1025
1261
|
}
|
|
1026
1262
|
|
|
@@ -1032,8 +1268,14 @@ export class TrackScore {
|
|
|
1032
1268
|
return removeNote(this, noteId);
|
|
1033
1269
|
}
|
|
1034
1270
|
|
|
1271
|
+
/** The song with `tracks`; notes on a track not listed go with it. */
|
|
1035
1272
|
withTracks(tracks: readonly TrackInput[]): TrackScore {
|
|
1036
|
-
|
|
1273
|
+
const ids = new Set(tracks.map((track) => track.id));
|
|
1274
|
+
return new TrackScore({
|
|
1275
|
+
...this.toJSON(),
|
|
1276
|
+
tracks,
|
|
1277
|
+
notes: this.notes.filter((note) => ids.has(note.trackId)),
|
|
1278
|
+
});
|
|
1037
1279
|
}
|
|
1038
1280
|
|
|
1039
1281
|
withTempo(tempoBpm: number): TrackScore {
|
|
@@ -1058,8 +1300,19 @@ export class TrackScore {
|
|
|
1058
1300
|
}
|
|
1059
1301
|
|
|
1060
1302
|
/** Resize the loop without discarding notes or automation outside its bounds. */
|
|
1303
|
+
/** A shorter song drops the time marks past its new end. */
|
|
1061
1304
|
withBars(bars: number): TrackScore {
|
|
1062
|
-
|
|
1305
|
+
const time =
|
|
1306
|
+
bars < this.bars
|
|
1307
|
+
? timeWithinSong({
|
|
1308
|
+
tempoBpm: this.tempoBpm,
|
|
1309
|
+
beatsPerBar: this.beatsPerBar,
|
|
1310
|
+
ticksPerBeat: this.ticksPerBeat,
|
|
1311
|
+
bars,
|
|
1312
|
+
...(this.time ? { time: this.time } : {}),
|
|
1313
|
+
})
|
|
1314
|
+
: this.time;
|
|
1315
|
+
return new TrackScore({ ...this.toJSON(), bars, time: time ?? null });
|
|
1063
1316
|
}
|
|
1064
1317
|
|
|
1065
1318
|
withKey(key: string | null): TrackScore {
|
|
@@ -1093,10 +1346,20 @@ export class TrackScore {
|
|
|
1093
1346
|
}
|
|
1094
1347
|
|
|
1095
1348
|
/** Replace the song master; `null` removes it (bypass). */
|
|
1349
|
+
/** Set the sound calibration revision; 0 or null is legacy. */
|
|
1350
|
+
withCalibration(calibration: number | null): TrackScore {
|
|
1351
|
+
return new TrackScore({ ...this.toJSON(), calibration });
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1096
1354
|
withMaster(master: SongMaster | null): TrackScore {
|
|
1097
1355
|
return new TrackScore({ ...this.toJSON(), master });
|
|
1098
1356
|
}
|
|
1099
1357
|
|
|
1358
|
+
/** Record (or clear, `null`) which style and seed made the song. */
|
|
1359
|
+
withStyle(style: SongStyle | null): TrackScore {
|
|
1360
|
+
return new TrackScore({ ...this.toJSON(), style });
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1100
1363
|
toJSON(): TrackScoreData & { version: typeof SCORE_VERSION } {
|
|
1101
1364
|
return {
|
|
1102
1365
|
version: SCORE_VERSION,
|
|
@@ -1110,11 +1373,13 @@ export class TrackScore {
|
|
|
1110
1373
|
tracks: this.tracks,
|
|
1111
1374
|
notes: this.notes,
|
|
1112
1375
|
...(this.master ? { master: this.master } : {}),
|
|
1376
|
+
...(this.style ? { style: this.style } : {}),
|
|
1113
1377
|
...(this.sections.length > 0 ? { sections: this.sections } : {}),
|
|
1114
1378
|
...(this.form.length > 0 ? { form: this.form } : {}),
|
|
1115
1379
|
...(this.loopSection === undefined
|
|
1116
1380
|
? {}
|
|
1117
1381
|
: { loopSection: this.loopSection }),
|
|
1382
|
+
...(this.calibration ? { calibration: this.calibration } : {}),
|
|
1118
1383
|
};
|
|
1119
1384
|
}
|
|
1120
1385
|
}
|
|
@@ -1181,6 +1446,7 @@ export type NotePatch = Readonly<
|
|
|
1181
1446
|
Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity" | "cents">
|
|
1182
1447
|
>
|
|
1183
1448
|
> &
|
|
1449
|
+
Readonly<{ lyric?: string | null; vowel?: string | null }> &
|
|
1184
1450
|
NoteExpressionPatch;
|
|
1185
1451
|
|
|
1186
1452
|
export function updateNote(
|
|
@@ -1189,7 +1455,10 @@ export function updateNote(
|
|
|
1189
1455
|
patch: NotePatch,
|
|
1190
1456
|
): TrackScore {
|
|
1191
1457
|
const current = score.notes.find((note) => note.id === noteId);
|
|
1192
|
-
|
|
1458
|
+
// A patch for a missing note is a conflict (a stale or diverged score),
|
|
1459
|
+
// never a silent no-op. removeNote stays idempotent: the note is gone.
|
|
1460
|
+
if (!current)
|
|
1461
|
+
throw new ScoreValidationError(`unknown note: ${noteId}`, "invalid-note");
|
|
1193
1462
|
return new TrackScore({
|
|
1194
1463
|
...score.toJSON(),
|
|
1195
1464
|
notes: score.notes.map((note) =>
|
|
@@ -1224,6 +1493,30 @@ export function updateTrack(
|
|
|
1224
1493
|
patch.instrument !== MODAL_INSTRUMENT
|
|
1225
1494
|
)
|
|
1226
1495
|
delete next.modal;
|
|
1496
|
+
// The legacy word `wind` is the legacy tone, so naming any instrument
|
|
1497
|
+
// (`wind` included) without a wind field leaves the wind engine.
|
|
1498
|
+
if (patch.instrument !== undefined && patch.wind === undefined)
|
|
1499
|
+
delete next.wind;
|
|
1500
|
+
// Likewise a sing field leaves with the singing instrument.
|
|
1501
|
+
if (patch.instrument !== undefined && patch.sing === undefined)
|
|
1502
|
+
delete next.sing;
|
|
1503
|
+
// Keys settings only belong to a keys family (piano, electric, organ):
|
|
1504
|
+
// switching to another instrument drops them unless the patch sets them.
|
|
1505
|
+
if (
|
|
1506
|
+
patch.instrument !== undefined &&
|
|
1507
|
+
patch.keys === undefined &&
|
|
1508
|
+
!isKeysFamily(patch.instrument)
|
|
1509
|
+
)
|
|
1510
|
+
delete next.keys;
|
|
1511
|
+
// f07-vocoder: naming the `vocoder` instrument turns on its built-in
|
|
1512
|
+
// carrier (an empty field keeps every default); a vocoder stage the
|
|
1513
|
+
// track already has (its source) stays.
|
|
1514
|
+
if (
|
|
1515
|
+
patch.instrument === VOCODER_INSTRUMENT &&
|
|
1516
|
+
patch.vocoder === undefined &&
|
|
1517
|
+
next.vocoder === undefined
|
|
1518
|
+
)
|
|
1519
|
+
next.vocoder = Object.freeze({});
|
|
1227
1520
|
return next as Track;
|
|
1228
1521
|
}),
|
|
1229
1522
|
);
|
|
@@ -1234,24 +1527,31 @@ export function removeTrack(score: TrackScore, trackId: string): TrackScore {
|
|
|
1234
1527
|
if (!score.tracks.some((track) => track.id === trackId)) return score;
|
|
1235
1528
|
return new TrackScore({
|
|
1236
1529
|
...score.toJSON(),
|
|
1237
|
-
tracks: score.tracks
|
|
1530
|
+
tracks: score.tracks
|
|
1531
|
+
.filter((track) => track.id !== trackId)
|
|
1532
|
+
.map((track) => dropTrackRefs(track, trackId)),
|
|
1238
1533
|
notes: score.notes.filter((note) => note.trackId !== trackId),
|
|
1239
1534
|
});
|
|
1240
1535
|
}
|
|
1241
1536
|
|
|
1242
|
-
/** Moves a track to position `index` (
|
|
1537
|
+
/** Moves a track to position `index` (0 .. tracks - 1) in score order. */
|
|
1243
1538
|
export function moveTrack(
|
|
1244
1539
|
score: TrackScore,
|
|
1245
1540
|
trackId: string,
|
|
1246
1541
|
index: number,
|
|
1247
1542
|
): TrackScore {
|
|
1248
1543
|
const from = score.tracks.findIndex((track) => track.id === trackId);
|
|
1249
|
-
if (
|
|
1544
|
+
if (
|
|
1545
|
+
from < 0 ||
|
|
1546
|
+
!Number.isInteger(index) ||
|
|
1547
|
+
index < 0 ||
|
|
1548
|
+
index >= score.tracks.length
|
|
1549
|
+
)
|
|
1250
1550
|
throw new ScoreValidationError(
|
|
1251
|
-
|
|
1551
|
+
`moveTrack needs an existing track and an index 0..${score.tracks.length - 1}`,
|
|
1252
1552
|
"invalid-track",
|
|
1253
1553
|
);
|
|
1254
|
-
const to =
|
|
1554
|
+
const to = index;
|
|
1255
1555
|
if (from === to) return score;
|
|
1256
1556
|
const tracks = [...score.tracks];
|
|
1257
1557
|
const [moved] = tracks.splice(from, 1);
|
|
@@ -1414,6 +1714,22 @@ export type ScoreOperation =
|
|
|
1414
1714
|
type: "setMaster";
|
|
1415
1715
|
master: SongMaster | null;
|
|
1416
1716
|
}>
|
|
1717
|
+
| Readonly<{
|
|
1718
|
+
/** Sound calibration revision (0.7); 0 or null is legacy. */
|
|
1719
|
+
type: "setCalibration";
|
|
1720
|
+
calibration: number | null;
|
|
1721
|
+
}>
|
|
1722
|
+
| Readonly<{
|
|
1723
|
+
/** Which style and seed made the song (quality-08); null clears it. */
|
|
1724
|
+
type: "setStyle";
|
|
1725
|
+
style: SongStyle | null;
|
|
1726
|
+
}>
|
|
1727
|
+
| Readonly<{
|
|
1728
|
+
/** Replaces a track's audio clips (0.7); null clears them. */
|
|
1729
|
+
type: "setClips";
|
|
1730
|
+
trackId: string;
|
|
1731
|
+
clips: readonly AudioClip[] | null;
|
|
1732
|
+
}>
|
|
1417
1733
|
| Readonly<{
|
|
1418
1734
|
type: "setSections";
|
|
1419
1735
|
sections: readonly Section[];
|
|
@@ -1436,6 +1752,9 @@ export function applyScoreOperation(
|
|
|
1436
1752
|
if (operation.type === "setTime") return score.withTime(operation.time);
|
|
1437
1753
|
if (operation.type === "setTuning") return score.withTuning(operation.tuning);
|
|
1438
1754
|
if (operation.type === "setMaster") return score.withMaster(operation.master);
|
|
1755
|
+
if (operation.type === "setCalibration")
|
|
1756
|
+
return score.withCalibration(operation.calibration);
|
|
1757
|
+
if (operation.type === "setStyle") return score.withStyle(operation.style);
|
|
1439
1758
|
if (operation.type === "setSections")
|
|
1440
1759
|
return score.withSections(
|
|
1441
1760
|
operation.sections,
|
|
@@ -1461,6 +1780,8 @@ export function applyScoreOperation(
|
|
|
1461
1780
|
);
|
|
1462
1781
|
if (operation.type === "clearTrack")
|
|
1463
1782
|
return clearTrack(score, operation.trackId);
|
|
1783
|
+
if (operation.type === "setClips")
|
|
1784
|
+
return setClips(score, operation.trackId, operation.clips);
|
|
1464
1785
|
return assertNever(operation);
|
|
1465
1786
|
}
|
|
1466
1787
|
|
|
@@ -1491,13 +1812,22 @@ export function scoreFromJSON(value: unknown): TrackScore {
|
|
|
1491
1812
|
tracks: readonly TrackInput[];
|
|
1492
1813
|
notes: readonly NoteInput[];
|
|
1493
1814
|
master?: SongMaster;
|
|
1815
|
+
style?: SongStyle;
|
|
1494
1816
|
sections?: readonly Section[];
|
|
1495
1817
|
form?: readonly FormEntry[];
|
|
1496
1818
|
loopSection?: string | null;
|
|
1819
|
+
calibration?: number | null;
|
|
1497
1820
|
} = {
|
|
1498
1821
|
tracks: optionalArray(value.tracks).map(parseTrack),
|
|
1499
1822
|
notes: optionalArray(value.notes).map(parseNote),
|
|
1500
1823
|
};
|
|
1824
|
+
// Files written before 0.7 could hold notes on a track that no longer
|
|
1825
|
+
// exists. They never sounded and no edit could reach them, so they go.
|
|
1826
|
+
// They are still checked like any note first.
|
|
1827
|
+
const trackIds = new Set(data.tracks.map((track) => track.id));
|
|
1828
|
+
data.notes = normalizeNotes(data.notes).filter((note) =>
|
|
1829
|
+
trackIds.has(note.trackId),
|
|
1830
|
+
);
|
|
1501
1831
|
const tempoBpm = optionalNumber(value.tempoBpm);
|
|
1502
1832
|
const beatsPerBar = optionalNumber(value.beatsPerBar);
|
|
1503
1833
|
const bars = optionalNumber(value.bars);
|
|
@@ -1514,6 +1844,9 @@ export function scoreFromJSON(value: unknown): TrackScore {
|
|
|
1514
1844
|
// Validated by the constructor (core/master.ts normalizeMaster).
|
|
1515
1845
|
if (value.master !== undefined && value.master !== null)
|
|
1516
1846
|
data.master = value.master as SongMaster;
|
|
1847
|
+
// Checked by the constructor (core/style-provenance.ts).
|
|
1848
|
+
if (value.style !== undefined && value.style !== null)
|
|
1849
|
+
data.style = value.style as SongStyle;
|
|
1517
1850
|
// Shapes are checked by the constructor (normalizeSections, normalizeForm).
|
|
1518
1851
|
if (value.sections !== undefined)
|
|
1519
1852
|
data.sections = optionalArray(value.sections) as readonly Section[];
|
|
@@ -1522,6 +1855,9 @@ export function scoreFromJSON(value: unknown): TrackScore {
|
|
|
1522
1855
|
// The constructor checks the type and drops a name matching no section.
|
|
1523
1856
|
if (value.loopSection !== undefined)
|
|
1524
1857
|
data.loopSection = value.loopSection as string | null;
|
|
1858
|
+
// The constructor checks the range.
|
|
1859
|
+
if (value.calibration !== undefined)
|
|
1860
|
+
data.calibration = value.calibration as number | null;
|
|
1525
1861
|
return new TrackScore(data);
|
|
1526
1862
|
}
|
|
1527
1863
|
|
|
@@ -1651,6 +1987,21 @@ function normalizeSections(inputs: readonly unknown[]): Section[] {
|
|
|
1651
1987
|
.map(({ section }) => section);
|
|
1652
1988
|
}
|
|
1653
1989
|
|
|
1990
|
+
function normalizeCalibration(value: unknown): number | undefined {
|
|
1991
|
+
if (value === undefined || value === null || value === 0) return undefined;
|
|
1992
|
+
if (
|
|
1993
|
+
typeof value !== "number" ||
|
|
1994
|
+
!Number.isInteger(value) ||
|
|
1995
|
+
value < 0 ||
|
|
1996
|
+
value > CALIBRATION_LATEST
|
|
1997
|
+
)
|
|
1998
|
+
throw new ScoreValidationError(
|
|
1999
|
+
`calibration must be an integer 0..${CALIBRATION_LATEST}`,
|
|
2000
|
+
"invalid-score",
|
|
2001
|
+
);
|
|
2002
|
+
return value;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1654
2005
|
function normalizeLoopSection(
|
|
1655
2006
|
value: unknown,
|
|
1656
2007
|
sections: readonly Section[],
|
|
@@ -1712,7 +2063,7 @@ function normalizeTracks(inputs: readonly unknown[]): Track[] {
|
|
|
1712
2063
|
);
|
|
1713
2064
|
}
|
|
1714
2065
|
const seen = new Set<string>();
|
|
1715
|
-
|
|
2066
|
+
const tracks = inputs.map((input) => {
|
|
1716
2067
|
const track = normalizeTrack(input);
|
|
1717
2068
|
if (seen.has(track.id))
|
|
1718
2069
|
throw new ScoreValidationError(
|
|
@@ -1722,6 +2073,22 @@ function normalizeTracks(inputs: readonly unknown[]): Track[] {
|
|
|
1722
2073
|
seen.add(track.id);
|
|
1723
2074
|
return track;
|
|
1724
2075
|
});
|
|
2076
|
+
const vocoderError = vocoderScoreError(tracks, SCORE_LIMITS.maxVocoderTracks);
|
|
2077
|
+
if (vocoderError)
|
|
2078
|
+
throw new ScoreValidationError(vocoderError, "invalid-track");
|
|
2079
|
+
// Cross-track references (0.7): autotune.from names another track.
|
|
2080
|
+
for (const track of tracks) {
|
|
2081
|
+
const from = track.autotune?.from;
|
|
2082
|
+
if (from === undefined) continue;
|
|
2083
|
+
if (from === track.id || !seen.has(from))
|
|
2084
|
+
throw new ScoreValidationError(
|
|
2085
|
+
from === track.id
|
|
2086
|
+
? `track ${track.id} autotune.from cannot be the track itself`
|
|
2087
|
+
: `track ${track.id} autotune.from: unknown track "${from}"`,
|
|
2088
|
+
"invalid-track",
|
|
2089
|
+
);
|
|
2090
|
+
}
|
|
2091
|
+
return tracks;
|
|
1725
2092
|
}
|
|
1726
2093
|
|
|
1727
2094
|
function normalizeTrack(input: unknown): Track {
|
|
@@ -1814,7 +2181,26 @@ function normalizeTrack(input: unknown): Track {
|
|
|
1814
2181
|
`track ${id} has modal settings but its instrument is "${instrument}"`,
|
|
1815
2182
|
"invalid-track",
|
|
1816
2183
|
);
|
|
2184
|
+
const guitar = normalizeGuitar(input.guitar);
|
|
2185
|
+
const wind = fxOrThrow(() => normalizeWind(input.wind));
|
|
2186
|
+
if (wind && instrument !== WIND_INSTRUMENT)
|
|
2187
|
+
throw new ScoreValidationError(
|
|
2188
|
+
`track ${id} has wind settings but its instrument is "${instrument}"`,
|
|
2189
|
+
"invalid-track",
|
|
2190
|
+
);
|
|
2191
|
+
const sing = fxOrThrow(() => normalizeSing(input.sing));
|
|
2192
|
+
if (sing && instrument !== SING_INSTRUMENT)
|
|
2193
|
+
throw new ScoreValidationError(
|
|
2194
|
+
`track ${id} has sing settings but its instrument is "${instrument}"`,
|
|
2195
|
+
"invalid-track",
|
|
2196
|
+
);
|
|
1817
2197
|
const sampler = normalizeSampler(input.sampler);
|
|
2198
|
+
const takes = normalizeTakes(input.takes, id);
|
|
2199
|
+
const vocoder = fxOrThrow(() => normalizeVocoder(input.vocoder));
|
|
2200
|
+
const clips = normalizeClips(input.clips, id, takes);
|
|
2201
|
+
const autotune = fxOrThrow(() =>
|
|
2202
|
+
normalizeAutotune(input.autotune, instrument),
|
|
2203
|
+
);
|
|
1818
2204
|
const rhythm = normalizeRhythm(input.rhythm, id);
|
|
1819
2205
|
const time = timeOrThrow(
|
|
1820
2206
|
() => normalizeTrackTime(input.time, `track ${id} time`),
|
|
@@ -1835,6 +2221,28 @@ function normalizeTrack(input: unknown): Track {
|
|
|
1835
2221
|
() => normalizeTuning(input.tuning, `track ${id} tuning`),
|
|
1836
2222
|
"invalid-track",
|
|
1837
2223
|
);
|
|
2224
|
+
let softPedal: readonly PedalEvent[] | undefined;
|
|
2225
|
+
let sostenuto: readonly PedalEvent[] | undefined;
|
|
2226
|
+
try {
|
|
2227
|
+
softPedal = normalizePedal(
|
|
2228
|
+
input.softPedal,
|
|
2229
|
+
SCORE_LIMITS.maxTick,
|
|
2230
|
+
"softPedal",
|
|
2231
|
+
);
|
|
2232
|
+
sostenuto = normalizePedal(
|
|
2233
|
+
input.sostenuto,
|
|
2234
|
+
SCORE_LIMITS.maxTick,
|
|
2235
|
+
"sostenuto",
|
|
2236
|
+
SOSTENUTO_STATES,
|
|
2237
|
+
);
|
|
2238
|
+
} catch (error) {
|
|
2239
|
+
if (error instanceof ExpressionValidationError)
|
|
2240
|
+
throw new ScoreValidationError(
|
|
2241
|
+
`track ${id} ${error.message}`,
|
|
2242
|
+
"invalid-track",
|
|
2243
|
+
);
|
|
2244
|
+
throw error;
|
|
2245
|
+
}
|
|
1838
2246
|
const string = fxOrThrow(() => normalizeString(input.string));
|
|
1839
2247
|
// A bare `granular` instrument (set_instrument, `instrument granular`)
|
|
1840
2248
|
// gets an empty object, so the engine plays its defaults.
|
|
@@ -1907,9 +2315,94 @@ function normalizeTrack(input: unknown): Track {
|
|
|
1907
2315
|
...(granular ? { granular } : {}),
|
|
1908
2316
|
...(keys ? { keys } : {}),
|
|
1909
2317
|
...(modal ? { modal } : {}),
|
|
2318
|
+
...(softPedal ? { softPedal } : {}),
|
|
2319
|
+
...(sostenuto ? { sostenuto } : {}),
|
|
2320
|
+
...(guitar ? { guitar } : {}),
|
|
2321
|
+
...(wind ? { wind } : {}),
|
|
2322
|
+
...(sing ? { sing } : {}),
|
|
2323
|
+
...(clips ? { clips } : {}),
|
|
2324
|
+
...(takes ? { takes } : {}),
|
|
2325
|
+
...(vocoder ? { vocoder } : {}),
|
|
2326
|
+
...(autotune ? { autotune } : {}),
|
|
1910
2327
|
});
|
|
1911
2328
|
}
|
|
1912
2329
|
|
|
2330
|
+
/** Track.guitar (0.6.1): a tuning name or open-string pitches plus fretting. */
|
|
2331
|
+
export type TrackGuitar = Readonly<{
|
|
2332
|
+
/** A GUITAR_TUNINGS name or 3..12 open-string MIDI pitches, low to high. */
|
|
2333
|
+
tune?: string | readonly number[];
|
|
2334
|
+
/** Capo fret 0..12. */
|
|
2335
|
+
capo?: number;
|
|
2336
|
+
/** Hand stretch in frets 3..6 (default 4). */
|
|
2337
|
+
hand?: number;
|
|
2338
|
+
/** 0 closed shapes .. 1 ringing open strings (default 0.5). */
|
|
2339
|
+
ring?: number;
|
|
2340
|
+
/** Preferred fret position 0..12. */
|
|
2341
|
+
position?: number;
|
|
2342
|
+
}>;
|
|
2343
|
+
|
|
2344
|
+
/** Validates Track.guitar; `{}` and null normalize to absent. */
|
|
2345
|
+
export function normalizeGuitar(input: unknown): TrackGuitar | undefined {
|
|
2346
|
+
if (input === undefined || input === null) return undefined;
|
|
2347
|
+
const fail = (message: string): never => {
|
|
2348
|
+
throw new ScoreValidationError(`track guitar ${message}`, "invalid-track");
|
|
2349
|
+
};
|
|
2350
|
+
if (typeof input !== "object" || Array.isArray(input))
|
|
2351
|
+
fail("must be an object or null");
|
|
2352
|
+
const out: Record<string, unknown> = {};
|
|
2353
|
+
const record = input as Record<string, unknown>;
|
|
2354
|
+
for (const key of Object.keys(record))
|
|
2355
|
+
if (!["tune", "capo", "hand", "ring", "position"].includes(key))
|
|
2356
|
+
fail(`has no field "${key.slice(0, 32)}"`);
|
|
2357
|
+
const tune = record.tune;
|
|
2358
|
+
if (tune !== undefined && tune !== null) {
|
|
2359
|
+
if (typeof tune === "string") {
|
|
2360
|
+
const name = tune.toLowerCase().replace(/[\s_-]/g, "");
|
|
2361
|
+
if (!(GUITAR_TUNING_NAMES as readonly string[]).includes(name))
|
|
2362
|
+
fail(
|
|
2363
|
+
`tune must be one of ${GUITAR_TUNING_NAMES.join(", ")} or a list of pitches`,
|
|
2364
|
+
);
|
|
2365
|
+
out.tune = name;
|
|
2366
|
+
} else if (
|
|
2367
|
+
Array.isArray(tune) &&
|
|
2368
|
+
tune.length >= 3 &&
|
|
2369
|
+
tune.length <= 12 &&
|
|
2370
|
+
tune.every((p) => Number.isInteger(p) && p >= 0 && p <= 127)
|
|
2371
|
+
)
|
|
2372
|
+
out.tune = Object.freeze([...(tune as number[])]);
|
|
2373
|
+
else fail("tune must be a tuning name or 3..12 MIDI pitches");
|
|
2374
|
+
}
|
|
2375
|
+
const int = (key: string, min: number, max: number) => {
|
|
2376
|
+
const value = record[key];
|
|
2377
|
+
if (value === undefined || value === null) return;
|
|
2378
|
+
if (
|
|
2379
|
+
typeof value !== "number" ||
|
|
2380
|
+
!Number.isInteger(value) ||
|
|
2381
|
+
value < min ||
|
|
2382
|
+
value > max
|
|
2383
|
+
)
|
|
2384
|
+
fail(`${key} must be an integer ${min}..${max}`);
|
|
2385
|
+
out[key] = value;
|
|
2386
|
+
};
|
|
2387
|
+
int("capo", 0, 12);
|
|
2388
|
+
int("hand", 3, 6);
|
|
2389
|
+
const ring = record.ring;
|
|
2390
|
+
if (ring !== undefined && ring !== null) {
|
|
2391
|
+
if (
|
|
2392
|
+
typeof ring !== "number" ||
|
|
2393
|
+
!Number.isFinite(ring) ||
|
|
2394
|
+
ring < 0 ||
|
|
2395
|
+
ring > 1
|
|
2396
|
+
)
|
|
2397
|
+
fail("ring must be 0..1");
|
|
2398
|
+
out.ring = Math.round((ring as number) * 1000) / 1000;
|
|
2399
|
+
}
|
|
2400
|
+
int("position", 0, 12);
|
|
2401
|
+
return Object.keys(out).length > 0
|
|
2402
|
+
? (Object.freeze(out) as TrackGuitar)
|
|
2403
|
+
: undefined;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
1913
2406
|
export function normalizeRhythm(
|
|
1914
2407
|
input: unknown,
|
|
1915
2408
|
trackId: string,
|
|
@@ -2032,6 +2525,8 @@ function trackEffectExtras(
|
|
|
2032
2525
|
}
|
|
2033
2526
|
|
|
2034
2527
|
const VOICE_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
2528
|
+
/** A sampler round-robin group name (`SampleRef.rr`). */
|
|
2529
|
+
const SAMPLE_RR_GROUP = /^[A-Za-z0-9_]{1,32}$/;
|
|
2035
2530
|
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
2036
2531
|
|
|
2037
2532
|
/** Validates a `Sampler` from unknown; `undefined`/`null` means none. */
|
|
@@ -2117,6 +2612,13 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
|
|
|
2117
2612
|
bpm?: number;
|
|
2118
2613
|
fitmode?: SampleFitMode;
|
|
2119
2614
|
len?: number;
|
|
2615
|
+
shift?: number;
|
|
2616
|
+
formant?: number;
|
|
2617
|
+
fadeTime?: number;
|
|
2618
|
+
fadeInTime?: number;
|
|
2619
|
+
from?: SampleProvenance;
|
|
2620
|
+
vel?: readonly [number, number];
|
|
2621
|
+
rr?: string;
|
|
2120
2622
|
} = { src };
|
|
2121
2623
|
if (input.sha256 !== undefined) {
|
|
2122
2624
|
if (typeof input.sha256 !== "string" || !SHA256_HEX.test(input.sha256))
|
|
@@ -2309,6 +2811,28 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
|
|
|
2309
2811
|
);
|
|
2310
2812
|
ref.len = len;
|
|
2311
2813
|
}
|
|
2814
|
+
if (input.vel !== undefined) {
|
|
2815
|
+
const vel = input.vel;
|
|
2816
|
+
if (
|
|
2817
|
+
!Array.isArray(vel) ||
|
|
2818
|
+
vel.length !== 2 ||
|
|
2819
|
+
!vel.every((v) => Number.isInteger(v) && v >= 0 && v <= 127) ||
|
|
2820
|
+
vel[0] > vel[1]
|
|
2821
|
+
)
|
|
2822
|
+
throw new ScoreValidationError(
|
|
2823
|
+
`${label} vel must be [lo, hi], MIDI velocities 0..127 with lo ≤ hi`,
|
|
2824
|
+
"invalid-track",
|
|
2825
|
+
);
|
|
2826
|
+
ref.vel = Object.freeze([vel[0] as number, vel[1] as number] as const);
|
|
2827
|
+
}
|
|
2828
|
+
if (input.rr !== undefined) {
|
|
2829
|
+
if (typeof input.rr !== "string" || !SAMPLE_RR_GROUP.test(input.rr))
|
|
2830
|
+
throw new ScoreValidationError(
|
|
2831
|
+
`${label} rr must be a round-robin group name (letters, digits, _; at most 32)`,
|
|
2832
|
+
"invalid-track",
|
|
2833
|
+
);
|
|
2834
|
+
ref.rr = input.rr;
|
|
2835
|
+
}
|
|
2312
2836
|
if (
|
|
2313
2837
|
ref.fitmode !== undefined &&
|
|
2314
2838
|
ref.fitmode !== "repitch" &&
|
|
@@ -2320,9 +2844,100 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
|
|
|
2320
2844
|
`${label} fitmode ${ref.fitmode} needs bpm, len or fit`,
|
|
2321
2845
|
"invalid-track",
|
|
2322
2846
|
);
|
|
2847
|
+
if (input.shift !== undefined)
|
|
2848
|
+
ref.shift = boundedNumber(
|
|
2849
|
+
input.shift,
|
|
2850
|
+
`${label} shift`,
|
|
2851
|
+
-SCORE_LIMITS.maxSampleShift,
|
|
2852
|
+
SCORE_LIMITS.maxSampleShift,
|
|
2853
|
+
);
|
|
2854
|
+
if (input.formant !== undefined)
|
|
2855
|
+
ref.formant = boundedNumber(
|
|
2856
|
+
input.formant,
|
|
2857
|
+
`${label} formant`,
|
|
2858
|
+
-SCORE_LIMITS.maxSampleShift,
|
|
2859
|
+
SCORE_LIMITS.maxSampleShift,
|
|
2860
|
+
);
|
|
2861
|
+
if (input.fadeTime !== undefined)
|
|
2862
|
+
ref.fadeTime = boundedNumber(
|
|
2863
|
+
input.fadeTime,
|
|
2864
|
+
`${label} fadeTime`,
|
|
2865
|
+
0,
|
|
2866
|
+
SCORE_LIMITS.maxSampleFadeSeconds,
|
|
2867
|
+
);
|
|
2868
|
+
if (input.fadeInTime !== undefined)
|
|
2869
|
+
ref.fadeInTime = boundedNumber(
|
|
2870
|
+
input.fadeInTime,
|
|
2871
|
+
`${label} fadeInTime`,
|
|
2872
|
+
0,
|
|
2873
|
+
SCORE_LIMITS.maxSampleFadeSeconds,
|
|
2874
|
+
);
|
|
2875
|
+
if (input.from !== undefined)
|
|
2876
|
+
ref.from = normalizeProvenance(input.from, `${label} from`);
|
|
2323
2877
|
return Object.freeze(ref);
|
|
2324
2878
|
}
|
|
2325
2879
|
|
|
2880
|
+
const PROVENANCE_SOURCE =
|
|
2881
|
+
/^(?:track:[A-Za-z0-9_-]{1,64}|orbit:[0-9]{1,2}|master)$/;
|
|
2882
|
+
|
|
2883
|
+
function normalizeProvenance(input: unknown, label: string): SampleProvenance {
|
|
2884
|
+
if (
|
|
2885
|
+
!isRecord(input) ||
|
|
2886
|
+
typeof input.source !== "string" ||
|
|
2887
|
+
!PROVENANCE_SOURCE.test(input.source) ||
|
|
2888
|
+
typeof input.score !== "string" ||
|
|
2889
|
+
!SHA256_HEX.test(input.score)
|
|
2890
|
+
)
|
|
2891
|
+
throw new ScoreValidationError(
|
|
2892
|
+
`${label} must be { source: track:<id>|orbit:<n>|master, score: <sha256> }`,
|
|
2893
|
+
"invalid-track",
|
|
2894
|
+
);
|
|
2895
|
+
const from: {
|
|
2896
|
+
source: string;
|
|
2897
|
+
section?: string;
|
|
2898
|
+
bars?: [number, number];
|
|
2899
|
+
score: string;
|
|
2900
|
+
} = {
|
|
2901
|
+
source: input.source,
|
|
2902
|
+
score: input.score,
|
|
2903
|
+
};
|
|
2904
|
+
if (input.section !== undefined) {
|
|
2905
|
+
if (
|
|
2906
|
+
typeof input.section !== "string" ||
|
|
2907
|
+
input.section.length === 0 ||
|
|
2908
|
+
input.section.length > 64
|
|
2909
|
+
)
|
|
2910
|
+
throw new ScoreValidationError(
|
|
2911
|
+
`${label} section must be a section name`,
|
|
2912
|
+
"invalid-track",
|
|
2913
|
+
);
|
|
2914
|
+
from.section = input.section;
|
|
2915
|
+
}
|
|
2916
|
+
if (input.bars !== undefined) {
|
|
2917
|
+
const bars = input.bars;
|
|
2918
|
+
if (
|
|
2919
|
+
!Array.isArray(bars) ||
|
|
2920
|
+
bars.length !== 2 ||
|
|
2921
|
+
!bars.every((b) => Number.isInteger(b) && b >= 1 && b <= 100_000) ||
|
|
2922
|
+
bars[0] > bars[1]
|
|
2923
|
+
)
|
|
2924
|
+
throw new ScoreValidationError(
|
|
2925
|
+
`${label} bars must be [a, b] with 1 ≤ a ≤ b`,
|
|
2926
|
+
"invalid-track",
|
|
2927
|
+
);
|
|
2928
|
+
from.bars = [bars[0], bars[1]];
|
|
2929
|
+
}
|
|
2930
|
+
// Key order is fixed so the printed JSON is stable.
|
|
2931
|
+
return Object.freeze({
|
|
2932
|
+
source: from.source,
|
|
2933
|
+
...(from.section !== undefined ? { section: from.section } : {}),
|
|
2934
|
+
...(from.bars !== undefined
|
|
2935
|
+
? { bars: Object.freeze(from.bars) as readonly [number, number] }
|
|
2936
|
+
: {}),
|
|
2937
|
+
score: from.score,
|
|
2938
|
+
});
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2326
2941
|
/** Prefix of a pack sound reference in `SampleRef.src`. */
|
|
2327
2942
|
export const PACK_PREFIX = "pack:";
|
|
2328
2943
|
|
|
@@ -2638,6 +3253,12 @@ function normalizeNote(input: unknown): Note {
|
|
|
2638
3253
|
"invalid-note",
|
|
2639
3254
|
);
|
|
2640
3255
|
}
|
|
3256
|
+
if (startTick + durationTicks > SCORE_LIMITS.maxTick) {
|
|
3257
|
+
throw new ScoreValidationError(
|
|
3258
|
+
`note ${id} must end by tick ${SCORE_LIMITS.maxTick}`,
|
|
3259
|
+
"invalid-note",
|
|
3260
|
+
);
|
|
3261
|
+
}
|
|
2641
3262
|
const pitch = input.pitch;
|
|
2642
3263
|
if (
|
|
2643
3264
|
typeof pitch !== "number" ||
|
|
@@ -2676,6 +3297,11 @@ function normalizeNote(input: unknown): Note {
|
|
|
2676
3297
|
() => normalizeNoteCents(input.cents, `note ${id}`),
|
|
2677
3298
|
"invalid-note",
|
|
2678
3299
|
);
|
|
3300
|
+
const lyric = normalizeLyric(input.lyric, id);
|
|
3301
|
+
const vowel =
|
|
3302
|
+
input.vowel === undefined || input.vowel === null
|
|
3303
|
+
? undefined
|
|
3304
|
+
: vowelOrThrow(input.vowel, id);
|
|
2679
3305
|
return Object.freeze({
|
|
2680
3306
|
id,
|
|
2681
3307
|
trackId,
|
|
@@ -2685,6 +3311,8 @@ function normalizeNote(input: unknown): Note {
|
|
|
2685
3311
|
velocity: input.velocity,
|
|
2686
3312
|
...expression,
|
|
2687
3313
|
...(cents !== undefined ? { cents } : {}),
|
|
3314
|
+
...(lyric !== undefined ? { lyric } : {}),
|
|
3315
|
+
...(vowel !== undefined ? { vowel } : {}),
|
|
2688
3316
|
});
|
|
2689
3317
|
}
|
|
2690
3318
|
|
|
@@ -2703,12 +3331,49 @@ function parseNote(value: unknown): NoteInput {
|
|
|
2703
3331
|
function compareNotes(a: Note, b: Note): number {
|
|
2704
3332
|
return (
|
|
2705
3333
|
a.startTick - b.startTick ||
|
|
2706
|
-
a.trackId
|
|
3334
|
+
compareIds(a.trackId, b.trackId) ||
|
|
2707
3335
|
a.pitch - b.pitch ||
|
|
2708
|
-
a.id
|
|
3336
|
+
compareIds(a.id, b.id)
|
|
2709
3337
|
);
|
|
2710
3338
|
}
|
|
2711
3339
|
|
|
3340
|
+
/**
|
|
3341
|
+
* Printable ASCII in the order ICU's root collation gives it (uppercase
|
|
3342
|
+
* letters share their lowercase rank and sort after them on a tie).
|
|
3343
|
+
*/
|
|
3344
|
+
const ID_COLLATION =
|
|
3345
|
+
" _-,;:!?.'\"()[]{}@*/\\&#%`^+<=>|~$0123456789abcdefghijklmnopqrstuvwxyz";
|
|
3346
|
+
const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
|
|
3347
|
+
|
|
3348
|
+
/**
|
|
3349
|
+
* Canonical id order, independent of locale and ICU version. Printable
|
|
3350
|
+
* ASCII ids keep the order `localeCompare` gave them before 0.7 (so older
|
|
3351
|
+
* projects print and render byte-identically); any other id sorts after
|
|
3352
|
+
* them by UTF-16 code units, so canonically equivalent spellings stay
|
|
3353
|
+
* distinct and the order is total.
|
|
3354
|
+
*/
|
|
3355
|
+
export function compareIds(a: string, b: string): number {
|
|
3356
|
+
if (a === b) return 0;
|
|
3357
|
+
const asciiA = PRINTABLE_ASCII.test(a);
|
|
3358
|
+
const asciiB = PRINTABLE_ASCII.test(b);
|
|
3359
|
+
if (asciiA !== asciiB) return asciiA ? -1 : 1;
|
|
3360
|
+
if (!asciiA) return a < b ? -1 : 1;
|
|
3361
|
+
const length = Math.min(a.length, b.length);
|
|
3362
|
+
for (let i = 0; i < length; i += 1) {
|
|
3363
|
+
const rank =
|
|
3364
|
+
ID_COLLATION.indexOf(a[i]!.toLowerCase()) -
|
|
3365
|
+
ID_COLLATION.indexOf(b[i]!.toLowerCase());
|
|
3366
|
+
if (rank !== 0) return rank;
|
|
3367
|
+
}
|
|
3368
|
+
if (a.length !== b.length) return a.length - b.length;
|
|
3369
|
+
for (let i = 0; i < length; i += 1) {
|
|
3370
|
+
if (a[i] === b[i]) continue;
|
|
3371
|
+
// Same letter, different case: lowercase first.
|
|
3372
|
+
return a[i] === a[i]!.toLowerCase() ? -1 : 1;
|
|
3373
|
+
}
|
|
3374
|
+
return 0;
|
|
3375
|
+
}
|
|
3376
|
+
|
|
2712
3377
|
function boundedString(
|
|
2713
3378
|
value: unknown,
|
|
2714
3379
|
label: string,
|
|
@@ -2758,3 +3423,503 @@ function assertNever(value: never): never {
|
|
|
2758
3423
|
`unsupported score operation: ${String(value)}`,
|
|
2759
3424
|
);
|
|
2760
3425
|
}
|
|
3426
|
+
|
|
3427
|
+
// ---------------------------------------------------------------------------
|
|
3428
|
+
// 0.7 Voice contract: audio clips, takes and lyrics (shapes only). The
|
|
3429
|
+
// renderer ignores these fields until the clips lane wires them up.
|
|
3430
|
+
// ---------------------------------------------------------------------------
|
|
3431
|
+
|
|
3432
|
+
/** How a text-to-speech clip was made and where its syllables fall (0.7.1). */
|
|
3433
|
+
export type ClipSay = Readonly<{
|
|
3434
|
+
engine: "say" | "espeak-ng" | "piper";
|
|
3435
|
+
/** Stock voice name, at most 64 characters. */
|
|
3436
|
+
voice: string;
|
|
3437
|
+
/** Words per minute 80..400 (default 180). */
|
|
3438
|
+
rate?: number;
|
|
3439
|
+
/** Licence of the voice output, recorded with the clip. */
|
|
3440
|
+
license: string;
|
|
3441
|
+
/** Syllable starts in seconds, strictly increasing. */
|
|
3442
|
+
cuts: readonly number[];
|
|
3443
|
+
/** Voicing onsets in seconds (= cuts for unvoiced syllables). */
|
|
3444
|
+
vowels: readonly number[];
|
|
3445
|
+
/** Indices of unvoiced syllables. */
|
|
3446
|
+
unvoiced?: readonly number[];
|
|
3447
|
+
/** Octave shift applied to targets, -3..3. */
|
|
3448
|
+
oct?: number;
|
|
3449
|
+
/** 0..1 share of the spoken contour kept. */
|
|
3450
|
+
soft?: number;
|
|
3451
|
+
/** The pinned sung buffer. */
|
|
3452
|
+
sung?: Readonly<{ src: string; sha256: string; key: string }>;
|
|
3453
|
+
}>;
|
|
3454
|
+
|
|
3455
|
+
/** An audio file placed on a track's timeline (0.7). Times in seconds. */
|
|
3456
|
+
export type AudioClip = Readonly<{
|
|
3457
|
+
id: string;
|
|
3458
|
+
src: string;
|
|
3459
|
+
sha256: string;
|
|
3460
|
+
startTick: number;
|
|
3461
|
+
offset?: number;
|
|
3462
|
+
dur?: number;
|
|
3463
|
+
gain?: number;
|
|
3464
|
+
fadeInTime?: number;
|
|
3465
|
+
fadeTime?: number;
|
|
3466
|
+
rev?: boolean;
|
|
3467
|
+
/** Name of the track's take this clip plays from. */
|
|
3468
|
+
take?: string;
|
|
3469
|
+
mute?: boolean;
|
|
3470
|
+
text?: string;
|
|
3471
|
+
say?: ClipSay;
|
|
3472
|
+
}>;
|
|
3473
|
+
|
|
3474
|
+
/** A recorded or imported take (0.7 shape; recording is 0.7.1). */
|
|
3475
|
+
export type Take = Readonly<{
|
|
3476
|
+
name: string;
|
|
3477
|
+
src: string;
|
|
3478
|
+
sha256: string;
|
|
3479
|
+
startTick: number;
|
|
3480
|
+
/** Seconds into the file where the take starts. */
|
|
3481
|
+
offset: number;
|
|
3482
|
+
/** Round-trip latency compensated, in seconds. */
|
|
3483
|
+
latency: number;
|
|
3484
|
+
latencyAssumed?: boolean;
|
|
3485
|
+
/** Clock drift in parts per million (±1000). */
|
|
3486
|
+
ppm?: number;
|
|
3487
|
+
/** 0..1 fit of the alignment. */
|
|
3488
|
+
fit?: number;
|
|
3489
|
+
warn?: string;
|
|
3490
|
+
/** Manual nudge in milliseconds (±250). */
|
|
3491
|
+
nudge?: number;
|
|
3492
|
+
inTick: number;
|
|
3493
|
+
outTick: number;
|
|
3494
|
+
}>;
|
|
3495
|
+
|
|
3496
|
+
const MAX_CLIP_LABEL_LENGTH = 128;
|
|
3497
|
+
|
|
3498
|
+
function clipError(message: string): ScoreValidationError {
|
|
3499
|
+
return new ScoreValidationError(message, "invalid-track");
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
function clipNumber(
|
|
3503
|
+
value: unknown,
|
|
3504
|
+
label: string,
|
|
3505
|
+
min: number,
|
|
3506
|
+
max: number,
|
|
3507
|
+
): number | undefined {
|
|
3508
|
+
if (value === undefined) return undefined;
|
|
3509
|
+
if (
|
|
3510
|
+
typeof value !== "number" ||
|
|
3511
|
+
!Number.isFinite(value) ||
|
|
3512
|
+
value < min ||
|
|
3513
|
+
value > max
|
|
3514
|
+
)
|
|
3515
|
+
throw clipError(`${label} must be a number ${min}..${max}`);
|
|
3516
|
+
return value;
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
function clipTick(value: unknown, label: string): number {
|
|
3520
|
+
if (
|
|
3521
|
+
typeof value !== "number" ||
|
|
3522
|
+
!Number.isInteger(value) ||
|
|
3523
|
+
value < 0 ||
|
|
3524
|
+
value > SCORE_LIMITS.maxTick
|
|
3525
|
+
)
|
|
3526
|
+
throw clipError(
|
|
3527
|
+
`${label} must be an integer tick 0..${SCORE_LIMITS.maxTick}`,
|
|
3528
|
+
);
|
|
3529
|
+
return value;
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3532
|
+
function clipString(
|
|
3533
|
+
value: unknown,
|
|
3534
|
+
label: string,
|
|
3535
|
+
max: number,
|
|
3536
|
+
): string | undefined {
|
|
3537
|
+
if (value === undefined) return undefined;
|
|
3538
|
+
if (typeof value !== "string" || value.length === 0 || value.length > max)
|
|
3539
|
+
throw clipError(`${label} must be 1..${max} characters`);
|
|
3540
|
+
return value;
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3543
|
+
function clipBool(value: unknown, label: string): boolean | undefined {
|
|
3544
|
+
if (value === undefined) return undefined;
|
|
3545
|
+
if (typeof value !== "boolean") throw clipError(`${label} must be a boolean`);
|
|
3546
|
+
return value || undefined;
|
|
3547
|
+
}
|
|
3548
|
+
|
|
3549
|
+
function clipSrc(value: unknown, label: string): string {
|
|
3550
|
+
const packRef = typeof value === "string" && value.startsWith(PACK_PREFIX);
|
|
3551
|
+
if (
|
|
3552
|
+
typeof value !== "string" ||
|
|
3553
|
+
value.length === 0 ||
|
|
3554
|
+
value.length > SCORE_LIMITS.maxSamplePathLength ||
|
|
3555
|
+
(packRef ? !isPackRef(value) : !isSafeRelativePath(value))
|
|
3556
|
+
)
|
|
3557
|
+
throw clipError(
|
|
3558
|
+
`${label} src must be a project-relative path without ".." or pack:<pack>/<sound>, at most ${SCORE_LIMITS.maxSamplePathLength} characters`,
|
|
3559
|
+
);
|
|
3560
|
+
return value;
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
function clipSha(value: unknown, label: string): string {
|
|
3564
|
+
if (typeof value !== "string" || !SHA256_HEX.test(value))
|
|
3565
|
+
throw clipError(`${label} sha256 must be 64 lowercase hex characters`);
|
|
3566
|
+
return value;
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3569
|
+
function increasingTimes(value: unknown, label: string): number[] {
|
|
3570
|
+
if (!Array.isArray(value) || value.length > SCORE_LIMITS.maxSayCuts)
|
|
3571
|
+
throw clipError(
|
|
3572
|
+
`${label} must be an array of at most ${SCORE_LIMITS.maxSayCuts} times`,
|
|
3573
|
+
);
|
|
3574
|
+
let previous = -Infinity;
|
|
3575
|
+
return value.map((time: unknown, index) => {
|
|
3576
|
+
if (
|
|
3577
|
+
typeof time !== "number" ||
|
|
3578
|
+
!Number.isFinite(time) ||
|
|
3579
|
+
time < 0 ||
|
|
3580
|
+
time > SCORE_LIMITS.maxClipSeconds
|
|
3581
|
+
)
|
|
3582
|
+
throw clipError(
|
|
3583
|
+
`${label}[${index}] must be seconds 0..${SCORE_LIMITS.maxClipSeconds}`,
|
|
3584
|
+
);
|
|
3585
|
+
if (time <= previous)
|
|
3586
|
+
throw clipError(`${label} must be strictly increasing`);
|
|
3587
|
+
previous = time;
|
|
3588
|
+
return time;
|
|
3589
|
+
});
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
/** Validates ClipSay; absent stays absent. */
|
|
3593
|
+
export function normalizeClipSay(
|
|
3594
|
+
input: unknown,
|
|
3595
|
+
label: string,
|
|
3596
|
+
): ClipSay | undefined {
|
|
3597
|
+
if (input === undefined || input === null) return undefined;
|
|
3598
|
+
if (!isRecord(input)) throw clipError(`${label} say must be an object`);
|
|
3599
|
+
const engine = input.engine;
|
|
3600
|
+
if (engine !== "say" && engine !== "espeak-ng" && engine !== "piper")
|
|
3601
|
+
throw clipError(`${label} say.engine must be say, espeak-ng or piper`);
|
|
3602
|
+
const voice = clipString(
|
|
3603
|
+
input.voice,
|
|
3604
|
+
`${label} say.voice`,
|
|
3605
|
+
SCORE_LIMITS.maxTtsVoiceLength,
|
|
3606
|
+
);
|
|
3607
|
+
if (voice === undefined) throw clipError(`${label} say.voice is required`);
|
|
3608
|
+
const license = clipString(
|
|
3609
|
+
input.license,
|
|
3610
|
+
`${label} say.license`,
|
|
3611
|
+
MAX_CLIP_LABEL_LENGTH,
|
|
3612
|
+
);
|
|
3613
|
+
if (license === undefined)
|
|
3614
|
+
throw clipError(`${label} say.license is required`);
|
|
3615
|
+
const rate = clipNumber(input.rate, `${label} say.rate`, 80, 400);
|
|
3616
|
+
const cuts = increasingTimes(input.cuts, `${label} say.cuts`);
|
|
3617
|
+
const vowels = increasingTimes(input.vowels, `${label} say.vowels`);
|
|
3618
|
+
if (cuts.length !== vowels.length)
|
|
3619
|
+
throw clipError(`${label} say.cuts and say.vowels must be the same length`);
|
|
3620
|
+
cuts.forEach((cut, index) => {
|
|
3621
|
+
if (cut > vowels[index]!)
|
|
3622
|
+
throw clipError(
|
|
3623
|
+
`${label} say.cuts[${index}] must be <= say.vowels[${index}]`,
|
|
3624
|
+
);
|
|
3625
|
+
});
|
|
3626
|
+
let unvoiced: number[] | undefined;
|
|
3627
|
+
if (input.unvoiced !== undefined) {
|
|
3628
|
+
if (!Array.isArray(input.unvoiced))
|
|
3629
|
+
throw clipError(`${label} say.unvoiced must be an array of indices`);
|
|
3630
|
+
let previous = -1;
|
|
3631
|
+
unvoiced = input.unvoiced.map((index: unknown) => {
|
|
3632
|
+
if (
|
|
3633
|
+
typeof index !== "number" ||
|
|
3634
|
+
!Number.isInteger(index) ||
|
|
3635
|
+
index <= previous ||
|
|
3636
|
+
index >= cuts.length
|
|
3637
|
+
)
|
|
3638
|
+
throw clipError(
|
|
3639
|
+
`${label} say.unvoiced must be increasing indices below ${cuts.length}`,
|
|
3640
|
+
);
|
|
3641
|
+
previous = index;
|
|
3642
|
+
return index;
|
|
3643
|
+
});
|
|
3644
|
+
}
|
|
3645
|
+
const oct = clipNumber(input.oct, `${label} say.oct`, -3, 3);
|
|
3646
|
+
if (oct !== undefined && !Number.isInteger(oct))
|
|
3647
|
+
throw clipError(`${label} say.oct must be an integer -3..3`);
|
|
3648
|
+
const soft = clipNumber(input.soft, `${label} say.soft`, 0, 1);
|
|
3649
|
+
let sung: ClipSay["sung"];
|
|
3650
|
+
if (input.sung !== undefined) {
|
|
3651
|
+
if (!isRecord(input.sung))
|
|
3652
|
+
throw clipError(`${label} say.sung must be { src, sha256, key }`);
|
|
3653
|
+
const key = clipString(
|
|
3654
|
+
input.sung.key,
|
|
3655
|
+
`${label} say.sung.key`,
|
|
3656
|
+
MAX_CLIP_LABEL_LENGTH,
|
|
3657
|
+
);
|
|
3658
|
+
if (key === undefined) throw clipError(`${label} say.sung.key is required`);
|
|
3659
|
+
sung = Object.freeze({
|
|
3660
|
+
src: clipSrc(input.sung.src, `${label} say.sung`),
|
|
3661
|
+
sha256: clipSha(input.sung.sha256, `${label} say.sung`),
|
|
3662
|
+
key,
|
|
3663
|
+
});
|
|
3664
|
+
}
|
|
3665
|
+
return Object.freeze({
|
|
3666
|
+
engine,
|
|
3667
|
+
voice,
|
|
3668
|
+
...(rate !== undefined ? { rate } : {}),
|
|
3669
|
+
license,
|
|
3670
|
+
cuts: Object.freeze(cuts),
|
|
3671
|
+
vowels: Object.freeze(vowels),
|
|
3672
|
+
...(unvoiced && unvoiced.length > 0
|
|
3673
|
+
? { unvoiced: Object.freeze(unvoiced) }
|
|
3674
|
+
: {}),
|
|
3675
|
+
...(oct !== undefined ? { oct } : {}),
|
|
3676
|
+
...(soft !== undefined ? { soft } : {}),
|
|
3677
|
+
...(sung ? { sung } : {}),
|
|
3678
|
+
});
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3681
|
+
/**
|
|
3682
|
+
* Validates Track.takes: unique names, sample path rules, inTick < outTick.
|
|
3683
|
+
* Null, absent and `[]` normalize to absent.
|
|
3684
|
+
*/
|
|
3685
|
+
export function normalizeTakes(
|
|
3686
|
+
input: unknown,
|
|
3687
|
+
trackId: string,
|
|
3688
|
+
): readonly Take[] | undefined {
|
|
3689
|
+
if (input === undefined || input === null) return undefined;
|
|
3690
|
+
if (!Array.isArray(input))
|
|
3691
|
+
throw clipError(`track ${trackId} takes must be an array`);
|
|
3692
|
+
if (input.length > SCORE_LIMITS.maxTakesPerTrack)
|
|
3693
|
+
throw new ScoreValidationError(
|
|
3694
|
+
`track ${trackId} takes holds at most ${SCORE_LIMITS.maxTakesPerTrack} takes`,
|
|
3695
|
+
"score-limit",
|
|
3696
|
+
);
|
|
3697
|
+
const names = new Set<string>();
|
|
3698
|
+
const takes = input.map((value: unknown, index): Take => {
|
|
3699
|
+
if (!isRecord(value))
|
|
3700
|
+
throw clipError(`track ${trackId} takes[${index}] must be an object`);
|
|
3701
|
+
const name = clipString(
|
|
3702
|
+
value.name,
|
|
3703
|
+
`track ${trackId} takes[${index}] name`,
|
|
3704
|
+
SCORE_LIMITS.maxNameLength,
|
|
3705
|
+
);
|
|
3706
|
+
if (name === undefined)
|
|
3707
|
+
throw clipError(`track ${trackId} takes[${index}] name is required`);
|
|
3708
|
+
if (names.has(name))
|
|
3709
|
+
throw clipError(`track ${trackId} take name ${name} is duplicated`);
|
|
3710
|
+
names.add(name);
|
|
3711
|
+
const label = `track ${trackId} take ${name}`;
|
|
3712
|
+
const offset = clipNumber(
|
|
3713
|
+
value.offset,
|
|
3714
|
+
`${label} offset`,
|
|
3715
|
+
0,
|
|
3716
|
+
SCORE_LIMITS.maxClipSeconds,
|
|
3717
|
+
);
|
|
3718
|
+
if (offset === undefined) throw clipError(`${label} offset is required`);
|
|
3719
|
+
const latency = clipNumber(value.latency, `${label} latency`, -1, 1);
|
|
3720
|
+
if (latency === undefined) throw clipError(`${label} latency is required`);
|
|
3721
|
+
const inTick = clipTick(value.inTick, `${label} inTick`);
|
|
3722
|
+
const outTick = clipTick(value.outTick, `${label} outTick`);
|
|
3723
|
+
if (inTick >= outTick)
|
|
3724
|
+
throw clipError(`${label} inTick must be before outTick`);
|
|
3725
|
+
const latencyAssumed = clipBool(
|
|
3726
|
+
value.latencyAssumed,
|
|
3727
|
+
`${label} latencyAssumed`,
|
|
3728
|
+
);
|
|
3729
|
+
const ppm = clipNumber(
|
|
3730
|
+
value.ppm,
|
|
3731
|
+
`${label} ppm`,
|
|
3732
|
+
-SCORE_LIMITS.maxTakePpm,
|
|
3733
|
+
SCORE_LIMITS.maxTakePpm,
|
|
3734
|
+
);
|
|
3735
|
+
const fit = clipNumber(value.fit, `${label} fit`, 0, 1);
|
|
3736
|
+
const warn = clipString(value.warn, `${label} warn`, MAX_CLIP_LABEL_LENGTH);
|
|
3737
|
+
const nudge = clipNumber(
|
|
3738
|
+
value.nudge,
|
|
3739
|
+
`${label} nudge`,
|
|
3740
|
+
-SCORE_LIMITS.maxTakeNudgeMs,
|
|
3741
|
+
SCORE_LIMITS.maxTakeNudgeMs,
|
|
3742
|
+
);
|
|
3743
|
+
return Object.freeze({
|
|
3744
|
+
name,
|
|
3745
|
+
src: clipSrc(value.src, label),
|
|
3746
|
+
sha256: clipSha(value.sha256, label),
|
|
3747
|
+
startTick: clipTick(value.startTick, `${label} startTick`),
|
|
3748
|
+
offset,
|
|
3749
|
+
latency,
|
|
3750
|
+
...(latencyAssumed ? { latencyAssumed } : {}),
|
|
3751
|
+
...(ppm !== undefined ? { ppm } : {}),
|
|
3752
|
+
...(fit !== undefined ? { fit } : {}),
|
|
3753
|
+
...(warn !== undefined ? { warn } : {}),
|
|
3754
|
+
...(nudge !== undefined ? { nudge } : {}),
|
|
3755
|
+
inTick,
|
|
3756
|
+
outTick,
|
|
3757
|
+
});
|
|
3758
|
+
});
|
|
3759
|
+
return takes.length > 0 ? Object.freeze(takes) : undefined;
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
/**
|
|
3763
|
+
* Validates Track.clips: unique ids, sample path rules, sha256 pins,
|
|
3764
|
+
* offset + dur within maxClipSeconds, fades that fit the clip, and takes
|
|
3765
|
+
* that exist on the track. Null, absent and `[]` normalize to absent.
|
|
3766
|
+
*/
|
|
3767
|
+
export function normalizeClips(
|
|
3768
|
+
input: unknown,
|
|
3769
|
+
trackId: string,
|
|
3770
|
+
takes: readonly Take[] | undefined,
|
|
3771
|
+
): readonly AudioClip[] | undefined {
|
|
3772
|
+
if (input === undefined || input === null) return undefined;
|
|
3773
|
+
if (!Array.isArray(input))
|
|
3774
|
+
throw clipError(`track ${trackId} clips must be an array`);
|
|
3775
|
+
if (input.length > SCORE_LIMITS.maxClipsPerTrack)
|
|
3776
|
+
throw new ScoreValidationError(
|
|
3777
|
+
`track ${trackId} clips holds at most ${SCORE_LIMITS.maxClipsPerTrack} clips`,
|
|
3778
|
+
"score-limit",
|
|
3779
|
+
);
|
|
3780
|
+
const takeNames = new Set((takes ?? []).map((take) => take.name));
|
|
3781
|
+
const ids = new Set<string>();
|
|
3782
|
+
const clips = input.map((value: unknown, index): AudioClip => {
|
|
3783
|
+
if (!isRecord(value))
|
|
3784
|
+
throw clipError(`track ${trackId} clips[${index}] must be an object`);
|
|
3785
|
+
const id = clipString(
|
|
3786
|
+
value.id,
|
|
3787
|
+
`track ${trackId} clips[${index}] id`,
|
|
3788
|
+
SCORE_LIMITS.maxIdLength,
|
|
3789
|
+
);
|
|
3790
|
+
if (id === undefined)
|
|
3791
|
+
throw clipError(`track ${trackId} clips[${index}] id is required`);
|
|
3792
|
+
if (ids.has(id))
|
|
3793
|
+
throw clipError(`track ${trackId} clip id ${id} is duplicated`);
|
|
3794
|
+
ids.add(id);
|
|
3795
|
+
const label = `track ${trackId} clip ${id}`;
|
|
3796
|
+
const max = SCORE_LIMITS.maxClipSeconds;
|
|
3797
|
+
const offset = clipNumber(value.offset, `${label} offset`, 0, max);
|
|
3798
|
+
const dur = clipNumber(value.dur, `${label} dur`, 0, max);
|
|
3799
|
+
if (dur === 0) throw clipError(`${label} dur must be above 0`);
|
|
3800
|
+
if ((offset ?? 0) + (dur ?? 0) > max)
|
|
3801
|
+
throw clipError(`${label} offset + dur must be at most ${max} seconds`);
|
|
3802
|
+
const gain = clipNumber(
|
|
3803
|
+
value.gain,
|
|
3804
|
+
`${label} gain`,
|
|
3805
|
+
0,
|
|
3806
|
+
SCORE_LIMITS.maxClipGain,
|
|
3807
|
+
);
|
|
3808
|
+
const fadeMax = SCORE_LIMITS.maxClipFadeSeconds;
|
|
3809
|
+
const fadeInTime = clipNumber(
|
|
3810
|
+
value.fadeInTime,
|
|
3811
|
+
`${label} fadeInTime`,
|
|
3812
|
+
0,
|
|
3813
|
+
fadeMax,
|
|
3814
|
+
);
|
|
3815
|
+
const fadeTime = clipNumber(
|
|
3816
|
+
value.fadeTime,
|
|
3817
|
+
`${label} fadeTime`,
|
|
3818
|
+
0,
|
|
3819
|
+
fadeMax,
|
|
3820
|
+
);
|
|
3821
|
+
if (dur !== undefined && (fadeInTime ?? 0) + (fadeTime ?? 0) > dur)
|
|
3822
|
+
throw clipError(`${label} fadeInTime + fadeTime must fit within dur`);
|
|
3823
|
+
const rev = clipBool(value.rev, `${label} rev`);
|
|
3824
|
+
const take = clipString(
|
|
3825
|
+
value.take,
|
|
3826
|
+
`${label} take`,
|
|
3827
|
+
SCORE_LIMITS.maxNameLength,
|
|
3828
|
+
);
|
|
3829
|
+
if (take !== undefined && !takeNames.has(take))
|
|
3830
|
+
throw clipError(`${label} take ${take} does not exist on the track`);
|
|
3831
|
+
const mute = clipBool(value.mute, `${label} mute`);
|
|
3832
|
+
const text = clipString(
|
|
3833
|
+
value.text,
|
|
3834
|
+
`${label} text`,
|
|
3835
|
+
SCORE_LIMITS.maxClipTextLength,
|
|
3836
|
+
);
|
|
3837
|
+
const say = normalizeClipSay(value.say, label);
|
|
3838
|
+
return Object.freeze({
|
|
3839
|
+
id,
|
|
3840
|
+
src: clipSrc(value.src, label),
|
|
3841
|
+
sha256: clipSha(value.sha256, label),
|
|
3842
|
+
startTick: clipTick(value.startTick, `${label} startTick`),
|
|
3843
|
+
...(offset !== undefined ? { offset } : {}),
|
|
3844
|
+
...(dur !== undefined ? { dur } : {}),
|
|
3845
|
+
...(gain !== undefined ? { gain } : {}),
|
|
3846
|
+
...(fadeInTime !== undefined ? { fadeInTime } : {}),
|
|
3847
|
+
...(fadeTime !== undefined ? { fadeTime } : {}),
|
|
3848
|
+
...(rev ? { rev } : {}),
|
|
3849
|
+
...(take !== undefined ? { take } : {}),
|
|
3850
|
+
...(mute ? { mute } : {}),
|
|
3851
|
+
...(text !== undefined ? { text } : {}),
|
|
3852
|
+
...(say ? { say } : {}),
|
|
3853
|
+
});
|
|
3854
|
+
});
|
|
3855
|
+
return clips.length > 0 ? Object.freeze(clips) : undefined;
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3858
|
+
/** Validates Note.vowel: `"a"` or a morph `"a>u"`; null clears. */
|
|
3859
|
+
function vowelOrThrow(input: unknown, noteId: string): string {
|
|
3860
|
+
try {
|
|
3861
|
+
return normalizeVowel(input);
|
|
3862
|
+
} catch (error) {
|
|
3863
|
+
if (error instanceof FxValidationError)
|
|
3864
|
+
throw new ScoreValidationError(
|
|
3865
|
+
`note ${noteId} ${error.message}`,
|
|
3866
|
+
"invalid-note",
|
|
3867
|
+
);
|
|
3868
|
+
throw error;
|
|
3869
|
+
}
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
/** Validates Note.lyric: one syllable without whitespace; null clears. */
|
|
3873
|
+
export function normalizeLyric(
|
|
3874
|
+
input: unknown,
|
|
3875
|
+
noteId: string,
|
|
3876
|
+
): string | undefined {
|
|
3877
|
+
if (input === undefined || input === null) return undefined;
|
|
3878
|
+
if (
|
|
3879
|
+
typeof input !== "string" ||
|
|
3880
|
+
input.length === 0 ||
|
|
3881
|
+
input.length > SCORE_LIMITS.maxLyricLength ||
|
|
3882
|
+
/\s/.test(input)
|
|
3883
|
+
)
|
|
3884
|
+
throw new ScoreValidationError(
|
|
3885
|
+
`note ${noteId} lyric must be 1..${SCORE_LIMITS.maxLyricLength} characters without whitespace`,
|
|
3886
|
+
"invalid-note",
|
|
3887
|
+
);
|
|
3888
|
+
return input;
|
|
3889
|
+
}
|
|
3890
|
+
|
|
3891
|
+
/** Replaces (or with null clears) a track's clips. */
|
|
3892
|
+
export function setClips(
|
|
3893
|
+
score: TrackScore,
|
|
3894
|
+
trackId: string,
|
|
3895
|
+
clips: readonly AudioClip[] | null,
|
|
3896
|
+
): TrackScore {
|
|
3897
|
+
return updateTrack(score, trackId, { clips });
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
/**
|
|
3901
|
+
* Cross-track references (0.7): fields that name another track register a
|
|
3902
|
+
* drop function here (through `registerTrackRefs` in core/routing.ts) so
|
|
3903
|
+
* removing a track never leaves a dangling reference.
|
|
3904
|
+
*/
|
|
3905
|
+
// `var` and a lazily created map: core/routing.ts registers while this
|
|
3906
|
+
// module may still be evaluating (score.ts imports routing.ts for its
|
|
3907
|
+
// side effect), so the map must not sit in a temporal dead zone.
|
|
3908
|
+
// eslint-disable-next-line no-var
|
|
3909
|
+
var trackRefDrops:
|
|
3910
|
+
Map<string, (track: Track, removedId: string) => Track> | undefined;
|
|
3911
|
+
|
|
3912
|
+
/** Registers how `field` forgets a removed track. Used by core/routing.ts. */
|
|
3913
|
+
export function registerTrackRefDrop(
|
|
3914
|
+
field: string,
|
|
3915
|
+
drop: (track: Track, removedId: string) => Track,
|
|
3916
|
+
): void {
|
|
3917
|
+
(trackRefDrops ??= new Map()).set(field, drop);
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
function dropTrackRefs(track: Track, removedId: string): Track {
|
|
3921
|
+
let next = track;
|
|
3922
|
+
for (const drop of trackRefDrops?.values() ?? [])
|
|
3923
|
+
next = drop(next, removedId);
|
|
3924
|
+
return next;
|
|
3925
|
+
}
|