@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.
Files changed (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. 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();