@hraness/dawg 0.4.0 → 0.5.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 +86 -0
- package/DAWG.md +300 -35
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -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 +27 -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 +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -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/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -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/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +12 -2
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/convolution.ts +43 -5
- 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/space.ts +197 -50
- package/src/audio/engine.ts +175 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -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 +87 -16
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +448 -59
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +826 -47
- package/src/render.ts +109 -7
- 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 +58 -5
- package/src/tui/euclid.ts +50 -6
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +656 -16
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +95 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +414 -41
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +79 -12
- 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
|
@@ -21,6 +21,32 @@ import {
|
|
|
21
21
|
type TrackFx,
|
|
22
22
|
} from "./fx.ts";
|
|
23
23
|
import { normalizeSynth, type TrackSynth } from "./synth.ts";
|
|
24
|
+
import {
|
|
25
|
+
checkSongTime,
|
|
26
|
+
normalizeSongTime,
|
|
27
|
+
normalizeTrackTime,
|
|
28
|
+
TimeValidationError,
|
|
29
|
+
type SongTime,
|
|
30
|
+
type TrackTime,
|
|
31
|
+
withMeterChange,
|
|
32
|
+
} from "./tempo.ts";
|
|
33
|
+
import {
|
|
34
|
+
ExpressionValidationError,
|
|
35
|
+
normalizeNoteExpression,
|
|
36
|
+
normalizeTrackPerformance,
|
|
37
|
+
type NoteExpression,
|
|
38
|
+
type NoteExpressionPatch,
|
|
39
|
+
type TrackPerformance,
|
|
40
|
+
} from "./expression.ts";
|
|
41
|
+
import {
|
|
42
|
+
normalizeNoteCents,
|
|
43
|
+
normalizeTuning,
|
|
44
|
+
TuningError,
|
|
45
|
+
type Tuning,
|
|
46
|
+
} from "./tuning.ts";
|
|
47
|
+
|
|
48
|
+
export type { Tuning } from "./tuning.ts";
|
|
49
|
+
import { normalizeMaster, type SongMaster } from "./master.ts";
|
|
24
50
|
|
|
25
51
|
export const SCORE_VERSION = 1 as const;
|
|
26
52
|
export const DEFAULT_TICKS_PER_BEAT = 480 as const;
|
|
@@ -36,6 +62,13 @@ export const SCORE_LIMITS = Object.freeze({
|
|
|
36
62
|
maxTempoBpm: 300,
|
|
37
63
|
minTempoBpm: 20,
|
|
38
64
|
maxBars: 256,
|
|
65
|
+
/** Song sections (0.5): named bar ranges and the form that orders them. */
|
|
66
|
+
maxSections: 64,
|
|
67
|
+
maxSectionNameLength: 32,
|
|
68
|
+
maxFormEntries: 128,
|
|
69
|
+
maxFormRepeat: 16,
|
|
70
|
+
maxSectionTranspose: 24,
|
|
71
|
+
maxSectionGain: 2,
|
|
39
72
|
maxBeatsPerBar: 16,
|
|
40
73
|
maxTicksPerBeat: 4096,
|
|
41
74
|
maxTick: 1_000_000,
|
|
@@ -328,7 +361,24 @@ export type Track = Readonly<{
|
|
|
328
361
|
* `lofi`, … see `core/kits.ts`). Absent plays the default voices.
|
|
329
362
|
*/
|
|
330
363
|
kit?: string;
|
|
331
|
-
|
|
364
|
+
/**
|
|
365
|
+
* Track time against the song (`core/tempo.ts`): `rate` (tempo ratio),
|
|
366
|
+
* `phase` (ticks the pattern shifts later) and `cycle` (track ticks per
|
|
367
|
+
* repetition) for polytempo, polymeter and phasing. Absent follows the
|
|
368
|
+
* song.
|
|
369
|
+
*/
|
|
370
|
+
time?: TrackTime;
|
|
371
|
+
/**
|
|
372
|
+
* Tuning for this track (`core/tuning.ts`): its own table, or just a
|
|
373
|
+
* `ref`/`root` over the song tuning. Absent follows the song tuning.
|
|
374
|
+
*/
|
|
375
|
+
tuning?: Tuning;
|
|
376
|
+
}> &
|
|
377
|
+
/**
|
|
378
|
+
* Performance (`core/expression.ts`): glide default, sustain pedal
|
|
379
|
+
* events, velocity curve and seeded humanize, all applied at render.
|
|
380
|
+
*/
|
|
381
|
+
TrackPerformance;
|
|
332
382
|
|
|
333
383
|
/**
|
|
334
384
|
* A track's sample voices (Strudel-aligned). In `oneshot` mode every voice is
|
|
@@ -627,6 +677,12 @@ export type TrackPatch = Readonly<
|
|
|
627
677
|
fxAutomation?: Track["fxAutomation"] | null;
|
|
628
678
|
synth?: TrackSynth | null;
|
|
629
679
|
wavetable?: TrackWavetable | null;
|
|
680
|
+
time?: TrackTime | null;
|
|
681
|
+
glide?: Track["glide"] | null;
|
|
682
|
+
pedal?: Track["pedal"] | null;
|
|
683
|
+
velocityCurve?: Track["velocityCurve"] | null;
|
|
684
|
+
humanize?: Track["humanize"] | null;
|
|
685
|
+
tuning?: Tuning | null;
|
|
630
686
|
}
|
|
631
687
|
>;
|
|
632
688
|
|
|
@@ -635,7 +691,11 @@ export type AutomationPoint = Readonly<{
|
|
|
635
691
|
value: number;
|
|
636
692
|
}>;
|
|
637
693
|
|
|
638
|
-
/**
|
|
694
|
+
/**
|
|
695
|
+
* A note's start and duration are integer ticks, never floating-point beats.
|
|
696
|
+
* Expression fields (`articulation`, `glide`, `bend`, `vibrato`, see
|
|
697
|
+
* `core/expression.ts`) are optional and absent when unused.
|
|
698
|
+
*/
|
|
639
699
|
export type Note = Readonly<{
|
|
640
700
|
id: string;
|
|
641
701
|
trackId: string;
|
|
@@ -643,7 +703,10 @@ export type Note = Readonly<{
|
|
|
643
703
|
durationTicks: number;
|
|
644
704
|
pitch: number;
|
|
645
705
|
velocity: number;
|
|
646
|
-
|
|
706
|
+
/** Static offset from the tuned pitch in cents (±1200); absent is 0. */
|
|
707
|
+
cents?: number;
|
|
708
|
+
}> &
|
|
709
|
+
NoteExpression;
|
|
647
710
|
|
|
648
711
|
export type TrackInput = Readonly<
|
|
649
712
|
Omit<
|
|
@@ -658,6 +721,12 @@ export type TrackInput = Readonly<
|
|
|
658
721
|
| "fxAutomation"
|
|
659
722
|
| "synth"
|
|
660
723
|
| "wavetable"
|
|
724
|
+
| "time"
|
|
725
|
+
| "glide"
|
|
726
|
+
| "pedal"
|
|
727
|
+
| "velocityCurve"
|
|
728
|
+
| "humanize"
|
|
729
|
+
| "tuning"
|
|
661
730
|
> &
|
|
662
731
|
Pick<Track, "id"> & {
|
|
663
732
|
filter?: TrackFilter | null;
|
|
@@ -670,6 +739,12 @@ export type TrackInput = Readonly<
|
|
|
670
739
|
fxAutomation?: Track["fxAutomation"] | null;
|
|
671
740
|
synth?: TrackSynth | null;
|
|
672
741
|
wavetable?: TrackWavetable | null;
|
|
742
|
+
time?: TrackTime | null;
|
|
743
|
+
glide?: Track["glide"] | number | null;
|
|
744
|
+
pedal?: Track["pedal"] | null;
|
|
745
|
+
velocityCurve?: Track["velocityCurve"] | string | null;
|
|
746
|
+
humanize?: Track["humanize"] | null;
|
|
747
|
+
tuning?: Tuning | null;
|
|
673
748
|
}
|
|
674
749
|
>;
|
|
675
750
|
|
|
@@ -686,6 +761,41 @@ export type NoteInput = Readonly<{
|
|
|
686
761
|
duration?: number;
|
|
687
762
|
pitch: number;
|
|
688
763
|
velocity: number;
|
|
764
|
+
cents?: number;
|
|
765
|
+
}> &
|
|
766
|
+
NoteExpressionPatch;
|
|
767
|
+
|
|
768
|
+
/** A per-track change inside a section: semitones and a velocity gain. */
|
|
769
|
+
export type SectionVariation = Readonly<{
|
|
770
|
+
/** Semitones, -24..24; drum and one-shot sampler tracks ignore it. */
|
|
771
|
+
transpose?: number;
|
|
772
|
+
/** Velocity multiplier 0..2 (1 leaves it alone). */
|
|
773
|
+
gain?: number;
|
|
774
|
+
}>;
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* A named bar range of the song (0.5). Sections may overlap: two sections
|
|
778
|
+
* over the same bars with different mutes or variations are two versions
|
|
779
|
+
* of that music. `mute` and `vary` name track ids; unknown ids are ignored.
|
|
780
|
+
*/
|
|
781
|
+
export type Section = Readonly<{
|
|
782
|
+
/** Unique (ignoring case), 1..32 characters: `intro`, `chorus 2`, `A`. */
|
|
783
|
+
name: string;
|
|
784
|
+
/** First bar, 0-based. */
|
|
785
|
+
startBar: number;
|
|
786
|
+
/** Length in bars, at least 1. */
|
|
787
|
+
bars: number;
|
|
788
|
+
/** Tracks silent in this section. */
|
|
789
|
+
mute?: readonly string[];
|
|
790
|
+
/** Per-track variations in this section. */
|
|
791
|
+
vary?: Readonly<Record<string, SectionVariation>>;
|
|
792
|
+
}>;
|
|
793
|
+
|
|
794
|
+
/** One step of the song form: a section by name, played `repeat` times. */
|
|
795
|
+
export type FormEntry = Readonly<{
|
|
796
|
+
section: string;
|
|
797
|
+
/** 1..16, default 1. */
|
|
798
|
+
repeat?: number;
|
|
689
799
|
}>;
|
|
690
800
|
|
|
691
801
|
export type TrackScoreData = Readonly<{
|
|
@@ -694,8 +804,26 @@ export type TrackScoreData = Readonly<{
|
|
|
694
804
|
bars?: number;
|
|
695
805
|
ticksPerBeat?: number;
|
|
696
806
|
key?: string | null;
|
|
807
|
+
/**
|
|
808
|
+
* Tempo map, meter changes and fermatas (`core/tempo.ts`). Absent keeps
|
|
809
|
+
* `tempoBpm` and `beatsPerBar` for the whole song.
|
|
810
|
+
*/
|
|
811
|
+
time?: SongTime | null;
|
|
812
|
+
/** Song tuning (`core/tuning.ts`); absent is 12-TET at A4 = 440 Hz. */
|
|
813
|
+
tuning?: Tuning | null;
|
|
697
814
|
tracks?: readonly TrackInput[];
|
|
698
815
|
notes?: readonly NoteInput[];
|
|
816
|
+
/** Song master chain and loudness target (core/master.ts); absent is off. */
|
|
817
|
+
master?: SongMaster | null;
|
|
818
|
+
/** Song sections (0.5); absent or empty means none. */
|
|
819
|
+
sections?: readonly Section[];
|
|
820
|
+
/** Song form (0.5): the order sections play in; absent plays the score straight through. */
|
|
821
|
+
form?: readonly FormEntry[];
|
|
822
|
+
/**
|
|
823
|
+
* The section playback loops (0.5), like a DAW's loop brace; absent plays
|
|
824
|
+
* the song (or its form). A name that matches no section is dropped.
|
|
825
|
+
*/
|
|
826
|
+
loopSection?: string | null;
|
|
699
827
|
}>;
|
|
700
828
|
|
|
701
829
|
/** Canonical immutable score. Use `addNote`/`removeNote` to create a revision. */
|
|
@@ -706,8 +834,19 @@ export class TrackScore {
|
|
|
706
834
|
readonly bars: number;
|
|
707
835
|
readonly ticksPerBeat: number;
|
|
708
836
|
readonly key: string | null;
|
|
837
|
+
/** Tempo map, meters and fermatas; absent when the song has none. */
|
|
838
|
+
declare readonly time?: SongTime;
|
|
839
|
+
readonly tuning: Tuning | undefined;
|
|
709
840
|
readonly tracks: readonly Track[];
|
|
710
841
|
readonly notes: readonly Note[];
|
|
842
|
+
/** Absent (not undefined-valued) without a master, so 0.4 scores are unchanged. */
|
|
843
|
+
declare readonly master?: SongMaster;
|
|
844
|
+
/** Song sections in bar order; empty when the song has none. */
|
|
845
|
+
readonly sections: readonly Section[];
|
|
846
|
+
/** Song form; empty plays the score straight through. */
|
|
847
|
+
readonly form: readonly FormEntry[];
|
|
848
|
+
/** The section playback loops; undefined plays the song. */
|
|
849
|
+
readonly loopSection: string | undefined;
|
|
711
850
|
|
|
712
851
|
constructor(data: TrackScoreData = {}) {
|
|
713
852
|
const tempoBpm = data.tempoBpm ?? 120;
|
|
@@ -760,15 +899,49 @@ export class TrackScore {
|
|
|
760
899
|
"invalid-score",
|
|
761
900
|
);
|
|
762
901
|
}
|
|
902
|
+
const time = timeOrThrow(() => {
|
|
903
|
+
const normalized = normalizeSongTime(data.time);
|
|
904
|
+
checkSongTime(normalized, ticksPerBeat, { tempoBpm, beatsPerBar, bars });
|
|
905
|
+
return normalized;
|
|
906
|
+
});
|
|
907
|
+
const tuning = tuningOrThrow(
|
|
908
|
+
() => normalizeTuning(data.tuning, "song tuning"),
|
|
909
|
+
"invalid-score",
|
|
910
|
+
);
|
|
763
911
|
const tracks = normalizeTracks(data.tracks ?? []);
|
|
764
912
|
const notes = normalizeNotes(data.notes ?? []);
|
|
913
|
+
this.tuning = tuning;
|
|
914
|
+
let master: SongMaster | undefined;
|
|
915
|
+
try {
|
|
916
|
+
master = normalizeMaster(data.master);
|
|
917
|
+
} catch (error) {
|
|
918
|
+
if (error instanceof FxValidationError)
|
|
919
|
+
throw new ScoreValidationError(error.message, "invalid-score");
|
|
920
|
+
throw error;
|
|
921
|
+
}
|
|
922
|
+
if (master) this.master = master;
|
|
923
|
+
const sections = normalizeSections(data.sections ?? []);
|
|
924
|
+
const form = normalizeForm(data.form ?? [], sections);
|
|
925
|
+
// Sections count bars in one meter; meter changes would move them.
|
|
926
|
+
if (
|
|
927
|
+
sections.length > 0 &&
|
|
928
|
+
time?.meter?.some((change, index) => index > 0 || change.bar > 0)
|
|
929
|
+
)
|
|
930
|
+
throw new ScoreValidationError(
|
|
931
|
+
"sections need one meter: remove the meter changes (time.meter) or the sections",
|
|
932
|
+
"invalid-score",
|
|
933
|
+
);
|
|
765
934
|
this.tempoBpm = tempoBpm;
|
|
766
935
|
this.beatsPerBar = beatsPerBar;
|
|
767
936
|
this.bars = bars;
|
|
768
937
|
this.ticksPerBeat = ticksPerBeat;
|
|
769
938
|
this.key = key;
|
|
939
|
+
if (time) this.time = time;
|
|
770
940
|
this.tracks = freezeArray(tracks);
|
|
771
941
|
this.notes = freezeArray(notes);
|
|
942
|
+
this.sections = freezeArray(sections);
|
|
943
|
+
this.form = freezeArray(form);
|
|
944
|
+
this.loopSection = normalizeLoopSection(data.loopSection, sections);
|
|
772
945
|
Object.freeze(this);
|
|
773
946
|
}
|
|
774
947
|
|
|
@@ -781,26 +954,27 @@ export class TrackScore {
|
|
|
781
954
|
}
|
|
782
955
|
|
|
783
956
|
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
|
-
});
|
|
957
|
+
return new TrackScore({ ...this.toJSON(), tracks });
|
|
793
958
|
}
|
|
794
959
|
|
|
795
960
|
withTempo(tempoBpm: number): TrackScore {
|
|
961
|
+
return new TrackScore({ ...this.toJSON(), tempoBpm });
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Replace the sections and the form (both validated together). The loop
|
|
966
|
+
* section is kept while a section of that name remains.
|
|
967
|
+
*/
|
|
968
|
+
withSections(
|
|
969
|
+
sections: readonly Section[],
|
|
970
|
+
form: readonly FormEntry[] = [],
|
|
971
|
+
loopSection: string | null | undefined = this.loopSection,
|
|
972
|
+
): TrackScore {
|
|
796
973
|
return new TrackScore({
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
key: this.key,
|
|
802
|
-
tracks: this.tracks,
|
|
803
|
-
notes: this.notes,
|
|
974
|
+
...this.toJSON(),
|
|
975
|
+
sections,
|
|
976
|
+
form,
|
|
977
|
+
loopSection: loopSection ?? null,
|
|
804
978
|
});
|
|
805
979
|
}
|
|
806
980
|
|
|
@@ -813,9 +987,35 @@ export class TrackScore {
|
|
|
813
987
|
return new TrackScore({ ...this.toJSON(), key });
|
|
814
988
|
}
|
|
815
989
|
|
|
816
|
-
/**
|
|
990
|
+
/** Set or clear (null) the song tuning. */
|
|
991
|
+
withTuning(tuning: Tuning | null): TrackScore {
|
|
992
|
+
return new TrackScore({ ...this.toJSON(), tuning });
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Change the meter; ticks are per beat, so notes keep their positions.
|
|
997
|
+
* Beats per bar is the song meter, so a meter change at bar 1
|
|
998
|
+
* (`time.meter` bar 0) is dropped rather than left overriding it.
|
|
999
|
+
*/
|
|
817
1000
|
withMeter(beatsPerBar: number): TrackScore {
|
|
818
|
-
|
|
1001
|
+
const time = this.time?.meter?.some((change) => change.bar === 0)
|
|
1002
|
+
? withMeterChange(this.time, 0, null)
|
|
1003
|
+
: this.time;
|
|
1004
|
+
return new TrackScore({
|
|
1005
|
+
...this.toJSON(),
|
|
1006
|
+
beatsPerBar,
|
|
1007
|
+
time: time ?? null,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** Replace the tempo map, meter changes and fermatas; null clears them. */
|
|
1012
|
+
withTime(time: SongTime | null | undefined): TrackScore {
|
|
1013
|
+
return new TrackScore({ ...this.toJSON(), time: time ?? null });
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** Replace the song master; `null` removes it (bypass). */
|
|
1017
|
+
withMaster(master: SongMaster | null): TrackScore {
|
|
1018
|
+
return new TrackScore({ ...this.toJSON(), master });
|
|
819
1019
|
}
|
|
820
1020
|
|
|
821
1021
|
toJSON(): TrackScoreData & { version: typeof SCORE_VERSION } {
|
|
@@ -826,8 +1026,16 @@ export class TrackScore {
|
|
|
826
1026
|
bars: this.bars,
|
|
827
1027
|
ticksPerBeat: this.ticksPerBeat,
|
|
828
1028
|
key: this.key,
|
|
1029
|
+
...(this.time ? { time: this.time } : {}),
|
|
1030
|
+
...(this.tuning ? { tuning: this.tuning } : {}),
|
|
829
1031
|
tracks: this.tracks,
|
|
830
1032
|
notes: this.notes,
|
|
1033
|
+
...(this.master ? { master: this.master } : {}),
|
|
1034
|
+
...(this.sections.length > 0 ? { sections: this.sections } : {}),
|
|
1035
|
+
...(this.form.length > 0 ? { form: this.form } : {}),
|
|
1036
|
+
...(this.loopSection === undefined
|
|
1037
|
+
? {}
|
|
1038
|
+
: { loopSection: this.loopSection }),
|
|
831
1039
|
};
|
|
832
1040
|
}
|
|
833
1041
|
}
|
|
@@ -862,15 +1070,7 @@ export function addNote(score: TrackScore, input: NoteInput): TrackScore {
|
|
|
862
1070
|
"score-limit",
|
|
863
1071
|
);
|
|
864
1072
|
}
|
|
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
|
-
});
|
|
1073
|
+
return new TrackScore({ ...score.toJSON(), notes: [...score.notes, note] });
|
|
874
1074
|
}
|
|
875
1075
|
|
|
876
1076
|
export function removeNote(score: TrackScore, noteId: string): TrackScore {
|
|
@@ -888,32 +1088,31 @@ export function removeNote(score: TrackScore, noteId: string): TrackScore {
|
|
|
888
1088
|
}
|
|
889
1089
|
if (!score.notes.some((note) => note.id === noteId)) return score;
|
|
890
1090
|
return new TrackScore({
|
|
891
|
-
|
|
892
|
-
beatsPerBar: score.beatsPerBar,
|
|
893
|
-
bars: score.bars,
|
|
894
|
-
ticksPerBeat: score.ticksPerBeat,
|
|
895
|
-
key: score.key,
|
|
896
|
-
tracks: score.tracks,
|
|
1091
|
+
...score.toJSON(),
|
|
897
1092
|
notes: score.notes.filter((note) => note.id !== noteId),
|
|
898
1093
|
});
|
|
899
1094
|
}
|
|
900
1095
|
|
|
1096
|
+
/**
|
|
1097
|
+
* A note edit: timing, pitch and velocity, plus the expression fields
|
|
1098
|
+
* (`null` clears one).
|
|
1099
|
+
*/
|
|
1100
|
+
export type NotePatch = Readonly<
|
|
1101
|
+
Partial<
|
|
1102
|
+
Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity" | "cents">
|
|
1103
|
+
>
|
|
1104
|
+
> &
|
|
1105
|
+
NoteExpressionPatch;
|
|
1106
|
+
|
|
901
1107
|
export function updateNote(
|
|
902
1108
|
score: TrackScore,
|
|
903
1109
|
noteId: string,
|
|
904
|
-
patch:
|
|
905
|
-
Partial<Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity">>
|
|
906
|
-
>,
|
|
1110
|
+
patch: NotePatch,
|
|
907
1111
|
): TrackScore {
|
|
908
1112
|
const current = score.notes.find((note) => note.id === noteId);
|
|
909
1113
|
if (!current) return score;
|
|
910
1114
|
return new TrackScore({
|
|
911
|
-
|
|
912
|
-
beatsPerBar: score.beatsPerBar,
|
|
913
|
-
bars: score.bars,
|
|
914
|
-
ticksPerBeat: score.ticksPerBeat,
|
|
915
|
-
key: score.key,
|
|
916
|
-
tracks: score.tracks,
|
|
1115
|
+
...score.toJSON(),
|
|
917
1116
|
notes: score.notes.map((note) =>
|
|
918
1117
|
note.id === noteId ? { ...note, ...patch } : note,
|
|
919
1118
|
),
|
|
@@ -923,12 +1122,7 @@ export function updateNote(
|
|
|
923
1122
|
export function clearTrack(score: TrackScore, trackId: string): TrackScore {
|
|
924
1123
|
if (!score.notes.some((note) => note.trackId === trackId)) return score;
|
|
925
1124
|
return new TrackScore({
|
|
926
|
-
|
|
927
|
-
beatsPerBar: score.beatsPerBar,
|
|
928
|
-
bars: score.bars,
|
|
929
|
-
ticksPerBeat: score.ticksPerBeat,
|
|
930
|
-
key: score.key,
|
|
931
|
-
tracks: score.tracks,
|
|
1125
|
+
...score.toJSON(),
|
|
932
1126
|
notes: score.notes.filter((note) => note.trackId !== trackId),
|
|
933
1127
|
});
|
|
934
1128
|
}
|
|
@@ -1076,11 +1270,7 @@ export type ScoreOperation =
|
|
|
1076
1270
|
| Readonly<{
|
|
1077
1271
|
type: "updateNote";
|
|
1078
1272
|
noteId: string;
|
|
1079
|
-
patch:
|
|
1080
|
-
Partial<
|
|
1081
|
-
Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity">
|
|
1082
|
-
>
|
|
1083
|
-
>;
|
|
1273
|
+
patch: NotePatch;
|
|
1084
1274
|
}>
|
|
1085
1275
|
| Readonly<{
|
|
1086
1276
|
type: "setTempo";
|
|
@@ -1121,6 +1311,26 @@ export type ScoreOperation =
|
|
|
1121
1311
|
| Readonly<{
|
|
1122
1312
|
type: "setMeter";
|
|
1123
1313
|
beatsPerBar: number;
|
|
1314
|
+
}>
|
|
1315
|
+
| Readonly<{
|
|
1316
|
+
/** Replaces the song's tempo map, meter changes and fermatas. */
|
|
1317
|
+
type: "setTime";
|
|
1318
|
+
time: SongTime | null;
|
|
1319
|
+
}>
|
|
1320
|
+
| Readonly<{
|
|
1321
|
+
type: "setTuning";
|
|
1322
|
+
tuning: Tuning | null;
|
|
1323
|
+
}>
|
|
1324
|
+
| Readonly<{
|
|
1325
|
+
type: "setMaster";
|
|
1326
|
+
master: SongMaster | null;
|
|
1327
|
+
}>
|
|
1328
|
+
| Readonly<{
|
|
1329
|
+
type: "setSections";
|
|
1330
|
+
sections: readonly Section[];
|
|
1331
|
+
form: readonly FormEntry[];
|
|
1332
|
+
/** The looped section; absent or null plays the song. */
|
|
1333
|
+
loopSection?: string | null;
|
|
1124
1334
|
}>;
|
|
1125
1335
|
|
|
1126
1336
|
export function applyScoreOperation(
|
|
@@ -1134,6 +1344,15 @@ export function applyScoreOperation(
|
|
|
1134
1344
|
if (operation.type === "setKey") return score.withKey(operation.key);
|
|
1135
1345
|
if (operation.type === "setMeter")
|
|
1136
1346
|
return score.withMeter(operation.beatsPerBar);
|
|
1347
|
+
if (operation.type === "setTime") return score.withTime(operation.time);
|
|
1348
|
+
if (operation.type === "setTuning") return score.withTuning(operation.tuning);
|
|
1349
|
+
if (operation.type === "setMaster") return score.withMaster(operation.master);
|
|
1350
|
+
if (operation.type === "setSections")
|
|
1351
|
+
return score.withSections(
|
|
1352
|
+
operation.sections,
|
|
1353
|
+
operation.form,
|
|
1354
|
+
operation.loopSection ?? null,
|
|
1355
|
+
);
|
|
1137
1356
|
if (operation.type === "addTrack") return addTrack(score, operation.track);
|
|
1138
1357
|
if (operation.type === "addNote") return addNote(score, operation.note);
|
|
1139
1358
|
if (operation.type === "removeNote")
|
|
@@ -1178,8 +1397,14 @@ export function scoreFromJSON(value: unknown): TrackScore {
|
|
|
1178
1397
|
bars?: number;
|
|
1179
1398
|
ticksPerBeat?: number;
|
|
1180
1399
|
key?: string | null;
|
|
1400
|
+
time?: SongTime | null;
|
|
1401
|
+
tuning?: Tuning | null;
|
|
1181
1402
|
tracks: readonly TrackInput[];
|
|
1182
1403
|
notes: readonly NoteInput[];
|
|
1404
|
+
master?: SongMaster;
|
|
1405
|
+
sections?: readonly Section[];
|
|
1406
|
+
form?: readonly FormEntry[];
|
|
1407
|
+
loopSection?: string | null;
|
|
1183
1408
|
} = {
|
|
1184
1409
|
tracks: optionalArray(value.tracks).map(parseTrack),
|
|
1185
1410
|
notes: optionalArray(value.notes).map(parseNote),
|
|
@@ -1194,9 +1419,202 @@ export function scoreFromJSON(value: unknown): TrackScore {
|
|
|
1194
1419
|
if (bars !== undefined) data.bars = bars;
|
|
1195
1420
|
if (ticksPerBeat !== undefined) data.ticksPerBeat = ticksPerBeat;
|
|
1196
1421
|
if (key !== undefined) data.key = key;
|
|
1422
|
+
if (value.time !== undefined && value.time !== null)
|
|
1423
|
+
data.time = value.time as SongTime;
|
|
1424
|
+
if (value.tuning !== undefined) data.tuning = value.tuning as Tuning | null;
|
|
1425
|
+
// Validated by the constructor (core/master.ts normalizeMaster).
|
|
1426
|
+
if (value.master !== undefined && value.master !== null)
|
|
1427
|
+
data.master = value.master as SongMaster;
|
|
1428
|
+
// Shapes are checked by the constructor (normalizeSections, normalizeForm).
|
|
1429
|
+
if (value.sections !== undefined)
|
|
1430
|
+
data.sections = optionalArray(value.sections) as readonly Section[];
|
|
1431
|
+
if (value.form !== undefined)
|
|
1432
|
+
data.form = optionalArray(value.form) as readonly FormEntry[];
|
|
1433
|
+
// The constructor checks the type and drops a name matching no section.
|
|
1434
|
+
if (value.loopSection !== undefined)
|
|
1435
|
+
data.loopSection = value.loopSection as string | null;
|
|
1197
1436
|
return new TrackScore(data);
|
|
1198
1437
|
}
|
|
1199
1438
|
|
|
1439
|
+
/** Section names: printable text without control characters, trimmed. */
|
|
1440
|
+
const SECTION_NAME = /^[^\u0000-\u001f\u007f]+$/u;
|
|
1441
|
+
|
|
1442
|
+
function normalizeSections(inputs: readonly unknown[]): Section[] {
|
|
1443
|
+
if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxSections)
|
|
1444
|
+
throw new ScoreValidationError(
|
|
1445
|
+
`score cannot contain more than ${SCORE_LIMITS.maxSections} sections`,
|
|
1446
|
+
"score-limit",
|
|
1447
|
+
);
|
|
1448
|
+
const seen = new Set<string>();
|
|
1449
|
+
const sections = inputs.map((input, index): Section => {
|
|
1450
|
+
if (!isRecord(input))
|
|
1451
|
+
throw new ScoreValidationError(`section ${index} must be an object`);
|
|
1452
|
+
const name =
|
|
1453
|
+
typeof input.name === "string"
|
|
1454
|
+
? input.name.trim().replace(/\s+/gu, " ")
|
|
1455
|
+
: "";
|
|
1456
|
+
if (
|
|
1457
|
+
name.length === 0 ||
|
|
1458
|
+
name.length > SCORE_LIMITS.maxSectionNameLength ||
|
|
1459
|
+
!SECTION_NAME.test(name)
|
|
1460
|
+
)
|
|
1461
|
+
throw new ScoreValidationError(
|
|
1462
|
+
`section ${index} needs a name of 1..${SCORE_LIMITS.maxSectionNameLength} characters`,
|
|
1463
|
+
);
|
|
1464
|
+
const folded = name.toLowerCase();
|
|
1465
|
+
if (seen.has(folded))
|
|
1466
|
+
throw new ScoreValidationError(`duplicate section name: ${name}`);
|
|
1467
|
+
seen.add(folded);
|
|
1468
|
+
const startBar = input.startBar;
|
|
1469
|
+
const bars = input.bars;
|
|
1470
|
+
if (
|
|
1471
|
+
typeof startBar !== "number" ||
|
|
1472
|
+
!Number.isInteger(startBar) ||
|
|
1473
|
+
startBar < 0 ||
|
|
1474
|
+
startBar >= SCORE_LIMITS.maxBars
|
|
1475
|
+
)
|
|
1476
|
+
throw new ScoreValidationError(
|
|
1477
|
+
`section ${name} startBar must be an integer 0..${SCORE_LIMITS.maxBars - 1}`,
|
|
1478
|
+
);
|
|
1479
|
+
if (
|
|
1480
|
+
typeof bars !== "number" ||
|
|
1481
|
+
!Number.isInteger(bars) ||
|
|
1482
|
+
bars < 1 ||
|
|
1483
|
+
startBar + bars > SCORE_LIMITS.maxBars
|
|
1484
|
+
)
|
|
1485
|
+
throw new ScoreValidationError(
|
|
1486
|
+
`section ${name} bars must be an integer 1..${SCORE_LIMITS.maxBars - startBar}`,
|
|
1487
|
+
);
|
|
1488
|
+
const out: { -readonly [K in keyof Section]: Section[K] } = {
|
|
1489
|
+
name,
|
|
1490
|
+
startBar,
|
|
1491
|
+
bars,
|
|
1492
|
+
};
|
|
1493
|
+
if (input.mute !== undefined) {
|
|
1494
|
+
if (
|
|
1495
|
+
!Array.isArray(input.mute) ||
|
|
1496
|
+
input.mute.length > SCORE_LIMITS.maxTracks ||
|
|
1497
|
+
input.mute.some((id) => typeof id !== "string" || id.length === 0)
|
|
1498
|
+
)
|
|
1499
|
+
throw new ScoreValidationError(
|
|
1500
|
+
`section ${name} mute must be a list of track ids`,
|
|
1501
|
+
);
|
|
1502
|
+
const mute = [...new Set(input.mute as string[])];
|
|
1503
|
+
if (mute.length > 0) out.mute = freezeArray(mute);
|
|
1504
|
+
}
|
|
1505
|
+
if (input.vary !== undefined) {
|
|
1506
|
+
if (!isRecord(input.vary))
|
|
1507
|
+
throw new ScoreValidationError(
|
|
1508
|
+
`section ${name} vary must map track ids to variations`,
|
|
1509
|
+
);
|
|
1510
|
+
const entries = Object.entries(input.vary);
|
|
1511
|
+
if (entries.length > SCORE_LIMITS.maxTracks)
|
|
1512
|
+
throw new ScoreValidationError(
|
|
1513
|
+
`section ${name} vary has too many tracks`,
|
|
1514
|
+
);
|
|
1515
|
+
const vary: Record<string, SectionVariation> = {};
|
|
1516
|
+
for (const [trackId, raw] of entries.sort(([a], [b]) =>
|
|
1517
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
1518
|
+
)) {
|
|
1519
|
+
if (!isRecord(raw) || trackId.length === 0)
|
|
1520
|
+
throw new ScoreValidationError(
|
|
1521
|
+
`section ${name} vary.${trackId} must be an object`,
|
|
1522
|
+
);
|
|
1523
|
+
const variation: { transpose?: number; gain?: number } = {};
|
|
1524
|
+
if (raw.transpose !== undefined) {
|
|
1525
|
+
const t = raw.transpose;
|
|
1526
|
+
if (
|
|
1527
|
+
typeof t !== "number" ||
|
|
1528
|
+
!Number.isInteger(t) ||
|
|
1529
|
+
Math.abs(t) > SCORE_LIMITS.maxSectionTranspose
|
|
1530
|
+
)
|
|
1531
|
+
throw new ScoreValidationError(
|
|
1532
|
+
`section ${name} vary.${trackId}.transpose must be an integer -${SCORE_LIMITS.maxSectionTranspose}..${SCORE_LIMITS.maxSectionTranspose}`,
|
|
1533
|
+
);
|
|
1534
|
+
if (t !== 0) variation.transpose = t;
|
|
1535
|
+
}
|
|
1536
|
+
if (raw.gain !== undefined) {
|
|
1537
|
+
const g = raw.gain;
|
|
1538
|
+
if (
|
|
1539
|
+
typeof g !== "number" ||
|
|
1540
|
+
!Number.isFinite(g) ||
|
|
1541
|
+
g < 0 ||
|
|
1542
|
+
g > SCORE_LIMITS.maxSectionGain
|
|
1543
|
+
)
|
|
1544
|
+
throw new ScoreValidationError(
|
|
1545
|
+
`section ${name} vary.${trackId}.gain must be 0..${SCORE_LIMITS.maxSectionGain}`,
|
|
1546
|
+
);
|
|
1547
|
+
if (g !== 1) variation.gain = g;
|
|
1548
|
+
}
|
|
1549
|
+
if (Object.keys(variation).length > 0)
|
|
1550
|
+
vary[trackId] = Object.freeze(variation);
|
|
1551
|
+
}
|
|
1552
|
+
if (Object.keys(vary).length > 0) out.vary = Object.freeze(vary);
|
|
1553
|
+
}
|
|
1554
|
+
return Object.freeze(out);
|
|
1555
|
+
});
|
|
1556
|
+
// Bar order; sections that start together keep the order they were given.
|
|
1557
|
+
return sections
|
|
1558
|
+
.map((section, index) => ({ section, index }))
|
|
1559
|
+
.sort(
|
|
1560
|
+
(a, b) => a.section.startBar - b.section.startBar || a.index - b.index,
|
|
1561
|
+
)
|
|
1562
|
+
.map(({ section }) => section);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function normalizeLoopSection(
|
|
1566
|
+
value: unknown,
|
|
1567
|
+
sections: readonly Section[],
|
|
1568
|
+
): string | undefined {
|
|
1569
|
+
if (value === undefined || value === null) return undefined;
|
|
1570
|
+
if (typeof value !== "string")
|
|
1571
|
+
throw new ScoreValidationError(
|
|
1572
|
+
"loopSection must be a section name or null",
|
|
1573
|
+
"invalid-score",
|
|
1574
|
+
);
|
|
1575
|
+
const key = value.trim().replace(/\s+/gu, " ").toLowerCase();
|
|
1576
|
+
return sections.find((section) => section.name.toLowerCase() === key)?.name;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function normalizeForm(
|
|
1580
|
+
inputs: readonly unknown[],
|
|
1581
|
+
sections: readonly Section[],
|
|
1582
|
+
): FormEntry[] {
|
|
1583
|
+
if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxFormEntries)
|
|
1584
|
+
throw new ScoreValidationError(
|
|
1585
|
+
`form cannot contain more than ${SCORE_LIMITS.maxFormEntries} entries`,
|
|
1586
|
+
"score-limit",
|
|
1587
|
+
);
|
|
1588
|
+
const byName = new Map(
|
|
1589
|
+
sections.map((section) => [section.name.toLowerCase(), section] as const),
|
|
1590
|
+
);
|
|
1591
|
+
return inputs.map((input, index): FormEntry => {
|
|
1592
|
+
const record = typeof input === "string" ? { section: input } : input;
|
|
1593
|
+
if (!isRecord(record) || typeof record.section !== "string")
|
|
1594
|
+
throw new ScoreValidationError(`form entry ${index} must name a section`);
|
|
1595
|
+
const section = byName.get(record.section.trim().toLowerCase());
|
|
1596
|
+
if (!section)
|
|
1597
|
+
throw new ScoreValidationError(
|
|
1598
|
+
`form entry ${index} names an unknown section: ${record.section}`,
|
|
1599
|
+
);
|
|
1600
|
+
const repeat = record.repeat ?? 1;
|
|
1601
|
+
if (
|
|
1602
|
+
typeof repeat !== "number" ||
|
|
1603
|
+
!Number.isInteger(repeat) ||
|
|
1604
|
+
repeat < 1 ||
|
|
1605
|
+
repeat > SCORE_LIMITS.maxFormRepeat
|
|
1606
|
+
)
|
|
1607
|
+
throw new ScoreValidationError(
|
|
1608
|
+
`form entry ${index} repeat must be an integer 1..${SCORE_LIMITS.maxFormRepeat}`,
|
|
1609
|
+
);
|
|
1610
|
+
return Object.freeze(
|
|
1611
|
+
repeat === 1
|
|
1612
|
+
? { section: section.name }
|
|
1613
|
+
: { section: section.name, repeat },
|
|
1614
|
+
);
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1200
1618
|
function normalizeTracks(inputs: readonly unknown[]): Track[] {
|
|
1201
1619
|
if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxTracks) {
|
|
1202
1620
|
throw new ScoreValidationError(
|
|
@@ -1302,6 +1720,25 @@ function normalizeTrack(input: unknown): Track {
|
|
|
1302
1720
|
const wavetable = normalizeWavetable(input.wavetable);
|
|
1303
1721
|
const sampler = normalizeSampler(input.sampler);
|
|
1304
1722
|
const rhythm = normalizeRhythm(input.rhythm, id);
|
|
1723
|
+
const time = timeOrThrow(
|
|
1724
|
+
() => normalizeTrackTime(input.time, `track ${id} time`),
|
|
1725
|
+
"invalid-track",
|
|
1726
|
+
);
|
|
1727
|
+
let performance: TrackPerformance;
|
|
1728
|
+
try {
|
|
1729
|
+
performance = normalizeTrackPerformance(input, SCORE_LIMITS.maxTick);
|
|
1730
|
+
} catch (error) {
|
|
1731
|
+
if (error instanceof ExpressionValidationError)
|
|
1732
|
+
throw new ScoreValidationError(
|
|
1733
|
+
`track ${id} ${error.message}`,
|
|
1734
|
+
"invalid-track",
|
|
1735
|
+
);
|
|
1736
|
+
throw error;
|
|
1737
|
+
}
|
|
1738
|
+
const tuning = tuningOrThrow(
|
|
1739
|
+
() => normalizeTuning(input.tuning, `track ${id} tuning`),
|
|
1740
|
+
"invalid-track",
|
|
1741
|
+
);
|
|
1305
1742
|
let kit: string | undefined;
|
|
1306
1743
|
if (input.kit !== undefined && input.kit !== null) {
|
|
1307
1744
|
const found =
|
|
@@ -1353,6 +1790,9 @@ function normalizeTrack(input: unknown): Track {
|
|
|
1353
1790
|
...(sampler ? { sampler } : {}),
|
|
1354
1791
|
...(rhythm ? { rhythm } : {}),
|
|
1355
1792
|
...(kit ? { kit } : {}),
|
|
1793
|
+
...(time ? { time } : {}),
|
|
1794
|
+
...performance,
|
|
1795
|
+
...(tuning ? { tuning } : {}),
|
|
1356
1796
|
});
|
|
1357
1797
|
}
|
|
1358
1798
|
|
|
@@ -1386,6 +1826,29 @@ export function normalizeRhythm(
|
|
|
1386
1826
|
return Object.freeze(rows);
|
|
1387
1827
|
}
|
|
1388
1828
|
|
|
1829
|
+
function timeOrThrow<T>(
|
|
1830
|
+
run: () => T,
|
|
1831
|
+
code: ScoreValidationError["code"] = "invalid-score",
|
|
1832
|
+
): T {
|
|
1833
|
+
try {
|
|
1834
|
+
return run();
|
|
1835
|
+
} catch (error) {
|
|
1836
|
+
if (error instanceof TimeValidationError)
|
|
1837
|
+
throw new ScoreValidationError(error.message, code);
|
|
1838
|
+
throw error;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
function tuningOrThrow<T>(run: () => T, code: ScoreValidationError["code"]): T {
|
|
1843
|
+
try {
|
|
1844
|
+
return run();
|
|
1845
|
+
} catch (error) {
|
|
1846
|
+
if (error instanceof TuningError)
|
|
1847
|
+
throw new ScoreValidationError(error.message, code);
|
|
1848
|
+
throw error;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1389
1852
|
function fxOrThrow<T>(run: () => T): T {
|
|
1390
1853
|
try {
|
|
1391
1854
|
return run();
|
|
@@ -2023,6 +2486,21 @@ function normalizeNote(input: unknown): Note {
|
|
|
2023
2486
|
"invalid-note",
|
|
2024
2487
|
);
|
|
2025
2488
|
}
|
|
2489
|
+
let expression: NoteExpression;
|
|
2490
|
+
try {
|
|
2491
|
+
expression = normalizeNoteExpression(input);
|
|
2492
|
+
} catch (error) {
|
|
2493
|
+
if (error instanceof ExpressionValidationError)
|
|
2494
|
+
throw new ScoreValidationError(
|
|
2495
|
+
`note ${id} ${error.message}`,
|
|
2496
|
+
"invalid-note",
|
|
2497
|
+
);
|
|
2498
|
+
throw error;
|
|
2499
|
+
}
|
|
2500
|
+
const cents = tuningOrThrow(
|
|
2501
|
+
() => normalizeNoteCents(input.cents, `note ${id}`),
|
|
2502
|
+
"invalid-note",
|
|
2503
|
+
);
|
|
2026
2504
|
return Object.freeze({
|
|
2027
2505
|
id,
|
|
2028
2506
|
trackId,
|
|
@@ -2030,6 +2508,8 @@ function normalizeNote(input: unknown): Note {
|
|
|
2030
2508
|
durationTicks,
|
|
2031
2509
|
pitch,
|
|
2032
2510
|
velocity: input.velocity,
|
|
2511
|
+
...expression,
|
|
2512
|
+
...(cents !== undefined ? { cents } : {}),
|
|
2033
2513
|
});
|
|
2034
2514
|
}
|
|
2035
2515
|
|