@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.
- package/CHANGELOG.md +126 -0
- package/DAWG.md +327 -0
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/core/diff.ts +249 -0
- package/core/drums.ts +102 -0
- package/core/key.ts +43 -0
- package/core/loop.ts +78 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +1388 -0
- 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 +45 -4
- package/src/agent/agent.ts +853 -0
- package/src/agent/brief.ts +160 -0
- package/src/agent/gateway.ts +441 -0
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +157 -0
- package/src/agent/planner.ts +259 -0
- package/src/agent/provider.ts +454 -0
- package/src/agent/sse.ts +114 -0
- package/src/agent/tools.ts +1373 -0
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +262 -0
- package/src/agent/xcb.ts +579 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +841 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +134 -0
- 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 +861 -0
- package/src/auth/cli.ts +231 -0
- package/src/auth/credentials.ts +411 -0
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +1191 -0
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +207 -0
- package/src/auth/tui.ts +107 -0
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +69 -0
- package/src/commands/music.ts +461 -0
- package/src/commands/sample.ts +302 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +2209 -0
- 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 +121 -0
- package/src/session/attach.ts +181 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +740 -0
- package/src/session/delta.ts +249 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +430 -0
- package/src/session/port.ts +481 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +168 -0
- package/src/session/store.ts +581 -0
- 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 +364 -0
- package/tui/app.ts +1372 -0
- package/tui/drums.ts +65 -0
- package/tui/highway.ts +921 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +80 -0
- package/tui/play-strip.ts +143 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- package/tui/theme.ts +451 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers shared by the media tools: bounded helper processes with progress
|
|
3
|
+
* taps, bounded HTTP downloads, temp directories, and cancellation.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { RunResult } from "../auth/runner.ts";
|
|
9
|
+
import { MEDIA_LIMITS, type MediaRunContext } from "./types.ts";
|
|
10
|
+
import { terminalSafeText } from "./vendor/util.ts";
|
|
11
|
+
|
|
12
|
+
export class MediaToolError extends Error {
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "MediaToolError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class MediaAbortError extends Error {
|
|
20
|
+
constructor() {
|
|
21
|
+
super("cancelled");
|
|
22
|
+
this.name = "MediaAbortError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function throwIfAborted(signal: AbortSignal): void {
|
|
27
|
+
if (signal.aborted) throw new MediaAbortError();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Deadline-bounded signal for one tool run. */
|
|
31
|
+
export function budgetSignal(
|
|
32
|
+
context: MediaRunContext,
|
|
33
|
+
timeoutMs: number,
|
|
34
|
+
): AbortSignal {
|
|
35
|
+
return AbortSignal.any([context.signal, AbortSignal.timeout(timeoutMs)]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type HelperOptions = Readonly<{
|
|
39
|
+
timeoutMs: number;
|
|
40
|
+
/** Parse one stderr/stdout chunk into a progress line, or undefined. */
|
|
41
|
+
progress?: (chunk: string) => string | undefined;
|
|
42
|
+
/** Progress lines closer than this are dropped (default 400 ms). */
|
|
43
|
+
minIntervalMs?: number;
|
|
44
|
+
cwd?: string;
|
|
45
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
46
|
+
}>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run one helper binary with the tool budget, SIGTERM→SIGKILL on abort, bounded
|
|
50
|
+
* output and throttled progress lines. Throws `MediaToolError` on non-zero exit.
|
|
51
|
+
*/
|
|
52
|
+
export async function runHelper(
|
|
53
|
+
context: MediaRunContext,
|
|
54
|
+
argv: readonly string[],
|
|
55
|
+
options: HelperOptions,
|
|
56
|
+
): Promise<RunResult> {
|
|
57
|
+
throwIfAborted(context.signal);
|
|
58
|
+
const [command, ...args] = argv;
|
|
59
|
+
if (!command) throw new MediaToolError("empty command");
|
|
60
|
+
let last = 0;
|
|
61
|
+
let lastLine = "";
|
|
62
|
+
const result = await context.runner.run(command, args, {
|
|
63
|
+
timeoutMs: options.timeoutMs,
|
|
64
|
+
signal: context.signal,
|
|
65
|
+
maxOutputBytes: MEDIA_LIMITS.maxToolOutputBytes,
|
|
66
|
+
...(options.env ? { env: options.env } : {}),
|
|
67
|
+
...(options.progress
|
|
68
|
+
? {
|
|
69
|
+
onOutput: (_stream, chunk) => {
|
|
70
|
+
const line = options.progress!(chunk);
|
|
71
|
+
if (line === undefined || line === lastLine) return;
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
if (now - last < (options.minIntervalMs ?? 400)) return;
|
|
74
|
+
last = now;
|
|
75
|
+
lastLine = line;
|
|
76
|
+
context.progress(line);
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
: {}),
|
|
80
|
+
});
|
|
81
|
+
if (context.signal.aborted) throw new MediaAbortError();
|
|
82
|
+
if (result.killed)
|
|
83
|
+
throw new MediaToolError(
|
|
84
|
+
`${command} exceeded its ${Math.round(options.timeoutMs / 60_000)} min budget and was stopped`,
|
|
85
|
+
);
|
|
86
|
+
if (result.code !== 0) {
|
|
87
|
+
const detail =
|
|
88
|
+
lastMeaningfulLine(result.stderr) ?? lastMeaningfulLine(result.stdout);
|
|
89
|
+
throw new MediaToolError(
|
|
90
|
+
`${command} failed (exit ${result.code})${detail ? `: ${detail}` : ""}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function lastMeaningfulLine(text: string): string | undefined {
|
|
97
|
+
const lines = text
|
|
98
|
+
.split(/\r?\n|\r/)
|
|
99
|
+
.map((line) => line.trim())
|
|
100
|
+
.filter((line) => line.length > 0);
|
|
101
|
+
const line = lines.at(-1);
|
|
102
|
+
return line === undefined ? undefined : terminalSafeText(line, 300, "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `42%` from tqdm-style output (`demucs`, `yt-dlp --newline`). */
|
|
106
|
+
export function percentProgress(label: string) {
|
|
107
|
+
return (chunk: string): string | undefined => {
|
|
108
|
+
const matches = chunk.match(/(\d{1,3}(?:\.\d)?)%/g);
|
|
109
|
+
const lastMatch = matches?.at(-1);
|
|
110
|
+
if (!lastMatch) return undefined;
|
|
111
|
+
const value = Math.min(100, Math.round(parseFloat(lastMatch)));
|
|
112
|
+
return `${label} ${value}%`;
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function withTempDir<T>(
|
|
117
|
+
prefix: string,
|
|
118
|
+
work: (dir: string) => Promise<T>,
|
|
119
|
+
): Promise<T> {
|
|
120
|
+
const dir = await mkdtemp(join(tmpdir(), `dawg-${prefix}-`));
|
|
121
|
+
try {
|
|
122
|
+
return await work(dir);
|
|
123
|
+
} finally {
|
|
124
|
+
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type FetchLike = typeof fetch;
|
|
129
|
+
|
|
130
|
+
/** Read a JSON body no larger than `maxBytes`. */
|
|
131
|
+
export async function fetchJson(
|
|
132
|
+
fetcher: FetchLike,
|
|
133
|
+
url: string,
|
|
134
|
+
init: RequestInit & { maxBytes?: number },
|
|
135
|
+
): Promise<unknown> {
|
|
136
|
+
const response = await fetcher(url, init);
|
|
137
|
+
const text = await readBoundedText(response, init.maxBytes ?? 1024 * 1024);
|
|
138
|
+
if (!response.ok)
|
|
139
|
+
throw new MediaToolError(
|
|
140
|
+
`${new URL(url).pathname} answered ${response.status}${text ? `: ${terminalSafeText(text, 200, "")}` : ""}`,
|
|
141
|
+
);
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(text) as unknown;
|
|
144
|
+
} catch {
|
|
145
|
+
throw new MediaToolError(`${new URL(url).pathname} did not return JSON`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function readBoundedText(response: Response, maxBytes: number) {
|
|
150
|
+
if (!response.body) return "";
|
|
151
|
+
const reader = response.body.getReader();
|
|
152
|
+
const chunks: Uint8Array[] = [];
|
|
153
|
+
let total = 0;
|
|
154
|
+
for (;;) {
|
|
155
|
+
const { done, value } = await reader.read();
|
|
156
|
+
if (done) break;
|
|
157
|
+
total += value.byteLength;
|
|
158
|
+
if (total > maxBytes) {
|
|
159
|
+
await reader.cancel().catch(() => undefined);
|
|
160
|
+
throw new MediaToolError(`response exceeded ${maxBytes} bytes`);
|
|
161
|
+
}
|
|
162
|
+
chunks.push(value);
|
|
163
|
+
}
|
|
164
|
+
return new TextDecoder().decode(Buffer.concat(chunks));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Stream a response body to `path` (via `.part`), capped at `maxBytes`. */
|
|
168
|
+
export async function downloadToFile(
|
|
169
|
+
fetcher: FetchLike,
|
|
170
|
+
url: string,
|
|
171
|
+
path: string,
|
|
172
|
+
options: Readonly<{
|
|
173
|
+
maxBytes: number;
|
|
174
|
+
signal: AbortSignal;
|
|
175
|
+
progress?: (receivedBytes: number, totalBytes: number | undefined) => void;
|
|
176
|
+
}>,
|
|
177
|
+
): Promise<number> {
|
|
178
|
+
const response = await fetcher(url, { signal: options.signal });
|
|
179
|
+
if (!response.ok || !response.body)
|
|
180
|
+
throw new MediaToolError(
|
|
181
|
+
`download of ${new URL(url).pathname} answered ${response.status}`,
|
|
182
|
+
);
|
|
183
|
+
const declared = Number(response.headers.get("content-length") ?? "");
|
|
184
|
+
const total =
|
|
185
|
+
Number.isFinite(declared) && declared > 0 ? declared : undefined;
|
|
186
|
+
if (total !== undefined && total > options.maxBytes)
|
|
187
|
+
throw new MediaToolError(
|
|
188
|
+
`${new URL(url).pathname} is ${total} bytes, over the ${options.maxBytes}-byte cap`,
|
|
189
|
+
);
|
|
190
|
+
const part = `${path}.part`;
|
|
191
|
+
const writer = Bun.file(part).writer();
|
|
192
|
+
const reader = response.body.getReader();
|
|
193
|
+
let received = 0;
|
|
194
|
+
let lastReport = 0;
|
|
195
|
+
try {
|
|
196
|
+
for (;;) {
|
|
197
|
+
const { done, value } = await reader.read();
|
|
198
|
+
if (done) break;
|
|
199
|
+
received += value.byteLength;
|
|
200
|
+
if (received > options.maxBytes) {
|
|
201
|
+
await reader.cancel().catch(() => undefined);
|
|
202
|
+
throw new MediaToolError(
|
|
203
|
+
`download exceeded the ${options.maxBytes}-byte cap`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
writer.write(value);
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
if (options.progress && now - lastReport > 500) {
|
|
209
|
+
lastReport = now;
|
|
210
|
+
options.progress(received, total);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
await writer.end();
|
|
214
|
+
const { rename } = await import("node:fs/promises");
|
|
215
|
+
await rename(part, path);
|
|
216
|
+
return received;
|
|
217
|
+
} catch (error) {
|
|
218
|
+
try {
|
|
219
|
+
await writer.end();
|
|
220
|
+
} catch {
|
|
221
|
+
// already closed
|
|
222
|
+
}
|
|
223
|
+
await rm(part, { force: true }).catch(() => undefined);
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Lookup over `MEDIA_TOOLS` shared by the CLI and tests. */
|
|
2
|
+
import type { AgentTool } from "../agent/tools.ts";
|
|
3
|
+
import { MEDIA_TOOLS } from "./tools.ts";
|
|
4
|
+
|
|
5
|
+
export function findMediaTool(name: string): AgentTool | undefined {
|
|
6
|
+
return MEDIA_TOOLS.find((tool) => tool.name === name);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export { MEDIA_TOOLS };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<name>.json` next to a downloaded wav: title, duration, source URL, hash,
|
|
3
|
+
* which backend fetched it and, for StemDeck downloads, the job id that later
|
|
4
|
+
* tools reuse for stems and beat grids.
|
|
5
|
+
*/
|
|
6
|
+
import { readJson, writeJsonAtomic } from "./paths.ts";
|
|
7
|
+
import { finiteNumber, isRecord, optionalString } from "./vendor/util.ts";
|
|
8
|
+
|
|
9
|
+
export type MediaSidecar = Readonly<{
|
|
10
|
+
title: string;
|
|
11
|
+
durationSeconds?: number;
|
|
12
|
+
source: string;
|
|
13
|
+
sha256?: string;
|
|
14
|
+
bytes?: number;
|
|
15
|
+
backend: "yt-dlp" | "stemdeck";
|
|
16
|
+
downloadedAt: string;
|
|
17
|
+
stemdeck?: Readonly<{ url: string; jobId: string }>;
|
|
18
|
+
bpm?: number;
|
|
19
|
+
key?: string;
|
|
20
|
+
}>;
|
|
21
|
+
|
|
22
|
+
export function sidecarPath(wavPath: string): string {
|
|
23
|
+
return wavPath.replace(/\.wav$/i, "") + ".json";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readSidecar(
|
|
27
|
+
wavPath: string,
|
|
28
|
+
): Promise<MediaSidecar | undefined> {
|
|
29
|
+
const value = await readJson(sidecarPath(wavPath));
|
|
30
|
+
if (!isRecord(value)) return undefined;
|
|
31
|
+
const title = optionalString(value.title, 200);
|
|
32
|
+
const source = optionalString(value.source, 2048);
|
|
33
|
+
const backend = value.backend;
|
|
34
|
+
if (!title || !source || (backend !== "yt-dlp" && backend !== "stemdeck"))
|
|
35
|
+
return undefined;
|
|
36
|
+
const stemdeck = isRecord(value.stemdeck)
|
|
37
|
+
? {
|
|
38
|
+
url: optionalString(value.stemdeck.url, 2048),
|
|
39
|
+
jobId: optionalString(value.stemdeck.jobId, 80),
|
|
40
|
+
}
|
|
41
|
+
: undefined;
|
|
42
|
+
const duration = finiteNumber(value.durationSeconds);
|
|
43
|
+
const bpm = finiteNumber(value.bpm);
|
|
44
|
+
const key = optionalString(value.key, 20);
|
|
45
|
+
const sha256 = optionalString(value.sha256, 64);
|
|
46
|
+
const bytes = finiteNumber(value.bytes);
|
|
47
|
+
return {
|
|
48
|
+
title,
|
|
49
|
+
source,
|
|
50
|
+
backend,
|
|
51
|
+
downloadedAt: optionalString(value.downloadedAt, 40) ?? "",
|
|
52
|
+
...(duration !== undefined ? { durationSeconds: duration } : {}),
|
|
53
|
+
...(sha256 ? { sha256 } : {}),
|
|
54
|
+
...(bytes !== undefined ? { bytes } : {}),
|
|
55
|
+
...(stemdeck?.url && stemdeck.jobId
|
|
56
|
+
? { stemdeck: { url: stemdeck.url, jobId: stemdeck.jobId } }
|
|
57
|
+
: {}),
|
|
58
|
+
...(bpm !== undefined ? { bpm } : {}),
|
|
59
|
+
...(key ? { key } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Merge one field into an existing sidecar (no-op when there is none). */
|
|
64
|
+
export async function writeSidecarField(
|
|
65
|
+
wavPath: string,
|
|
66
|
+
field: string,
|
|
67
|
+
value: unknown,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const current = await readJson(sidecarPath(wavPath));
|
|
70
|
+
if (!isRecord(current)) return;
|
|
71
|
+
await writeJsonAtomic(sidecarPath(wavPath), { ...current, [field]: value });
|
|
72
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal StemDeck client (the local separation service soundfish also talks
|
|
3
|
+
* to): submit a YouTube URL, poll the job, fetch its beat grid and stems. All
|
|
4
|
+
* bodies are validated from `unknown`, stem URLs must be same-origin
|
|
5
|
+
* `/api/jobs/<id>/stems/<name>.wav`, and every read is size-capped.
|
|
6
|
+
*/
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { BeatGrid, MediaRunContext } from "./types.ts";
|
|
9
|
+
import type { StemDeckHealth } from "./backend.ts";
|
|
10
|
+
import {
|
|
11
|
+
MediaAbortError,
|
|
12
|
+
MediaToolError,
|
|
13
|
+
downloadToFile,
|
|
14
|
+
fetchJson,
|
|
15
|
+
throwIfAborted,
|
|
16
|
+
} from "./process.ts";
|
|
17
|
+
import { parseBeatGrid } from "./vendor/grid.ts";
|
|
18
|
+
import { finiteNumber, isRecord, optionalString } from "./vendor/util.ts";
|
|
19
|
+
|
|
20
|
+
export const STEM_NAMES = [
|
|
21
|
+
"vocals",
|
|
22
|
+
"drums",
|
|
23
|
+
"bass",
|
|
24
|
+
"guitar",
|
|
25
|
+
"piano",
|
|
26
|
+
"other",
|
|
27
|
+
] as const;
|
|
28
|
+
export type StemName = (typeof STEM_NAMES)[number];
|
|
29
|
+
|
|
30
|
+
const JOB_ID_PATTERN = /^[A-Za-z0-9_-]{1,80}$/;
|
|
31
|
+
const TERMINAL = new Set(["done", "error", "cancelled", "unavailable"]);
|
|
32
|
+
const STAGES = new Set([
|
|
33
|
+
"queued",
|
|
34
|
+
"downloading",
|
|
35
|
+
"analyzing",
|
|
36
|
+
"separating",
|
|
37
|
+
"processing",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
export type StemDeckJob = Readonly<{
|
|
41
|
+
jobId: string;
|
|
42
|
+
title?: string;
|
|
43
|
+
durationSeconds?: number;
|
|
44
|
+
bpm?: number;
|
|
45
|
+
key?: string;
|
|
46
|
+
stems: readonly Readonly<{ name: StemName; url: string }>[];
|
|
47
|
+
}>;
|
|
48
|
+
|
|
49
|
+
export type StemDeckOptions = Readonly<{
|
|
50
|
+
/** Poll spacing; tests pass 0. */
|
|
51
|
+
pollIntervalMs?: number;
|
|
52
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
53
|
+
}>;
|
|
54
|
+
|
|
55
|
+
function defaultSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
if (signal.aborted) return reject(new MediaAbortError());
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
signal.removeEventListener("abort", onAbort);
|
|
60
|
+
resolve();
|
|
61
|
+
}, ms);
|
|
62
|
+
const onAbort = () => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
reject(new MediaAbortError());
|
|
65
|
+
};
|
|
66
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createStemDeck(
|
|
71
|
+
health: StemDeckHealth,
|
|
72
|
+
context: MediaRunContext,
|
|
73
|
+
options: StemDeckOptions = {},
|
|
74
|
+
) {
|
|
75
|
+
const base = new URL(health.url);
|
|
76
|
+
// `URL` keeps "/" for an empty path; drop it so paths join without "//".
|
|
77
|
+
const basePath = base.pathname.replace(/\/+$/u, "");
|
|
78
|
+
const fetcher = context.fetch ?? fetch;
|
|
79
|
+
const pollIntervalMs = options.pollIntervalMs ?? 2_000;
|
|
80
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
81
|
+
const api = (path: string) => `${base.origin}${basePath}${path}`;
|
|
82
|
+
|
|
83
|
+
async function submit(sourceUrl: string): Promise<string> {
|
|
84
|
+
const body = await fetchJson(fetcher, api("/api/jobs"), {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: {
|
|
87
|
+
"content-type": "application/json",
|
|
88
|
+
accept: "application/json",
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify({ url: sourceUrl, stems: [...STEM_NAMES] }),
|
|
91
|
+
signal: context.signal,
|
|
92
|
+
maxBytes: 64 * 1024,
|
|
93
|
+
});
|
|
94
|
+
const jobId = isRecord(body) ? optionalString(body.job_id, 80) : undefined;
|
|
95
|
+
if (!jobId || !JOB_ID_PATTERN.test(jobId))
|
|
96
|
+
throw new MediaToolError("StemDeck did not return a job id");
|
|
97
|
+
return jobId;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function cancel(jobId: string): Promise<void> {
|
|
101
|
+
try {
|
|
102
|
+
await fetcher(api(`/api/jobs/${jobId}/cancel`), {
|
|
103
|
+
method: "POST",
|
|
104
|
+
signal: AbortSignal.timeout(2_000),
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
// Best effort; the job times out on its own.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseJob(jobId: string, body: unknown): StemDeckJob {
|
|
112
|
+
if (!isRecord(body))
|
|
113
|
+
throw new MediaToolError("StemDeck job body was not an object");
|
|
114
|
+
const stems = Array.isArray(body.stems)
|
|
115
|
+
? body.stems.slice(0, 16).flatMap((entry) => {
|
|
116
|
+
if (!isRecord(entry)) return [];
|
|
117
|
+
const name = optionalString(entry.name, 20);
|
|
118
|
+
const url = optionalString(entry.url, 512);
|
|
119
|
+
if (
|
|
120
|
+
!name ||
|
|
121
|
+
!url ||
|
|
122
|
+
!(STEM_NAMES as readonly string[]).includes(name)
|
|
123
|
+
)
|
|
124
|
+
return [];
|
|
125
|
+
const expected = `${basePath}/api/jobs/${jobId}/stems/${name}.wav`;
|
|
126
|
+
let resolved: URL;
|
|
127
|
+
try {
|
|
128
|
+
resolved = new URL(url, base.origin);
|
|
129
|
+
} catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
if (resolved.origin !== base.origin || resolved.pathname !== expected)
|
|
133
|
+
return [];
|
|
134
|
+
return [{ name: name as StemName, url: resolved.toString() }];
|
|
135
|
+
})
|
|
136
|
+
: [];
|
|
137
|
+
const title = optionalString(body.title, 200);
|
|
138
|
+
const duration = finiteNumber(body.duration);
|
|
139
|
+
const bpm = finiteNumber(body.bpm);
|
|
140
|
+
const key = optionalString(body.key, 20);
|
|
141
|
+
return {
|
|
142
|
+
jobId,
|
|
143
|
+
...(title ? { title } : {}),
|
|
144
|
+
...(duration !== undefined && duration > 0
|
|
145
|
+
? { durationSeconds: duration }
|
|
146
|
+
: {}),
|
|
147
|
+
...(bpm !== undefined && bpm > 0 ? { bpm } : {}),
|
|
148
|
+
...(key ? { key } : {}),
|
|
149
|
+
stems,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Poll until the job ends; progress lines read `stemdeck separating 42%`. */
|
|
154
|
+
async function wait(jobId: string, timeoutMs: number): Promise<StemDeckJob> {
|
|
155
|
+
const deadline = Date.now() + timeoutMs;
|
|
156
|
+
let lastLine = "";
|
|
157
|
+
for (;;) {
|
|
158
|
+
throwIfAborted(context.signal);
|
|
159
|
+
if (Date.now() > deadline) {
|
|
160
|
+
await cancel(jobId);
|
|
161
|
+
throw new MediaToolError(
|
|
162
|
+
`StemDeck job ${jobId} exceeded its ${Math.round(timeoutMs / 60_000)} min budget`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
let body: unknown;
|
|
166
|
+
try {
|
|
167
|
+
body = await fetchJson(fetcher, api(`/api/jobs/${jobId}`), {
|
|
168
|
+
signal: context.signal,
|
|
169
|
+
maxBytes: 256 * 1024,
|
|
170
|
+
});
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (context.signal.aborted) {
|
|
173
|
+
await cancel(jobId);
|
|
174
|
+
throw new MediaAbortError();
|
|
175
|
+
}
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
const status = isRecord(body)
|
|
179
|
+
? optionalString(body.status, 20)
|
|
180
|
+
: undefined;
|
|
181
|
+
if (status === "done") return parseJob(jobId, body);
|
|
182
|
+
if (status !== undefined && TERMINAL.has(status)) {
|
|
183
|
+
const detail = isRecord(body)
|
|
184
|
+
? optionalString(body.error, 200)
|
|
185
|
+
: undefined;
|
|
186
|
+
throw new MediaToolError(
|
|
187
|
+
`StemDeck job ${status}${detail ? `: ${detail}` : ""}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const stage =
|
|
191
|
+
status !== undefined && STAGES.has(status)
|
|
192
|
+
? status
|
|
193
|
+
: ((isRecord(body) ? optionalString(body.stage, 20) : undefined) ??
|
|
194
|
+
"working");
|
|
195
|
+
const progress = isRecord(body) ? finiteNumber(body.progress) : undefined;
|
|
196
|
+
const line =
|
|
197
|
+
progress === undefined
|
|
198
|
+
? `stemdeck ${stage}`
|
|
199
|
+
: `stemdeck ${stage} ${Math.round(Math.min(1, Math.max(0, progress)) * 100)}%`;
|
|
200
|
+
if (line !== lastLine) {
|
|
201
|
+
lastLine = line;
|
|
202
|
+
context.progress(line);
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
await sleep(pollIntervalMs, context.signal);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
await cancel(jobId);
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function beats(jobId: string): Promise<BeatGrid | undefined> {
|
|
214
|
+
try {
|
|
215
|
+
const response = await fetcher(api(`/api/jobs/${jobId}/beats`), {
|
|
216
|
+
signal: context.signal,
|
|
217
|
+
headers: { accept: "application/json" },
|
|
218
|
+
});
|
|
219
|
+
if (!response.ok) return undefined;
|
|
220
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
221
|
+
if (bytes.byteLength > 4 * 1024 * 1024) return undefined;
|
|
222
|
+
return parseBeatGrid(bytes);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (context.signal.aborted) throw new MediaAbortError();
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Download every stem into `dir` as `<name>.wav`. */
|
|
230
|
+
async function fetchStems(
|
|
231
|
+
job: StemDeckJob,
|
|
232
|
+
dir: string,
|
|
233
|
+
maxBytesPerStem: number,
|
|
234
|
+
): Promise<readonly string[]> {
|
|
235
|
+
const written: string[] = [];
|
|
236
|
+
for (const [index, stem] of job.stems.entries()) {
|
|
237
|
+
throwIfAborted(context.signal);
|
|
238
|
+
context.progress(
|
|
239
|
+
`stemdeck fetching ${stem.name} (${index + 1}/${job.stems.length})`,
|
|
240
|
+
);
|
|
241
|
+
const path = join(dir, `${stem.name}.wav`);
|
|
242
|
+
await downloadToFile(fetcher, stem.url, path, {
|
|
243
|
+
maxBytes: maxBytesPerStem,
|
|
244
|
+
signal: context.signal,
|
|
245
|
+
});
|
|
246
|
+
written.push(path);
|
|
247
|
+
}
|
|
248
|
+
return written;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { health, submit, wait, beats, fetchStems, cancel };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export type StemDeckClient = ReturnType<typeof createStemDeck>;
|