@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
+ * `dawg media <verb>`: the same six tools from the shell, plus `doctor`.
3
+ *
4
+ * dawg media doctor
5
+ * dawg media download <youtube-url> [--name <slug>] [--track <name>]
6
+ * dawg media stems <file>
7
+ * dawg media analyze <file>
8
+ * dawg media notes <file> [--kind drums|bass|vocals|guitar|piano|other] [--from s] [--to s]
9
+ * dawg media sample <file> <name> [--begin 0..1] [--end 0..1] [--root C4]
10
+ * dawg media lyrics <file> [--lang en]
11
+ *
12
+ * Progress goes to stderr, the JSON result to stdout. Ctrl-C sends SIGTERM to
13
+ * the helper (SIGKILL after 15 s) and exits 130.
14
+ */
15
+ import { systemRunner, type CommandRunner } from "../auth/runner.ts";
16
+ import { doctor, formatDoctor } from "./backend.ts";
17
+ import { trackSlug } from "./paths.ts";
18
+ import { MediaAbortError } from "./process.ts";
19
+ import { findMediaTool } from "./registry.ts";
20
+ import type { MediaHost, MediaResult } from "./types.ts";
21
+ import { errorMessage } from "./vendor/util.ts";
22
+
23
+ export type Output = { write(text: string): unknown };
24
+
25
+ export const MEDIA_HELP = `dawg media — local media tools (see DAWG.md "Media tools")
26
+
27
+ dawg media doctor backend + binaries + install commands
28
+ dawg media download <url> [--name n] YouTube audio → tracks/<slug>/downloads/<n>.wav
29
+ dawg media stems <file> six stems → <file>.stems/
30
+ dawg media analyze <file> tempo, key, beat grid, peaks → <file>.analysis.json
31
+ dawg media notes <file> [--kind k] [--from s] [--to s]
32
+ notes/hits + quantized snippet → <file>.<kind>.notes.json
33
+ dawg media sample <file> <name> [--begin f] [--end f] [--root C4]
34
+ 48 kHz wav → tracks/<slug>/samples/<name>.wav + sampler snippet
35
+ dawg media lyrics <file> [--lang en] whisper transcript → <file>.lyrics.json/.txt
36
+
37
+ Options: --track <name> picks the track folder (default main); --json prints only the result.
38
+ Env: DAWG_STEMDECK_URL (default http://127.0.0.1:8000).`;
39
+
40
+ const VERBS: Record<string, { tool: string; positional: string[] }> = {
41
+ download: { tool: "download_audio", positional: ["url"] },
42
+ stems: { tool: "split_stems", positional: ["file"] },
43
+ analyze: { tool: "analyze_audio", positional: ["file"] },
44
+ notes: { tool: "transcribe_notes", positional: ["file"] },
45
+ sample: { tool: "import_sample", positional: ["file", "name"] },
46
+ lyrics: { tool: "transcribe_lyrics", positional: ["file"] },
47
+ };
48
+
49
+ const NUMERIC = new Set(["from", "to", "begin", "end"]);
50
+
51
+ export function parseMediaArgv(argv: readonly string[]): {
52
+ verb: string | undefined;
53
+ args: Record<string, unknown>;
54
+ track: string;
55
+ json: boolean;
56
+ help: boolean;
57
+ } {
58
+ const [verb, ...rest] = argv;
59
+ const args: Record<string, unknown> = {};
60
+ const positional: string[] = [];
61
+ let track = "main";
62
+ let json = false;
63
+ let help = false;
64
+ for (let index = 0; index < rest.length; index += 1) {
65
+ const item = rest[index]!;
66
+ if (item === "--help" || item === "-h") help = true;
67
+ else if (item === "--json") json = true;
68
+ else if (item.startsWith("--")) {
69
+ const key = item.slice(2);
70
+ const value = rest[index + 1];
71
+ if (value === undefined) throw new Error(`--${key} needs a value`);
72
+ index += 1;
73
+ if (key === "track") track = value;
74
+ else if (NUMERIC.has(key)) {
75
+ const parsed = Number(value);
76
+ if (!Number.isFinite(parsed))
77
+ throw new Error(`--${key} must be a number`);
78
+ args[key] = parsed;
79
+ } else args[key] = value;
80
+ } else positional.push(item);
81
+ }
82
+ const spec = verb ? VERBS[verb] : undefined;
83
+ if (spec) {
84
+ spec.positional.forEach((name, index) => {
85
+ if (positional[index] !== undefined) args[name] = positional[index];
86
+ });
87
+ if (positional.length > spec.positional.length)
88
+ throw new Error(
89
+ `unexpected argument ${positional[spec.positional.length]}`,
90
+ );
91
+ }
92
+ return { verb, args, track, json, help };
93
+ }
94
+
95
+ export async function runMediaCommand(
96
+ argv: readonly string[],
97
+ cwd: string,
98
+ stdout: Output,
99
+ stderr: Output,
100
+ options: {
101
+ runner?: CommandRunner;
102
+ fetch?: typeof fetch;
103
+ signal?: AbortSignal;
104
+ } = {},
105
+ ): Promise<number> {
106
+ let parsed: ReturnType<typeof parseMediaArgv>;
107
+ try {
108
+ parsed = parseMediaArgv(argv.slice(1));
109
+ } catch (error) {
110
+ stderr.write(`${errorMessage(error)}\n${MEDIA_HELP}\n`);
111
+ return 2;
112
+ }
113
+ if (parsed.help || parsed.verb === undefined) {
114
+ stdout.write(`${MEDIA_HELP}\n`);
115
+ return parsed.verb === undefined && !parsed.help ? 2 : 0;
116
+ }
117
+ const host: MediaHost = {
118
+ projectRoot: cwd,
119
+ trackSlug: trackSlug(parsed.track),
120
+ runner: options.runner ?? systemRunner,
121
+ ...(options.fetch ? { fetch: options.fetch } : {}),
122
+ env: process.env,
123
+ };
124
+ if (parsed.verb === "doctor") {
125
+ const report = await doctor({
126
+ ...host,
127
+ ...(options.signal ? { signal: options.signal } : {}),
128
+ });
129
+ if (parsed.json) stdout.write(`${JSON.stringify(report, null, 2)}\n`);
130
+ else stdout.write(`${formatDoctor(report).join("\n")}\n`);
131
+ return 0;
132
+ }
133
+ const spec = VERBS[parsed.verb];
134
+ const tool = spec ? findMediaTool(spec.tool) : undefined;
135
+ if (!spec || !tool) {
136
+ stderr.write(`unknown media command ${parsed.verb}\n${MEDIA_HELP}\n`);
137
+ return 2;
138
+ }
139
+ const controller = new AbortController();
140
+ const onInterrupt = () => controller.abort(new MediaAbortError());
141
+ process.once("SIGINT", onInterrupt);
142
+ process.once("SIGTERM", onInterrupt);
143
+ const signal = options.signal
144
+ ? AbortSignal.any([options.signal, controller.signal])
145
+ : controller.signal;
146
+ try {
147
+ const plan = tool.plan(parsed.args, undefined as never);
148
+ if (plan.kind !== "media") throw new Error("not a media tool");
149
+ let lastLine = "";
150
+ const result: MediaResult = await plan.run({
151
+ ...host,
152
+ signal,
153
+ progress: (line) => {
154
+ if (line === lastLine || parsed.json) return;
155
+ lastLine = line;
156
+ stderr.write(`· ${line}\n`);
157
+ },
158
+ });
159
+ if (!parsed.json) stderr.write(`✓ ${result.summary}\n`);
160
+ stdout.write(`${JSON.stringify(result.content, null, 2)}\n`);
161
+ return 0;
162
+ } catch (error) {
163
+ if (signal.aborted || error instanceof MediaAbortError) {
164
+ stderr.write("cancelled\n");
165
+ return 130;
166
+ }
167
+ stderr.write(`✗ ${errorMessage(error)}\n`);
168
+ return 1;
169
+ } finally {
170
+ process.off("SIGINT", onInterrupt);
171
+ process.off("SIGTERM", onInterrupt);
172
+ }
173
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * `download_audio(url, name?)`: YouTube only. With yt-dlp installed the audio
3
+ * is extracted straight to `tracks/<slug>/downloads/<name>.wav`; without it a
4
+ * running StemDeck does the download and separation, the stems land in
5
+ * `<name>.stems/` and ffmpeg (when present) sums them back into `<name>.wav`.
6
+ * A `<name>.json` sidecar records title, duration, source URL and sha256 so
7
+ * later tools (and the brief) never fetch the same thing twice.
8
+ */
9
+ import { readdir, rename, rm } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import { detectStemDeck, missingTool } from "./backend.ts";
12
+ import {
13
+ downloadsDir,
14
+ ensureDir,
15
+ exists,
16
+ formatBytes,
17
+ formatSeconds,
18
+ freshPath,
19
+ projectPath,
20
+ readJson,
21
+ sha256File,
22
+ trackSlug,
23
+ writeJsonAtomic,
24
+ } from "./paths.ts";
25
+ import {
26
+ MediaToolError,
27
+ runHelper,
28
+ throwIfAborted,
29
+ withTempDir,
30
+ } from "./process.ts";
31
+ import { readSidecar, sidecarPath, type MediaSidecar } from "./sidecar.ts";
32
+ import {
33
+ STEM_NAMES,
34
+ createStemDeck,
35
+ type StemDeckOptions,
36
+ } from "./stemdeck.ts";
37
+ import {
38
+ MEDIA_LIMITS,
39
+ type MediaResult,
40
+ type MediaRunContext,
41
+ } from "./types.ts";
42
+ import {
43
+ finiteNumber,
44
+ isRecord,
45
+ optionalString,
46
+ validateYoutubeUrl,
47
+ } from "./vendor/util.ts";
48
+
49
+ export type { MediaSidecar } from "./sidecar.ts";
50
+
51
+ export type DownloadArgs = Readonly<{ url: string; name?: string }>;
52
+
53
+ export type DownloadOptions = Readonly<{
54
+ stemdeck?: StemDeckOptions;
55
+ now?: () => Date;
56
+ }>;
57
+
58
+ function ytdlpProgress(chunk: string): string | undefined {
59
+ if (/\[ExtractAudio\]/.test(chunk)) return "yt-dlp converting to wav";
60
+ const match = chunk.match(/\[download\]\s+(\d{1,3}(?:\.\d)?)%/g)?.at(-1);
61
+ if (!match) return undefined;
62
+ const percent = Math.min(
63
+ 100,
64
+ Math.round(parseFloat(match.replace(/.*\]\s+/, ""))),
65
+ );
66
+ return `yt-dlp ${percent}%`;
67
+ }
68
+
69
+ export async function downloadAudio(
70
+ args: DownloadArgs,
71
+ context: MediaRunContext,
72
+ options: DownloadOptions = {},
73
+ ): Promise<MediaResult> {
74
+ const url = validateYoutubeUrl(args.url);
75
+ const dir = await ensureDir(downloadsDir(context));
76
+ const now = options.now ?? (() => new Date());
77
+ // Reuse an earlier download of the same source.
78
+ for (const name of await readdir(dir)) {
79
+ if (!name.endsWith(".json")) continue;
80
+ const wav = join(dir, name.replace(/\.json$/, ".wav"));
81
+ const sidecar = await readSidecar(wav);
82
+ if (sidecar?.source === url && (await exists(wav))) {
83
+ const relativePath = projectPath(context.projectRoot, wav);
84
+ return {
85
+ summary: `already downloaded: ${relativePath}`,
86
+ outputs: [
87
+ relativePath,
88
+ projectPath(context.projectRoot, sidecarPath(wav)),
89
+ ],
90
+ content: { reused: true, wav: relativePath, ...sidecar },
91
+ };
92
+ }
93
+ }
94
+ if (context.runner.which("yt-dlp")) {
95
+ if (!context.runner.which("ffmpeg"))
96
+ throw new MediaToolError(missingTool("ffmpeg"));
97
+ return downloadWithYtDlp(url, args.name, dir, context, now);
98
+ }
99
+ const health = await detectStemDeck(context);
100
+ if (!health)
101
+ throw new MediaToolError(
102
+ `${missingTool("yt-dlp")}; no StemDeck answered at the configured URL either`,
103
+ );
104
+ return downloadWithStemDeck(
105
+ url,
106
+ args.name,
107
+ dir,
108
+ context,
109
+ health,
110
+ options,
111
+ now,
112
+ );
113
+ }
114
+
115
+ async function downloadWithYtDlp(
116
+ url: string,
117
+ requestedName: string | undefined,
118
+ dir: string,
119
+ context: MediaRunContext,
120
+ now: () => Date,
121
+ ): Promise<MediaResult> {
122
+ return withTempDir("download", async (temp) => {
123
+ context.progress("yt-dlp starting");
124
+ await runHelper(
125
+ context,
126
+ [
127
+ "yt-dlp",
128
+ "--no-playlist",
129
+ "--newline",
130
+ "--no-colors",
131
+ "--no-progress",
132
+ "--progress",
133
+ "--max-filesize",
134
+ `${Math.floor(MEDIA_LIMITS.downloadMaxBytes / (1024 * 1024))}m`,
135
+ "--write-info-json",
136
+ "--no-write-playlist-metafiles",
137
+ "-x",
138
+ "--audio-format",
139
+ "wav",
140
+ "-o",
141
+ join(temp, "audio.%(ext)s"),
142
+ "--",
143
+ url,
144
+ ],
145
+ { timeoutMs: MEDIA_LIMITS.downloadTimeoutMs, progress: ytdlpProgress },
146
+ );
147
+ throwIfAborted(context.signal);
148
+ const wavTemp = join(temp, "audio.wav");
149
+ if (!(await exists(wavTemp)))
150
+ throw new MediaToolError(
151
+ "yt-dlp finished without producing audio.wav (over the 500 MiB cap, or no audio stream)",
152
+ );
153
+ const size = Bun.file(wavTemp).size;
154
+ if (size > MEDIA_LIMITS.downloadMaxBytes)
155
+ throw new MediaToolError(
156
+ `downloaded audio is ${formatBytes(size)}, over the 500 MiB cap`,
157
+ );
158
+ const info = await readJson(join(temp, "audio.info.json"));
159
+ const title =
160
+ (isRecord(info) ? optionalString(info.title, 200) : undefined) ??
161
+ "download";
162
+ const duration = isRecord(info) ? finiteNumber(info.duration) : undefined;
163
+ const base = trackSlug(requestedName ?? title);
164
+ const { path: wav, base: finalBase } = await freshPath(dir, base, ".wav");
165
+ await rename(wavTemp, wav).catch(async () => {
166
+ // Temp dir may sit on another volume.
167
+ await Bun.write(wav, Bun.file(wavTemp));
168
+ await rm(wavTemp, { force: true });
169
+ });
170
+ const sha256 = await sha256File(wav);
171
+ const sidecar: MediaSidecar = {
172
+ title,
173
+ ...(duration !== undefined ? { durationSeconds: duration } : {}),
174
+ source: url,
175
+ sha256,
176
+ bytes: size,
177
+ backend: "yt-dlp",
178
+ downloadedAt: now().toISOString(),
179
+ };
180
+ await writeJsonAtomic(sidecarPath(wav), sidecar);
181
+ const relativePath = projectPath(context.projectRoot, wav);
182
+ return {
183
+ summary: `downloaded ${finalBase}.wav (${duration !== undefined ? formatSeconds(duration) : formatBytes(size)})`,
184
+ outputs: [
185
+ relativePath,
186
+ projectPath(context.projectRoot, sidecarPath(wav)),
187
+ ],
188
+ content: { wav: relativePath, ...sidecar },
189
+ };
190
+ });
191
+ }
192
+
193
+ async function downloadWithStemDeck(
194
+ url: string,
195
+ requestedName: string | undefined,
196
+ dir: string,
197
+ context: MediaRunContext,
198
+ health: NonNullable<Awaited<ReturnType<typeof detectStemDeck>>>,
199
+ options: DownloadOptions,
200
+ now: () => Date,
201
+ ): Promise<MediaResult> {
202
+ const client = createStemDeck(health, context, options.stemdeck);
203
+ context.progress("stemdeck submitting");
204
+ const jobId = await client.submit(url);
205
+ const job = await client.wait(jobId, MEDIA_LIMITS.downloadTimeoutMs);
206
+ if (job.stems.length === 0)
207
+ throw new MediaToolError("StemDeck finished the job but offered no stems");
208
+ const base = trackSlug(requestedName ?? job.title ?? "download");
209
+ const { path: wav, base: finalBase } = await freshPath(dir, base, ".wav");
210
+ const stemsDir = await ensureDir(join(dir, `${finalBase}.stems`));
211
+ const stemPaths = await client.fetchStems(
212
+ job,
213
+ stemsDir,
214
+ MEDIA_LIMITS.downloadMaxBytes,
215
+ );
216
+ const outputs = stemPaths.map((path) =>
217
+ projectPath(context.projectRoot, path),
218
+ );
219
+ let mixed = false;
220
+ if (context.runner.which("ffmpeg")) {
221
+ context.progress("ffmpeg mixing stems");
222
+ const inputs = stemPaths.flatMap((path) => ["-i", path]);
223
+ await runHelper(
224
+ context,
225
+ [
226
+ "ffmpeg",
227
+ "-hide_banner",
228
+ "-nostdin",
229
+ "-loglevel",
230
+ "error",
231
+ "-y",
232
+ ...inputs,
233
+ "-filter_complex",
234
+ `amix=inputs=${stemPaths.length}:normalize=0`,
235
+ "-c:a",
236
+ "pcm_s16le",
237
+ wav,
238
+ ],
239
+ { timeoutMs: MEDIA_LIMITS.sampleTimeoutMs },
240
+ );
241
+ mixed = true;
242
+ }
243
+ const sidecar: MediaSidecar = {
244
+ title: job.title ?? finalBase,
245
+ ...(job.durationSeconds !== undefined
246
+ ? { durationSeconds: job.durationSeconds }
247
+ : {}),
248
+ source: url,
249
+ ...(mixed
250
+ ? { sha256: await sha256File(wav), bytes: Bun.file(wav).size }
251
+ : {}),
252
+ backend: "stemdeck",
253
+ downloadedAt: now().toISOString(),
254
+ stemdeck: { url: health.url, jobId },
255
+ ...(job.bpm !== undefined ? { bpm: job.bpm } : {}),
256
+ ...(job.key ? { key: job.key } : {}),
257
+ };
258
+ await writeJsonAtomic(sidecarPath(wav), sidecar);
259
+ const relativeWav = projectPath(context.projectRoot, wav);
260
+ return {
261
+ summary: `stemdeck downloaded ${finalBase} (${job.stems.length} stems${mixed ? " + mix" : ""})`,
262
+ outputs: [
263
+ ...(mixed ? [relativeWav] : []),
264
+ projectPath(context.projectRoot, sidecarPath(wav)),
265
+ ...outputs,
266
+ ],
267
+ content: {
268
+ ...(mixed
269
+ ? { wav: relativeWav }
270
+ : {
271
+ wav: null,
272
+ note: "ffmpeg is not installed, so no mixed wav was written; the stems are complete",
273
+ }),
274
+ stems: Object.fromEntries(
275
+ job.stems.map((stem, index) => [stem.name, outputs[index]]),
276
+ ),
277
+ expectedStems: STEM_NAMES,
278
+ ...sidecar,
279
+ },
280
+ };
281
+ }