@hraness/dawg 0.0.0-stage → 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 (113) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +327 -0
  3. package/LICENSE +21 -0
  4. package/README.md +213 -2
  5. package/core/diff.ts +249 -0
  6. package/core/drums.ts +102 -0
  7. package/core/key.ts +43 -0
  8. package/core/loop.ts +78 -0
  9. package/core/pitch.ts +60 -0
  10. package/core/score.ts +1388 -0
  11. package/core/sdk/eval-child.ts +113 -0
  12. package/core/sdk/eval.ts +257 -0
  13. package/core/sdk/print.ts +393 -0
  14. package/core/sdk/v1.ts +954 -0
  15. package/core/slug.ts +19 -0
  16. package/package.json +45 -4
  17. package/src/agent/agent.ts +853 -0
  18. package/src/agent/brief.ts +160 -0
  19. package/src/agent/gateway.ts +441 -0
  20. package/src/agent/models.ts +633 -0
  21. package/src/agent/ops.ts +157 -0
  22. package/src/agent/planner.ts +259 -0
  23. package/src/agent/provider.ts +454 -0
  24. package/src/agent/sse.ts +114 -0
  25. package/src/agent/tools.ts +1373 -0
  26. package/src/agent/usage.ts +296 -0
  27. package/src/agent/workspace.ts +683 -0
  28. package/src/agent/xcb-agent.ts +262 -0
  29. package/src/agent/xcb.ts +579 -0
  30. package/src/audio/click.ts +125 -0
  31. package/src/audio/clock.ts +68 -0
  32. package/src/audio/engine.ts +841 -0
  33. package/src/audio/live.ts +152 -0
  34. package/src/audio/lock.ts +57 -0
  35. package/src/audio/player.ts +134 -0
  36. package/src/audio/render-worker.ts +68 -0
  37. package/src/audio/renderer.ts +174 -0
  38. package/src/audio/sampler.ts +292 -0
  39. package/src/audio/samples.ts +683 -0
  40. package/src/audio/wav.ts +861 -0
  41. package/src/auth/cli.ts +231 -0
  42. package/src/auth/credentials.ts +411 -0
  43. package/src/auth/discover.ts +481 -0
  44. package/src/auth/login.ts +1191 -0
  45. package/src/auth/openrouter.ts +206 -0
  46. package/src/auth/picker.ts +282 -0
  47. package/src/auth/runner.ts +207 -0
  48. package/src/auth/tui.ts +107 -0
  49. package/src/commands/edit.ts +170 -0
  50. package/src/commands/help.ts +247 -0
  51. package/src/commands/history.ts +69 -0
  52. package/src/commands/music.ts +461 -0
  53. package/src/commands/sample.ts +302 -0
  54. package/src/daemon.ts +31 -0
  55. package/src/main.ts +2209 -0
  56. package/src/media/analyze.ts +364 -0
  57. package/src/media/backend.ts +253 -0
  58. package/src/media/cli.ts +173 -0
  59. package/src/media/download.ts +281 -0
  60. package/src/media/dsp.ts +281 -0
  61. package/src/media/import.ts +130 -0
  62. package/src/media/lyrics.ts +201 -0
  63. package/src/media/notes.ts +363 -0
  64. package/src/media/paths.ts +168 -0
  65. package/src/media/process.ts +226 -0
  66. package/src/media/registry.ts +9 -0
  67. package/src/media/sidecar.ts +72 -0
  68. package/src/media/stemdeck.ts +254 -0
  69. package/src/media/stems.ts +173 -0
  70. package/src/media/tools.ts +292 -0
  71. package/src/media/types.ts +92 -0
  72. package/src/media/vendor/basic-pitch.ts +261 -0
  73. package/src/media/vendor/drums.ts +817 -0
  74. package/src/media/vendor/grid.ts +203 -0
  75. package/src/media/vendor/util.ts +139 -0
  76. package/src/media/vendor/wav.ts +233 -0
  77. package/src/project/check.ts +80 -0
  78. package/src/project/init.ts +253 -0
  79. package/src/project/sync.ts +432 -0
  80. package/src/project/typecheck.ts +149 -0
  81. package/src/render.ts +121 -0
  82. package/src/session/attach.ts +181 -0
  83. package/src/session/client.ts +498 -0
  84. package/src/session/daemon.ts +740 -0
  85. package/src/session/delta.ts +249 -0
  86. package/src/session/list.ts +180 -0
  87. package/src/session/lock.ts +92 -0
  88. package/src/session/meta.ts +253 -0
  89. package/src/session/naming.ts +430 -0
  90. package/src/session/port.ts +481 -0
  91. package/src/session/presence.ts +159 -0
  92. package/src/session/protocol.ts +618 -0
  93. package/src/session/rebase.ts +168 -0
  94. package/src/session/store.ts +581 -0
  95. package/src/tui/menu.ts +1083 -0
  96. package/src/tui/play-mode.ts +442 -0
  97. package/src/tui/play-session.ts +636 -0
  98. package/src/web/fetch.ts +340 -0
  99. package/src/web/http.ts +137 -0
  100. package/src/web/search.ts +681 -0
  101. package/tui/activity.ts +364 -0
  102. package/tui/app.ts +1372 -0
  103. package/tui/drums.ts +65 -0
  104. package/tui/highway.ts +921 -0
  105. package/tui/input.ts +63 -0
  106. package/tui/keys.ts +102 -0
  107. package/tui/layers.ts +80 -0
  108. package/tui/play-strip.ts +143 -0
  109. package/tui/prompt.ts +609 -0
  110. package/tui/render.ts +124 -0
  111. package/tui/screen.ts +247 -0
  112. package/tui/text.ts +72 -0
  113. package/tui/theme.ts +451 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * `split_stems(file)`: six stems (vocals, drums, bass, guitar, piano, other)
3
+ * into `<file>.stems/<stem>.wav` next to the input. A download that came
4
+ * through StemDeck (sidecar has a job id) or that has a source URL and a
5
+ * running StemDeck is separated there; otherwise `demucs -n htdemucs_6s`
6
+ * runs locally (`uv tool run demucs` or a PATH binary).
7
+ */
8
+ import { readdir, rename, rm } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import {
11
+ detectStemDeck,
12
+ missingTool,
13
+ toolCommand,
14
+ uvInstalledTools,
15
+ FIRST_RUN_NOTES,
16
+ } from "./backend.ts";
17
+ import { readSidecar, sidecarPath, writeSidecarField } from "./sidecar.ts";
18
+ import { ensureDir, exists, projectPath, resolveInput } from "./paths.ts";
19
+ import {
20
+ MediaToolError,
21
+ percentProgress,
22
+ runHelper,
23
+ throwIfAborted,
24
+ withTempDir,
25
+ } from "./process.ts";
26
+ import {
27
+ STEM_NAMES,
28
+ createStemDeck,
29
+ type StemDeckOptions,
30
+ } from "./stemdeck.ts";
31
+ import {
32
+ MEDIA_LIMITS,
33
+ type MediaResult,
34
+ type MediaRunContext,
35
+ } from "./types.ts";
36
+
37
+ export const DEMUCS_MODEL = "htdemucs_6s";
38
+
39
+ export type SplitStemsArgs = Readonly<{ file: string }>;
40
+ export type SplitStemsOptions = Readonly<{ stemdeck?: StemDeckOptions }>;
41
+
42
+ export function stemsDirFor(wavPath: string): string {
43
+ return wavPath.replace(/\.wav$/i, "") + ".stems";
44
+ }
45
+
46
+ async function existingStems(
47
+ dir: string,
48
+ ): Promise<readonly string[] | undefined> {
49
+ if (!(await exists(dir))) return undefined;
50
+ const present = new Set(await readdir(dir));
51
+ const all = STEM_NAMES.every((name) => present.has(`${name}.wav`));
52
+ return all ? STEM_NAMES.map((name) => join(dir, `${name}.wav`)) : undefined;
53
+ }
54
+
55
+ export async function splitStems(
56
+ args: SplitStemsArgs,
57
+ context: MediaRunContext,
58
+ options: SplitStemsOptions = {},
59
+ ): Promise<MediaResult> {
60
+ const input = await resolveInput(context, args.file);
61
+ const dir = stemsDirFor(input.absolute);
62
+ const cached = await existingStems(dir);
63
+ if (cached)
64
+ return result(context, input.relative, cached, "already split", "cache");
65
+ const sidecar = await readSidecar(input.absolute);
66
+ const health = sidecar?.source ? await detectStemDeck(context) : undefined;
67
+ if (health && sidecar?.source) {
68
+ const client = createStemDeck(health, context, options.stemdeck);
69
+ let jobId = sidecar.stemdeck?.jobId;
70
+ if (!jobId || sidecar.stemdeck?.url !== health.url) {
71
+ context.progress("stemdeck submitting");
72
+ jobId = await client.submit(sidecar.source);
73
+ await writeSidecarField(input.absolute, "stemdeck", {
74
+ url: health.url,
75
+ jobId,
76
+ });
77
+ }
78
+ const job = await client.wait(jobId, MEDIA_LIMITS.stemsTimeoutMs);
79
+ if (job.stems.length === 0)
80
+ throw new MediaToolError(
81
+ "StemDeck finished the job but offered no stems",
82
+ );
83
+ await ensureDir(dir);
84
+ const paths = await client.fetchStems(
85
+ job,
86
+ dir,
87
+ MEDIA_LIMITS.downloadMaxBytes,
88
+ );
89
+ return result(context, input.relative, paths, "stemdeck split", "stemdeck");
90
+ }
91
+ const demucs = toolCommand(
92
+ "demucs",
93
+ context.runner,
94
+ await uvInstalledTools(context.runner, context.signal),
95
+ );
96
+ if (!demucs) throw new MediaToolError(missingTool("demucs"));
97
+ context.progress(FIRST_RUN_NOTES.demucs ?? "demucs starting");
98
+ const paths = await withTempDir("stems", async (temp) => {
99
+ await runHelper(
100
+ context,
101
+ [
102
+ ...demucs,
103
+ "-n",
104
+ DEMUCS_MODEL,
105
+ "-o",
106
+ temp,
107
+ "--filename",
108
+ "{stem}.{ext}",
109
+ input.absolute,
110
+ ],
111
+ {
112
+ timeoutMs: MEDIA_LIMITS.stemsTimeoutMs,
113
+ progress: percentProgress("demucs"),
114
+ env: { PYTHONUNBUFFERED: "1" },
115
+ },
116
+ );
117
+ throwIfAborted(context.signal);
118
+ const produced = join(temp, DEMUCS_MODEL);
119
+ const missing = [];
120
+ for (const name of STEM_NAMES)
121
+ if (!(await exists(join(produced, `${name}.wav`)))) missing.push(name);
122
+ if (missing.length > 0)
123
+ throw new MediaToolError(`demucs did not write ${missing.join(", ")}`);
124
+ await rm(dir, { recursive: true, force: true });
125
+ await ensureDir(dir);
126
+ const moved: string[] = [];
127
+ for (const name of STEM_NAMES) {
128
+ const from = join(produced, `${name}.wav`);
129
+ const to = join(dir, `${name}.wav`);
130
+ await rename(from, to).catch(async () => {
131
+ await Bun.write(to, Bun.file(from));
132
+ });
133
+ moved.push(to);
134
+ }
135
+ return moved;
136
+ });
137
+ return result(
138
+ context,
139
+ input.relative,
140
+ paths,
141
+ `demucs ${DEMUCS_MODEL} split`,
142
+ "demucs",
143
+ );
144
+ }
145
+
146
+ function result(
147
+ context: MediaRunContext,
148
+ inputRelative: string,
149
+ paths: readonly string[],
150
+ verb: string,
151
+ backend: string,
152
+ ): MediaResult {
153
+ const outputs = paths.map((path) => projectPath(context.projectRoot, path));
154
+ const stems = Object.fromEntries(
155
+ paths.map((path) => [
156
+ path
157
+ .split("/")
158
+ .at(-1)!
159
+ .replace(/\.wav$/, ""),
160
+ projectPath(context.projectRoot, path),
161
+ ]),
162
+ );
163
+ return {
164
+ summary: `${verb}: ${inputRelative} → ${outputs.length} stems`,
165
+ outputs,
166
+ content: {
167
+ file: inputRelative,
168
+ backend,
169
+ stems,
170
+ sidecar: projectPath(context.projectRoot, sidecarPath(inputRelative)),
171
+ },
172
+ };
173
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * The six media tools as `AgentTool`s. `plan()` validates arguments from
3
+ * `unknown` and returns a `media` plan whose `run()` the agent loop executes
4
+ * with the host's `MediaHost` (project root, focused slug, runner, fetch),
5
+ * a cancellation signal and a progress sink. `dawg media <verb>` reuses the
6
+ * same `run` functions through `src/media/cli.ts`.
7
+ */
8
+ import type { AgentTool, ToolPlan } from "../agent/tools.ts";
9
+ import { analyzeAudio } from "./analyze.ts";
10
+ import { downloadAudio } from "./download.ts";
11
+ import { importSample } from "./import.ts";
12
+ import { transcribeLyrics } from "./lyrics.ts";
13
+ import { NOTE_KINDS, transcribeNotes, type NoteKind } from "./notes.ts";
14
+ import { MAX_NAME_LENGTH, trackSlug } from "./paths.ts";
15
+ import { splitStems } from "./stems.ts";
16
+ import { validateYoutubeUrl } from "./vendor/util.ts";
17
+
18
+ export class MediaArgumentError extends Error {
19
+ constructor(message: string) {
20
+ super(message);
21
+ this.name = "MediaArgumentError";
22
+ }
23
+ }
24
+
25
+ function text(args: Record<string, unknown>, key: string, max: number): string {
26
+ const value = args[key];
27
+ if (
28
+ typeof value !== "string" ||
29
+ value.trim().length === 0 ||
30
+ value.length > max
31
+ )
32
+ throw new MediaArgumentError(
33
+ `${key} must be a non-empty string (≤ ${max} chars)`,
34
+ );
35
+ return value.trim();
36
+ }
37
+
38
+ function optionalText(args: Record<string, unknown>, key: string, max: number) {
39
+ return args[key] === undefined ? undefined : text(args, key, max);
40
+ }
41
+
42
+ function optionalFraction(args: Record<string, unknown>, key: string) {
43
+ const value = args[key];
44
+ if (value === undefined) return undefined;
45
+ if (
46
+ typeof value !== "number" ||
47
+ !Number.isFinite(value) ||
48
+ value < 0 ||
49
+ value > 1
50
+ )
51
+ throw new MediaArgumentError(`${key} must be a number in 0..1`);
52
+ return value;
53
+ }
54
+
55
+ function optionalSeconds(args: Record<string, unknown>, key: string) {
56
+ const value = args[key];
57
+ if (value === undefined) return undefined;
58
+ if (
59
+ typeof value !== "number" ||
60
+ !Number.isFinite(value) ||
61
+ value < 0 ||
62
+ value > 7_200
63
+ )
64
+ throw new MediaArgumentError(`${key} must be seconds in 0..7200`);
65
+ return value;
66
+ }
67
+
68
+ function sampleName(args: Record<string, unknown>): string {
69
+ const value = text(args, "name", MAX_NAME_LENGTH);
70
+ if (!/^[a-z][a-z0-9_]{0,31}$/.test(value))
71
+ throw new MediaArgumentError(
72
+ "name must be a short identifier: lowercase letter, then letters, digits or _, at most 32 characters",
73
+ );
74
+ return value;
75
+ }
76
+
77
+ const fileSchema = {
78
+ type: "string",
79
+ maxLength: 1024,
80
+ description:
81
+ "Project-relative path (as listed in the brief's downloads or returned by another media tool).",
82
+ };
83
+
84
+ type MediaRun = Extract<ToolPlan, { kind: "media" }>["run"];
85
+
86
+ const media = (summary: string, run: MediaRun): ToolPlan => ({
87
+ kind: "media",
88
+ summary,
89
+ run,
90
+ });
91
+
92
+ export const MEDIA_TOOLS: readonly AgentTool[] = Object.freeze([
93
+ {
94
+ name: "download_audio",
95
+ description:
96
+ "Download the audio of a YouTube video as a wav into the focused track's downloads/ folder (with a .json sidecar: title, duration, source, sha256). YouTube URLs only; 500 MiB and 15 minutes of wall time at most. Check the brief's downloads list first so the same video is never fetched twice.",
97
+ parameters: {
98
+ type: "object",
99
+ properties: {
100
+ url: {
101
+ type: "string",
102
+ maxLength: 2048,
103
+ description: "A youtube.com or youtu.be URL.",
104
+ },
105
+ name: {
106
+ type: "string",
107
+ maxLength: MAX_NAME_LENGTH,
108
+ description:
109
+ "Optional file name (slugified); defaults to the video title.",
110
+ },
111
+ },
112
+ required: ["url"],
113
+ additionalProperties: false,
114
+ },
115
+ plan: (args) => {
116
+ const url = validateYoutubeUrl(text(args, "url", 2048));
117
+ const name = optionalText(args, "name", MAX_NAME_LENGTH);
118
+ return media(
119
+ `download ${name ? trackSlug(name) : new URL(url).hostname}`,
120
+ (context) => downloadAudio({ url, ...(name ? { name } : {}) }, context),
121
+ );
122
+ },
123
+ },
124
+ {
125
+ name: "split_stems",
126
+ description:
127
+ "Separate a downloaded wav into six stems (vocals, drums, bass, guitar, piano, other) in <file>.stems/. Uses a local StemDeck when one is running, otherwise demucs htdemucs_6s (up to 20 minutes; the first run downloads the model).",
128
+ parameters: {
129
+ type: "object",
130
+ properties: { file: fileSchema },
131
+ required: ["file"],
132
+ additionalProperties: false,
133
+ },
134
+ plan: (args) => {
135
+ const file = text(args, "file", 1024);
136
+ return media(`split stems of ${file}`, (context) =>
137
+ splitStems({ file }, context),
138
+ );
139
+ },
140
+ },
141
+ {
142
+ name: "analyze_audio",
143
+ description:
144
+ "Measure an audio file: duration, sample rate, tempo (bpm with a confidence), beat grid, key and 240 waveform peaks. Written to <name>.analysis.json; later transcribe_notes calls align to this grid.",
145
+ parameters: {
146
+ type: "object",
147
+ properties: { file: fileSchema },
148
+ required: ["file"],
149
+ additionalProperties: false,
150
+ },
151
+ plan: (args) => {
152
+ const file = text(args, "file", 1024);
153
+ return media(`analyze ${file}`, (context) =>
154
+ analyzeAudio({ file }, context),
155
+ );
156
+ },
157
+ },
158
+ {
159
+ name: "transcribe_notes",
160
+ description:
161
+ 'Transcribe a stem to notes. kind=drums classifies kick/snare/hat/… hits; other kinds run basic-pitch. Returns up to 2048 timed notes and a quantized snippet (note("A1", startBeat, lengthBeats, velocity) or hit("kick", beat)) aligned to the file\'s beat grid; use from/to (seconds) to transcribe one section.',
162
+ parameters: {
163
+ type: "object",
164
+ properties: {
165
+ file: fileSchema,
166
+ kind: {
167
+ type: "string",
168
+ enum: [...NOTE_KINDS],
169
+ description:
170
+ "Defaults from the file name (drums.wav → drums, bass.wav → bass, else other).",
171
+ },
172
+ from: {
173
+ type: "number",
174
+ minimum: 0,
175
+ description: "Start of the window in seconds.",
176
+ },
177
+ to: {
178
+ type: "number",
179
+ minimum: 0,
180
+ description: "End of the window in seconds.",
181
+ },
182
+ },
183
+ required: ["file"],
184
+ additionalProperties: false,
185
+ },
186
+ plan: (args) => {
187
+ const file = text(args, "file", 1024);
188
+ const kindValue = optionalText(args, "kind", 16);
189
+ if (
190
+ kindValue !== undefined &&
191
+ !(NOTE_KINDS as readonly string[]).includes(kindValue)
192
+ )
193
+ throw new MediaArgumentError(
194
+ `kind must be one of ${NOTE_KINDS.join(", ")}`,
195
+ );
196
+ const kind = kindValue as NoteKind | undefined;
197
+ const from = optionalSeconds(args, "from");
198
+ const to = optionalSeconds(args, "to");
199
+ if (from !== undefined && to !== undefined && to <= from)
200
+ throw new MediaArgumentError("to must be greater than from");
201
+ return media(`transcribe ${kind ?? "notes"} from ${file}`, (context) =>
202
+ transcribeNotes(
203
+ {
204
+ file,
205
+ ...(kind ? { kind } : {}),
206
+ ...(from !== undefined ? { from } : {}),
207
+ ...(to !== undefined ? { to } : {}),
208
+ },
209
+ context,
210
+ ),
211
+ );
212
+ },
213
+ },
214
+ {
215
+ name: "import_sample",
216
+ description:
217
+ "Copy an audio file into the focused track's samples/ as 48 kHz wav and return the sampler({...}) snippet for track.ts. begin/end are fractions 0..1 of the file; root is the note the sample is pitched at (default C4).",
218
+ parameters: {
219
+ type: "object",
220
+ properties: {
221
+ file: fileSchema,
222
+ name: {
223
+ type: "string",
224
+ maxLength: MAX_NAME_LENGTH,
225
+ description: "Voice name: lowercase identifier such as kick or vox.",
226
+ },
227
+ begin: { type: "number", minimum: 0, maximum: 1 },
228
+ end: { type: "number", minimum: 0, maximum: 1 },
229
+ root: {
230
+ type: "string",
231
+ maxLength: 4,
232
+ description: "Note name like C4 or A#2.",
233
+ },
234
+ },
235
+ required: ["file", "name"],
236
+ additionalProperties: false,
237
+ },
238
+ plan: (args) => {
239
+ const file = text(args, "file", 1024);
240
+ const name = sampleName(args);
241
+ const begin = optionalFraction(args, "begin");
242
+ const end = optionalFraction(args, "end");
243
+ if (begin !== undefined && end !== undefined && end <= begin)
244
+ throw new MediaArgumentError("end must be greater than begin");
245
+ const root = optionalText(args, "root", 4);
246
+ if (root !== undefined && !/^[A-Ga-g][#b]?-?\d$/.test(root))
247
+ throw new MediaArgumentError("root must be a note name like C4");
248
+ return media(`import ${file} as sample ${name}`, (context) =>
249
+ importSample(
250
+ {
251
+ file,
252
+ name,
253
+ ...(begin !== undefined ? { begin } : {}),
254
+ ...(end !== undefined ? { end } : {}),
255
+ ...(root ? { root } : {}),
256
+ },
257
+ context,
258
+ ),
259
+ );
260
+ },
261
+ },
262
+ {
263
+ name: "transcribe_lyrics",
264
+ description:
265
+ "Transcribe sung or spoken words with whisper.cpp. Writes <name>.lyrics.json (timed segments) and .lyrics.txt; the first run downloads the ~148 MB ggml-base model into ~/.cache/dawg/whisper.",
266
+ parameters: {
267
+ type: "object",
268
+ properties: {
269
+ file: fileSchema,
270
+ lang: {
271
+ type: "string",
272
+ maxLength: 4,
273
+ description: "Language code (en default, or auto).",
274
+ },
275
+ },
276
+ required: ["file"],
277
+ additionalProperties: false,
278
+ },
279
+ plan: (args) => {
280
+ const file = text(args, "file", 1024);
281
+ const lang = optionalText(args, "lang", 4)?.toLowerCase();
282
+ return media(`transcribe lyrics of ${file}`, (context) =>
283
+ transcribeLyrics({ file, ...(lang ? { lang } : {}) }, context),
284
+ );
285
+ },
286
+ },
287
+ ]);
288
+
289
+ /** Agent tool names that touch `downloads/`; the host refreshes its listing after them. */
290
+ export const MEDIA_TOOL_NAME_SET: ReadonlySet<string> = new Set(
291
+ MEDIA_TOOLS.map((tool) => tool.name),
292
+ );
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Shared types for the local media tools (`download_audio`, `split_stems`,
3
+ * `analyze_audio`, `transcribe_notes`, `import_sample`, `transcribe_lyrics`).
4
+ *
5
+ * Nothing here imports the agent, so `src/agent/tools.ts` can reference these
6
+ * types without a cycle. Every tool runs through a `MediaRunContext` that the
7
+ * host supplies (project root, focused track slug, subprocess runner, fetch),
8
+ * which is what lets tests script yt-dlp, ffprobe, demucs and StemDeck.
9
+ */
10
+ import type { CommandRunner } from "../auth/runner.ts";
11
+
12
+ /** One transcribed note or drum hit, in source seconds. */
13
+ export type TimedNote = Readonly<{
14
+ pitch: number;
15
+ startSeconds: number;
16
+ endSeconds: number;
17
+ /** 0..1, the scale `note()` and `hit()` use. */
18
+ velocity: number;
19
+ /** Model confidence 0..1 when the transcriber reports one. */
20
+ confidence?: number;
21
+ /** Heuristic ranking score 0..1 for classifier output. */
22
+ heuristicScore?: number;
23
+ }>;
24
+
25
+ /** A beat grid in source seconds; `bars` mark downbeats by beat index. */
26
+ export type BeatGrid = Readonly<{
27
+ beats: readonly number[];
28
+ bars: readonly Readonly<{ beat: number; beatsPerBar: number }>[];
29
+ durationSeconds: number;
30
+ bpm?: number;
31
+ detector?: string;
32
+ confidence?: number;
33
+ intervalCv?: number;
34
+ }>;
35
+
36
+ /**
37
+ * Services the agent host lends the media tools. The project root and track
38
+ * slug come from the workspace tools (`AgentHost.workspace` and the focused
39
+ * track), so media outputs share their write scope.
40
+ */
41
+ export type MediaServices = Readonly<{
42
+ runner: CommandRunner;
43
+ /** Injectable for tests; defaults to global fetch. */
44
+ fetch?: typeof fetch;
45
+ env?: Readonly<Record<string, string | undefined>>;
46
+ /** Overrides `$HOME` for model caches (`~/.cache/dawg`). */
47
+ homeDir?: string;
48
+ }>;
49
+
50
+ /** What every media tool runs with. Paths are absolute. */
51
+ export type MediaHost = MediaServices &
52
+ Readonly<{
53
+ /** Workspace root; media outputs live under `tracks/<slug>/downloads/`. */
54
+ projectRoot: string;
55
+ /** `trackSlug()` of the focused track's name, as the workspace tools use. */
56
+ trackSlug: string;
57
+ }>;
58
+
59
+ export type MediaRunContext = MediaHost &
60
+ Readonly<{
61
+ signal: AbortSignal;
62
+ /** Short progress line for the activity feed, e.g. `demucs 42%`. */
63
+ progress: (line: string) => void;
64
+ }>;
65
+
66
+ /** What a finished media tool reports; `content` goes back to the model. */
67
+ export type MediaResult = Readonly<{
68
+ /** One line for the activity card. */
69
+ summary: string;
70
+ /** JSON-serialisable result, bounded by the tool. */
71
+ content: Record<string, unknown>;
72
+ /** Project-relative output paths, so the model can chain tools. */
73
+ outputs: readonly string[];
74
+ }>;
75
+
76
+ export const MEDIA_LIMITS = Object.freeze({
77
+ downloadTimeoutMs: 15 * 60_000,
78
+ downloadMaxBytes: 500 * 1024 * 1024,
79
+ stemsTimeoutMs: 20 * 60_000,
80
+ analyzeTimeoutMs: 5 * 60_000,
81
+ notesTimeoutMs: 10 * 60_000,
82
+ sampleTimeoutMs: 2 * 60_000,
83
+ lyricsTimeoutMs: 10 * 60_000,
84
+ /** Largest file any tool reads whole into memory. */
85
+ maxWavBytes: 256 * 1024 * 1024,
86
+ maxNotes: 2048,
87
+ waveformBuckets: 240,
88
+ /** Bound on captured stdout/stderr of every helper process. */
89
+ maxToolOutputBytes: 1024 * 1024,
90
+ /** Model files announced before download (whisper). */
91
+ whisperModelBytes: 148_000_000,
92
+ });