@hraness/dawg 0.4.1 → 0.6.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 +99 -0
- package/DAWG.md +551 -33
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +41 -12
- package/core/expression.ts +1241 -0
- package/core/fx.ts +437 -2
- package/core/granular.ts +528 -0
- package/core/instruments.ts +281 -0
- package/core/keys.ts +386 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/resonators.ts +574 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +717 -62
- package/core/sdk/eval-child.ts +40 -3
- package/core/sdk/print.ts +614 -27
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2824 -45
- package/core/sections.ts +2072 -0
- package/core/strings.ts +845 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +29 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +29 -0
- package/guides/tempo.md +27 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +9 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +68 -6
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +27 -4
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +574 -16
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +9 -0
- package/src/audio/arrange.ts +489 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/dsp/bank.ts +233 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +6 -1
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/effects/space.ts +92 -2
- package/src/audio/engine.ts +100 -21
- package/src/audio/fit.ts +402 -0
- package/src/audio/granular.ts +664 -0
- package/src/audio/instrument-check.ts +133 -0
- package/src/audio/instruments.ts +111 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/engine.ts +361 -0
- package/src/audio/keys/piano.ts +432 -0
- package/src/audio/live-worker.ts +54 -0
- package/src/audio/live.ts +263 -17
- package/src/audio/loudness.ts +596 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +76 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +269 -34
- package/src/audio/samples.ts +30 -7
- package/src/audio/strings/body.ts +250 -0
- package/src/audio/strings/engine.ts +369 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +198 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +147 -0
- package/src/audio/wav.ts +377 -30
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +941 -0
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +31 -0
- package/src/commands/granular.ts +427 -0
- package/src/commands/help.ts +355 -27
- package/src/commands/keys.ts +353 -0
- package/src/commands/master.ts +361 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +967 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +919 -41
- package/src/project/check.ts +13 -0
- package/src/render.ts +139 -8
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +967 -23
- package/src/tui/modal-menu.ts +118 -0
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +459 -43
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +63 -1
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
package/core/score.ts
CHANGED
|
@@ -20,7 +20,45 @@ import {
|
|
|
20
20
|
type FxLane,
|
|
21
21
|
type TrackFx,
|
|
22
22
|
} from "./fx.ts";
|
|
23
|
+
import {
|
|
24
|
+
MODAL_INSTRUMENT,
|
|
25
|
+
normalizeModal,
|
|
26
|
+
type TrackModal,
|
|
27
|
+
} from "./resonators.ts";
|
|
23
28
|
import { normalizeSynth, type TrackSynth } from "./synth.ts";
|
|
29
|
+
import { normalizeString, type TrackString } from "./strings.ts";
|
|
30
|
+
import {
|
|
31
|
+
isGranularInstrument,
|
|
32
|
+
normalizeGranular,
|
|
33
|
+
type TrackGranular,
|
|
34
|
+
} from "./granular.ts";
|
|
35
|
+
import { normalizeKeys, type TrackKeys } from "./keys.ts";
|
|
36
|
+
import {
|
|
37
|
+
checkSongTime,
|
|
38
|
+
normalizeSongTime,
|
|
39
|
+
normalizeTrackTime,
|
|
40
|
+
TimeValidationError,
|
|
41
|
+
type SongTime,
|
|
42
|
+
type TrackTime,
|
|
43
|
+
withMeterChange,
|
|
44
|
+
} from "./tempo.ts";
|
|
45
|
+
import {
|
|
46
|
+
ExpressionValidationError,
|
|
47
|
+
normalizeNoteExpression,
|
|
48
|
+
normalizeTrackPerformance,
|
|
49
|
+
type NoteExpression,
|
|
50
|
+
type NoteExpressionPatch,
|
|
51
|
+
type TrackPerformance,
|
|
52
|
+
} from "./expression.ts";
|
|
53
|
+
import {
|
|
54
|
+
normalizeNoteCents,
|
|
55
|
+
normalizeTuning,
|
|
56
|
+
TuningError,
|
|
57
|
+
type Tuning,
|
|
58
|
+
} from "./tuning.ts";
|
|
59
|
+
|
|
60
|
+
export type { Tuning } from "./tuning.ts";
|
|
61
|
+
import { normalizeMaster, type SongMaster } from "./master.ts";
|
|
24
62
|
|
|
25
63
|
export const SCORE_VERSION = 1 as const;
|
|
26
64
|
export const DEFAULT_TICKS_PER_BEAT = 480 as const;
|
|
@@ -36,6 +74,13 @@ export const SCORE_LIMITS = Object.freeze({
|
|
|
36
74
|
maxTempoBpm: 300,
|
|
37
75
|
minTempoBpm: 20,
|
|
38
76
|
maxBars: 256,
|
|
77
|
+
/** Song sections (0.5): named bar ranges and the form that orders them. */
|
|
78
|
+
maxSections: 64,
|
|
79
|
+
maxSectionNameLength: 32,
|
|
80
|
+
maxFormEntries: 128,
|
|
81
|
+
maxFormRepeat: 16,
|
|
82
|
+
maxSectionTranspose: 24,
|
|
83
|
+
maxSectionGain: 2,
|
|
39
84
|
maxBeatsPerBar: 16,
|
|
40
85
|
maxTicksPerBeat: 4096,
|
|
41
86
|
maxTick: 1_000_000,
|
|
@@ -66,6 +111,11 @@ export const SCORE_LIMITS = Object.freeze({
|
|
|
66
111
|
maxSampleAccelerate: 8,
|
|
67
112
|
/** Sampler `squiz` ratio bound. */
|
|
68
113
|
maxSampleSquiz: 32,
|
|
114
|
+
/** Sampler `bpm` (source tempo) bounds. */
|
|
115
|
+
minSampleBpm: 20,
|
|
116
|
+
maxSampleBpm: 400,
|
|
117
|
+
/** Sampler `len` (window length in beats) bound. */
|
|
118
|
+
maxSampleLenBeats: 1024,
|
|
69
119
|
/** Per sample file, enforced by the decoder and the import tool. */
|
|
70
120
|
maxSampleFileBytes: 50 * 1024 * 1024,
|
|
71
121
|
maxSampleSeconds: 600,
|
|
@@ -328,7 +378,47 @@ export type Track = Readonly<{
|
|
|
328
378
|
* `lofi`, … see `core/kits.ts`). Absent plays the default voices.
|
|
329
379
|
*/
|
|
330
380
|
kit?: string;
|
|
331
|
-
|
|
381
|
+
/**
|
|
382
|
+
* Track time against the song (`core/tempo.ts`): `rate` (tempo ratio),
|
|
383
|
+
* `phase` (ticks the pattern shifts later) and `cycle` (track ticks per
|
|
384
|
+
* repetition) for polytempo, polymeter and phasing. Absent follows the
|
|
385
|
+
* song.
|
|
386
|
+
*/
|
|
387
|
+
time?: TrackTime;
|
|
388
|
+
/**
|
|
389
|
+
* Tuning for this track (`core/tuning.ts`): its own table, or just a
|
|
390
|
+
* `ref`/`root` over the song tuning. Absent follows the song tuning.
|
|
391
|
+
*/
|
|
392
|
+
tuning?: Tuning;
|
|
393
|
+
/**
|
|
394
|
+
* String engine settings (`core/strings.ts`): a preset plus overrides.
|
|
395
|
+
* Played only when `instrument` is "string"; absent keeps today's voice.
|
|
396
|
+
*/
|
|
397
|
+
string?: TrackString;
|
|
398
|
+
/**
|
|
399
|
+
* Granular instrument settings (0.6, `core/granular.ts`). Played when
|
|
400
|
+
* `instrument` is `"granular"`; kept when the instrument changes.
|
|
401
|
+
*/
|
|
402
|
+
granular?: TrackGranular;
|
|
403
|
+
/**
|
|
404
|
+
* Optional (0.6): modelled piano settings (`core/keys.ts`), `{ preset?,
|
|
405
|
+
* ...overrides }`. The modelled piano plays only when `instrument` is a
|
|
406
|
+
* piano family (grand upright felt honkytonk prepared) and this is
|
|
407
|
+
* present; `{}` is the family's defaults.
|
|
408
|
+
*/
|
|
409
|
+
keys?: TrackKeys;
|
|
410
|
+
/**
|
|
411
|
+
* Optional (0.6): modal percussion (`core/resonators.ts`), a preset plus
|
|
412
|
+
* overrides. Plays only when `instrument` is `"modal"`; any other
|
|
413
|
+
* instrument with this field is rejected. Absent keeps today's tone.
|
|
414
|
+
*/
|
|
415
|
+
modal?: TrackModal;
|
|
416
|
+
}> &
|
|
417
|
+
/**
|
|
418
|
+
* Performance (`core/expression.ts`): glide default, sustain pedal
|
|
419
|
+
* events, velocity curve and seeded humanize, all applied at render.
|
|
420
|
+
*/
|
|
421
|
+
TrackPerformance;
|
|
332
422
|
|
|
333
423
|
/**
|
|
334
424
|
* A track's sample voices (Strudel-aligned). In `oneshot` mode every voice is
|
|
@@ -399,8 +489,35 @@ export type SampleRef = Readonly<{
|
|
|
399
489
|
* (1 is off, up to 32).
|
|
400
490
|
*/
|
|
401
491
|
squiz?: number;
|
|
492
|
+
/**
|
|
493
|
+
* Optional: the sample's own tempo (20..400). The window then follows
|
|
494
|
+
* the song's tempo map (ramps included): a 174 BPM break in a 128 BPM
|
|
495
|
+
* song plays 128/174 as fast. `fitmode` picks how.
|
|
496
|
+
*/
|
|
497
|
+
bpm?: number;
|
|
498
|
+
/**
|
|
499
|
+
* Optional: how a fitted sample changes its time. `repitch` (default)
|
|
500
|
+
* changes speed and pitch together like tape; `beats` cuts the window at
|
|
501
|
+
* its onsets and places each slice on its new time, unstretched (drums,
|
|
502
|
+
* speech); `tones` time-stretches with a phase vocoder and keeps the
|
|
503
|
+
* pitch (pads, loops, vocals). Needs `bpm`, `len` or `fit`.
|
|
504
|
+
*/
|
|
505
|
+
fitmode?: SampleFitMode;
|
|
506
|
+
/**
|
|
507
|
+
* Optional: the window's length in beats (0 < len ≤ 1024), followed
|
|
508
|
+
* through the song's tempo map. `fit` wins over `bpm`, `bpm` over `len`.
|
|
509
|
+
*/
|
|
510
|
+
len?: number;
|
|
402
511
|
}>;
|
|
403
512
|
|
|
513
|
+
/** Sampler `fitmode`: how a fitted window changes its time. */
|
|
514
|
+
export type SampleFitMode = "repitch" | "beats" | "tones";
|
|
515
|
+
export const SAMPLE_FIT_MODES: readonly SampleFitMode[] = Object.freeze([
|
|
516
|
+
"repitch",
|
|
517
|
+
"beats",
|
|
518
|
+
"tones",
|
|
519
|
+
]);
|
|
520
|
+
|
|
404
521
|
/** Sample `unit`: rate, cycles (bars) or seconds. */
|
|
405
522
|
export type SampleUnit = "r" | "c" | "s";
|
|
406
523
|
export const SAMPLE_UNITS: readonly SampleUnit[] = Object.freeze([
|
|
@@ -627,6 +744,16 @@ export type TrackPatch = Readonly<
|
|
|
627
744
|
fxAutomation?: Track["fxAutomation"] | null;
|
|
628
745
|
synth?: TrackSynth | null;
|
|
629
746
|
wavetable?: TrackWavetable | null;
|
|
747
|
+
time?: TrackTime | null;
|
|
748
|
+
glide?: Track["glide"] | null;
|
|
749
|
+
pedal?: Track["pedal"] | null;
|
|
750
|
+
velocityCurve?: Track["velocityCurve"] | null;
|
|
751
|
+
humanize?: Track["humanize"] | null;
|
|
752
|
+
tuning?: Tuning | null;
|
|
753
|
+
string?: TrackString | null;
|
|
754
|
+
granular?: TrackGranular | null;
|
|
755
|
+
keys?: TrackKeys | null;
|
|
756
|
+
modal?: TrackModal | null;
|
|
630
757
|
}
|
|
631
758
|
>;
|
|
632
759
|
|
|
@@ -635,7 +762,11 @@ export type AutomationPoint = Readonly<{
|
|
|
635
762
|
value: number;
|
|
636
763
|
}>;
|
|
637
764
|
|
|
638
|
-
/**
|
|
765
|
+
/**
|
|
766
|
+
* A note's start and duration are integer ticks, never floating-point beats.
|
|
767
|
+
* Expression fields (`articulation`, `glide`, `bend`, `vibrato`, see
|
|
768
|
+
* `core/expression.ts`) are optional and absent when unused.
|
|
769
|
+
*/
|
|
639
770
|
export type Note = Readonly<{
|
|
640
771
|
id: string;
|
|
641
772
|
trackId: string;
|
|
@@ -643,7 +774,10 @@ export type Note = Readonly<{
|
|
|
643
774
|
durationTicks: number;
|
|
644
775
|
pitch: number;
|
|
645
776
|
velocity: number;
|
|
646
|
-
|
|
777
|
+
/** Static offset from the tuned pitch in cents (±1200); absent is 0. */
|
|
778
|
+
cents?: number;
|
|
779
|
+
}> &
|
|
780
|
+
NoteExpression;
|
|
647
781
|
|
|
648
782
|
export type TrackInput = Readonly<
|
|
649
783
|
Omit<
|
|
@@ -658,6 +792,16 @@ export type TrackInput = Readonly<
|
|
|
658
792
|
| "fxAutomation"
|
|
659
793
|
| "synth"
|
|
660
794
|
| "wavetable"
|
|
795
|
+
| "time"
|
|
796
|
+
| "glide"
|
|
797
|
+
| "pedal"
|
|
798
|
+
| "velocityCurve"
|
|
799
|
+
| "humanize"
|
|
800
|
+
| "tuning"
|
|
801
|
+
| "string"
|
|
802
|
+
| "granular"
|
|
803
|
+
| "keys"
|
|
804
|
+
| "modal"
|
|
661
805
|
> &
|
|
662
806
|
Pick<Track, "id"> & {
|
|
663
807
|
filter?: TrackFilter | null;
|
|
@@ -670,6 +814,16 @@ export type TrackInput = Readonly<
|
|
|
670
814
|
fxAutomation?: Track["fxAutomation"] | null;
|
|
671
815
|
synth?: TrackSynth | null;
|
|
672
816
|
wavetable?: TrackWavetable | null;
|
|
817
|
+
time?: TrackTime | null;
|
|
818
|
+
glide?: Track["glide"] | number | null;
|
|
819
|
+
pedal?: Track["pedal"] | null;
|
|
820
|
+
velocityCurve?: Track["velocityCurve"] | string | null;
|
|
821
|
+
humanize?: Track["humanize"] | null;
|
|
822
|
+
tuning?: Tuning | null;
|
|
823
|
+
string?: TrackString | null;
|
|
824
|
+
granular?: TrackGranular | null;
|
|
825
|
+
keys?: TrackKeys | null;
|
|
826
|
+
modal?: TrackModal | null;
|
|
673
827
|
}
|
|
674
828
|
>;
|
|
675
829
|
|
|
@@ -686,6 +840,41 @@ export type NoteInput = Readonly<{
|
|
|
686
840
|
duration?: number;
|
|
687
841
|
pitch: number;
|
|
688
842
|
velocity: number;
|
|
843
|
+
cents?: number;
|
|
844
|
+
}> &
|
|
845
|
+
NoteExpressionPatch;
|
|
846
|
+
|
|
847
|
+
/** A per-track change inside a section: semitones and a velocity gain. */
|
|
848
|
+
export type SectionVariation = Readonly<{
|
|
849
|
+
/** Semitones, -24..24; drum and one-shot sampler tracks ignore it. */
|
|
850
|
+
transpose?: number;
|
|
851
|
+
/** Velocity multiplier 0..2 (1 leaves it alone). */
|
|
852
|
+
gain?: number;
|
|
853
|
+
}>;
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* A named bar range of the song (0.5). Sections may overlap: two sections
|
|
857
|
+
* over the same bars with different mutes or variations are two versions
|
|
858
|
+
* of that music. `mute` and `vary` name track ids; unknown ids are ignored.
|
|
859
|
+
*/
|
|
860
|
+
export type Section = Readonly<{
|
|
861
|
+
/** Unique (ignoring case), 1..32 characters: `intro`, `chorus 2`, `A`. */
|
|
862
|
+
name: string;
|
|
863
|
+
/** First bar, 0-based. */
|
|
864
|
+
startBar: number;
|
|
865
|
+
/** Length in bars, at least 1. */
|
|
866
|
+
bars: number;
|
|
867
|
+
/** Tracks silent in this section. */
|
|
868
|
+
mute?: readonly string[];
|
|
869
|
+
/** Per-track variations in this section. */
|
|
870
|
+
vary?: Readonly<Record<string, SectionVariation>>;
|
|
871
|
+
}>;
|
|
872
|
+
|
|
873
|
+
/** One step of the song form: a section by name, played `repeat` times. */
|
|
874
|
+
export type FormEntry = Readonly<{
|
|
875
|
+
section: string;
|
|
876
|
+
/** 1..16, default 1. */
|
|
877
|
+
repeat?: number;
|
|
689
878
|
}>;
|
|
690
879
|
|
|
691
880
|
export type TrackScoreData = Readonly<{
|
|
@@ -694,8 +883,26 @@ export type TrackScoreData = Readonly<{
|
|
|
694
883
|
bars?: number;
|
|
695
884
|
ticksPerBeat?: number;
|
|
696
885
|
key?: string | null;
|
|
886
|
+
/**
|
|
887
|
+
* Tempo map, meter changes and fermatas (`core/tempo.ts`). Absent keeps
|
|
888
|
+
* `tempoBpm` and `beatsPerBar` for the whole song.
|
|
889
|
+
*/
|
|
890
|
+
time?: SongTime | null;
|
|
891
|
+
/** Song tuning (`core/tuning.ts`); absent is 12-TET at A4 = 440 Hz. */
|
|
892
|
+
tuning?: Tuning | null;
|
|
697
893
|
tracks?: readonly TrackInput[];
|
|
698
894
|
notes?: readonly NoteInput[];
|
|
895
|
+
/** Song master chain and loudness target (core/master.ts); absent is off. */
|
|
896
|
+
master?: SongMaster | null;
|
|
897
|
+
/** Song sections (0.5); absent or empty means none. */
|
|
898
|
+
sections?: readonly Section[];
|
|
899
|
+
/** Song form (0.5): the order sections play in; absent plays the score straight through. */
|
|
900
|
+
form?: readonly FormEntry[];
|
|
901
|
+
/**
|
|
902
|
+
* The section playback loops (0.5), like a DAW's loop brace; absent plays
|
|
903
|
+
* the song (or its form). A name that matches no section is dropped.
|
|
904
|
+
*/
|
|
905
|
+
loopSection?: string | null;
|
|
699
906
|
}>;
|
|
700
907
|
|
|
701
908
|
/** Canonical immutable score. Use `addNote`/`removeNote` to create a revision. */
|
|
@@ -706,8 +913,19 @@ export class TrackScore {
|
|
|
706
913
|
readonly bars: number;
|
|
707
914
|
readonly ticksPerBeat: number;
|
|
708
915
|
readonly key: string | null;
|
|
916
|
+
/** Tempo map, meters and fermatas; absent when the song has none. */
|
|
917
|
+
declare readonly time?: SongTime;
|
|
918
|
+
readonly tuning: Tuning | undefined;
|
|
709
919
|
readonly tracks: readonly Track[];
|
|
710
920
|
readonly notes: readonly Note[];
|
|
921
|
+
/** Absent (not undefined-valued) without a master, so 0.4 scores are unchanged. */
|
|
922
|
+
declare readonly master?: SongMaster;
|
|
923
|
+
/** Song sections in bar order; empty when the song has none. */
|
|
924
|
+
readonly sections: readonly Section[];
|
|
925
|
+
/** Song form; empty plays the score straight through. */
|
|
926
|
+
readonly form: readonly FormEntry[];
|
|
927
|
+
/** The section playback loops; undefined plays the song. */
|
|
928
|
+
readonly loopSection: string | undefined;
|
|
711
929
|
|
|
712
930
|
constructor(data: TrackScoreData = {}) {
|
|
713
931
|
const tempoBpm = data.tempoBpm ?? 120;
|
|
@@ -760,15 +978,49 @@ export class TrackScore {
|
|
|
760
978
|
"invalid-score",
|
|
761
979
|
);
|
|
762
980
|
}
|
|
981
|
+
const time = timeOrThrow(() => {
|
|
982
|
+
const normalized = normalizeSongTime(data.time);
|
|
983
|
+
checkSongTime(normalized, ticksPerBeat, { tempoBpm, beatsPerBar, bars });
|
|
984
|
+
return normalized;
|
|
985
|
+
});
|
|
986
|
+
const tuning = tuningOrThrow(
|
|
987
|
+
() => normalizeTuning(data.tuning, "song tuning"),
|
|
988
|
+
"invalid-score",
|
|
989
|
+
);
|
|
763
990
|
const tracks = normalizeTracks(data.tracks ?? []);
|
|
764
991
|
const notes = normalizeNotes(data.notes ?? []);
|
|
992
|
+
this.tuning = tuning;
|
|
993
|
+
let master: SongMaster | undefined;
|
|
994
|
+
try {
|
|
995
|
+
master = normalizeMaster(data.master);
|
|
996
|
+
} catch (error) {
|
|
997
|
+
if (error instanceof FxValidationError)
|
|
998
|
+
throw new ScoreValidationError(error.message, "invalid-score");
|
|
999
|
+
throw error;
|
|
1000
|
+
}
|
|
1001
|
+
if (master) this.master = master;
|
|
1002
|
+
const sections = normalizeSections(data.sections ?? []);
|
|
1003
|
+
const form = normalizeForm(data.form ?? [], sections);
|
|
1004
|
+
// Sections count bars in one meter; meter changes would move them.
|
|
1005
|
+
if (
|
|
1006
|
+
sections.length > 0 &&
|
|
1007
|
+
time?.meter?.some((change, index) => index > 0 || change.bar > 0)
|
|
1008
|
+
)
|
|
1009
|
+
throw new ScoreValidationError(
|
|
1010
|
+
"sections need one meter: remove the meter changes (time.meter) or the sections",
|
|
1011
|
+
"invalid-score",
|
|
1012
|
+
);
|
|
765
1013
|
this.tempoBpm = tempoBpm;
|
|
766
1014
|
this.beatsPerBar = beatsPerBar;
|
|
767
1015
|
this.bars = bars;
|
|
768
1016
|
this.ticksPerBeat = ticksPerBeat;
|
|
769
1017
|
this.key = key;
|
|
1018
|
+
if (time) this.time = time;
|
|
770
1019
|
this.tracks = freezeArray(tracks);
|
|
771
1020
|
this.notes = freezeArray(notes);
|
|
1021
|
+
this.sections = freezeArray(sections);
|
|
1022
|
+
this.form = freezeArray(form);
|
|
1023
|
+
this.loopSection = normalizeLoopSection(data.loopSection, sections);
|
|
772
1024
|
Object.freeze(this);
|
|
773
1025
|
}
|
|
774
1026
|
|
|
@@ -781,26 +1033,27 @@ export class TrackScore {
|
|
|
781
1033
|
}
|
|
782
1034
|
|
|
783
1035
|
withTracks(tracks: readonly TrackInput[]): TrackScore {
|
|
784
|
-
return new TrackScore({
|
|
785
|
-
tempoBpm: this.tempoBpm,
|
|
786
|
-
beatsPerBar: this.beatsPerBar,
|
|
787
|
-
bars: this.bars,
|
|
788
|
-
ticksPerBeat: this.ticksPerBeat,
|
|
789
|
-
key: this.key,
|
|
790
|
-
tracks,
|
|
791
|
-
notes: this.notes,
|
|
792
|
-
});
|
|
1036
|
+
return new TrackScore({ ...this.toJSON(), tracks });
|
|
793
1037
|
}
|
|
794
1038
|
|
|
795
1039
|
withTempo(tempoBpm: number): TrackScore {
|
|
1040
|
+
return new TrackScore({ ...this.toJSON(), tempoBpm });
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Replace the sections and the form (both validated together). The loop
|
|
1045
|
+
* section is kept while a section of that name remains.
|
|
1046
|
+
*/
|
|
1047
|
+
withSections(
|
|
1048
|
+
sections: readonly Section[],
|
|
1049
|
+
form: readonly FormEntry[] = [],
|
|
1050
|
+
loopSection: string | null | undefined = this.loopSection,
|
|
1051
|
+
): TrackScore {
|
|
796
1052
|
return new TrackScore({
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
key: this.key,
|
|
802
|
-
tracks: this.tracks,
|
|
803
|
-
notes: this.notes,
|
|
1053
|
+
...this.toJSON(),
|
|
1054
|
+
sections,
|
|
1055
|
+
form,
|
|
1056
|
+
loopSection: loopSection ?? null,
|
|
804
1057
|
});
|
|
805
1058
|
}
|
|
806
1059
|
|
|
@@ -813,9 +1066,35 @@ export class TrackScore {
|
|
|
813
1066
|
return new TrackScore({ ...this.toJSON(), key });
|
|
814
1067
|
}
|
|
815
1068
|
|
|
816
|
-
/**
|
|
1069
|
+
/** Set or clear (null) the song tuning. */
|
|
1070
|
+
withTuning(tuning: Tuning | null): TrackScore {
|
|
1071
|
+
return new TrackScore({ ...this.toJSON(), tuning });
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Change the meter; ticks are per beat, so notes keep their positions.
|
|
1076
|
+
* Beats per bar is the song meter, so a meter change at bar 1
|
|
1077
|
+
* (`time.meter` bar 0) is dropped rather than left overriding it.
|
|
1078
|
+
*/
|
|
817
1079
|
withMeter(beatsPerBar: number): TrackScore {
|
|
818
|
-
|
|
1080
|
+
const time = this.time?.meter?.some((change) => change.bar === 0)
|
|
1081
|
+
? withMeterChange(this.time, 0, null)
|
|
1082
|
+
: this.time;
|
|
1083
|
+
return new TrackScore({
|
|
1084
|
+
...this.toJSON(),
|
|
1085
|
+
beatsPerBar,
|
|
1086
|
+
time: time ?? null,
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** Replace the tempo map, meter changes and fermatas; null clears them. */
|
|
1091
|
+
withTime(time: SongTime | null | undefined): TrackScore {
|
|
1092
|
+
return new TrackScore({ ...this.toJSON(), time: time ?? null });
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/** Replace the song master; `null` removes it (bypass). */
|
|
1096
|
+
withMaster(master: SongMaster | null): TrackScore {
|
|
1097
|
+
return new TrackScore({ ...this.toJSON(), master });
|
|
819
1098
|
}
|
|
820
1099
|
|
|
821
1100
|
toJSON(): TrackScoreData & { version: typeof SCORE_VERSION } {
|
|
@@ -826,8 +1105,16 @@ export class TrackScore {
|
|
|
826
1105
|
bars: this.bars,
|
|
827
1106
|
ticksPerBeat: this.ticksPerBeat,
|
|
828
1107
|
key: this.key,
|
|
1108
|
+
...(this.time ? { time: this.time } : {}),
|
|
1109
|
+
...(this.tuning ? { tuning: this.tuning } : {}),
|
|
829
1110
|
tracks: this.tracks,
|
|
830
1111
|
notes: this.notes,
|
|
1112
|
+
...(this.master ? { master: this.master } : {}),
|
|
1113
|
+
...(this.sections.length > 0 ? { sections: this.sections } : {}),
|
|
1114
|
+
...(this.form.length > 0 ? { form: this.form } : {}),
|
|
1115
|
+
...(this.loopSection === undefined
|
|
1116
|
+
? {}
|
|
1117
|
+
: { loopSection: this.loopSection }),
|
|
831
1118
|
};
|
|
832
1119
|
}
|
|
833
1120
|
}
|
|
@@ -862,15 +1149,7 @@ export function addNote(score: TrackScore, input: NoteInput): TrackScore {
|
|
|
862
1149
|
"score-limit",
|
|
863
1150
|
);
|
|
864
1151
|
}
|
|
865
|
-
return new TrackScore({
|
|
866
|
-
tempoBpm: score.tempoBpm,
|
|
867
|
-
beatsPerBar: score.beatsPerBar,
|
|
868
|
-
bars: score.bars,
|
|
869
|
-
ticksPerBeat: score.ticksPerBeat,
|
|
870
|
-
key: score.key,
|
|
871
|
-
tracks: score.tracks,
|
|
872
|
-
notes: [...score.notes, note],
|
|
873
|
-
});
|
|
1152
|
+
return new TrackScore({ ...score.toJSON(), notes: [...score.notes, note] });
|
|
874
1153
|
}
|
|
875
1154
|
|
|
876
1155
|
export function removeNote(score: TrackScore, noteId: string): TrackScore {
|
|
@@ -888,32 +1167,31 @@ export function removeNote(score: TrackScore, noteId: string): TrackScore {
|
|
|
888
1167
|
}
|
|
889
1168
|
if (!score.notes.some((note) => note.id === noteId)) return score;
|
|
890
1169
|
return new TrackScore({
|
|
891
|
-
|
|
892
|
-
beatsPerBar: score.beatsPerBar,
|
|
893
|
-
bars: score.bars,
|
|
894
|
-
ticksPerBeat: score.ticksPerBeat,
|
|
895
|
-
key: score.key,
|
|
896
|
-
tracks: score.tracks,
|
|
1170
|
+
...score.toJSON(),
|
|
897
1171
|
notes: score.notes.filter((note) => note.id !== noteId),
|
|
898
1172
|
});
|
|
899
1173
|
}
|
|
900
1174
|
|
|
1175
|
+
/**
|
|
1176
|
+
* A note edit: timing, pitch and velocity, plus the expression fields
|
|
1177
|
+
* (`null` clears one).
|
|
1178
|
+
*/
|
|
1179
|
+
export type NotePatch = Readonly<
|
|
1180
|
+
Partial<
|
|
1181
|
+
Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity" | "cents">
|
|
1182
|
+
>
|
|
1183
|
+
> &
|
|
1184
|
+
NoteExpressionPatch;
|
|
1185
|
+
|
|
901
1186
|
export function updateNote(
|
|
902
1187
|
score: TrackScore,
|
|
903
1188
|
noteId: string,
|
|
904
|
-
patch:
|
|
905
|
-
Partial<Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity">>
|
|
906
|
-
>,
|
|
1189
|
+
patch: NotePatch,
|
|
907
1190
|
): TrackScore {
|
|
908
1191
|
const current = score.notes.find((note) => note.id === noteId);
|
|
909
1192
|
if (!current) return score;
|
|
910
1193
|
return new TrackScore({
|
|
911
|
-
|
|
912
|
-
beatsPerBar: score.beatsPerBar,
|
|
913
|
-
bars: score.bars,
|
|
914
|
-
ticksPerBeat: score.ticksPerBeat,
|
|
915
|
-
key: score.key,
|
|
916
|
-
tracks: score.tracks,
|
|
1194
|
+
...score.toJSON(),
|
|
917
1195
|
notes: score.notes.map((note) =>
|
|
918
1196
|
note.id === noteId ? { ...note, ...patch } : note,
|
|
919
1197
|
),
|
|
@@ -923,12 +1201,7 @@ export function updateNote(
|
|
|
923
1201
|
export function clearTrack(score: TrackScore, trackId: string): TrackScore {
|
|
924
1202
|
if (!score.notes.some((note) => note.trackId === trackId)) return score;
|
|
925
1203
|
return new TrackScore({
|
|
926
|
-
|
|
927
|
-
beatsPerBar: score.beatsPerBar,
|
|
928
|
-
bars: score.bars,
|
|
929
|
-
ticksPerBeat: score.ticksPerBeat,
|
|
930
|
-
key: score.key,
|
|
931
|
-
tracks: score.tracks,
|
|
1204
|
+
...score.toJSON(),
|
|
932
1205
|
notes: score.notes.filter((note) => note.trackId !== trackId),
|
|
933
1206
|
});
|
|
934
1207
|
}
|
|
@@ -940,9 +1213,19 @@ export function updateTrack(
|
|
|
940
1213
|
): TrackScore {
|
|
941
1214
|
if (!score.tracks.some((track) => track.id === trackId)) return score;
|
|
942
1215
|
return score.withTracks(
|
|
943
|
-
score.tracks.map((track) =>
|
|
944
|
-
track.id
|
|
945
|
-
|
|
1216
|
+
score.tracks.map((track) => {
|
|
1217
|
+
if (track.id !== trackId) return track;
|
|
1218
|
+
const next: Record<string, unknown> = { ...track, ...patch };
|
|
1219
|
+
// A modal field only belongs to a modal track: switching the
|
|
1220
|
+
// instrument away drops it unless the patch sets it too.
|
|
1221
|
+
if (
|
|
1222
|
+
patch.instrument !== undefined &&
|
|
1223
|
+
patch.modal === undefined &&
|
|
1224
|
+
patch.instrument !== MODAL_INSTRUMENT
|
|
1225
|
+
)
|
|
1226
|
+
delete next.modal;
|
|
1227
|
+
return next as Track;
|
|
1228
|
+
}),
|
|
946
1229
|
);
|
|
947
1230
|
}
|
|
948
1231
|
|
|
@@ -1076,11 +1359,7 @@ export type ScoreOperation =
|
|
|
1076
1359
|
| Readonly<{
|
|
1077
1360
|
type: "updateNote";
|
|
1078
1361
|
noteId: string;
|
|
1079
|
-
patch:
|
|
1080
|
-
Partial<
|
|
1081
|
-
Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity">
|
|
1082
|
-
>
|
|
1083
|
-
>;
|
|
1362
|
+
patch: NotePatch;
|
|
1084
1363
|
}>
|
|
1085
1364
|
| Readonly<{
|
|
1086
1365
|
type: "setTempo";
|
|
@@ -1121,6 +1400,26 @@ export type ScoreOperation =
|
|
|
1121
1400
|
| Readonly<{
|
|
1122
1401
|
type: "setMeter";
|
|
1123
1402
|
beatsPerBar: number;
|
|
1403
|
+
}>
|
|
1404
|
+
| Readonly<{
|
|
1405
|
+
/** Replaces the song's tempo map, meter changes and fermatas. */
|
|
1406
|
+
type: "setTime";
|
|
1407
|
+
time: SongTime | null;
|
|
1408
|
+
}>
|
|
1409
|
+
| Readonly<{
|
|
1410
|
+
type: "setTuning";
|
|
1411
|
+
tuning: Tuning | null;
|
|
1412
|
+
}>
|
|
1413
|
+
| Readonly<{
|
|
1414
|
+
type: "setMaster";
|
|
1415
|
+
master: SongMaster | null;
|
|
1416
|
+
}>
|
|
1417
|
+
| Readonly<{
|
|
1418
|
+
type: "setSections";
|
|
1419
|
+
sections: readonly Section[];
|
|
1420
|
+
form: readonly FormEntry[];
|
|
1421
|
+
/** The looped section; absent or null plays the song. */
|
|
1422
|
+
loopSection?: string | null;
|
|
1124
1423
|
}>;
|
|
1125
1424
|
|
|
1126
1425
|
export function applyScoreOperation(
|
|
@@ -1134,6 +1433,15 @@ export function applyScoreOperation(
|
|
|
1134
1433
|
if (operation.type === "setKey") return score.withKey(operation.key);
|
|
1135
1434
|
if (operation.type === "setMeter")
|
|
1136
1435
|
return score.withMeter(operation.beatsPerBar);
|
|
1436
|
+
if (operation.type === "setTime") return score.withTime(operation.time);
|
|
1437
|
+
if (operation.type === "setTuning") return score.withTuning(operation.tuning);
|
|
1438
|
+
if (operation.type === "setMaster") return score.withMaster(operation.master);
|
|
1439
|
+
if (operation.type === "setSections")
|
|
1440
|
+
return score.withSections(
|
|
1441
|
+
operation.sections,
|
|
1442
|
+
operation.form,
|
|
1443
|
+
operation.loopSection ?? null,
|
|
1444
|
+
);
|
|
1137
1445
|
if (operation.type === "addTrack") return addTrack(score, operation.track);
|
|
1138
1446
|
if (operation.type === "addNote") return addNote(score, operation.note);
|
|
1139
1447
|
if (operation.type === "removeNote")
|
|
@@ -1178,8 +1486,14 @@ export function scoreFromJSON(value: unknown): TrackScore {
|
|
|
1178
1486
|
bars?: number;
|
|
1179
1487
|
ticksPerBeat?: number;
|
|
1180
1488
|
key?: string | null;
|
|
1489
|
+
time?: SongTime | null;
|
|
1490
|
+
tuning?: Tuning | null;
|
|
1181
1491
|
tracks: readonly TrackInput[];
|
|
1182
1492
|
notes: readonly NoteInput[];
|
|
1493
|
+
master?: SongMaster;
|
|
1494
|
+
sections?: readonly Section[];
|
|
1495
|
+
form?: readonly FormEntry[];
|
|
1496
|
+
loopSection?: string | null;
|
|
1183
1497
|
} = {
|
|
1184
1498
|
tracks: optionalArray(value.tracks).map(parseTrack),
|
|
1185
1499
|
notes: optionalArray(value.notes).map(parseNote),
|
|
@@ -1194,9 +1508,202 @@ export function scoreFromJSON(value: unknown): TrackScore {
|
|
|
1194
1508
|
if (bars !== undefined) data.bars = bars;
|
|
1195
1509
|
if (ticksPerBeat !== undefined) data.ticksPerBeat = ticksPerBeat;
|
|
1196
1510
|
if (key !== undefined) data.key = key;
|
|
1511
|
+
if (value.time !== undefined && value.time !== null)
|
|
1512
|
+
data.time = value.time as SongTime;
|
|
1513
|
+
if (value.tuning !== undefined) data.tuning = value.tuning as Tuning | null;
|
|
1514
|
+
// Validated by the constructor (core/master.ts normalizeMaster).
|
|
1515
|
+
if (value.master !== undefined && value.master !== null)
|
|
1516
|
+
data.master = value.master as SongMaster;
|
|
1517
|
+
// Shapes are checked by the constructor (normalizeSections, normalizeForm).
|
|
1518
|
+
if (value.sections !== undefined)
|
|
1519
|
+
data.sections = optionalArray(value.sections) as readonly Section[];
|
|
1520
|
+
if (value.form !== undefined)
|
|
1521
|
+
data.form = optionalArray(value.form) as readonly FormEntry[];
|
|
1522
|
+
// The constructor checks the type and drops a name matching no section.
|
|
1523
|
+
if (value.loopSection !== undefined)
|
|
1524
|
+
data.loopSection = value.loopSection as string | null;
|
|
1197
1525
|
return new TrackScore(data);
|
|
1198
1526
|
}
|
|
1199
1527
|
|
|
1528
|
+
/** Section names: printable text without control characters, trimmed. */
|
|
1529
|
+
const SECTION_NAME = /^[^\u0000-\u001f\u007f]+$/u;
|
|
1530
|
+
|
|
1531
|
+
function normalizeSections(inputs: readonly unknown[]): Section[] {
|
|
1532
|
+
if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxSections)
|
|
1533
|
+
throw new ScoreValidationError(
|
|
1534
|
+
`score cannot contain more than ${SCORE_LIMITS.maxSections} sections`,
|
|
1535
|
+
"score-limit",
|
|
1536
|
+
);
|
|
1537
|
+
const seen = new Set<string>();
|
|
1538
|
+
const sections = inputs.map((input, index): Section => {
|
|
1539
|
+
if (!isRecord(input))
|
|
1540
|
+
throw new ScoreValidationError(`section ${index} must be an object`);
|
|
1541
|
+
const name =
|
|
1542
|
+
typeof input.name === "string"
|
|
1543
|
+
? input.name.trim().replace(/\s+/gu, " ")
|
|
1544
|
+
: "";
|
|
1545
|
+
if (
|
|
1546
|
+
name.length === 0 ||
|
|
1547
|
+
name.length > SCORE_LIMITS.maxSectionNameLength ||
|
|
1548
|
+
!SECTION_NAME.test(name)
|
|
1549
|
+
)
|
|
1550
|
+
throw new ScoreValidationError(
|
|
1551
|
+
`section ${index} needs a name of 1..${SCORE_LIMITS.maxSectionNameLength} characters`,
|
|
1552
|
+
);
|
|
1553
|
+
const folded = name.toLowerCase();
|
|
1554
|
+
if (seen.has(folded))
|
|
1555
|
+
throw new ScoreValidationError(`duplicate section name: ${name}`);
|
|
1556
|
+
seen.add(folded);
|
|
1557
|
+
const startBar = input.startBar;
|
|
1558
|
+
const bars = input.bars;
|
|
1559
|
+
if (
|
|
1560
|
+
typeof startBar !== "number" ||
|
|
1561
|
+
!Number.isInteger(startBar) ||
|
|
1562
|
+
startBar < 0 ||
|
|
1563
|
+
startBar >= SCORE_LIMITS.maxBars
|
|
1564
|
+
)
|
|
1565
|
+
throw new ScoreValidationError(
|
|
1566
|
+
`section ${name} startBar must be an integer 0..${SCORE_LIMITS.maxBars - 1}`,
|
|
1567
|
+
);
|
|
1568
|
+
if (
|
|
1569
|
+
typeof bars !== "number" ||
|
|
1570
|
+
!Number.isInteger(bars) ||
|
|
1571
|
+
bars < 1 ||
|
|
1572
|
+
startBar + bars > SCORE_LIMITS.maxBars
|
|
1573
|
+
)
|
|
1574
|
+
throw new ScoreValidationError(
|
|
1575
|
+
`section ${name} bars must be an integer 1..${SCORE_LIMITS.maxBars - startBar}`,
|
|
1576
|
+
);
|
|
1577
|
+
const out: { -readonly [K in keyof Section]: Section[K] } = {
|
|
1578
|
+
name,
|
|
1579
|
+
startBar,
|
|
1580
|
+
bars,
|
|
1581
|
+
};
|
|
1582
|
+
if (input.mute !== undefined) {
|
|
1583
|
+
if (
|
|
1584
|
+
!Array.isArray(input.mute) ||
|
|
1585
|
+
input.mute.length > SCORE_LIMITS.maxTracks ||
|
|
1586
|
+
input.mute.some((id) => typeof id !== "string" || id.length === 0)
|
|
1587
|
+
)
|
|
1588
|
+
throw new ScoreValidationError(
|
|
1589
|
+
`section ${name} mute must be a list of track ids`,
|
|
1590
|
+
);
|
|
1591
|
+
const mute = [...new Set(input.mute as string[])];
|
|
1592
|
+
if (mute.length > 0) out.mute = freezeArray(mute);
|
|
1593
|
+
}
|
|
1594
|
+
if (input.vary !== undefined) {
|
|
1595
|
+
if (!isRecord(input.vary))
|
|
1596
|
+
throw new ScoreValidationError(
|
|
1597
|
+
`section ${name} vary must map track ids to variations`,
|
|
1598
|
+
);
|
|
1599
|
+
const entries = Object.entries(input.vary);
|
|
1600
|
+
if (entries.length > SCORE_LIMITS.maxTracks)
|
|
1601
|
+
throw new ScoreValidationError(
|
|
1602
|
+
`section ${name} vary has too many tracks`,
|
|
1603
|
+
);
|
|
1604
|
+
const vary: Record<string, SectionVariation> = {};
|
|
1605
|
+
for (const [trackId, raw] of entries.sort(([a], [b]) =>
|
|
1606
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
1607
|
+
)) {
|
|
1608
|
+
if (!isRecord(raw) || trackId.length === 0)
|
|
1609
|
+
throw new ScoreValidationError(
|
|
1610
|
+
`section ${name} vary.${trackId} must be an object`,
|
|
1611
|
+
);
|
|
1612
|
+
const variation: { transpose?: number; gain?: number } = {};
|
|
1613
|
+
if (raw.transpose !== undefined) {
|
|
1614
|
+
const t = raw.transpose;
|
|
1615
|
+
if (
|
|
1616
|
+
typeof t !== "number" ||
|
|
1617
|
+
!Number.isInteger(t) ||
|
|
1618
|
+
Math.abs(t) > SCORE_LIMITS.maxSectionTranspose
|
|
1619
|
+
)
|
|
1620
|
+
throw new ScoreValidationError(
|
|
1621
|
+
`section ${name} vary.${trackId}.transpose must be an integer -${SCORE_LIMITS.maxSectionTranspose}..${SCORE_LIMITS.maxSectionTranspose}`,
|
|
1622
|
+
);
|
|
1623
|
+
if (t !== 0) variation.transpose = t;
|
|
1624
|
+
}
|
|
1625
|
+
if (raw.gain !== undefined) {
|
|
1626
|
+
const g = raw.gain;
|
|
1627
|
+
if (
|
|
1628
|
+
typeof g !== "number" ||
|
|
1629
|
+
!Number.isFinite(g) ||
|
|
1630
|
+
g < 0 ||
|
|
1631
|
+
g > SCORE_LIMITS.maxSectionGain
|
|
1632
|
+
)
|
|
1633
|
+
throw new ScoreValidationError(
|
|
1634
|
+
`section ${name} vary.${trackId}.gain must be 0..${SCORE_LIMITS.maxSectionGain}`,
|
|
1635
|
+
);
|
|
1636
|
+
if (g !== 1) variation.gain = g;
|
|
1637
|
+
}
|
|
1638
|
+
if (Object.keys(variation).length > 0)
|
|
1639
|
+
vary[trackId] = Object.freeze(variation);
|
|
1640
|
+
}
|
|
1641
|
+
if (Object.keys(vary).length > 0) out.vary = Object.freeze(vary);
|
|
1642
|
+
}
|
|
1643
|
+
return Object.freeze(out);
|
|
1644
|
+
});
|
|
1645
|
+
// Bar order; sections that start together keep the order they were given.
|
|
1646
|
+
return sections
|
|
1647
|
+
.map((section, index) => ({ section, index }))
|
|
1648
|
+
.sort(
|
|
1649
|
+
(a, b) => a.section.startBar - b.section.startBar || a.index - b.index,
|
|
1650
|
+
)
|
|
1651
|
+
.map(({ section }) => section);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
function normalizeLoopSection(
|
|
1655
|
+
value: unknown,
|
|
1656
|
+
sections: readonly Section[],
|
|
1657
|
+
): string | undefined {
|
|
1658
|
+
if (value === undefined || value === null) return undefined;
|
|
1659
|
+
if (typeof value !== "string")
|
|
1660
|
+
throw new ScoreValidationError(
|
|
1661
|
+
"loopSection must be a section name or null",
|
|
1662
|
+
"invalid-score",
|
|
1663
|
+
);
|
|
1664
|
+
const key = value.trim().replace(/\s+/gu, " ").toLowerCase();
|
|
1665
|
+
return sections.find((section) => section.name.toLowerCase() === key)?.name;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
function normalizeForm(
|
|
1669
|
+
inputs: readonly unknown[],
|
|
1670
|
+
sections: readonly Section[],
|
|
1671
|
+
): FormEntry[] {
|
|
1672
|
+
if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxFormEntries)
|
|
1673
|
+
throw new ScoreValidationError(
|
|
1674
|
+
`form cannot contain more than ${SCORE_LIMITS.maxFormEntries} entries`,
|
|
1675
|
+
"score-limit",
|
|
1676
|
+
);
|
|
1677
|
+
const byName = new Map(
|
|
1678
|
+
sections.map((section) => [section.name.toLowerCase(), section] as const),
|
|
1679
|
+
);
|
|
1680
|
+
return inputs.map((input, index): FormEntry => {
|
|
1681
|
+
const record = typeof input === "string" ? { section: input } : input;
|
|
1682
|
+
if (!isRecord(record) || typeof record.section !== "string")
|
|
1683
|
+
throw new ScoreValidationError(`form entry ${index} must name a section`);
|
|
1684
|
+
const section = byName.get(record.section.trim().toLowerCase());
|
|
1685
|
+
if (!section)
|
|
1686
|
+
throw new ScoreValidationError(
|
|
1687
|
+
`form entry ${index} names an unknown section: ${record.section}`,
|
|
1688
|
+
);
|
|
1689
|
+
const repeat = record.repeat ?? 1;
|
|
1690
|
+
if (
|
|
1691
|
+
typeof repeat !== "number" ||
|
|
1692
|
+
!Number.isInteger(repeat) ||
|
|
1693
|
+
repeat < 1 ||
|
|
1694
|
+
repeat > SCORE_LIMITS.maxFormRepeat
|
|
1695
|
+
)
|
|
1696
|
+
throw new ScoreValidationError(
|
|
1697
|
+
`form entry ${index} repeat must be an integer 1..${SCORE_LIMITS.maxFormRepeat}`,
|
|
1698
|
+
);
|
|
1699
|
+
return Object.freeze(
|
|
1700
|
+
repeat === 1
|
|
1701
|
+
? { section: section.name }
|
|
1702
|
+
: { section: section.name, repeat },
|
|
1703
|
+
);
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1200
1707
|
function normalizeTracks(inputs: readonly unknown[]): Track[] {
|
|
1201
1708
|
if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxTracks) {
|
|
1202
1709
|
throw new ScoreValidationError(
|
|
@@ -1299,9 +1806,44 @@ function normalizeTrack(input: unknown): Track {
|
|
|
1299
1806
|
const fx = fxOrThrow(() => normalizeFx(input.fx));
|
|
1300
1807
|
const fxAutomation = normalizeFxAutomation(input.fxAutomation);
|
|
1301
1808
|
const synth = fxOrThrow(() => normalizeSynth(input.synth));
|
|
1809
|
+
const keys = fxOrThrow(() => normalizeKeys(input.keys));
|
|
1302
1810
|
const wavetable = normalizeWavetable(input.wavetable);
|
|
1811
|
+
const modal = fxOrThrow(() => normalizeModal(input.modal));
|
|
1812
|
+
if (modal && instrument !== MODAL_INSTRUMENT)
|
|
1813
|
+
throw new ScoreValidationError(
|
|
1814
|
+
`track ${id} has modal settings but its instrument is "${instrument}"`,
|
|
1815
|
+
"invalid-track",
|
|
1816
|
+
);
|
|
1303
1817
|
const sampler = normalizeSampler(input.sampler);
|
|
1304
1818
|
const rhythm = normalizeRhythm(input.rhythm, id);
|
|
1819
|
+
const time = timeOrThrow(
|
|
1820
|
+
() => normalizeTrackTime(input.time, `track ${id} time`),
|
|
1821
|
+
"invalid-track",
|
|
1822
|
+
);
|
|
1823
|
+
let performance: TrackPerformance;
|
|
1824
|
+
try {
|
|
1825
|
+
performance = normalizeTrackPerformance(input, SCORE_LIMITS.maxTick);
|
|
1826
|
+
} catch (error) {
|
|
1827
|
+
if (error instanceof ExpressionValidationError)
|
|
1828
|
+
throw new ScoreValidationError(
|
|
1829
|
+
`track ${id} ${error.message}`,
|
|
1830
|
+
"invalid-track",
|
|
1831
|
+
);
|
|
1832
|
+
throw error;
|
|
1833
|
+
}
|
|
1834
|
+
const tuning = tuningOrThrow(
|
|
1835
|
+
() => normalizeTuning(input.tuning, `track ${id} tuning`),
|
|
1836
|
+
"invalid-track",
|
|
1837
|
+
);
|
|
1838
|
+
const string = fxOrThrow(() => normalizeString(input.string));
|
|
1839
|
+
// A bare `granular` instrument (set_instrument, `instrument granular`)
|
|
1840
|
+
// gets an empty object, so the engine plays its defaults.
|
|
1841
|
+
const granular =
|
|
1842
|
+
fxOrThrow(() =>
|
|
1843
|
+
normalizeGranular(input.granular, (ref) =>
|
|
1844
|
+
normalizeSampleRef(ref, "granular src"),
|
|
1845
|
+
),
|
|
1846
|
+
) ?? (isGranularInstrument(instrument) ? Object.freeze({}) : undefined);
|
|
1305
1847
|
let kit: string | undefined;
|
|
1306
1848
|
if (input.kit !== undefined && input.kit !== null) {
|
|
1307
1849
|
const found =
|
|
@@ -1323,7 +1865,12 @@ function normalizeTrack(input: unknown): Track {
|
|
|
1323
1865
|
`track ${id} instrument "sampler" needs a sampler`,
|
|
1324
1866
|
"invalid-track",
|
|
1325
1867
|
);
|
|
1326
|
-
|
|
1868
|
+
// A granular track keeps the sampler it grains (`grain off` goes back).
|
|
1869
|
+
if (
|
|
1870
|
+
sampler &&
|
|
1871
|
+
!isSamplerInstrument(instrument) &&
|
|
1872
|
+
!isGranularInstrument(instrument)
|
|
1873
|
+
)
|
|
1327
1874
|
throw new ScoreValidationError(
|
|
1328
1875
|
`track ${id} has a sampler but its instrument is "${instrument}"`,
|
|
1329
1876
|
"invalid-track",
|
|
@@ -1353,6 +1900,13 @@ function normalizeTrack(input: unknown): Track {
|
|
|
1353
1900
|
...(sampler ? { sampler } : {}),
|
|
1354
1901
|
...(rhythm ? { rhythm } : {}),
|
|
1355
1902
|
...(kit ? { kit } : {}),
|
|
1903
|
+
...(time ? { time } : {}),
|
|
1904
|
+
...performance,
|
|
1905
|
+
...(tuning ? { tuning } : {}),
|
|
1906
|
+
...(string ? { string } : {}),
|
|
1907
|
+
...(granular ? { granular } : {}),
|
|
1908
|
+
...(keys ? { keys } : {}),
|
|
1909
|
+
...(modal ? { modal } : {}),
|
|
1356
1910
|
});
|
|
1357
1911
|
}
|
|
1358
1912
|
|
|
@@ -1386,6 +1940,29 @@ export function normalizeRhythm(
|
|
|
1386
1940
|
return Object.freeze(rows);
|
|
1387
1941
|
}
|
|
1388
1942
|
|
|
1943
|
+
function timeOrThrow<T>(
|
|
1944
|
+
run: () => T,
|
|
1945
|
+
code: ScoreValidationError["code"] = "invalid-score",
|
|
1946
|
+
): T {
|
|
1947
|
+
try {
|
|
1948
|
+
return run();
|
|
1949
|
+
} catch (error) {
|
|
1950
|
+
if (error instanceof TimeValidationError)
|
|
1951
|
+
throw new ScoreValidationError(error.message, code);
|
|
1952
|
+
throw error;
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
function tuningOrThrow<T>(run: () => T, code: ScoreValidationError["code"]): T {
|
|
1957
|
+
try {
|
|
1958
|
+
return run();
|
|
1959
|
+
} catch (error) {
|
|
1960
|
+
if (error instanceof TuningError)
|
|
1961
|
+
throw new ScoreValidationError(error.message, code);
|
|
1962
|
+
throw error;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1389
1966
|
function fxOrThrow<T>(run: () => T): T {
|
|
1390
1967
|
try {
|
|
1391
1968
|
return run();
|
|
@@ -1537,6 +2114,9 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
|
|
|
1537
2114
|
fit?: boolean;
|
|
1538
2115
|
accelerate?: number;
|
|
1539
2116
|
squiz?: number;
|
|
2117
|
+
bpm?: number;
|
|
2118
|
+
fitmode?: SampleFitMode;
|
|
2119
|
+
len?: number;
|
|
1540
2120
|
} = { src };
|
|
1541
2121
|
if (input.sha256 !== undefined) {
|
|
1542
2122
|
if (typeof input.sha256 !== "string" || !SHA256_HEX.test(input.sha256))
|
|
@@ -1700,6 +2280,46 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
|
|
|
1700
2280
|
1,
|
|
1701
2281
|
SCORE_LIMITS.maxSampleSquiz,
|
|
1702
2282
|
);
|
|
2283
|
+
if (input.bpm !== undefined)
|
|
2284
|
+
ref.bpm = boundedNumber(
|
|
2285
|
+
input.bpm,
|
|
2286
|
+
`${label} bpm`,
|
|
2287
|
+
SCORE_LIMITS.minSampleBpm,
|
|
2288
|
+
SCORE_LIMITS.maxSampleBpm,
|
|
2289
|
+
);
|
|
2290
|
+
if (input.fitmode !== undefined) {
|
|
2291
|
+
if (!SAMPLE_FIT_MODES.includes(input.fitmode as SampleFitMode))
|
|
2292
|
+
throw new ScoreValidationError(
|
|
2293
|
+
`${label} fitmode must be "repitch", "beats" or "tones"`,
|
|
2294
|
+
"invalid-track",
|
|
2295
|
+
);
|
|
2296
|
+
ref.fitmode = input.fitmode as SampleFitMode;
|
|
2297
|
+
}
|
|
2298
|
+
if (input.len !== undefined) {
|
|
2299
|
+
const len = boundedNumber(
|
|
2300
|
+
input.len,
|
|
2301
|
+
`${label} len`,
|
|
2302
|
+
0,
|
|
2303
|
+
SCORE_LIMITS.maxSampleLenBeats,
|
|
2304
|
+
);
|
|
2305
|
+
if (len === 0)
|
|
2306
|
+
throw new ScoreValidationError(
|
|
2307
|
+
`${label} len must be greater than 0`,
|
|
2308
|
+
"invalid-track",
|
|
2309
|
+
);
|
|
2310
|
+
ref.len = len;
|
|
2311
|
+
}
|
|
2312
|
+
if (
|
|
2313
|
+
ref.fitmode !== undefined &&
|
|
2314
|
+
ref.fitmode !== "repitch" &&
|
|
2315
|
+
ref.bpm === undefined &&
|
|
2316
|
+
ref.len === undefined &&
|
|
2317
|
+
ref.fit !== true
|
|
2318
|
+
)
|
|
2319
|
+
throw new ScoreValidationError(
|
|
2320
|
+
`${label} fitmode ${ref.fitmode} needs bpm, len or fit`,
|
|
2321
|
+
"invalid-track",
|
|
2322
|
+
);
|
|
1703
2323
|
return Object.freeze(ref);
|
|
1704
2324
|
}
|
|
1705
2325
|
|
|
@@ -1940,10 +2560,28 @@ function normalizeAutomation(
|
|
|
1940
2560
|
return freezeArray(points);
|
|
1941
2561
|
}
|
|
1942
2562
|
|
|
2563
|
+
/** The note cap `normalizeNotes` enforces; see `withNoteCap`. */
|
|
2564
|
+
let noteCap: number = SCORE_LIMITS.maxNotes;
|
|
2565
|
+
|
|
2566
|
+
/**
|
|
2567
|
+
* Runs `fn` with the note cap raised to `cap`. Stored scores keep
|
|
2568
|
+
* SCORE_LIMITS.maxNotes; the arranged renderer bakes timed tracks (cycle,
|
|
2569
|
+
* rate) into plain notes and may hold more than a stored score allows.
|
|
2570
|
+
*/
|
|
2571
|
+
export function withNoteCap<T>(cap: number, fn: () => T): T {
|
|
2572
|
+
const previous = noteCap;
|
|
2573
|
+
noteCap = Math.max(previous, cap);
|
|
2574
|
+
try {
|
|
2575
|
+
return fn();
|
|
2576
|
+
} finally {
|
|
2577
|
+
noteCap = previous;
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
|
|
1943
2581
|
function normalizeNotes(inputs: readonly unknown[]): Note[] {
|
|
1944
|
-
if (!Array.isArray(inputs) || inputs.length >
|
|
2582
|
+
if (!Array.isArray(inputs) || inputs.length > noteCap) {
|
|
1945
2583
|
throw new ScoreValidationError(
|
|
1946
|
-
`score cannot contain more than ${
|
|
2584
|
+
`score cannot contain more than ${noteCap} notes`,
|
|
1947
2585
|
"score-limit",
|
|
1948
2586
|
);
|
|
1949
2587
|
}
|
|
@@ -2023,6 +2661,21 @@ function normalizeNote(input: unknown): Note {
|
|
|
2023
2661
|
"invalid-note",
|
|
2024
2662
|
);
|
|
2025
2663
|
}
|
|
2664
|
+
let expression: NoteExpression;
|
|
2665
|
+
try {
|
|
2666
|
+
expression = normalizeNoteExpression(input);
|
|
2667
|
+
} catch (error) {
|
|
2668
|
+
if (error instanceof ExpressionValidationError)
|
|
2669
|
+
throw new ScoreValidationError(
|
|
2670
|
+
`note ${id} ${error.message}`,
|
|
2671
|
+
"invalid-note",
|
|
2672
|
+
);
|
|
2673
|
+
throw error;
|
|
2674
|
+
}
|
|
2675
|
+
const cents = tuningOrThrow(
|
|
2676
|
+
() => normalizeNoteCents(input.cents, `note ${id}`),
|
|
2677
|
+
"invalid-note",
|
|
2678
|
+
);
|
|
2026
2679
|
return Object.freeze({
|
|
2027
2680
|
id,
|
|
2028
2681
|
trackId,
|
|
@@ -2030,6 +2683,8 @@ function normalizeNote(input: unknown): Note {
|
|
|
2030
2683
|
durationTicks,
|
|
2031
2684
|
pitch,
|
|
2032
2685
|
velocity: input.velocity,
|
|
2686
|
+
...expression,
|
|
2687
|
+
...(cents !== undefined ? { cents } : {}),
|
|
2033
2688
|
});
|
|
2034
2689
|
}
|
|
2035
2690
|
|