@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,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/sample <path> [as <voice>]` and `/sample`: add a sample voice to the
|
|
3
|
+
* focused track, or list its voices. The score edit is pure
|
|
4
|
+
* (`addSampleVoice`); the window copies the file into the project
|
|
5
|
+
* (`placeSampleFile`) and commits, and project sync reprints track.ts.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import {
|
|
9
|
+
copyFile,
|
|
10
|
+
mkdir,
|
|
11
|
+
readFile,
|
|
12
|
+
realpath,
|
|
13
|
+
rename,
|
|
14
|
+
stat,
|
|
15
|
+
} from "node:fs/promises";
|
|
16
|
+
import { basename, extname, join, relative, resolve, sep } from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
SAMPLER_INSTRUMENT,
|
|
19
|
+
SCORE_LIMITS,
|
|
20
|
+
TrackScore,
|
|
21
|
+
addTrack,
|
|
22
|
+
isSamplerInstrument,
|
|
23
|
+
samplerVoiceSlots,
|
|
24
|
+
type Sampler,
|
|
25
|
+
type SampleRef,
|
|
26
|
+
type Track,
|
|
27
|
+
} from "../../core/score.ts";
|
|
28
|
+
import { trackDirectories } from "../../core/sdk/print.ts";
|
|
29
|
+
|
|
30
|
+
export type SampleCommand =
|
|
31
|
+
| Readonly<{ kind: "list" }>
|
|
32
|
+
| Readonly<{ kind: "add"; path: string; voice?: string }>;
|
|
33
|
+
|
|
34
|
+
/** `/sample`, `/sample <path>`, `/sample <path> as <voice>`; else undefined. */
|
|
35
|
+
export function parseSampleCommand(command: string): SampleCommand | undefined {
|
|
36
|
+
const match = command.trim().match(/^\/samples?(?:\s+(.+?))?\s*$/i);
|
|
37
|
+
if (!match) return undefined;
|
|
38
|
+
const rest = match[1];
|
|
39
|
+
if (!rest) return { kind: "list" };
|
|
40
|
+
const named = rest.match(/^(.+?)\s+as\s+(\S+)$/i);
|
|
41
|
+
const path = unquote((named ? named[1]! : rest).trim());
|
|
42
|
+
return named
|
|
43
|
+
? { kind: "add", path, voice: named[2]! }
|
|
44
|
+
: { kind: "add", path };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function unquote(value: string): string {
|
|
48
|
+
const quoted = value.match(/^(["'])(.*)\1$/);
|
|
49
|
+
return quoted ? quoted[2]! : value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const VOICE = /^[a-z][a-z0-9_]{0,31}$/;
|
|
53
|
+
|
|
54
|
+
/** A voice name from a file name: `Kick 01.wav` → `kick_01`. */
|
|
55
|
+
export function voiceNameFrom(path: string): string {
|
|
56
|
+
const stem = basename(path, extname(path))
|
|
57
|
+
.toLowerCase()
|
|
58
|
+
.replace(/[^a-z0-9_]+/g, "_")
|
|
59
|
+
.replace(/^[^a-z]+/, "")
|
|
60
|
+
.replace(/_+$/, "")
|
|
61
|
+
.slice(0, 32);
|
|
62
|
+
return stem || "sample";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Lines for `/sample` with no args: one per voice, in slot order. */
|
|
66
|
+
export function listSampleVoices(track: Track | undefined): string[] {
|
|
67
|
+
const sampler = track?.sampler;
|
|
68
|
+
if (!track || !isSamplerInstrument(track.instrument) || !sampler) return [];
|
|
69
|
+
const slots = samplerVoiceSlots(sampler);
|
|
70
|
+
return Object.keys(sampler.voices)
|
|
71
|
+
.sort((a, b) => (slots.get(a) ?? 0) - (slots.get(b) ?? 0) || cmp(a, b))
|
|
72
|
+
.map((voice) => {
|
|
73
|
+
const ref = sampler.voices[voice]!;
|
|
74
|
+
const slot = slots.get(voice);
|
|
75
|
+
const where =
|
|
76
|
+
slot === undefined ? `root ${ref.root ?? 60}` : `slot ${slot}`;
|
|
77
|
+
return `${voice} · ${ref.src} · ${where}`;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cmp(a: string, b: string): number {
|
|
82
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type AddVoiceResult =
|
|
86
|
+
| Readonly<{
|
|
87
|
+
ok: true;
|
|
88
|
+
next: TrackScore;
|
|
89
|
+
/** Track that received the voice (new when the focus was not a sampler). */
|
|
90
|
+
trackId: string;
|
|
91
|
+
voice: string;
|
|
92
|
+
message: string;
|
|
93
|
+
}>
|
|
94
|
+
| Readonly<{ ok: false; message: string }>;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Where a voice lands: the focused track when it is a sampler or an empty
|
|
98
|
+
* track (converted), otherwise a new `samples` track so existing notes keep
|
|
99
|
+
* their instrument.
|
|
100
|
+
*/
|
|
101
|
+
export function samplerTarget(score: TrackScore, focusedId: string): string {
|
|
102
|
+
const focused = score.tracks.find((track) => track.id === focusedId);
|
|
103
|
+
if (focused && isSamplerInstrument(focused.instrument)) return focused.id;
|
|
104
|
+
const empty = !score.notes.some((note) => note.trackId === focusedId);
|
|
105
|
+
if (!focused || empty) return focusedId;
|
|
106
|
+
let id = "samples";
|
|
107
|
+
for (let n = 2; score.tracks.some((track) => track.id === id); n += 1)
|
|
108
|
+
id = `samples-${n}`;
|
|
109
|
+
return id;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Add `voice` → `ref` to `trackId`, creating or converting it to a oneshot
|
|
114
|
+
* sampler. One-shot hits are addressed by slot (36 + voice-name order), so
|
|
115
|
+
* existing hits are re-pitched to keep playing the same voice when a new
|
|
116
|
+
* name sorts before them.
|
|
117
|
+
*/
|
|
118
|
+
export function addSampleVoice(
|
|
119
|
+
score: TrackScore,
|
|
120
|
+
trackId: string,
|
|
121
|
+
voice: string,
|
|
122
|
+
ref: SampleRef,
|
|
123
|
+
): AddVoiceResult {
|
|
124
|
+
if (!VOICE.test(voice))
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
message: `sample · voice "${voice.slice(0, 40)}" is not a valid name · use a-z, 0-9 and _, starting with a letter (/sample <path> as kick)`,
|
|
128
|
+
};
|
|
129
|
+
const existing = score.tracks.find((track) => track.id === trackId);
|
|
130
|
+
const previous =
|
|
131
|
+
existing && isSamplerInstrument(existing.instrument)
|
|
132
|
+
? existing.sampler
|
|
133
|
+
: undefined;
|
|
134
|
+
if (previous && voice in previous.voices)
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
message: `sample · ${trackId} already has a voice "${voice}" · pick another name with as <voice>`,
|
|
138
|
+
};
|
|
139
|
+
const count = Object.keys(previous?.voices ?? {}).length;
|
|
140
|
+
if (count >= SCORE_LIMITS.maxSamplerVoices)
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
message: `sample · ${trackId} has ${count} voices, the limit · remove one in track.ts first`,
|
|
144
|
+
};
|
|
145
|
+
const sampler: Sampler = {
|
|
146
|
+
mode: previous?.mode ?? "oneshot",
|
|
147
|
+
voices: { ...(previous?.voices ?? {}), [voice]: ref },
|
|
148
|
+
};
|
|
149
|
+
let next: TrackScore;
|
|
150
|
+
try {
|
|
151
|
+
if (!existing)
|
|
152
|
+
next = addTrack(score, {
|
|
153
|
+
id: trackId,
|
|
154
|
+
name: trackId,
|
|
155
|
+
instrument: SAMPLER_INSTRUMENT,
|
|
156
|
+
sampler,
|
|
157
|
+
});
|
|
158
|
+
else {
|
|
159
|
+
const json = score.toJSON();
|
|
160
|
+
const oldSlots = previous ? samplerVoiceSlots(previous) : new Map();
|
|
161
|
+
const newSlots = samplerVoiceSlots(sampler);
|
|
162
|
+
const remap = new Map<number, number>();
|
|
163
|
+
for (const [name, slot] of oldSlots) {
|
|
164
|
+
const moved = newSlots.get(name);
|
|
165
|
+
if (moved !== undefined && moved !== slot) remap.set(slot, moved);
|
|
166
|
+
}
|
|
167
|
+
next = new TrackScore({
|
|
168
|
+
...json,
|
|
169
|
+
tracks: score.tracks.map((track) =>
|
|
170
|
+
track.id === trackId
|
|
171
|
+
? { ...track, instrument: SAMPLER_INSTRUMENT, sampler }
|
|
172
|
+
: track,
|
|
173
|
+
),
|
|
174
|
+
notes: score.notes.map((note) =>
|
|
175
|
+
note.trackId === trackId && remap.has(note.pitch)
|
|
176
|
+
? { ...note, pitch: remap.get(note.pitch)! }
|
|
177
|
+
: note,
|
|
178
|
+
),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
message: `sample · ${error instanceof Error ? error.message : String(error)}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const slot = samplerVoiceSlots(sampler).get(voice);
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
next,
|
|
191
|
+
trackId,
|
|
192
|
+
voice,
|
|
193
|
+
message: `sample · ${voice} on ${trackId}${slot === undefined ? "" : ` · slot ${slot}`} · ${Object.keys(sampler.voices).length} voice${Object.keys(sampler.voices).length === 1 ? "" : "s"}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** First free voice name based on `base` (`kick`, `kick_2`, …). */
|
|
198
|
+
export function freeVoiceName(
|
|
199
|
+
score: TrackScore,
|
|
200
|
+
trackId: string,
|
|
201
|
+
base: string,
|
|
202
|
+
): string {
|
|
203
|
+
const voices =
|
|
204
|
+
score.tracks.find((track) => track.id === trackId)?.sampler?.voices ?? {};
|
|
205
|
+
if (!(base in voices)) return base;
|
|
206
|
+
for (let n = 2; ; n += 1) {
|
|
207
|
+
const name = `${base.slice(0, 29)}_${n}`;
|
|
208
|
+
if (!(name in voices)) return name;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export class SamplePlacementError extends Error {}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Make `input` (relative to `cwd`) available to `trackId` and return its
|
|
216
|
+
* `src`, relative to the track directory. A file already under the track's
|
|
217
|
+
* directory is referenced in place; anything else is copied to
|
|
218
|
+
* `tracks/<slug>/samples/<voice><ext>`. Enforces the sample size limit.
|
|
219
|
+
*/
|
|
220
|
+
export async function placeSampleFile(
|
|
221
|
+
options: Readonly<{
|
|
222
|
+
projectRoot: string;
|
|
223
|
+
cwd: string;
|
|
224
|
+
input: string;
|
|
225
|
+
score: TrackScore;
|
|
226
|
+
trackId: string;
|
|
227
|
+
voice: string;
|
|
228
|
+
}>,
|
|
229
|
+
): Promise<Readonly<{ src: string; sha256: string; copied: boolean }>> {
|
|
230
|
+
let real: string;
|
|
231
|
+
try {
|
|
232
|
+
real = await realpath(resolve(options.cwd, options.input));
|
|
233
|
+
} catch {
|
|
234
|
+
throw new SamplePlacementError(`${options.input}: no such file`);
|
|
235
|
+
}
|
|
236
|
+
const info = await stat(real);
|
|
237
|
+
if (!info.isFile())
|
|
238
|
+
throw new SamplePlacementError(`${options.input} is not a file`);
|
|
239
|
+
if (info.size > SCORE_LIMITS.maxSampleFileBytes)
|
|
240
|
+
throw new SamplePlacementError(
|
|
241
|
+
`${options.input} is ${Math.ceil(info.size / 1048576)} MiB, over the ${SCORE_LIMITS.maxSampleFileBytes / 1048576} MiB sample limit`,
|
|
242
|
+
);
|
|
243
|
+
const root = await realpath(options.projectRoot);
|
|
244
|
+
// The track directory is decided by the score that will hold the voice.
|
|
245
|
+
const withTrack = options.score.tracks.some((t) => t.id === options.trackId)
|
|
246
|
+
? options.score
|
|
247
|
+
: addTrack(options.score, { id: options.trackId, name: options.trackId });
|
|
248
|
+
const slug =
|
|
249
|
+
trackDirectories(withTrack).get(options.trackId) ?? options.trackId;
|
|
250
|
+
const trackDir = join(root, "tracks", slug);
|
|
251
|
+
const sha256 = createHash("sha256")
|
|
252
|
+
.update(await readFile(real))
|
|
253
|
+
.digest("hex");
|
|
254
|
+
const inTrack = relative(trackDir, real);
|
|
255
|
+
if (
|
|
256
|
+
inTrack &&
|
|
257
|
+
!inTrack.startsWith("..") &&
|
|
258
|
+
!inTrack.startsWith(sep) &&
|
|
259
|
+
!inTrack.includes(`..${sep}`)
|
|
260
|
+
)
|
|
261
|
+
return { src: inTrack.split(sep).join("/"), sha256, copied: false };
|
|
262
|
+
const ext =
|
|
263
|
+
extname(real)
|
|
264
|
+
.toLowerCase()
|
|
265
|
+
.match(/^\.[a-z0-9]{1,8}$/)?.[0] ?? ".wav";
|
|
266
|
+
const dir = join(trackDir, "samples");
|
|
267
|
+
await mkdir(dir, { recursive: true });
|
|
268
|
+
// The samples directory must still be inside the project after links.
|
|
269
|
+
const realDir = await realpath(dir);
|
|
270
|
+
const back = relative(root, realDir);
|
|
271
|
+
if (back.startsWith("..") || resolve(root, back) !== realDir)
|
|
272
|
+
throw new SamplePlacementError(
|
|
273
|
+
`tracks/${slug}/samples resolves outside the project`,
|
|
274
|
+
);
|
|
275
|
+
let name = `${options.voice}${ext}`;
|
|
276
|
+
for (let n = 2; await exists(join(realDir, name)); n += 1) {
|
|
277
|
+
if ((await sha256Of(join(realDir, name))) === sha256) break;
|
|
278
|
+
name = `${options.voice}_${n}${ext}`;
|
|
279
|
+
}
|
|
280
|
+
const target = join(realDir, name);
|
|
281
|
+
if (!(await exists(target))) {
|
|
282
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
283
|
+
await copyFile(real, temporary);
|
|
284
|
+
await rename(temporary, target);
|
|
285
|
+
}
|
|
286
|
+
return { src: `samples/${name}`, sha256, copied: true };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function exists(path: string): Promise<boolean> {
|
|
290
|
+
try {
|
|
291
|
+
await stat(path);
|
|
292
|
+
return true;
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function sha256Of(path: string): Promise<string> {
|
|
299
|
+
return createHash("sha256")
|
|
300
|
+
.update(await readFile(path))
|
|
301
|
+
.digest("hex");
|
|
302
|
+
}
|