@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,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `transcribe_notes(file, {kind, from?, to?})`: pitched stems go through the
|
|
3
|
+
* Basic Pitch CLI with soundfish's per-stem parameters; drums go through the
|
|
4
|
+
* vendored STFT classifier. Both yield `TimedNote[]` in source seconds plus a
|
|
5
|
+
* quantized snippet (`note("A1", startBeat, lengthBeats, velocity)` /
|
|
6
|
+
* `hit("kick", beat)`) aligned to the file's analysis beat grid, capped at
|
|
7
|
+
* 2048 notes, written to `<name>.<kind>.notes.json`.
|
|
8
|
+
*/
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { analyzeAudio, readAnalysis } from "./analyze.ts";
|
|
11
|
+
import {
|
|
12
|
+
FIRST_RUN_NOTES,
|
|
13
|
+
missingTool,
|
|
14
|
+
toolCommand,
|
|
15
|
+
uvInstalledTools,
|
|
16
|
+
} from "./backend.ts";
|
|
17
|
+
import {
|
|
18
|
+
formatSeconds,
|
|
19
|
+
projectPath,
|
|
20
|
+
resolveInput,
|
|
21
|
+
writeJsonAtomic,
|
|
22
|
+
} from "./paths.ts";
|
|
23
|
+
import {
|
|
24
|
+
MediaToolError,
|
|
25
|
+
runHelper,
|
|
26
|
+
throwIfAborted,
|
|
27
|
+
withTempDir,
|
|
28
|
+
} from "./process.ts";
|
|
29
|
+
import {
|
|
30
|
+
MEDIA_LIMITS,
|
|
31
|
+
type BeatGrid,
|
|
32
|
+
type MediaResult,
|
|
33
|
+
type MediaRunContext,
|
|
34
|
+
type TimedNote,
|
|
35
|
+
} from "./types.ts";
|
|
36
|
+
import {
|
|
37
|
+
BASIC_PITCH_STEM_PARAMETERS,
|
|
38
|
+
PITCHED_KINDS,
|
|
39
|
+
basicPitchOutputBase,
|
|
40
|
+
basicPitchParameterArguments,
|
|
41
|
+
parseBasicPitchNoteCsv,
|
|
42
|
+
type PitchedKind,
|
|
43
|
+
} from "./vendor/basic-pitch.ts";
|
|
44
|
+
import { classifyDrumWav } from "./vendor/drums.ts";
|
|
45
|
+
import { fixedBeatGrid, gridMedianBpm, secondsToBeat } from "./vendor/grid.ts";
|
|
46
|
+
|
|
47
|
+
export const NOTE_KINDS = ["drums", ...PITCHED_KINDS] as const;
|
|
48
|
+
export type NoteKind = (typeof NOTE_KINDS)[number];
|
|
49
|
+
|
|
50
|
+
/** Beat resolution of the quantized snippet (sixteenths). */
|
|
51
|
+
const GRID_DIVISION = 4;
|
|
52
|
+
const SNIPPET_LINES = 128;
|
|
53
|
+
const NOTE_NAMES = [
|
|
54
|
+
"C",
|
|
55
|
+
"C#",
|
|
56
|
+
"D",
|
|
57
|
+
"D#",
|
|
58
|
+
"E",
|
|
59
|
+
"F",
|
|
60
|
+
"F#",
|
|
61
|
+
"G",
|
|
62
|
+
"G#",
|
|
63
|
+
"A",
|
|
64
|
+
"A#",
|
|
65
|
+
"B",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** Classifier pitches → dawg drum voices (`core/drums.ts`). */
|
|
69
|
+
const DRUM_VOICE_FOR_PITCH: Readonly<Record<number, string>> = {
|
|
70
|
+
36: "kick",
|
|
71
|
+
38: "snare",
|
|
72
|
+
39: "clap",
|
|
73
|
+
45: "tom",
|
|
74
|
+
47: "tom",
|
|
75
|
+
42: "hat",
|
|
76
|
+
46: "openhat",
|
|
77
|
+
49: "openhat",
|
|
78
|
+
51: "rim",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type TranscribeNotesArgs = Readonly<{
|
|
82
|
+
file: string;
|
|
83
|
+
kind?: NoteKind;
|
|
84
|
+
from?: number;
|
|
85
|
+
to?: number;
|
|
86
|
+
}>;
|
|
87
|
+
|
|
88
|
+
export type QuantizedNote = Readonly<{
|
|
89
|
+
pitch: number;
|
|
90
|
+
name: string;
|
|
91
|
+
startBeat: number;
|
|
92
|
+
lengthBeats: number;
|
|
93
|
+
velocity: number;
|
|
94
|
+
}>;
|
|
95
|
+
|
|
96
|
+
export function inferKind(fileName: string): NoteKind {
|
|
97
|
+
const base = fileName
|
|
98
|
+
.split("/")
|
|
99
|
+
.at(-1)!
|
|
100
|
+
.replace(/\.[^.]+$/, "")
|
|
101
|
+
.toLowerCase();
|
|
102
|
+
for (const kind of NOTE_KINDS)
|
|
103
|
+
if (
|
|
104
|
+
base === kind ||
|
|
105
|
+
base.startsWith(`${kind}-`) ||
|
|
106
|
+
base.startsWith(`${kind}_`)
|
|
107
|
+
)
|
|
108
|
+
return kind;
|
|
109
|
+
return "other";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function noteName(midi: number): string {
|
|
113
|
+
return `${NOTE_NAMES[((midi % 12) + 12) % 12]}${Math.floor(midi / 12) - 1}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Snap notes to the grid: beat 0 is the first downbeat the grid marks (or
|
|
118
|
+
* its first beat), sixteenth resolution, and lengths of at least one step.
|
|
119
|
+
*/
|
|
120
|
+
export function quantizeNotes(
|
|
121
|
+
notes: readonly TimedNote[],
|
|
122
|
+
grid: BeatGrid,
|
|
123
|
+
): { quantized: readonly QuantizedNote[]; originBeat: number } {
|
|
124
|
+
const firstBar = grid.bars.reduce<number | undefined>(
|
|
125
|
+
(best, bar) => (best === undefined || bar.beat < best ? bar.beat : best),
|
|
126
|
+
undefined,
|
|
127
|
+
);
|
|
128
|
+
const originBeat = firstBar ?? 0;
|
|
129
|
+
const snap = (beat: number) =>
|
|
130
|
+
Math.round(beat * GRID_DIVISION) / GRID_DIVISION;
|
|
131
|
+
const quantized = notes.map((note) => {
|
|
132
|
+
const start = secondsToBeat(grid, note.startSeconds) - originBeat;
|
|
133
|
+
const end = secondsToBeat(grid, note.endSeconds) - originBeat;
|
|
134
|
+
const startBeat = Math.max(0, snap(start));
|
|
135
|
+
const lengthBeats = Math.max(1 / GRID_DIVISION, snap(end - start));
|
|
136
|
+
return {
|
|
137
|
+
pitch: note.pitch,
|
|
138
|
+
name: noteName(note.pitch),
|
|
139
|
+
startBeat,
|
|
140
|
+
lengthBeats,
|
|
141
|
+
velocity: note.velocity,
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
return { quantized, originBeat };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function snippetLines(
|
|
148
|
+
quantized: readonly QuantizedNote[],
|
|
149
|
+
kind: NoteKind,
|
|
150
|
+
): readonly string[] {
|
|
151
|
+
if (kind === "drums") {
|
|
152
|
+
// Two onsets that snap to one voice and step are one hit.
|
|
153
|
+
const seen = new Set<string>();
|
|
154
|
+
const lines: string[] = [];
|
|
155
|
+
for (const note of quantized) {
|
|
156
|
+
const voice = DRUM_VOICE_FOR_PITCH[note.pitch] ?? "rim";
|
|
157
|
+
const key = `${voice}@${note.startBeat}`;
|
|
158
|
+
if (seen.has(key)) continue;
|
|
159
|
+
seen.add(key);
|
|
160
|
+
lines.push(`hit("${voice}", ${note.startBeat})`);
|
|
161
|
+
}
|
|
162
|
+
return lines;
|
|
163
|
+
}
|
|
164
|
+
return quantized.map(
|
|
165
|
+
(note) =>
|
|
166
|
+
`note("${note.name}", ${note.startBeat}, ${note.lengthBeats}, ${note.velocity})`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function gridFor(
|
|
171
|
+
context: MediaRunContext,
|
|
172
|
+
input: { absolute: string; relative: string },
|
|
173
|
+
warnings: string[],
|
|
174
|
+
): Promise<{ grid: BeatGrid; bpm: number; analysis?: string }> {
|
|
175
|
+
let analysis = await readAnalysis(input.absolute);
|
|
176
|
+
let analysisOutput: string | undefined;
|
|
177
|
+
if (!analysis) {
|
|
178
|
+
context.progress("analyzing beat grid first");
|
|
179
|
+
const result = await analyzeAudio({ file: input.relative }, context);
|
|
180
|
+
analysisOutput = result.outputs[0];
|
|
181
|
+
analysis = await readAnalysis(input.absolute);
|
|
182
|
+
}
|
|
183
|
+
if (analysis?.grid) {
|
|
184
|
+
return {
|
|
185
|
+
grid: analysis.grid,
|
|
186
|
+
bpm: analysis.bpm ?? gridMedianBpm(analysis.grid),
|
|
187
|
+
...(analysisOutput ? { analysis: analysisOutput } : {}),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
warnings.push(
|
|
191
|
+
"no tempo could be estimated; the snippet assumes 120 bpm from 0 s",
|
|
192
|
+
);
|
|
193
|
+
return {
|
|
194
|
+
grid: fixedBeatGrid(120, analysis?.durationSeconds ?? 60),
|
|
195
|
+
bpm: 120,
|
|
196
|
+
...(analysisOutput ? { analysis: analysisOutput } : {}),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function transcribeNotes(
|
|
201
|
+
args: TranscribeNotesArgs,
|
|
202
|
+
context: MediaRunContext,
|
|
203
|
+
): Promise<MediaResult> {
|
|
204
|
+
const input = await resolveInput(context, args.file);
|
|
205
|
+
const kind = args.kind ?? inferKind(input.relative);
|
|
206
|
+
const from = args.from ?? 0;
|
|
207
|
+
if (args.to !== undefined && args.to <= from)
|
|
208
|
+
throw new MediaToolError("to must be greater than from");
|
|
209
|
+
const warnings: string[] = [];
|
|
210
|
+
const { grid, bpm, analysis } = await gridFor(context, input, warnings);
|
|
211
|
+
throwIfAborted(context.signal);
|
|
212
|
+
|
|
213
|
+
return withTempDir("notes", async (temp) => {
|
|
214
|
+
// Work on a trimmed 22.05 kHz mono copy when a window is set or the
|
|
215
|
+
// input is not a wav; otherwise feed the file itself.
|
|
216
|
+
let wavPath = input.absolute;
|
|
217
|
+
const head = new Uint8Array(
|
|
218
|
+
await Bun.file(input.absolute).slice(0, 4).arrayBuffer(),
|
|
219
|
+
);
|
|
220
|
+
const isWav = String.fromCharCode(...head) === "RIFF";
|
|
221
|
+
if (args.from !== undefined || args.to !== undefined || !isWav) {
|
|
222
|
+
if (!context.runner.which("ffmpeg"))
|
|
223
|
+
throw new MediaToolError(missingTool("ffmpeg"));
|
|
224
|
+
wavPath = join(temp, `${kind}.wav`);
|
|
225
|
+
context.progress("ffmpeg trimming");
|
|
226
|
+
await runHelper(
|
|
227
|
+
context,
|
|
228
|
+
[
|
|
229
|
+
"ffmpeg",
|
|
230
|
+
"-hide_banner",
|
|
231
|
+
"-nostdin",
|
|
232
|
+
"-loglevel",
|
|
233
|
+
"error",
|
|
234
|
+
"-y",
|
|
235
|
+
"-ss",
|
|
236
|
+
String(from),
|
|
237
|
+
...(args.to !== undefined ? ["-to", String(args.to)] : []),
|
|
238
|
+
"-i",
|
|
239
|
+
input.absolute,
|
|
240
|
+
"-vn",
|
|
241
|
+
"-t",
|
|
242
|
+
"600",
|
|
243
|
+
"-ar",
|
|
244
|
+
"22050",
|
|
245
|
+
"-ac",
|
|
246
|
+
"1",
|
|
247
|
+
"-c:a",
|
|
248
|
+
"pcm_s16le",
|
|
249
|
+
wavPath,
|
|
250
|
+
],
|
|
251
|
+
{ timeoutMs: MEDIA_LIMITS.sampleTimeoutMs },
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
let method: string;
|
|
255
|
+
let raw: readonly TimedNote[];
|
|
256
|
+
if (kind === "drums") {
|
|
257
|
+
context.progress("classifying drum hits");
|
|
258
|
+
const file = Bun.file(wavPath);
|
|
259
|
+
if (file.size > MEDIA_LIMITS.maxWavBytes)
|
|
260
|
+
throw new MediaToolError("drum stem is over the 256 MiB cap");
|
|
261
|
+
raw = classifyDrumWav(new Uint8Array(await file.arrayBuffer()));
|
|
262
|
+
method = "dawg-stft-drums (vendored from soundfish)";
|
|
263
|
+
} else {
|
|
264
|
+
const basicPitch = toolCommand(
|
|
265
|
+
"basic-pitch",
|
|
266
|
+
context.runner,
|
|
267
|
+
await uvInstalledTools(context.runner, context.signal),
|
|
268
|
+
);
|
|
269
|
+
if (!basicPitch) throw new MediaToolError(missingTool("basic-pitch"));
|
|
270
|
+
context.progress(
|
|
271
|
+
FIRST_RUN_NOTES["basic-pitch"] ?? "basic-pitch starting",
|
|
272
|
+
);
|
|
273
|
+
const outDir = join(temp, "out");
|
|
274
|
+
await Bun.write(join(outDir, ".keep"), "");
|
|
275
|
+
const parameters = BASIC_PITCH_STEM_PARAMETERS[kind as PitchedKind];
|
|
276
|
+
await runHelper(
|
|
277
|
+
context,
|
|
278
|
+
[
|
|
279
|
+
...basicPitch,
|
|
280
|
+
...basicPitchParameterArguments(parameters),
|
|
281
|
+
outDir,
|
|
282
|
+
wavPath,
|
|
283
|
+
"--save-note-events",
|
|
284
|
+
],
|
|
285
|
+
{
|
|
286
|
+
timeoutMs: MEDIA_LIMITS.notesTimeoutMs,
|
|
287
|
+
progress: () => "basic-pitch running",
|
|
288
|
+
env: { PYTHONUNBUFFERED: "1" },
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
const csv = Bun.file(
|
|
292
|
+
join(outDir, `${basicPitchOutputBase(wavPath.split("/").at(-1)!)}.csv`),
|
|
293
|
+
);
|
|
294
|
+
if (csv.size === 0)
|
|
295
|
+
throw new MediaToolError("basic-pitch wrote no note CSV");
|
|
296
|
+
if (csv.size > 16 * 1024 * 1024)
|
|
297
|
+
throw new MediaToolError("basic-pitch CSV is over 16 MiB");
|
|
298
|
+
raw = parseBasicPitchNoteCsv(new Uint8Array(await csv.arrayBuffer()));
|
|
299
|
+
method = `basic-pitch (onset ${parameters.onsetThreshold}, min ${parameters.minimumNoteLengthMs} ms${parameters.minimumFrequencyHz !== undefined ? `, ${parameters.minimumFrequencyHz}–${parameters.maximumFrequencyHz} Hz` : ""})`;
|
|
300
|
+
}
|
|
301
|
+
throwIfAborted(context.signal);
|
|
302
|
+
const shifted = raw
|
|
303
|
+
.map((note) => ({
|
|
304
|
+
...note,
|
|
305
|
+
startSeconds: note.startSeconds + from,
|
|
306
|
+
endSeconds: note.endSeconds + from,
|
|
307
|
+
}))
|
|
308
|
+
.sort(
|
|
309
|
+
(left, right) =>
|
|
310
|
+
left.startSeconds - right.startSeconds || left.pitch - right.pitch,
|
|
311
|
+
);
|
|
312
|
+
const truncated = Math.max(0, shifted.length - MEDIA_LIMITS.maxNotes);
|
|
313
|
+
const notes = shifted.slice(0, MEDIA_LIMITS.maxNotes);
|
|
314
|
+
if (truncated > 0)
|
|
315
|
+
warnings.push(
|
|
316
|
+
`kept the first ${MEDIA_LIMITS.maxNotes} notes; ${truncated} later notes were dropped`,
|
|
317
|
+
);
|
|
318
|
+
if (notes.length === 0)
|
|
319
|
+
warnings.push(
|
|
320
|
+
`no ${kind === "drums" ? "hits" : "notes"} were found${args.to !== undefined ? " in that window" : ""}`,
|
|
321
|
+
);
|
|
322
|
+
const { quantized, originBeat } = quantizeNotes(notes, grid);
|
|
323
|
+
const lines = snippetLines(quantized, kind);
|
|
324
|
+
const base = input.absolute.replace(/\.[A-Za-z0-9]{1,5}$/, "");
|
|
325
|
+
const out = `${base}.${kind}.notes.json`;
|
|
326
|
+
await writeJsonAtomic(out, {
|
|
327
|
+
file: input.relative,
|
|
328
|
+
kind,
|
|
329
|
+
method,
|
|
330
|
+
bpm,
|
|
331
|
+
window: { from, ...(args.to !== undefined ? { to: args.to } : {}) },
|
|
332
|
+
gridOrigin: {
|
|
333
|
+
beat: originBeat,
|
|
334
|
+
seconds: grid.beats[originBeat] ?? grid.beats[0],
|
|
335
|
+
},
|
|
336
|
+
detector: grid.detector ?? "stemdeck",
|
|
337
|
+
notes,
|
|
338
|
+
quantized,
|
|
339
|
+
snippet: lines,
|
|
340
|
+
warnings,
|
|
341
|
+
});
|
|
342
|
+
const relativeOut = projectPath(context.projectRoot, out);
|
|
343
|
+
return {
|
|
344
|
+
summary: `${kind}: ${notes.length} ${kind === "drums" ? "hits" : "notes"} from ${input.relative}${args.to !== undefined ? ` (${formatSeconds(from)}–${formatSeconds(args.to)})` : ""}`,
|
|
345
|
+
outputs: [relativeOut, ...(analysis ? [analysis] : [])],
|
|
346
|
+
content: {
|
|
347
|
+
file: input.relative,
|
|
348
|
+
kind,
|
|
349
|
+
method,
|
|
350
|
+
bpm,
|
|
351
|
+
notesFile: relativeOut,
|
|
352
|
+
notes: notes.length,
|
|
353
|
+
...(truncated > 0 ? { truncated } : {}),
|
|
354
|
+
gridOriginSeconds: grid.beats[originBeat] ?? grid.beats[0],
|
|
355
|
+
snippet: lines.slice(0, SNIPPET_LINES),
|
|
356
|
+
...(lines.length > SNIPPET_LINES
|
|
357
|
+
? { snippetOmitted: lines.length - SNIPPET_LINES }
|
|
358
|
+
: {}),
|
|
359
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
});
|
|
363
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where media tools read and write. Every output lives under
|
|
3
|
+
* `tracks/<slug>/downloads/` (samples under `tracks/<slug>/samples/`), inputs
|
|
4
|
+
* must resolve inside the project root, and results quote project-relative
|
|
5
|
+
* paths so the model can chain tools without learning absolute paths.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { createReadStream, realpathSync } from "node:fs";
|
|
9
|
+
import {
|
|
10
|
+
mkdir,
|
|
11
|
+
readdir,
|
|
12
|
+
realpath,
|
|
13
|
+
rename,
|
|
14
|
+
stat,
|
|
15
|
+
writeFile,
|
|
16
|
+
} from "node:fs/promises";
|
|
17
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
18
|
+
import type { MediaHost } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
export const MAX_NAME_LENGTH = 64;
|
|
21
|
+
|
|
22
|
+
/** The same slug the workspace tools use for `tracks/<slug>/`. */
|
|
23
|
+
export { trackSlug } from "../../core/slug.ts";
|
|
24
|
+
|
|
25
|
+
export function downloadsDir(
|
|
26
|
+
host: Pick<MediaHost, "projectRoot" | "trackSlug">,
|
|
27
|
+
) {
|
|
28
|
+
return join(host.projectRoot, "tracks", host.trackSlug, "downloads");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function samplesDir(host: Pick<MediaHost, "projectRoot" | "trackSlug">) {
|
|
32
|
+
return join(host.projectRoot, "tracks", host.trackSlug, "samples");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function ensureDir(path: string): Promise<string> {
|
|
36
|
+
await mkdir(path, { recursive: true });
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Project-relative POSIX path for results. */
|
|
41
|
+
export function projectPath(root: string, absolute: string): string {
|
|
42
|
+
// `absolute` usually comes from realpath; try the resolved root too so a
|
|
43
|
+
// symlinked project directory (macOS /var → /private/var) stays relative.
|
|
44
|
+
const roots = [root];
|
|
45
|
+
try {
|
|
46
|
+
const real = realpathSync(root);
|
|
47
|
+
if (real !== root) roots.push(real);
|
|
48
|
+
} catch {
|
|
49
|
+
// Missing root: fall through to the plain relative path.
|
|
50
|
+
}
|
|
51
|
+
for (const base of roots) {
|
|
52
|
+
const path = relative(base, absolute);
|
|
53
|
+
if (path && !path.startsWith("..") && !isAbsolute(path))
|
|
54
|
+
return path.split(sep).join("/");
|
|
55
|
+
}
|
|
56
|
+
return relative(root, absolute).split(sep).join("/");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class MediaPathError extends Error {
|
|
60
|
+
constructor(message: string) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = "MediaPathError";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a model- or user-supplied input file: project-relative or absolute,
|
|
68
|
+
* but always a regular file inside the project root once symlinks resolve.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveInput(
|
|
71
|
+
host: Pick<MediaHost, "projectRoot" | "trackSlug">,
|
|
72
|
+
file: unknown,
|
|
73
|
+
): Promise<{ absolute: string; relative: string; size: number }> {
|
|
74
|
+
if (typeof file !== "string" || file.length === 0 || file.length > 1024)
|
|
75
|
+
throw new MediaPathError("file must be a project-relative path");
|
|
76
|
+
if (file.includes("\0"))
|
|
77
|
+
throw new MediaPathError("file contains an invalid character");
|
|
78
|
+
const candidates = isAbsolute(file)
|
|
79
|
+
? [file]
|
|
80
|
+
: [resolve(host.projectRoot, file), join(downloadsDir(host), file)];
|
|
81
|
+
const root = await realpath(host.projectRoot);
|
|
82
|
+
for (const candidate of candidates) {
|
|
83
|
+
let real: string;
|
|
84
|
+
let info;
|
|
85
|
+
try {
|
|
86
|
+
real = await realpath(candidate);
|
|
87
|
+
info = await stat(real);
|
|
88
|
+
} catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!info.isFile()) continue;
|
|
92
|
+
if (real !== root && !real.startsWith(root + sep))
|
|
93
|
+
throw new MediaPathError(`${file} is outside the project`);
|
|
94
|
+
return {
|
|
95
|
+
absolute: real,
|
|
96
|
+
relative: projectPath(root, real),
|
|
97
|
+
size: info.size,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
throw new MediaPathError(
|
|
101
|
+
`${file} was not found (paths are relative to the project or to tracks/${host.trackSlug}/downloads)`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `<base>.<ext>` in `dir`, or `<base>-2.<ext>`, … when it already exists. */
|
|
106
|
+
export async function freshPath(
|
|
107
|
+
dir: string,
|
|
108
|
+
base: string,
|
|
109
|
+
suffix: string,
|
|
110
|
+
): Promise<{ path: string; base: string }> {
|
|
111
|
+
for (let attempt = 1; attempt <= 99; attempt += 1) {
|
|
112
|
+
const name = attempt === 1 ? base : `${base}-${attempt}`;
|
|
113
|
+
const path = join(dir, `${name}${suffix}`);
|
|
114
|
+
try {
|
|
115
|
+
await stat(path);
|
|
116
|
+
} catch {
|
|
117
|
+
return { path, base: name };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
throw new MediaPathError(`too many files named ${base} in ${dir}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function exists(path: string): Promise<boolean> {
|
|
124
|
+
try {
|
|
125
|
+
await stat(path);
|
|
126
|
+
return true;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function sha256File(path: string): Promise<string> {
|
|
133
|
+
return new Promise((resolvePromise, reject) => {
|
|
134
|
+
const hash = createHash("sha256");
|
|
135
|
+
createReadStream(path)
|
|
136
|
+
.on("data", (chunk) => hash.update(chunk))
|
|
137
|
+
.on("error", reject)
|
|
138
|
+
.on("end", () => resolvePromise(hash.digest("hex")));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Write JSON through a temp file and rename, so readers never see a partial file. */
|
|
143
|
+
export async function writeJsonAtomic(path: string, value: unknown) {
|
|
144
|
+
const temp = `${path}.tmp-${process.pid}`;
|
|
145
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`);
|
|
146
|
+
await rename(temp, path);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function readJson(path: string): Promise<unknown> {
|
|
150
|
+
try {
|
|
151
|
+
const file = Bun.file(path);
|
|
152
|
+
if (file.size > 16 * 1024 * 1024) return undefined;
|
|
153
|
+
return JSON.parse(await file.text()) as unknown;
|
|
154
|
+
} catch {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function formatBytes(bytes: number): string {
|
|
160
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
161
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KiB`;
|
|
162
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function formatSeconds(seconds: number): string {
|
|
166
|
+
const whole = Math.max(0, Math.round(seconds));
|
|
167
|
+
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
|
|
168
|
+
}
|