@hraness/dawg 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/DAWG.md +196 -11
- package/README.md +79 -30
- package/core/diff.ts +249 -0
- package/core/key.ts +43 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +324 -1
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +280 -14
- package/src/agent/brief.ts +23 -3
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/planner.ts +38 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +398 -1
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +27 -12
- package/src/agent/xcb.ts +233 -21
- package/src/audio/click.ts +125 -0
- package/src/audio/engine.ts +407 -33
- package/src/audio/live.ts +152 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +290 -76
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +24 -7
- package/src/commands/sample.ts +302 -0
- package/src/main.ts +1043 -166
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +28 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +3 -37
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +261 -18
- package/tui/drums.ts +44 -0
- package/tui/highway.ts +18 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +143 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `analyze_audio(file)`: ffprobe for format and duration, a beat grid from
|
|
3
|
+
* StemDeck when the file came through it, otherwise a TypeScript onset
|
|
4
|
+
* autocorrelation tempo estimate; key from a pitch-class histogram (the same
|
|
5
|
+
* scale fit the session namer uses); and 240 waveform peaks. Everything is
|
|
6
|
+
* written to `<name>.analysis.json` next to the file and summarised for the
|
|
7
|
+
* model.
|
|
8
|
+
*/
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { detectStemDeck, missingTool } from "./backend.ts";
|
|
11
|
+
import {
|
|
12
|
+
estimateTempo,
|
|
13
|
+
keyFromHistogram,
|
|
14
|
+
monoSignal,
|
|
15
|
+
onsetEnvelope,
|
|
16
|
+
pitchClassHistogram,
|
|
17
|
+
waveformPeaks,
|
|
18
|
+
} from "./dsp.ts";
|
|
19
|
+
import {
|
|
20
|
+
formatSeconds,
|
|
21
|
+
projectPath,
|
|
22
|
+
readJson,
|
|
23
|
+
resolveInput,
|
|
24
|
+
writeJsonAtomic,
|
|
25
|
+
} from "./paths.ts";
|
|
26
|
+
import {
|
|
27
|
+
MediaToolError,
|
|
28
|
+
runHelper,
|
|
29
|
+
throwIfAborted,
|
|
30
|
+
withTempDir,
|
|
31
|
+
} from "./process.ts";
|
|
32
|
+
import { readSidecar } from "./sidecar.ts";
|
|
33
|
+
import { createStemDeck, type StemDeckOptions } from "./stemdeck.ts";
|
|
34
|
+
import {
|
|
35
|
+
MEDIA_LIMITS,
|
|
36
|
+
type BeatGrid,
|
|
37
|
+
type MediaResult,
|
|
38
|
+
type MediaRunContext,
|
|
39
|
+
} from "./types.ts";
|
|
40
|
+
import { beatIntervalCv, fixedBeatGrid, gridMedianBpm } from "./vendor/grid.ts";
|
|
41
|
+
import { finiteNumber, isRecord, optionalString } from "./vendor/util.ts";
|
|
42
|
+
import { parseWav, type ParsedWav } from "./vendor/wav.ts";
|
|
43
|
+
|
|
44
|
+
export type AudioAnalysis = Readonly<{
|
|
45
|
+
file: string;
|
|
46
|
+
durationSeconds: number;
|
|
47
|
+
sampleRate: number;
|
|
48
|
+
channels: number;
|
|
49
|
+
codec: string;
|
|
50
|
+
bytes: number;
|
|
51
|
+
bpm?: number;
|
|
52
|
+
bpmConfidence?: number;
|
|
53
|
+
key?: string;
|
|
54
|
+
grid?: BeatGrid;
|
|
55
|
+
peaks: readonly number[];
|
|
56
|
+
analyzedAt: string;
|
|
57
|
+
}>;
|
|
58
|
+
|
|
59
|
+
export type AnalyzeArgs = Readonly<{ file: string }>;
|
|
60
|
+
export type AnalyzeOptions = Readonly<{
|
|
61
|
+
stemdeck?: StemDeckOptions;
|
|
62
|
+
now?: () => Date;
|
|
63
|
+
}>;
|
|
64
|
+
|
|
65
|
+
export function analysisPath(filePath: string): string {
|
|
66
|
+
return filePath.replace(/\.[A-Za-z0-9]{1,5}$/, "") + ".analysis.json";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type Probe = Readonly<{
|
|
70
|
+
durationSeconds: number;
|
|
71
|
+
sampleRate: number;
|
|
72
|
+
channels: number;
|
|
73
|
+
codec: string;
|
|
74
|
+
bytes: number;
|
|
75
|
+
}>;
|
|
76
|
+
|
|
77
|
+
export async function ffprobe(
|
|
78
|
+
context: MediaRunContext,
|
|
79
|
+
path: string,
|
|
80
|
+
): Promise<Probe> {
|
|
81
|
+
if (!context.runner.which("ffprobe"))
|
|
82
|
+
throw new MediaToolError(missingTool("ffprobe"));
|
|
83
|
+
const result = await runHelper(
|
|
84
|
+
context,
|
|
85
|
+
[
|
|
86
|
+
"ffprobe",
|
|
87
|
+
"-v",
|
|
88
|
+
"error",
|
|
89
|
+
"-print_format",
|
|
90
|
+
"json",
|
|
91
|
+
"-show_format",
|
|
92
|
+
"-show_streams",
|
|
93
|
+
"-select_streams",
|
|
94
|
+
"a:0",
|
|
95
|
+
path,
|
|
96
|
+
],
|
|
97
|
+
{ timeoutMs: 60_000 },
|
|
98
|
+
);
|
|
99
|
+
let body: unknown;
|
|
100
|
+
try {
|
|
101
|
+
body = JSON.parse(result.stdout) as unknown;
|
|
102
|
+
} catch {
|
|
103
|
+
throw new MediaToolError("ffprobe did not return JSON");
|
|
104
|
+
}
|
|
105
|
+
if (!isRecord(body))
|
|
106
|
+
throw new MediaToolError("ffprobe output was not an object");
|
|
107
|
+
const format = isRecord(body.format) ? body.format : {};
|
|
108
|
+
const stream =
|
|
109
|
+
Array.isArray(body.streams) && isRecord(body.streams[0])
|
|
110
|
+
? body.streams[0]
|
|
111
|
+
: {};
|
|
112
|
+
const duration = Number(
|
|
113
|
+
optionalString(format.duration ?? stream.duration, 40),
|
|
114
|
+
);
|
|
115
|
+
const sampleRate = Number(optionalString(stream.sample_rate, 20));
|
|
116
|
+
const channels = finiteNumber(stream.channels);
|
|
117
|
+
const bytes = Number(optionalString(format.size, 30));
|
|
118
|
+
if (!Number.isFinite(duration) || duration <= 0)
|
|
119
|
+
throw new MediaToolError(
|
|
120
|
+
"ffprobe found no audio duration (not an audio file?)",
|
|
121
|
+
);
|
|
122
|
+
return {
|
|
123
|
+
durationSeconds: Math.round(duration * 1000) / 1000,
|
|
124
|
+
sampleRate: Number.isFinite(sampleRate) && sampleRate > 0 ? sampleRate : 0,
|
|
125
|
+
channels: channels !== undefined && channels > 0 ? channels : 0,
|
|
126
|
+
codec: optionalString(stream.codec_name, 40) ?? "unknown",
|
|
127
|
+
bytes: Number.isFinite(bytes) ? bytes : Bun.file(path).size,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Parse a wav directly, or decode anything else to 22.05 kHz mono via ffmpeg. */
|
|
132
|
+
export async function loadWav(
|
|
133
|
+
context: MediaRunContext,
|
|
134
|
+
path: string,
|
|
135
|
+
): Promise<ParsedWav> {
|
|
136
|
+
const file = Bun.file(path);
|
|
137
|
+
if (file.size > MEDIA_LIMITS.maxWavBytes)
|
|
138
|
+
throw new MediaToolError(
|
|
139
|
+
`${path.split("/").at(-1)} is over the 256 MiB analysis cap`,
|
|
140
|
+
);
|
|
141
|
+
const head = new Uint8Array(await file.slice(0, 12).arrayBuffer());
|
|
142
|
+
const riff = String.fromCharCode(...head.subarray(0, 4)) === "RIFF";
|
|
143
|
+
if (riff) return parseWav(new Uint8Array(await file.arrayBuffer()));
|
|
144
|
+
if (!context.runner.which("ffmpeg"))
|
|
145
|
+
throw new MediaToolError(missingTool("ffmpeg"));
|
|
146
|
+
return withTempDir("decode", async (temp) => {
|
|
147
|
+
const out = join(temp, "decoded.wav");
|
|
148
|
+
context.progress("ffmpeg decoding");
|
|
149
|
+
await runHelper(
|
|
150
|
+
context,
|
|
151
|
+
[
|
|
152
|
+
"ffmpeg",
|
|
153
|
+
"-hide_banner",
|
|
154
|
+
"-nostdin",
|
|
155
|
+
"-loglevel",
|
|
156
|
+
"error",
|
|
157
|
+
"-y",
|
|
158
|
+
"-i",
|
|
159
|
+
path,
|
|
160
|
+
"-vn",
|
|
161
|
+
"-t",
|
|
162
|
+
"600",
|
|
163
|
+
"-ar",
|
|
164
|
+
"22050",
|
|
165
|
+
"-ac",
|
|
166
|
+
"1",
|
|
167
|
+
"-c:a",
|
|
168
|
+
"pcm_s16le",
|
|
169
|
+
out,
|
|
170
|
+
],
|
|
171
|
+
{ timeoutMs: MEDIA_LIMITS.analyzeTimeoutMs },
|
|
172
|
+
);
|
|
173
|
+
return parseWav(new Uint8Array(await Bun.file(out).arrayBuffer()));
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function readAnalysis(
|
|
178
|
+
filePath: string,
|
|
179
|
+
): Promise<AudioAnalysis | undefined> {
|
|
180
|
+
const value = await readJson(analysisPath(filePath));
|
|
181
|
+
if (!isRecord(value)) return undefined;
|
|
182
|
+
const duration = finiteNumber(value.durationSeconds);
|
|
183
|
+
if (duration === undefined) return undefined;
|
|
184
|
+
const grid =
|
|
185
|
+
isRecord(value.grid) && Array.isArray(value.grid.beats)
|
|
186
|
+
? parseGrid(value.grid)
|
|
187
|
+
: undefined;
|
|
188
|
+
const bpm = finiteNumber(value.bpm);
|
|
189
|
+
const key = optionalString(value.key, 20);
|
|
190
|
+
return {
|
|
191
|
+
file: optionalString(value.file, 1024) ?? "",
|
|
192
|
+
durationSeconds: duration,
|
|
193
|
+
sampleRate: finiteNumber(value.sampleRate) ?? 0,
|
|
194
|
+
channels: finiteNumber(value.channels) ?? 0,
|
|
195
|
+
codec: optionalString(value.codec, 40) ?? "unknown",
|
|
196
|
+
bytes: finiteNumber(value.bytes) ?? 0,
|
|
197
|
+
...(bpm !== undefined ? { bpm } : {}),
|
|
198
|
+
...(key ? { key } : {}),
|
|
199
|
+
...(grid ? { grid } : {}),
|
|
200
|
+
peaks: Array.isArray(value.peaks)
|
|
201
|
+
? value.peaks.slice(0, 240).map((entry) => finiteNumber(entry) ?? 0)
|
|
202
|
+
: [],
|
|
203
|
+
analyzedAt: optionalString(value.analyzedAt, 40) ?? "",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function parseGrid(value: Record<string, unknown>): BeatGrid | undefined {
|
|
208
|
+
const beats = (value.beats as unknown[])
|
|
209
|
+
.slice(0, 20_000)
|
|
210
|
+
.map((entry) => finiteNumber(entry));
|
|
211
|
+
if (beats.length < 2 || beats.some((beat) => beat === undefined))
|
|
212
|
+
return undefined;
|
|
213
|
+
const clean = beats as number[];
|
|
214
|
+
for (let index = 1; index < clean.length; index += 1)
|
|
215
|
+
if (clean[index]! <= clean[index - 1]!) return undefined;
|
|
216
|
+
const bars = Array.isArray(value.bars)
|
|
217
|
+
? value.bars.slice(0, 2_000).flatMap((entry) => {
|
|
218
|
+
if (!isRecord(entry)) return [];
|
|
219
|
+
const beat = finiteNumber(entry.beat);
|
|
220
|
+
const beatsPerBar = finiteNumber(entry.beatsPerBar);
|
|
221
|
+
return beat === undefined || beatsPerBar === undefined
|
|
222
|
+
? []
|
|
223
|
+
: [{ beat, beatsPerBar }];
|
|
224
|
+
})
|
|
225
|
+
: [];
|
|
226
|
+
const bpm = finiteNumber(value.bpm);
|
|
227
|
+
const detector = optionalString(value.detector, 80);
|
|
228
|
+
return {
|
|
229
|
+
beats: clean,
|
|
230
|
+
bars,
|
|
231
|
+
durationSeconds: finiteNumber(value.durationSeconds) ?? clean.at(-1)!,
|
|
232
|
+
...(bpm !== undefined ? { bpm } : {}),
|
|
233
|
+
...(detector ? { detector } : {}),
|
|
234
|
+
intervalCv: beatIntervalCv(clean),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function analyzeAudio(
|
|
239
|
+
args: AnalyzeArgs,
|
|
240
|
+
context: MediaRunContext,
|
|
241
|
+
options: AnalyzeOptions = {},
|
|
242
|
+
): Promise<MediaResult> {
|
|
243
|
+
const input = await resolveInput(context, args.file);
|
|
244
|
+
context.progress("ffprobe");
|
|
245
|
+
const probe = await ffprobe(context, input.absolute);
|
|
246
|
+
if (probe.durationSeconds > 7_200)
|
|
247
|
+
throw new MediaToolError("analysis is limited to files under two hours");
|
|
248
|
+
throwIfAborted(context.signal);
|
|
249
|
+
|
|
250
|
+
// Beat grid from StemDeck when this file (or the download it was split from) went through it.
|
|
251
|
+
let grid: BeatGrid | undefined;
|
|
252
|
+
let bpm: number | undefined;
|
|
253
|
+
let bpmConfidence: number | undefined;
|
|
254
|
+
let key: string | undefined;
|
|
255
|
+
const sidecar =
|
|
256
|
+
(await readSidecar(input.absolute)) ??
|
|
257
|
+
(await readSidecar(parentDownload(input.absolute)));
|
|
258
|
+
if (sidecar?.stemdeck) {
|
|
259
|
+
const health = await detectStemDeck(context);
|
|
260
|
+
if (health && health.url === sidecar.stemdeck.url) {
|
|
261
|
+
context.progress("stemdeck beat grid");
|
|
262
|
+
const client = createStemDeck(health, context, options.stemdeck);
|
|
263
|
+
grid = await client.beats(sidecar.stemdeck.jobId);
|
|
264
|
+
if (grid) {
|
|
265
|
+
bpm = gridMedianBpm(grid);
|
|
266
|
+
bpmConfidence =
|
|
267
|
+
grid.confidence !== undefined
|
|
268
|
+
? Math.min(1, grid.confidence / 100)
|
|
269
|
+
: 1 - Math.min(1, grid.intervalCv ?? 0);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (sidecar.key) key = sidecar.key.toLowerCase();
|
|
273
|
+
if (bpm === undefined && sidecar.bpm !== undefined) bpm = sidecar.bpm;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
context.progress("decoding");
|
|
277
|
+
const wav = await loadWav(context, input.absolute);
|
|
278
|
+
throwIfAborted(context.signal);
|
|
279
|
+
const mono = monoSignal(wav);
|
|
280
|
+
if (grid === undefined) {
|
|
281
|
+
context.progress("tempo");
|
|
282
|
+
const { envelope, hopSeconds } = onsetEnvelope(mono, wav.sampleRate);
|
|
283
|
+
const tempo = estimateTempo(envelope, hopSeconds);
|
|
284
|
+
if (tempo) {
|
|
285
|
+
bpm = bpm ?? tempo.bpm;
|
|
286
|
+
bpmConfidence = tempo.confidence;
|
|
287
|
+
grid = fixedBeatGrid(bpm, probe.durationSeconds, tempo.offsetSeconds);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
throwIfAborted(context.signal);
|
|
291
|
+
if (key === undefined) {
|
|
292
|
+
context.progress("key");
|
|
293
|
+
key =
|
|
294
|
+
keyFromHistogram(pitchClassHistogram(mono, wav.sampleRate)) ?? undefined;
|
|
295
|
+
}
|
|
296
|
+
context.progress("waveform");
|
|
297
|
+
const peaks = waveformPeaks(wav);
|
|
298
|
+
const analysis: AudioAnalysis = {
|
|
299
|
+
file: input.relative,
|
|
300
|
+
...probe,
|
|
301
|
+
...(bpm !== undefined ? { bpm: Math.round(bpm * 10) / 10 } : {}),
|
|
302
|
+
...(bpmConfidence !== undefined
|
|
303
|
+
? { bpmConfidence: Math.round(bpmConfidence * 100) / 100 }
|
|
304
|
+
: {}),
|
|
305
|
+
...(key ? { key } : {}),
|
|
306
|
+
...(grid ? { grid } : {}),
|
|
307
|
+
peaks,
|
|
308
|
+
analyzedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
309
|
+
};
|
|
310
|
+
const out = analysisPath(input.absolute);
|
|
311
|
+
await writeJsonAtomic(out, analysis);
|
|
312
|
+
const relativeOut = projectPath(context.projectRoot, out);
|
|
313
|
+
return {
|
|
314
|
+
summary: `analyzed ${input.relative}: ${formatSeconds(probe.durationSeconds)}${bpm !== undefined ? ` · ${analysis.bpm} bpm` : ""}${key ? ` · ${key}` : ""}`,
|
|
315
|
+
outputs: [relativeOut],
|
|
316
|
+
content: {
|
|
317
|
+
file: input.relative,
|
|
318
|
+
analysis: relativeOut,
|
|
319
|
+
durationSeconds: probe.durationSeconds,
|
|
320
|
+
sampleRate: probe.sampleRate,
|
|
321
|
+
channels: probe.channels,
|
|
322
|
+
codec: probe.codec,
|
|
323
|
+
...(analysis.bpm !== undefined ? { bpm: analysis.bpm } : {}),
|
|
324
|
+
...(analysis.bpmConfidence !== undefined
|
|
325
|
+
? { bpmConfidence: analysis.bpmConfidence }
|
|
326
|
+
: {}),
|
|
327
|
+
...(key ? { key } : {}),
|
|
328
|
+
...(grid
|
|
329
|
+
? {
|
|
330
|
+
grid: {
|
|
331
|
+
detector: grid.detector ?? "unknown",
|
|
332
|
+
beats: grid.beats.length,
|
|
333
|
+
firstBeatSeconds: Math.round(grid.beats[0]! * 1000) / 1000,
|
|
334
|
+
bars: grid.bars.length,
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
: {}),
|
|
338
|
+
peaks: downsample(peaks, 48),
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** `<x>.stems/<stem>.wav` → `<x>.wav`, so stems inherit the download's sidecar. */
|
|
344
|
+
function parentDownload(path: string): string {
|
|
345
|
+
const match = /^(.*)\.stems\/[^/]+\.wav$/.exec(path);
|
|
346
|
+
return match ? `${match[1]}.wav` : path;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function downsample(values: readonly number[], buckets: number): number[] {
|
|
350
|
+
if (values.length <= buckets) return [...values];
|
|
351
|
+
const out: number[] = [];
|
|
352
|
+
const per = values.length / buckets;
|
|
353
|
+
for (let bucket = 0; bucket < buckets; bucket += 1) {
|
|
354
|
+
const start = Math.floor(bucket * per);
|
|
355
|
+
const end = Math.max(start + 1, Math.floor((bucket + 1) * per));
|
|
356
|
+
let maximum = 0;
|
|
357
|
+
for (let index = start; index < end; index += 1)
|
|
358
|
+
maximum = Math.max(maximum, values[index] ?? 0);
|
|
359
|
+
out.push(Math.round(maximum * 100) / 100);
|
|
360
|
+
}
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export { analysisPath as analysisPathFor };
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which backend a media tool can use. A local StemDeck
|
|
3
|
+
* (`DAWG_STEMDECK_URL`, default http://127.0.0.1:8000) answers `/api/health`
|
|
4
|
+
* within a second, or the tools run the binaries directly: `yt-dlp`,
|
|
5
|
+
* `ffmpeg`, `ffprobe`, `uv` (for `uv tool run demucs` / `basic-pitch`) and
|
|
6
|
+
* `whisper-cli`. Nothing here installs anything; `doctor()` prints the exact
|
|
7
|
+
* command for each missing piece instead.
|
|
8
|
+
*/
|
|
9
|
+
import type { CommandRunner } from "../auth/runner.ts";
|
|
10
|
+
import type { MediaHost } from "./types.ts";
|
|
11
|
+
import {
|
|
12
|
+
finiteNumber,
|
|
13
|
+
isRecord,
|
|
14
|
+
normalizeServiceBaseUrl,
|
|
15
|
+
optionalString,
|
|
16
|
+
} from "./vendor/util.ts";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_STEMDECK_URL = "http://127.0.0.1:8000";
|
|
19
|
+
const HEALTH_TIMEOUT_MS = 1_000;
|
|
20
|
+
const UV_LIST_TIMEOUT_MS = 10_000;
|
|
21
|
+
|
|
22
|
+
export const MEDIA_TOOL_NAMES = [
|
|
23
|
+
"yt-dlp",
|
|
24
|
+
"ffmpeg",
|
|
25
|
+
"ffprobe",
|
|
26
|
+
"uv",
|
|
27
|
+
"demucs",
|
|
28
|
+
"basic-pitch",
|
|
29
|
+
"whisper-cli",
|
|
30
|
+
] as const;
|
|
31
|
+
export type MediaToolName = (typeof MEDIA_TOOL_NAMES)[number];
|
|
32
|
+
|
|
33
|
+
export const INSTALL_COMMANDS: Readonly<Record<MediaToolName, string>> = {
|
|
34
|
+
"yt-dlp": "brew install yt-dlp ffmpeg whisper-cpp",
|
|
35
|
+
ffmpeg: "brew install yt-dlp ffmpeg whisper-cpp",
|
|
36
|
+
ffprobe: "brew install yt-dlp ffmpeg whisper-cpp",
|
|
37
|
+
"whisper-cli": "brew install yt-dlp ffmpeg whisper-cpp",
|
|
38
|
+
uv: "curl -LsSf https://astral.sh/uv/install.sh | sh",
|
|
39
|
+
demucs: "uv tool install demucs",
|
|
40
|
+
"basic-pitch": "uv tool install basic-pitch",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** What each tool is for, in the doctor report. */
|
|
44
|
+
const PURPOSE: Readonly<Record<MediaToolName, string>> = {
|
|
45
|
+
"yt-dlp": "download_audio",
|
|
46
|
+
ffmpeg: "import_sample, decoding for analysis",
|
|
47
|
+
ffprobe: "analyze_audio",
|
|
48
|
+
uv: "runs demucs and basic-pitch",
|
|
49
|
+
demucs: "split_stems (direct backend)",
|
|
50
|
+
"basic-pitch": "transcribe_notes for pitched stems",
|
|
51
|
+
"whisper-cli": "transcribe_lyrics",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type ToolStatus = Readonly<{
|
|
55
|
+
name: MediaToolName;
|
|
56
|
+
available: boolean;
|
|
57
|
+
/** How the tool is started: a PATH binary or `uv tool run <name>`. */
|
|
58
|
+
command?: readonly string[];
|
|
59
|
+
install: string;
|
|
60
|
+
purpose: string;
|
|
61
|
+
/** First-run model download the tool performs on its own. */
|
|
62
|
+
firstRun?: string;
|
|
63
|
+
}>;
|
|
64
|
+
|
|
65
|
+
export type StemDeckHealth = Readonly<{
|
|
66
|
+
url: string;
|
|
67
|
+
version?: string;
|
|
68
|
+
demucsModel?: string;
|
|
69
|
+
demucsDevice?: string;
|
|
70
|
+
}>;
|
|
71
|
+
|
|
72
|
+
export type MediaBackend =
|
|
73
|
+
| Readonly<{ kind: "stemdeck"; stemdeck: StemDeckHealth }>
|
|
74
|
+
/** `checked` is the StemDeck origin that did not answer (no credentials). */
|
|
75
|
+
| Readonly<{ kind: "direct"; checked?: string }>;
|
|
76
|
+
|
|
77
|
+
export const FIRST_RUN_NOTES: Readonly<Partial<Record<MediaToolName, string>>> =
|
|
78
|
+
{
|
|
79
|
+
demucs:
|
|
80
|
+
"demucs downloads the htdemucs_6s model (~80 MB) into ~/.cache/torch on its first run",
|
|
81
|
+
"basic-pitch":
|
|
82
|
+
"basic-pitch ships its model in the package; the first run compiles it and is slower",
|
|
83
|
+
"whisper-cli":
|
|
84
|
+
"dawg downloads ggml-base.en.bin (~148 MB) into ~/.cache/dawg/whisper before the first transcription",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** The StemDeck base URL from the environment, or the default. Never logged with credentials. */
|
|
88
|
+
export function stemdeckUrl(
|
|
89
|
+
env: Readonly<Record<string, string | undefined>> | undefined,
|
|
90
|
+
): URL {
|
|
91
|
+
const raw = env?.DAWG_STEMDECK_URL?.trim();
|
|
92
|
+
return normalizeServiceBaseUrl(raw ? raw : DEFAULT_STEMDECK_URL);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `GET /api/health` with a one-second budget; undefined when StemDeck is not there. */
|
|
96
|
+
export async function detectStemDeck(
|
|
97
|
+
host: Pick<MediaHost, "fetch" | "env"> & { signal?: AbortSignal },
|
|
98
|
+
): Promise<StemDeckHealth | undefined> {
|
|
99
|
+
let base: URL;
|
|
100
|
+
try {
|
|
101
|
+
base = stemdeckUrl(host.env);
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const prefix = `${base.origin}${base.pathname.replace(/\/+$/u, "")}`;
|
|
106
|
+
const fetcher = host.fetch ?? fetch;
|
|
107
|
+
const timeout = AbortSignal.timeout(HEALTH_TIMEOUT_MS);
|
|
108
|
+
const signal = host.signal
|
|
109
|
+
? AbortSignal.any([host.signal, timeout])
|
|
110
|
+
: timeout;
|
|
111
|
+
try {
|
|
112
|
+
const response = await fetcher(`${prefix}/api/health`, {
|
|
113
|
+
signal,
|
|
114
|
+
headers: { accept: "application/json" },
|
|
115
|
+
});
|
|
116
|
+
if (!response.ok) return undefined;
|
|
117
|
+
const text = await response.text();
|
|
118
|
+
if (text.length > 64 * 1024) return undefined;
|
|
119
|
+
const body: unknown = JSON.parse(text);
|
|
120
|
+
if (!isRecord(body) || body.status !== "ok") return undefined;
|
|
121
|
+
const name = optionalString(body.name, 40);
|
|
122
|
+
if (name !== undefined && name.toLowerCase() !== "stemdeck")
|
|
123
|
+
return undefined;
|
|
124
|
+
const version = optionalString(body.version, 40);
|
|
125
|
+
const demucsModel = optionalString(body.demucs_model, 40);
|
|
126
|
+
const demucsDevice = optionalString(body.demucs_device, 40);
|
|
127
|
+
return {
|
|
128
|
+
url: prefix,
|
|
129
|
+
...(version ? { version } : {}),
|
|
130
|
+
...(demucsModel ? { demucsModel } : {}),
|
|
131
|
+
...(demucsDevice ? { demucsDevice } : {}),
|
|
132
|
+
};
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function detectBackend(
|
|
139
|
+
host: Pick<MediaHost, "fetch" | "env"> & { signal?: AbortSignal },
|
|
140
|
+
): Promise<MediaBackend> {
|
|
141
|
+
const stemdeck = await detectStemDeck(host);
|
|
142
|
+
if (stemdeck) return { kind: "stemdeck", stemdeck };
|
|
143
|
+
try {
|
|
144
|
+
return { kind: "direct", checked: stemdeckUrl(host.env).origin };
|
|
145
|
+
} catch {
|
|
146
|
+
return { kind: "direct" };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Names `uv tool list` reports as installed (never installs anything). */
|
|
151
|
+
export async function uvInstalledTools(
|
|
152
|
+
runner: CommandRunner,
|
|
153
|
+
signal?: AbortSignal,
|
|
154
|
+
): Promise<ReadonlySet<string>> {
|
|
155
|
+
if (!runner.which("uv")) return new Set();
|
|
156
|
+
try {
|
|
157
|
+
const result = await runner.run("uv", ["tool", "list"], {
|
|
158
|
+
timeoutMs: UV_LIST_TIMEOUT_MS,
|
|
159
|
+
maxOutputBytes: 64 * 1024,
|
|
160
|
+
...(signal ? { signal } : {}),
|
|
161
|
+
});
|
|
162
|
+
if (result.code !== 0) return new Set();
|
|
163
|
+
const names = new Set<string>();
|
|
164
|
+
for (const line of result.stdout.split("\n")) {
|
|
165
|
+
// `demucs v4.0.1` heads each block; indented lines are entry points.
|
|
166
|
+
const match = /^([A-Za-z0-9_.-]+) v\S+/.exec(line);
|
|
167
|
+
if (match) names.add(match[1]!.toLowerCase());
|
|
168
|
+
}
|
|
169
|
+
return names;
|
|
170
|
+
} catch {
|
|
171
|
+
return new Set();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Resolve one tool to the argv prefix that starts it, or undefined. */
|
|
176
|
+
export function toolCommand(
|
|
177
|
+
name: MediaToolName,
|
|
178
|
+
runner: CommandRunner,
|
|
179
|
+
uvTools: ReadonlySet<string>,
|
|
180
|
+
): readonly string[] | undefined {
|
|
181
|
+
const direct = runner.which(name);
|
|
182
|
+
if (direct) return [name];
|
|
183
|
+
if ((name === "demucs" || name === "basic-pitch") && runner.which("uv")) {
|
|
184
|
+
if (uvTools.has(name)) return ["uv", "tool", "run", name];
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function probeTools(
|
|
190
|
+
runner: CommandRunner,
|
|
191
|
+
signal?: AbortSignal,
|
|
192
|
+
): Promise<readonly ToolStatus[]> {
|
|
193
|
+
const uvTools = await uvInstalledTools(runner, signal);
|
|
194
|
+
return MEDIA_TOOL_NAMES.map((name) => {
|
|
195
|
+
const command = toolCommand(name, runner, uvTools);
|
|
196
|
+
const firstRun = FIRST_RUN_NOTES[name];
|
|
197
|
+
return {
|
|
198
|
+
name,
|
|
199
|
+
available: command !== undefined,
|
|
200
|
+
...(command ? { command } : {}),
|
|
201
|
+
install: INSTALL_COMMANDS[name],
|
|
202
|
+
purpose: PURPOSE[name],
|
|
203
|
+
...(firstRun ? { firstRun } : {}),
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export type DoctorReport = Readonly<{
|
|
209
|
+
backend: MediaBackend;
|
|
210
|
+
tools: readonly ToolStatus[];
|
|
211
|
+
}>;
|
|
212
|
+
|
|
213
|
+
export async function doctor(
|
|
214
|
+
host: Pick<MediaHost, "runner" | "fetch" | "env"> & { signal?: AbortSignal },
|
|
215
|
+
): Promise<DoctorReport> {
|
|
216
|
+
const [backend, tools] = await Promise.all([
|
|
217
|
+
detectBackend(host),
|
|
218
|
+
probeTools(host.runner, host.signal),
|
|
219
|
+
]);
|
|
220
|
+
return { backend, tools };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Human lines for `dawg media doctor` and activity cards. */
|
|
224
|
+
export function formatDoctor(report: DoctorReport): readonly string[] {
|
|
225
|
+
const lines: string[] = [];
|
|
226
|
+
if (report.backend.kind === "stemdeck") {
|
|
227
|
+
const s = report.backend.stemdeck;
|
|
228
|
+
lines.push(
|
|
229
|
+
`✓ StemDeck at ${s.url}${s.version ? ` v${s.version}` : ""}${s.demucsModel ? ` · ${s.demucsModel}` : ""}${s.demucsDevice ? ` on ${s.demucsDevice}` : ""}`,
|
|
230
|
+
);
|
|
231
|
+
} else {
|
|
232
|
+
lines.push(
|
|
233
|
+
`· no StemDeck at ${report.backend.checked ?? DEFAULT_STEMDECK_URL} (set DAWG_STEMDECK_URL); using local binaries`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
for (const tool of report.tools) {
|
|
237
|
+
lines.push(
|
|
238
|
+
tool.available
|
|
239
|
+
? `✓ ${tool.name} · ${tool.purpose}${tool.command && tool.command.length > 1 ? ` · via ${tool.command.join(" ")}` : ""}`
|
|
240
|
+
: `✗ ${tool.name} · ${tool.purpose} · install: ${tool.install}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
for (const tool of report.tools)
|
|
244
|
+
if (tool.available && tool.firstRun) lines.push(` ${tool.firstRun}`);
|
|
245
|
+
return lines;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Error text for a tool a run needs but could not find. */
|
|
249
|
+
export function missingTool(name: MediaToolName): string {
|
|
250
|
+
return `${name} is not installed; install it with: ${INSTALL_COMMANDS[name]} (dawg never installs tools itself; run \`dawg media doctor\`)`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export { finiteNumber };
|