@hraness/dawg 0.2.0 → 0.3.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 +79 -0
- package/DAWG.md +196 -11
- package/README.md +79 -30
- package/core/diff.ts +249 -0
- package/core/key.ts +43 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +324 -1
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +280 -14
- package/src/agent/brief.ts +23 -3
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/planner.ts +38 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +398 -1
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +27 -12
- package/src/agent/xcb.ts +233 -21
- package/src/audio/click.ts +125 -0
- package/src/audio/engine.ts +407 -33
- package/src/audio/live.ts +152 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +290 -76
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +24 -7
- package/src/commands/sample.ts +302 -0
- package/src/main.ts +1043 -166
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +28 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +3 -37
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +261 -18
- package/tui/drums.ts +44 -0
- package/tui/highway.ts +18 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +143 -0
package/core/score.ts
CHANGED
|
@@ -33,8 +33,27 @@ export const SCORE_LIMITS = Object.freeze({
|
|
|
33
33
|
maxReverbMix: 1,
|
|
34
34
|
minReverbSize: 0,
|
|
35
35
|
maxReverbSize: 1,
|
|
36
|
+
/** Sampler voices per track (score v2, `sampler` field). */
|
|
37
|
+
maxSamplerVoices: 64,
|
|
38
|
+
maxSamplerVoiceNameLength: 32,
|
|
39
|
+
maxSamplePathLength: 256,
|
|
40
|
+
maxSampleGain: 2,
|
|
41
|
+
/** |speed| bound; negative speeds play in reverse. */
|
|
42
|
+
maxSampleSpeed: 8,
|
|
43
|
+
maxChokeGroupLength: 32,
|
|
44
|
+
/** Per sample file, enforced by the decoder and the import tool. */
|
|
45
|
+
maxSampleFileBytes: 50 * 1024 * 1024,
|
|
46
|
+
maxSampleSeconds: 600,
|
|
47
|
+
/** Decoded PCM cache under `.dawg/assets`, LRU. */
|
|
48
|
+
maxSampleCacheBytes: 512 * 1024 * 1024,
|
|
36
49
|
} as const);
|
|
37
50
|
|
|
51
|
+
/** Instrument name that selects a track's `sampler`. */
|
|
52
|
+
export const SAMPLER_INSTRUMENT = "sampler" as const;
|
|
53
|
+
|
|
54
|
+
/** First pitch slot assigned to one-shot sampler voices (GM kick). */
|
|
55
|
+
export const SAMPLER_FIRST_SLOT = 36;
|
|
56
|
+
|
|
38
57
|
export class ScoreValidationError extends Error {
|
|
39
58
|
readonly code:
|
|
40
59
|
| "invalid-score"
|
|
@@ -85,6 +104,43 @@ export type Track = Readonly<{
|
|
|
85
104
|
delayMixAutomation?: readonly AutomationPoint[];
|
|
86
105
|
/** Algorithmic stereo reverb send, mixed after the delay. */
|
|
87
106
|
reverb?: TrackReverb;
|
|
107
|
+
/**
|
|
108
|
+
* Score v2: sample voices; present exactly when `instrument` is
|
|
109
|
+
* `"sampler"`. Documents without it decode unchanged.
|
|
110
|
+
*/
|
|
111
|
+
sampler?: Sampler;
|
|
112
|
+
}>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A track's sample voices (Strudel-aligned). In `oneshot` mode every voice is
|
|
116
|
+
* a drum-like hit addressed by the pitch slot `samplerVoiceSlots` assigns
|
|
117
|
+
* (36, 37, … in voice-name order); in `keyed` mode notes are ordinary pitches
|
|
118
|
+
* and a voice is resampled from its `root`.
|
|
119
|
+
*/
|
|
120
|
+
export type Sampler = Readonly<{
|
|
121
|
+
voices: Readonly<Record<string, SampleRef>>;
|
|
122
|
+
mode: "oneshot" | "keyed";
|
|
123
|
+
}>;
|
|
124
|
+
|
|
125
|
+
export type SampleRef = Readonly<{
|
|
126
|
+
/** Project-relative path, normally `tracks/<slug>/samples/<file>`. */
|
|
127
|
+
src: string;
|
|
128
|
+
/** Content hash (64 hex) when known; the decoded cache is keyed by it. */
|
|
129
|
+
sha256?: string;
|
|
130
|
+
/** MIDI note the file plays at in keyed mode, default 60. */
|
|
131
|
+
root?: number;
|
|
132
|
+
/** Start fraction 0..1 of the file. */
|
|
133
|
+
begin?: number;
|
|
134
|
+
/** End fraction 0..1 of the file, greater than `begin`. */
|
|
135
|
+
end?: number;
|
|
136
|
+
/** Linear gain 0..2. */
|
|
137
|
+
gain?: number;
|
|
138
|
+
/** Playback rate; negative reverses. |speed| ≤ 8, never 0. */
|
|
139
|
+
speed?: number;
|
|
140
|
+
/** Sustain by looping begin..end. */
|
|
141
|
+
loop?: boolean;
|
|
142
|
+
/** Choke group: a new hit in the group stops the previous one. */
|
|
143
|
+
choke?: string;
|
|
88
144
|
}>;
|
|
89
145
|
|
|
90
146
|
export type TrackReverb = Readonly<{
|
|
@@ -185,6 +241,7 @@ export type TrackPatch = Readonly<
|
|
|
185
241
|
filter?: TrackFilter | null;
|
|
186
242
|
delay?: TrackDelay | null;
|
|
187
243
|
reverb?: TrackReverb | null;
|
|
244
|
+
sampler?: Sampler | null;
|
|
188
245
|
}
|
|
189
246
|
>;
|
|
190
247
|
|
|
@@ -204,11 +261,12 @@ export type Note = Readonly<{
|
|
|
204
261
|
}>;
|
|
205
262
|
|
|
206
263
|
export type TrackInput = Readonly<
|
|
207
|
-
Omit<Partial<Track>, "filter" | "delay" | "reverb"> &
|
|
264
|
+
Omit<Partial<Track>, "filter" | "delay" | "reverb" | "sampler"> &
|
|
208
265
|
Pick<Track, "id"> & {
|
|
209
266
|
filter?: TrackFilter | null;
|
|
210
267
|
delay?: TrackDelay | null;
|
|
211
268
|
reverb?: TrackReverb | null;
|
|
269
|
+
sampler?: Sampler | null;
|
|
212
270
|
}
|
|
213
271
|
>;
|
|
214
272
|
|
|
@@ -348,6 +406,15 @@ export class TrackScore {
|
|
|
348
406
|
return new TrackScore({ ...this.toJSON(), bars });
|
|
349
407
|
}
|
|
350
408
|
|
|
409
|
+
withKey(key: string | null): TrackScore {
|
|
410
|
+
return new TrackScore({ ...this.toJSON(), key });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Change the meter; ticks are per beat, so notes keep their positions. */
|
|
414
|
+
withMeter(beatsPerBar: number): TrackScore {
|
|
415
|
+
return new TrackScore({ ...this.toJSON(), beatsPerBar });
|
|
416
|
+
}
|
|
417
|
+
|
|
351
418
|
toJSON(): TrackScoreData & { version: typeof SCORE_VERSION } {
|
|
352
419
|
return {
|
|
353
420
|
version: SCORE_VERSION,
|
|
@@ -476,6 +543,62 @@ export function updateTrack(
|
|
|
476
543
|
);
|
|
477
544
|
}
|
|
478
545
|
|
|
546
|
+
/** Removes a track and every note on it; unknown ids are a no-op. */
|
|
547
|
+
export function removeTrack(score: TrackScore, trackId: string): TrackScore {
|
|
548
|
+
if (!score.tracks.some((track) => track.id === trackId)) return score;
|
|
549
|
+
return new TrackScore({
|
|
550
|
+
...score.toJSON(),
|
|
551
|
+
tracks: score.tracks.filter((track) => track.id !== trackId),
|
|
552
|
+
notes: score.notes.filter((note) => note.trackId !== trackId),
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Moves a track to position `index` (clamped) in score order. */
|
|
557
|
+
export function moveTrack(
|
|
558
|
+
score: TrackScore,
|
|
559
|
+
trackId: string,
|
|
560
|
+
index: number,
|
|
561
|
+
): TrackScore {
|
|
562
|
+
const from = score.tracks.findIndex((track) => track.id === trackId);
|
|
563
|
+
if (from < 0 || !Number.isInteger(index))
|
|
564
|
+
throw new ScoreValidationError(
|
|
565
|
+
"moveTrack needs an existing track and an integer index",
|
|
566
|
+
"invalid-track",
|
|
567
|
+
);
|
|
568
|
+
const to = Math.max(0, Math.min(score.tracks.length - 1, index));
|
|
569
|
+
if (from === to) return score;
|
|
570
|
+
const tracks = [...score.tracks];
|
|
571
|
+
const [moved] = tracks.splice(from, 1);
|
|
572
|
+
tracks.splice(to, 0, moved!);
|
|
573
|
+
return score.withTracks(tracks);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function isSamplerInstrument(instrument: string | undefined): boolean {
|
|
577
|
+
return (
|
|
578
|
+
typeof instrument === "string" &&
|
|
579
|
+
instrument.trim().toLowerCase() === SAMPLER_INSTRUMENT
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* One-shot voices are addressed by pitch slots so the drum-lane projection
|
|
585
|
+
* and note tools keep working: voice names sorted by code point, slots from
|
|
586
|
+
* `SAMPLER_FIRST_SLOT` upward. Keyed samplers have no slots.
|
|
587
|
+
*/
|
|
588
|
+
export function samplerVoiceSlots(
|
|
589
|
+
sampler: Sampler,
|
|
590
|
+
): ReadonlyMap<string, number> {
|
|
591
|
+
const slots = new Map<string, number>();
|
|
592
|
+
if (sampler.mode !== "oneshot") return slots;
|
|
593
|
+
const names = Object.keys(sampler.voices).sort(compareCodePoints);
|
|
594
|
+
names.forEach((name, index) => slots.set(name, SAMPLER_FIRST_SLOT + index));
|
|
595
|
+
return slots;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function compareCodePoints(a: string, b: string): number {
|
|
599
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
600
|
+
}
|
|
601
|
+
|
|
479
602
|
export function setVolumeAutomation(
|
|
480
603
|
score: TrackScore,
|
|
481
604
|
trackId: string,
|
|
@@ -569,12 +692,36 @@ export type ScoreOperation =
|
|
|
569
692
|
| Readonly<{
|
|
570
693
|
type: "clearTrack";
|
|
571
694
|
trackId: string;
|
|
695
|
+
}>
|
|
696
|
+
| Readonly<{
|
|
697
|
+
type: "removeTrack";
|
|
698
|
+
trackId: string;
|
|
699
|
+
}>
|
|
700
|
+
| Readonly<{
|
|
701
|
+
type: "moveTrack";
|
|
702
|
+
trackId: string;
|
|
703
|
+
index: number;
|
|
704
|
+
}>
|
|
705
|
+
| Readonly<{
|
|
706
|
+
type: "setKey";
|
|
707
|
+
key: string | null;
|
|
708
|
+
}>
|
|
709
|
+
| Readonly<{
|
|
710
|
+
type: "setMeter";
|
|
711
|
+
beatsPerBar: number;
|
|
572
712
|
}>;
|
|
573
713
|
|
|
574
714
|
export function applyScoreOperation(
|
|
575
715
|
score: TrackScore,
|
|
576
716
|
operation: ScoreOperation,
|
|
577
717
|
): TrackScore {
|
|
718
|
+
if (operation.type === "removeTrack")
|
|
719
|
+
return removeTrack(score, operation.trackId);
|
|
720
|
+
if (operation.type === "moveTrack")
|
|
721
|
+
return moveTrack(score, operation.trackId, operation.index);
|
|
722
|
+
if (operation.type === "setKey") return score.withKey(operation.key);
|
|
723
|
+
if (operation.type === "setMeter")
|
|
724
|
+
return score.withMeter(operation.beatsPerBar);
|
|
578
725
|
if (operation.type === "addTrack") return addTrack(score, operation.track);
|
|
579
726
|
if (operation.type === "addNote") return addNote(score, operation.note);
|
|
580
727
|
if (operation.type === "removeNote")
|
|
@@ -736,6 +883,17 @@ function normalizeTrack(input: unknown): Track {
|
|
|
736
883
|
const delayFeedbackAutomation = lane("delay-feedback");
|
|
737
884
|
const delayMixAutomation = lane("delay-mix");
|
|
738
885
|
const reverb = normalizeReverb(input.reverb);
|
|
886
|
+
const sampler = normalizeSampler(input.sampler);
|
|
887
|
+
if (isSamplerInstrument(instrument) && !sampler)
|
|
888
|
+
throw new ScoreValidationError(
|
|
889
|
+
`track ${id} instrument "sampler" needs a sampler`,
|
|
890
|
+
"invalid-track",
|
|
891
|
+
);
|
|
892
|
+
if (sampler && !isSamplerInstrument(instrument))
|
|
893
|
+
throw new ScoreValidationError(
|
|
894
|
+
`track ${id} has a sampler but its instrument is "${instrument}"`,
|
|
895
|
+
"invalid-track",
|
|
896
|
+
);
|
|
739
897
|
return Object.freeze({
|
|
740
898
|
id,
|
|
741
899
|
name,
|
|
@@ -753,9 +911,174 @@ function normalizeTrack(input: unknown): Track {
|
|
|
753
911
|
...(delayFeedbackAutomation.length > 0 ? { delayFeedbackAutomation } : {}),
|
|
754
912
|
...(delayMixAutomation.length > 0 ? { delayMixAutomation } : {}),
|
|
755
913
|
...(reverb ? { reverb } : {}),
|
|
914
|
+
...(sampler ? { sampler } : {}),
|
|
756
915
|
});
|
|
757
916
|
}
|
|
758
917
|
|
|
918
|
+
const VOICE_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
919
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
920
|
+
|
|
921
|
+
/** Validates a `Sampler` from unknown; `undefined`/`null` means none. */
|
|
922
|
+
export function normalizeSampler(input: unknown): Sampler | undefined {
|
|
923
|
+
if (input === undefined || input === null) return undefined;
|
|
924
|
+
if (!isRecord(input))
|
|
925
|
+
throw new ScoreValidationError(
|
|
926
|
+
"track sampler must be an object or null",
|
|
927
|
+
"invalid-track",
|
|
928
|
+
);
|
|
929
|
+
const mode = input.mode ?? "oneshot";
|
|
930
|
+
if (mode !== "oneshot" && mode !== "keyed")
|
|
931
|
+
throw new ScoreValidationError(
|
|
932
|
+
'sampler mode must be "oneshot" or "keyed"',
|
|
933
|
+
"invalid-track",
|
|
934
|
+
);
|
|
935
|
+
if (!isRecord(input.voices))
|
|
936
|
+
throw new ScoreValidationError(
|
|
937
|
+
"sampler voices must be an object of name → sample",
|
|
938
|
+
"invalid-track",
|
|
939
|
+
);
|
|
940
|
+
const names = Object.keys(input.voices);
|
|
941
|
+
if (names.length === 0 || names.length > SCORE_LIMITS.maxSamplerVoices)
|
|
942
|
+
throw new ScoreValidationError(
|
|
943
|
+
`sampler must have between 1 and ${SCORE_LIMITS.maxSamplerVoices} voices`,
|
|
944
|
+
"score-limit",
|
|
945
|
+
);
|
|
946
|
+
const voices: Record<string, SampleRef> = {};
|
|
947
|
+
for (const name of names.sort(compareCodePoints)) {
|
|
948
|
+
if (
|
|
949
|
+
name.length > SCORE_LIMITS.maxSamplerVoiceNameLength ||
|
|
950
|
+
!VOICE_NAME.test(name)
|
|
951
|
+
)
|
|
952
|
+
throw new ScoreValidationError(
|
|
953
|
+
`sampler voice name "${name.slice(0, 40)}" must match ${VOICE_NAME.source} and be at most ${SCORE_LIMITS.maxSamplerVoiceNameLength} characters`,
|
|
954
|
+
"invalid-track",
|
|
955
|
+
);
|
|
956
|
+
voices[name] = normalizeSampleRef(input.voices[name], name);
|
|
957
|
+
}
|
|
958
|
+
return Object.freeze({ voices: Object.freeze(voices), mode });
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
export function normalizeSampleRef(input: unknown, name: string): SampleRef {
|
|
962
|
+
const label = `sampler voice ${name}`;
|
|
963
|
+
if (!isRecord(input))
|
|
964
|
+
throw new ScoreValidationError(
|
|
965
|
+
`${label} must be an object`,
|
|
966
|
+
"invalid-track",
|
|
967
|
+
);
|
|
968
|
+
const src = input.src;
|
|
969
|
+
if (
|
|
970
|
+
typeof src !== "string" ||
|
|
971
|
+
src.length === 0 ||
|
|
972
|
+
src.length > SCORE_LIMITS.maxSamplePathLength ||
|
|
973
|
+
!isSafeRelativePath(src)
|
|
974
|
+
)
|
|
975
|
+
throw new ScoreValidationError(
|
|
976
|
+
`${label} src must be a project-relative path without "..", at most ${SCORE_LIMITS.maxSamplePathLength} characters`,
|
|
977
|
+
"invalid-track",
|
|
978
|
+
);
|
|
979
|
+
const ref: {
|
|
980
|
+
src: string;
|
|
981
|
+
sha256?: string;
|
|
982
|
+
root?: number;
|
|
983
|
+
begin?: number;
|
|
984
|
+
end?: number;
|
|
985
|
+
gain?: number;
|
|
986
|
+
speed?: number;
|
|
987
|
+
loop?: boolean;
|
|
988
|
+
choke?: string;
|
|
989
|
+
} = { src };
|
|
990
|
+
if (input.sha256 !== undefined) {
|
|
991
|
+
if (typeof input.sha256 !== "string" || !SHA256_HEX.test(input.sha256))
|
|
992
|
+
throw new ScoreValidationError(
|
|
993
|
+
`${label} sha256 must be 64 lowercase hex characters`,
|
|
994
|
+
"invalid-track",
|
|
995
|
+
);
|
|
996
|
+
ref.sha256 = input.sha256;
|
|
997
|
+
}
|
|
998
|
+
if (input.root !== undefined) {
|
|
999
|
+
if (
|
|
1000
|
+
typeof input.root !== "number" ||
|
|
1001
|
+
!Number.isInteger(input.root) ||
|
|
1002
|
+
input.root < 0 ||
|
|
1003
|
+
input.root > 127
|
|
1004
|
+
)
|
|
1005
|
+
throw new ScoreValidationError(
|
|
1006
|
+
`${label} root must be a MIDI integer between 0 and 127`,
|
|
1007
|
+
"invalid-track",
|
|
1008
|
+
);
|
|
1009
|
+
ref.root = input.root;
|
|
1010
|
+
}
|
|
1011
|
+
const begin = input.begin === undefined ? 0 : input.begin;
|
|
1012
|
+
const end = input.end === undefined ? 1 : input.end;
|
|
1013
|
+
if (
|
|
1014
|
+
typeof begin !== "number" ||
|
|
1015
|
+
typeof end !== "number" ||
|
|
1016
|
+
!Number.isFinite(begin) ||
|
|
1017
|
+
!Number.isFinite(end) ||
|
|
1018
|
+
begin < 0 ||
|
|
1019
|
+
end > 1 ||
|
|
1020
|
+
begin >= end
|
|
1021
|
+
)
|
|
1022
|
+
throw new ScoreValidationError(
|
|
1023
|
+
`${label} begin/end must be fractions with 0 ≤ begin < end ≤ 1`,
|
|
1024
|
+
"invalid-track",
|
|
1025
|
+
);
|
|
1026
|
+
if (input.begin !== undefined) ref.begin = begin;
|
|
1027
|
+
if (input.end !== undefined) ref.end = end;
|
|
1028
|
+
if (input.gain !== undefined)
|
|
1029
|
+
ref.gain = boundedNumber(
|
|
1030
|
+
input.gain,
|
|
1031
|
+
`${label} gain`,
|
|
1032
|
+
0,
|
|
1033
|
+
SCORE_LIMITS.maxSampleGain,
|
|
1034
|
+
);
|
|
1035
|
+
if (input.speed !== undefined) {
|
|
1036
|
+
const speed = boundedNumber(
|
|
1037
|
+
input.speed,
|
|
1038
|
+
`${label} speed`,
|
|
1039
|
+
-SCORE_LIMITS.maxSampleSpeed,
|
|
1040
|
+
SCORE_LIMITS.maxSampleSpeed,
|
|
1041
|
+
);
|
|
1042
|
+
if (speed === 0)
|
|
1043
|
+
throw new ScoreValidationError(
|
|
1044
|
+
`${label} speed cannot be 0`,
|
|
1045
|
+
"invalid-track",
|
|
1046
|
+
);
|
|
1047
|
+
ref.speed = speed;
|
|
1048
|
+
}
|
|
1049
|
+
if (input.loop !== undefined) {
|
|
1050
|
+
if (typeof input.loop !== "boolean")
|
|
1051
|
+
throw new ScoreValidationError(
|
|
1052
|
+
`${label} loop must be boolean`,
|
|
1053
|
+
"invalid-track",
|
|
1054
|
+
);
|
|
1055
|
+
ref.loop = input.loop;
|
|
1056
|
+
}
|
|
1057
|
+
if (input.choke !== undefined) {
|
|
1058
|
+
if (
|
|
1059
|
+
typeof input.choke !== "string" ||
|
|
1060
|
+
input.choke.length === 0 ||
|
|
1061
|
+
input.choke.length > SCORE_LIMITS.maxChokeGroupLength ||
|
|
1062
|
+
!VOICE_NAME.test(input.choke)
|
|
1063
|
+
)
|
|
1064
|
+
throw new ScoreValidationError(
|
|
1065
|
+
`${label} choke must be a short group name`,
|
|
1066
|
+
"invalid-track",
|
|
1067
|
+
);
|
|
1068
|
+
ref.choke = input.choke;
|
|
1069
|
+
}
|
|
1070
|
+
return Object.freeze(ref);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/** Relative, forward-slash, no empty/dot segments, no control characters. */
|
|
1074
|
+
function isSafeRelativePath(path: string): boolean {
|
|
1075
|
+
if (path.startsWith("/") || /[\\\u0000-\u001f]/.test(path)) return false;
|
|
1076
|
+
if (/^[A-Za-z]:/.test(path)) return false;
|
|
1077
|
+
return path
|
|
1078
|
+
.split("/")
|
|
1079
|
+
.every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
1080
|
+
}
|
|
1081
|
+
|
|
759
1082
|
export function normalizeReverb(input: unknown): TrackReverb | undefined {
|
|
760
1083
|
if (input === undefined || input === null) return undefined;
|
|
761
1084
|
if (!isRecord(input))
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs inside the evaluation subprocess (`bun --no-addons --no-install
|
|
3
|
+
* --smol eval-child.ts <project>`): imports the project's `song.ts`, fills
|
|
4
|
+
* sample hashes, and prints one JSON line. Never imported by dawg itself.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { realpathSync } from "node:fs";
|
|
9
|
+
import { readFile, stat } from "node:fs/promises";
|
|
10
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
|
|
13
|
+
const MAX_SAMPLE_FILE_BYTES = 50 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
type Diagnostic = {
|
|
16
|
+
message: string;
|
|
17
|
+
file?: string;
|
|
18
|
+
line?: number;
|
|
19
|
+
col?: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
async function main(): Promise<void> {
|
|
23
|
+
const project = realpathSync(resolve(process.argv[2] ?? "."));
|
|
24
|
+
const entry = join(project, "song.ts");
|
|
25
|
+
let output: unknown;
|
|
26
|
+
try {
|
|
27
|
+
const module = (await import(pathToFileURL(entry).href)) as {
|
|
28
|
+
default?: unknown;
|
|
29
|
+
};
|
|
30
|
+
const song = module.default;
|
|
31
|
+
if (typeof song !== "object" || song === null)
|
|
32
|
+
throw new Error("song.ts must `export default song({...})`");
|
|
33
|
+
output = { ok: true, score: await withSampleHashes(song, project) };
|
|
34
|
+
} catch (error) {
|
|
35
|
+
output = { ok: false, error: diagnostic(error, project) };
|
|
36
|
+
}
|
|
37
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function diagnostic(error: unknown, project: string): Diagnostic {
|
|
41
|
+
if (typeof error !== "object" || error === null)
|
|
42
|
+
return { message: String(error) };
|
|
43
|
+
let record = error as Record<string, unknown>;
|
|
44
|
+
// Bun wraps build errors in an AggregateError; the first one has the position.
|
|
45
|
+
if (Array.isArray(record.errors) && record.errors.length > 0) {
|
|
46
|
+
const first: unknown = record.errors[0];
|
|
47
|
+
if (typeof first === "object" && first !== null)
|
|
48
|
+
record = first as Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
const message =
|
|
51
|
+
typeof record.message === "string" ? record.message : String(error);
|
|
52
|
+
const out: Diagnostic = { message };
|
|
53
|
+
// Bun's BuildMessage/ResolveMessage carry a position.
|
|
54
|
+
const position = record.position as Record<string, unknown> | undefined;
|
|
55
|
+
if (position && typeof position.file === "string") {
|
|
56
|
+
out.file = relative(project, position.file) || position.file;
|
|
57
|
+
if (typeof position.line === "number") out.line = position.line;
|
|
58
|
+
if (typeof position.column === "number") out.col = position.column;
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
const stack = typeof record.stack === "string" ? record.stack : "";
|
|
62
|
+
for (const line of stack.split("\n")) {
|
|
63
|
+
const match = line.match(
|
|
64
|
+
/\(?((?:\/|file:\/\/)[^\s()]+?):(\d+):(\d+)\)?\s*$/,
|
|
65
|
+
);
|
|
66
|
+
if (!match) continue;
|
|
67
|
+
const file = match[1]!.replace(/^file:\/\//, "");
|
|
68
|
+
const rel = relative(project, file);
|
|
69
|
+
if (rel.startsWith("..") || isAbsolute(rel) || rel.startsWith(".dawg"))
|
|
70
|
+
continue;
|
|
71
|
+
out.file = rel;
|
|
72
|
+
out.line = Number(match[2]);
|
|
73
|
+
out.col = Number(match[3]);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Adds `sha256` to sampler voices whose file exists and is within the size limit. */
|
|
80
|
+
async function withSampleHashes(
|
|
81
|
+
song: object,
|
|
82
|
+
project: string,
|
|
83
|
+
): Promise<unknown> {
|
|
84
|
+
const plain = JSON.parse(JSON.stringify(song)) as Record<string, unknown>;
|
|
85
|
+
const tracks = Array.isArray(plain.tracks) ? plain.tracks : [];
|
|
86
|
+
for (const track of tracks) {
|
|
87
|
+
if (typeof track !== "object" || track === null) continue;
|
|
88
|
+
const sampler = (track as Record<string, unknown>).sampler;
|
|
89
|
+
if (typeof sampler !== "object" || sampler === null) continue;
|
|
90
|
+
const voices = (sampler as Record<string, unknown>).voices;
|
|
91
|
+
if (typeof voices !== "object" || voices === null) continue;
|
|
92
|
+
for (const voice of Object.values(voices as Record<string, unknown>)) {
|
|
93
|
+
if (typeof voice !== "object" || voice === null) continue;
|
|
94
|
+
const ref = voice as Record<string, unknown>;
|
|
95
|
+
if (typeof ref.src !== "string" || typeof ref.sha256 === "string")
|
|
96
|
+
continue;
|
|
97
|
+
const path = resolve(project, ref.src);
|
|
98
|
+
if (relative(project, path).startsWith("..")) continue;
|
|
99
|
+
try {
|
|
100
|
+
const info = await stat(path);
|
|
101
|
+
if (!info.isFile() || info.size > MAX_SAMPLE_FILE_BYTES) continue;
|
|
102
|
+
ref.sha256 = createHash("sha256")
|
|
103
|
+
.update(await readFile(path))
|
|
104
|
+
.digest("hex");
|
|
105
|
+
} catch {
|
|
106
|
+
// Missing sample: left for `dawg check` to report.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return plain;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await main();
|