@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,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typechecks a project with the TypeScript compiler dawg depends on.
|
|
3
|
+
*
|
|
4
|
+
* TypeScript 7 is a native binary with no JavaScript watch API, so each check
|
|
5
|
+
* is one `tsc --noEmit --incremental` run whose build info lives in
|
|
6
|
+
* `.dawg/tsbuild/`; a warm check on a small project takes tens of
|
|
7
|
+
* milliseconds. Diagnostics come back as `file:line:col message`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { realpathSync } from "node:fs";
|
|
11
|
+
import { mkdir } from "node:fs/promises";
|
|
12
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import type { Diagnostic } from "../../core/sdk/eval.ts";
|
|
15
|
+
import type { CommandRunner } from "../auth/runner.ts";
|
|
16
|
+
import { systemRunner } from "../auth/runner.ts";
|
|
17
|
+
|
|
18
|
+
export const TYPECHECK_TIMEOUT_MS = 60_000;
|
|
19
|
+
const MAX_DIAGNOSTICS = 200;
|
|
20
|
+
|
|
21
|
+
export type TypecheckResult = Readonly<{
|
|
22
|
+
ok: boolean;
|
|
23
|
+
diagnostics: readonly Diagnostic[];
|
|
24
|
+
ms: number;
|
|
25
|
+
}>;
|
|
26
|
+
|
|
27
|
+
export type TypecheckOptions = Readonly<{
|
|
28
|
+
runner?: CommandRunner;
|
|
29
|
+
/** Path of the `tsc` executable; resolved from the dependency by default. */
|
|
30
|
+
tsc?: string;
|
|
31
|
+
}>;
|
|
32
|
+
|
|
33
|
+
/** Native `tsc` from `@typescript/typescript-<platform>-<arch>`, else `tsc` on PATH. */
|
|
34
|
+
export function resolveTsc(
|
|
35
|
+
runner: CommandRunner = systemRunner,
|
|
36
|
+
): string | undefined {
|
|
37
|
+
const name = `@typescript/typescript-${process.platform}-${process.arch}/package.json`;
|
|
38
|
+
try {
|
|
39
|
+
const url = import.meta.resolve(name);
|
|
40
|
+
return join(dirname(fileURLToPath(url)), "lib", "tsc");
|
|
41
|
+
} catch {
|
|
42
|
+
return runner.which("tsc");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Runs `tsc` on `<project>/tsconfig.json` and parses its diagnostics. */
|
|
47
|
+
export async function typecheckProject(
|
|
48
|
+
project: string,
|
|
49
|
+
options: TypecheckOptions = {},
|
|
50
|
+
): Promise<TypecheckResult> {
|
|
51
|
+
const started = performance.now();
|
|
52
|
+
const runner = options.runner ?? systemRunner;
|
|
53
|
+
const tsc = options.tsc ?? resolveTsc(runner);
|
|
54
|
+
if (!tsc)
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
diagnostics: [{ message: "typescript is not installed (no tsc found)" }],
|
|
58
|
+
ms: elapsed(started),
|
|
59
|
+
};
|
|
60
|
+
const stateDir = join(project, ".dawg", "tsbuild");
|
|
61
|
+
await mkdir(stateDir, { recursive: true });
|
|
62
|
+
const result = await runner.run(
|
|
63
|
+
tsc,
|
|
64
|
+
[
|
|
65
|
+
"--noEmit",
|
|
66
|
+
"--pretty",
|
|
67
|
+
"false",
|
|
68
|
+
"--incremental",
|
|
69
|
+
"--tsBuildInfoFile",
|
|
70
|
+
join(stateDir, "project.tsbuildinfo"),
|
|
71
|
+
"-p",
|
|
72
|
+
join(project, "tsconfig.json"),
|
|
73
|
+
],
|
|
74
|
+
{
|
|
75
|
+
timeoutMs: TYPECHECK_TIMEOUT_MS,
|
|
76
|
+
maxOutputBytes: 1024 * 1024,
|
|
77
|
+
env: { NO_COLOR: "1" },
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
if (result.killed)
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
diagnostics: [{ message: "typecheck timed out" }],
|
|
84
|
+
ms: elapsed(started),
|
|
85
|
+
};
|
|
86
|
+
const diagnostics = parseTscOutput(
|
|
87
|
+
`${result.stdout}\n${result.stderr}`,
|
|
88
|
+
project,
|
|
89
|
+
);
|
|
90
|
+
if (result.code !== 0 && diagnostics.length === 0)
|
|
91
|
+
diagnostics.push({
|
|
92
|
+
message: `tsc exited with code ${result.code}${
|
|
93
|
+
result.stderr.trim() ? `: ${result.stderr.trim().split("\n")[0]}` : ""
|
|
94
|
+
}`,
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
ok: result.code === 0 && diagnostics.length === 0,
|
|
98
|
+
diagnostics,
|
|
99
|
+
ms: elapsed(started),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parses `file(line,col): error TSnnnn: message` lines. tsc prints paths
|
|
105
|
+
* relative to its working directory; they come back project-relative.
|
|
106
|
+
*/
|
|
107
|
+
export function parseTscOutput(
|
|
108
|
+
output: string,
|
|
109
|
+
project: string,
|
|
110
|
+
cwd: string = process.cwd(),
|
|
111
|
+
): Diagnostic[] {
|
|
112
|
+
const out: Diagnostic[] = [];
|
|
113
|
+
const roots = [project];
|
|
114
|
+
try {
|
|
115
|
+
roots.push(realpathSync(project));
|
|
116
|
+
} catch {
|
|
117
|
+
// An unreadable project keeps the literal path.
|
|
118
|
+
}
|
|
119
|
+
const relativize = (file: string): string => {
|
|
120
|
+
const absolute = isAbsolute(file) ? file : resolve(cwd, file);
|
|
121
|
+
for (const root of roots) {
|
|
122
|
+
const rel = relative(root, absolute);
|
|
123
|
+
if (rel && !rel.startsWith("..") && !isAbsolute(rel)) return rel;
|
|
124
|
+
}
|
|
125
|
+
return file;
|
|
126
|
+
};
|
|
127
|
+
for (const raw of output.split("\n")) {
|
|
128
|
+
const line = raw.trimEnd();
|
|
129
|
+
const match = line.match(/^(.*?)\((\d+),(\d+)\): error (TS\d+): (.*)$/);
|
|
130
|
+
if (match) {
|
|
131
|
+
const file = relativize(match[1]!);
|
|
132
|
+
out.push({
|
|
133
|
+
file,
|
|
134
|
+
line: Number(match[2]),
|
|
135
|
+
col: Number(match[3]),
|
|
136
|
+
message: `${match[5]} (${match[4]})`,
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
const bare = line.match(/^error (TS\d+): (.*)$/);
|
|
140
|
+
if (bare) out.push({ message: `${bare[2]} (${bare[1]})` });
|
|
141
|
+
}
|
|
142
|
+
if (out.length >= MAX_DIAGNOSTICS) break;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function elapsed(started: number): number {
|
|
148
|
+
return Math.round(performance.now() - started);
|
|
149
|
+
}
|
package/src/render.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `dawg render <out.wav>`: renders a session
|
|
2
|
+
* `dawg render <out.wav>`: renders a session, the project files (`song.ts`,
|
|
3
|
+
* in a project with no `--session`), or a `track.loop/v1` file to
|
|
3
4
|
* a stereo 16-bit PCM WAV through the same deterministic renderer playback
|
|
4
5
|
* uses, so two renders of one score are byte-identical. It reads the session
|
|
5
6
|
* record from disk and never starts dawgd or plays audio.
|
|
@@ -10,6 +11,9 @@ import { resolve } from "node:path";
|
|
|
10
11
|
import { scoreFromJSON, type TrackScore } from "../core/score.ts";
|
|
11
12
|
import { decodeLoop } from "../core/loop.ts";
|
|
12
13
|
import { renderScoreWav } from "./audio/wav.ts";
|
|
14
|
+
import { SampleLibrary, hasSamplerTracks } from "./audio/samples.ts";
|
|
15
|
+
import { evaluateProject, formatDiagnostic } from "../core/sdk/eval.ts";
|
|
16
|
+
import { isProject } from "./project/init.ts";
|
|
13
17
|
import { resolveSessionArg } from "./session/attach.ts";
|
|
14
18
|
import {
|
|
15
19
|
loadSession,
|
|
@@ -31,6 +35,10 @@ export async function runRenderCommand(
|
|
|
31
35
|
stderr: Output,
|
|
32
36
|
): Promise<number> {
|
|
33
37
|
const rest = argv.slice(1);
|
|
38
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
39
|
+
stdout.write(`${RENDER_USAGE}\n`);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
34
42
|
const options = new Map<string, string>();
|
|
35
43
|
const positional: string[] = [];
|
|
36
44
|
for (let index = 0; index < rest.length; index += 1) {
|
|
@@ -38,13 +46,13 @@ export async function runRenderCommand(
|
|
|
38
46
|
if (arg === "--session" || arg === "--import") {
|
|
39
47
|
const value = rest[index + 1];
|
|
40
48
|
if (value === undefined || value.startsWith("--")) {
|
|
41
|
-
stderr.write(`${arg} needs a value
|
|
49
|
+
stderr.write(`${arg} needs a value · ${RENDER_USAGE}\n`);
|
|
42
50
|
return 2;
|
|
43
51
|
}
|
|
44
52
|
options.set(arg, value);
|
|
45
53
|
index += 1;
|
|
46
54
|
} else if (arg.startsWith("--")) {
|
|
47
|
-
stderr.write(`unknown option ${arg}
|
|
55
|
+
stderr.write(`unknown option · ${arg} · ${RENDER_USAGE}\n`);
|
|
48
56
|
return 2;
|
|
49
57
|
} else positional.push(arg);
|
|
50
58
|
}
|
|
@@ -62,7 +70,13 @@ export async function runRenderCommand(
|
|
|
62
70
|
);
|
|
63
71
|
return 1;
|
|
64
72
|
}
|
|
65
|
-
|
|
73
|
+
let samples;
|
|
74
|
+
if (hasSamplerTracks(score)) {
|
|
75
|
+
samples = await new SampleLibrary({ projectRoot: workspace }).load(score);
|
|
76
|
+
for (const problem of samples.problems)
|
|
77
|
+
stderr.write(`sample ${problem.level} · ${problem.message}\n`);
|
|
78
|
+
}
|
|
79
|
+
const wav = renderScoreWav(score, { samples });
|
|
66
80
|
const path = resolve(workspace, target);
|
|
67
81
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
68
82
|
await writeFile(temporary, wav);
|
|
@@ -84,16 +98,24 @@ async function loadScore(
|
|
|
84
98
|
return decodeLoop(contents.toString("utf8"));
|
|
85
99
|
}
|
|
86
100
|
const query = options.get("--session");
|
|
101
|
+
if (query === undefined && (await isProject(workspace))) {
|
|
102
|
+
const evaluated = await evaluateProject(workspace);
|
|
103
|
+
if (!evaluated.ok)
|
|
104
|
+
throw new Error(
|
|
105
|
+
`${evaluated.diagnostics.map(formatDiagnostic).join("; ").slice(0, 400)} · fix song.ts and retry (dawg check)`,
|
|
106
|
+
);
|
|
107
|
+
return evaluated.score;
|
|
108
|
+
}
|
|
87
109
|
const sessionId =
|
|
88
110
|
query === undefined
|
|
89
111
|
? await readCurrentSessionId(workspace)
|
|
90
112
|
: await resolveSessionArg(workspace, query);
|
|
91
113
|
if (sessionId === undefined)
|
|
92
|
-
throw new Error("no session here
|
|
114
|
+
throw new Error("no session here · run dawg first or pass --import");
|
|
93
115
|
const record = await loadSession<unknown>(
|
|
94
116
|
sessionPaths(workspace, sessionId),
|
|
95
117
|
).catch(() => {
|
|
96
|
-
throw new Error(`no session named "${query ?? sessionId}"`);
|
|
118
|
+
throw new Error(`no session named "${query ?? sessionId}" · dawg sessions`);
|
|
97
119
|
});
|
|
98
120
|
return scoreFromJSON(record.composition);
|
|
99
121
|
}
|
package/src/session/attach.ts
CHANGED
|
@@ -38,8 +38,8 @@ export class SessionLookupError extends Error {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Maps `--session <name|id>` to
|
|
42
|
-
*
|
|
41
|
+
* Maps `--session <name|id>` to the id of an existing session. An unknown
|
|
42
|
+
* value throws (a typo must not create a new session; `--new` does that);
|
|
43
43
|
* an ambiguous name throws with the candidate list.
|
|
44
44
|
*/
|
|
45
45
|
export async function resolveSessionArg(
|
|
@@ -54,8 +54,7 @@ export async function resolveSessionArg(
|
|
|
54
54
|
throw new SessionLookupError(
|
|
55
55
|
ambiguousSessionMessage(query, result.candidates),
|
|
56
56
|
);
|
|
57
|
-
|
|
58
|
-
throw new SessionLookupError(`no session named "${query}"`);
|
|
57
|
+
throw new SessionLookupError(`no session named "${query}" · dawg sessions`);
|
|
59
58
|
}
|
|
60
59
|
|
|
61
60
|
export type TrackAttachment = {
|
package/src/session/daemon.ts
CHANGED
|
@@ -95,6 +95,21 @@ export class DawgDaemon {
|
|
|
95
95
|
this.graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
|
|
96
96
|
this.audio = new AudioEngine({
|
|
97
97
|
lockPath: `${this.paths.record}.audio.lock`,
|
|
98
|
+
projectRoot: options.workspace,
|
|
99
|
+
onStatus: (status) => {
|
|
100
|
+
void this.log(`audio: ${status.message}`);
|
|
101
|
+
// A player that keeps dying must not leave a silent "playing" transport.
|
|
102
|
+
if (status.state === "stopped" && this.clock.playing) {
|
|
103
|
+
this.clock.pause();
|
|
104
|
+
this.broadcastTransport();
|
|
105
|
+
}
|
|
106
|
+
this.broadcast({
|
|
107
|
+
v: 1,
|
|
108
|
+
type: "error",
|
|
109
|
+
code: "audio",
|
|
110
|
+
message: status.message,
|
|
111
|
+
});
|
|
112
|
+
},
|
|
98
113
|
});
|
|
99
114
|
}
|
|
100
115
|
|
|
@@ -158,7 +173,7 @@ export class DawgDaemon {
|
|
|
158
173
|
private async shutdown(reason: string): Promise<void> {
|
|
159
174
|
if (this.graceTimer) clearTimeout(this.graceTimer);
|
|
160
175
|
if (this.diskTimer) clearInterval(this.diskTimer);
|
|
161
|
-
await this.audio.
|
|
176
|
+
await this.audio.dispose();
|
|
162
177
|
for (const client of this.clients) client.socket.destroy();
|
|
163
178
|
this.clients.clear();
|
|
164
179
|
await this.queue.catch(() => undefined);
|
|
@@ -427,18 +442,16 @@ export class DawgDaemon {
|
|
|
427
442
|
message: error instanceof Error ? error.message : String(error),
|
|
428
443
|
};
|
|
429
444
|
}
|
|
430
|
-
//
|
|
431
|
-
//
|
|
445
|
+
// The store records how to rewind every event, so undo and later rebases
|
|
446
|
+
// can recover the score it replaced; a rebased event also notes its base.
|
|
432
447
|
const payload =
|
|
448
|
+
rebased &&
|
|
433
449
|
typeof message.payload === "object" &&
|
|
434
450
|
message.payload !== null &&
|
|
435
451
|
!Array.isArray(message.payload)
|
|
436
452
|
? {
|
|
437
453
|
...(message.payload as Record<string, unknown>),
|
|
438
|
-
|
|
439
|
-
? { before: this.record.composition }
|
|
440
|
-
: {}),
|
|
441
|
-
...(rebased ? { rebasedFrom: message.base } : {}),
|
|
454
|
+
rebasedFrom: message.base,
|
|
442
455
|
}
|
|
443
456
|
: message.payload;
|
|
444
457
|
const previousTempo = this.score.tempoBpm;
|
|
@@ -506,7 +519,7 @@ export class DawgDaemon {
|
|
|
506
519
|
this.record.revision - revision > MAX_REBASE_DISTANCE
|
|
507
520
|
)
|
|
508
521
|
return undefined;
|
|
509
|
-
const composition = compositionAt(this.record
|
|
522
|
+
const composition = compositionAt(this.record, revision);
|
|
510
523
|
if (composition === undefined) return undefined;
|
|
511
524
|
try {
|
|
512
525
|
return scoreFromJSON(composition);
|
|
@@ -678,6 +691,10 @@ const OPERATION_TYPES = new Set([
|
|
|
678
691
|
"updateTrack",
|
|
679
692
|
"setAutomation",
|
|
680
693
|
"clearTrack",
|
|
694
|
+
"removeTrack",
|
|
695
|
+
"moveTrack",
|
|
696
|
+
"setKey",
|
|
697
|
+
"setMeter",
|
|
681
698
|
]);
|
|
682
699
|
|
|
683
700
|
/** Shape gate before the reducer, which validates every field it reads. */
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse deltas for session history.
|
|
3
|
+
*
|
|
4
|
+
* Every event stores a `rewind`: how to turn the composition it produced back
|
|
5
|
+
* into the one it replaced, instead of the whole previous composition.
|
|
6
|
+
* Objects diff per key, arrays of `{id}` objects diff per id and position,
|
|
7
|
+
* and anything else is replaced wholesale. Applying the rewinds of the newest
|
|
8
|
+
* events in reverse order recovers any older composition, which is what undo
|
|
9
|
+
* and the daemon's rebase need. A single-note edit costs about 150 bytes
|
|
10
|
+
* instead of a full copy of the score, so the record stays small and the
|
|
11
|
+
* session never becomes un-editable.
|
|
12
|
+
*/
|
|
13
|
+
export type Rewind =
|
|
14
|
+
/** The value in the earlier composition (also used for a key that was removed). */
|
|
15
|
+
| { set: unknown }
|
|
16
|
+
/** The key did not exist in the earlier composition. */
|
|
17
|
+
| { del: true }
|
|
18
|
+
/** Per-key rewinds for a plain object. */
|
|
19
|
+
| { obj: Record<string, Rewind> }
|
|
20
|
+
/**
|
|
21
|
+
* An array of `{id}` objects: remove the items at `drop` (indices into the
|
|
22
|
+
* later array), then place `put` values at their earlier indices.
|
|
23
|
+
*/
|
|
24
|
+
| { arr: { drop: number[]; put: [number, unknown][] } };
|
|
25
|
+
|
|
26
|
+
const MAX_DEPTH = 16;
|
|
27
|
+
|
|
28
|
+
/** Applying this rewind changes nothing. */
|
|
29
|
+
export const IDENTITY_REWIND: Rewind = Object.freeze({ obj: {} }) as Rewind;
|
|
30
|
+
|
|
31
|
+
export function isIdentityRewind(value: Rewind | undefined): boolean {
|
|
32
|
+
return (
|
|
33
|
+
value !== undefined && "obj" in value && Object.keys(value.obj).length === 0
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The rewind that turns `after` back into `before`; identity when equal. */
|
|
38
|
+
export function diffRewind(before: unknown, after: unknown): Rewind {
|
|
39
|
+
return diff(before, after, 0) ?? IDENTITY_REWIND;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function diff(
|
|
43
|
+
before: unknown,
|
|
44
|
+
after: unknown,
|
|
45
|
+
depth: number,
|
|
46
|
+
): Rewind | undefined {
|
|
47
|
+
if (depth >= MAX_DEPTH)
|
|
48
|
+
return deepEqual(before, after) ? undefined : { set: before };
|
|
49
|
+
if (isPlainObject(before) && isPlainObject(after)) {
|
|
50
|
+
const obj: Record<string, Rewind> = {};
|
|
51
|
+
for (const key of Object.keys(before)) {
|
|
52
|
+
if (!(key in after) || after[key] === undefined)
|
|
53
|
+
obj[key] = { set: before[key] };
|
|
54
|
+
else {
|
|
55
|
+
const child = diff(before[key], after[key], depth + 1);
|
|
56
|
+
if (child) obj[key] = child;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const key of Object.keys(after))
|
|
60
|
+
if (!(key in before) || before[key] === undefined)
|
|
61
|
+
obj[key] = { del: true };
|
|
62
|
+
return Object.keys(obj).length === 0 ? undefined : { obj };
|
|
63
|
+
}
|
|
64
|
+
if (isKeyedArray(before) && isKeyedArray(after)) {
|
|
65
|
+
const beforeIndex = new Map<string, number>();
|
|
66
|
+
before.forEach((item, index) => beforeIndex.set(item.id, index));
|
|
67
|
+
// Greedy common subsequence by id: an item is kept when it is unchanged
|
|
68
|
+
// and keeps its relative order. Edits touch few items, so this is near
|
|
69
|
+
// optimal and always correct; a full reorder degrades to a full copy.
|
|
70
|
+
const kept = new Set<number>();
|
|
71
|
+
const drop: number[] = [];
|
|
72
|
+
let last = -1;
|
|
73
|
+
after.forEach((item, index) => {
|
|
74
|
+
const at = beforeIndex.get(item.id);
|
|
75
|
+
if (at !== undefined && at > last && deepEqual(before[at], item)) {
|
|
76
|
+
kept.add(at);
|
|
77
|
+
last = at;
|
|
78
|
+
} else drop.push(index);
|
|
79
|
+
});
|
|
80
|
+
const put: [number, unknown][] = [];
|
|
81
|
+
before.forEach((item, index) => {
|
|
82
|
+
if (!kept.has(index)) put.push([index, item]);
|
|
83
|
+
});
|
|
84
|
+
return drop.length === 0 && put.length === 0
|
|
85
|
+
? undefined
|
|
86
|
+
: { arr: { drop, put } };
|
|
87
|
+
}
|
|
88
|
+
return deepEqual(before, after) ? undefined : { set: before };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The earlier value: `rewind` applied to `after`. Throws on a shape mismatch. */
|
|
92
|
+
export function applyRewind(after: unknown, rewind: Rewind): unknown {
|
|
93
|
+
if ("set" in rewind) return rewind.set;
|
|
94
|
+
if ("del" in rewind) return undefined;
|
|
95
|
+
if ("obj" in rewind) {
|
|
96
|
+
// The identity rewind applies to any value, not only objects.
|
|
97
|
+
if (isIdentityRewind(rewind)) return after;
|
|
98
|
+
if (!isPlainObject(after)) throw new Error("rewind expects an object");
|
|
99
|
+
const result: Record<string, unknown> = { ...after };
|
|
100
|
+
for (const [key, child] of Object.entries(rewind.obj)) {
|
|
101
|
+
if ("del" in child) delete result[key];
|
|
102
|
+
else result[key] = applyRewind(result[key], child);
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
if (!Array.isArray(after)) throw new Error("rewind expects an array");
|
|
107
|
+
const dropped = new Set(rewind.arr.drop);
|
|
108
|
+
const kept: unknown[] = [];
|
|
109
|
+
after.forEach((item, index) => {
|
|
110
|
+
if (!dropped.has(index)) kept.push(item);
|
|
111
|
+
});
|
|
112
|
+
const put = new Map(rewind.arr.put);
|
|
113
|
+
const length = kept.length + put.size;
|
|
114
|
+
const result: unknown[] = [];
|
|
115
|
+
let next = 0;
|
|
116
|
+
for (let index = 0; index < length; index += 1) {
|
|
117
|
+
if (put.has(index)) result.push(put.get(index));
|
|
118
|
+
else {
|
|
119
|
+
if (next >= kept.length) throw new Error("rewind indices are invalid");
|
|
120
|
+
result.push(kept[next]);
|
|
121
|
+
next += 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (next !== kept.length) throw new Error("rewind indices are invalid");
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The composition before `events[index]`, by rewinding `composition` (the
|
|
130
|
+
* one after the last event) through every later event. Undefined when any
|
|
131
|
+
* event in that range has no usable rewind.
|
|
132
|
+
*/
|
|
133
|
+
export function rewindComposition(
|
|
134
|
+
composition: unknown,
|
|
135
|
+
events: readonly { rewind?: Rewind | undefined }[],
|
|
136
|
+
index: number,
|
|
137
|
+
): unknown {
|
|
138
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= events.length)
|
|
139
|
+
return undefined;
|
|
140
|
+
let value = composition;
|
|
141
|
+
try {
|
|
142
|
+
for (let at = events.length - 1; at >= index; at -= 1) {
|
|
143
|
+
const rewind = events[at]!.rewind;
|
|
144
|
+
if (rewind === undefined) return undefined;
|
|
145
|
+
value = applyRewind(value, rewind);
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Validates a stored rewind from `unknown`; `undefined` when absent. */
|
|
154
|
+
export function parseRewind(value: unknown): Rewind | undefined {
|
|
155
|
+
if (value === undefined) return undefined;
|
|
156
|
+
return parse(value, 0);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parse(value: unknown, depth: number): Rewind {
|
|
160
|
+
if (!isPlainObject(value) || depth > MAX_DEPTH)
|
|
161
|
+
throw new Error("session event rewind is invalid");
|
|
162
|
+
const keys = Object.keys(value);
|
|
163
|
+
if (keys.length !== 1) throw new Error("session event rewind is invalid");
|
|
164
|
+
if ("set" in value) {
|
|
165
|
+
if (value.set === undefined)
|
|
166
|
+
throw new Error("session event rewind is invalid");
|
|
167
|
+
return { set: value.set };
|
|
168
|
+
}
|
|
169
|
+
if ("del" in value) {
|
|
170
|
+
if (value.del !== true) throw new Error("session event rewind is invalid");
|
|
171
|
+
return { del: true };
|
|
172
|
+
}
|
|
173
|
+
if ("obj" in value) {
|
|
174
|
+
if (!isPlainObject(value.obj))
|
|
175
|
+
throw new Error("session event rewind is invalid");
|
|
176
|
+
const obj: Record<string, Rewind> = {};
|
|
177
|
+
for (const [key, child] of Object.entries(value.obj))
|
|
178
|
+
obj[key] = parse(child, depth + 1);
|
|
179
|
+
return { obj };
|
|
180
|
+
}
|
|
181
|
+
if ("arr" in value && isPlainObject(value.arr)) {
|
|
182
|
+
const { drop, put } = value.arr;
|
|
183
|
+
if (
|
|
184
|
+
!Array.isArray(drop) ||
|
|
185
|
+
!Array.isArray(put) ||
|
|
186
|
+
!drop.every((index) => Number.isSafeInteger(index) && index >= 0) ||
|
|
187
|
+
!put.every(
|
|
188
|
+
(entry) =>
|
|
189
|
+
Array.isArray(entry) &&
|
|
190
|
+
entry.length === 2 &&
|
|
191
|
+
Number.isSafeInteger(entry[0]) &&
|
|
192
|
+
entry[0] >= 0 &&
|
|
193
|
+
entry[1] !== undefined,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
throw new Error("session event rewind is invalid");
|
|
197
|
+
return {
|
|
198
|
+
arr: {
|
|
199
|
+
drop: drop as number[],
|
|
200
|
+
put: put.map((entry) => [entry[0], entry[1]] as [number, unknown]),
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
throw new Error("session event rewind is invalid");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
208
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isKeyedArray(value: unknown): value is { id: string }[] {
|
|
212
|
+
if (!Array.isArray(value)) return false;
|
|
213
|
+
const seen = new Set<string>();
|
|
214
|
+
for (const item of value) {
|
|
215
|
+
if (!isPlainObject(item) || typeof item.id !== "string") return false;
|
|
216
|
+
if (seen.has(item.id)) return false;
|
|
217
|
+
seen.add(item.id);
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
223
|
+
if (a === b) return true;
|
|
224
|
+
if (
|
|
225
|
+
typeof a !== "object" ||
|
|
226
|
+
typeof b !== "object" ||
|
|
227
|
+
a === null ||
|
|
228
|
+
b === null
|
|
229
|
+
)
|
|
230
|
+
return false;
|
|
231
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
232
|
+
if (Array.isArray(a)) {
|
|
233
|
+
const other = b as unknown[];
|
|
234
|
+
if (a.length !== other.length) return false;
|
|
235
|
+
for (let index = 0; index < a.length; index += 1)
|
|
236
|
+
if (!deepEqual(a[index], other[index])) return false;
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
const left = a as Record<string, unknown>;
|
|
240
|
+
const right = b as Record<string, unknown>;
|
|
241
|
+
const keys = Object.keys(left).filter((key) => left[key] !== undefined);
|
|
242
|
+
const otherKeys = Object.keys(right).filter(
|
|
243
|
+
(key) => right[key] !== undefined,
|
|
244
|
+
);
|
|
245
|
+
if (keys.length !== otherKeys.length) return false;
|
|
246
|
+
for (const key of keys)
|
|
247
|
+
if (!(key in right) || !deepEqual(left[key], right[key])) return false;
|
|
248
|
+
return true;
|
|
249
|
+
}
|
package/src/session/naming.ts
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
14
|
import type { TrackScore } from "../../core/score.ts";
|
|
15
15
|
import { isDrumInstrument } from "../../core/drums.ts";
|
|
16
|
+
import { estimateKey } from "../../core/key.ts";
|
|
17
|
+
|
|
18
|
+
export { estimateKey };
|
|
16
19
|
import type { ProviderSelection } from "../agent/provider.ts";
|
|
17
20
|
import {
|
|
18
21
|
DEFAULT_SESSION_NAME,
|
|
@@ -27,22 +30,6 @@ import {
|
|
|
27
30
|
export const NAME_MAX_TOKENS = 12;
|
|
28
31
|
export const NAME_TEMPERATURE = 0.3;
|
|
29
32
|
const PROMPT_SNIPPET_CHARS = 60;
|
|
30
|
-
const NOTE_NAMES = [
|
|
31
|
-
"c",
|
|
32
|
-
"c#",
|
|
33
|
-
"d",
|
|
34
|
-
"eb",
|
|
35
|
-
"e",
|
|
36
|
-
"f",
|
|
37
|
-
"f#",
|
|
38
|
-
"g",
|
|
39
|
-
"ab",
|
|
40
|
-
"a",
|
|
41
|
-
"bb",
|
|
42
|
-
"b",
|
|
43
|
-
] as const;
|
|
44
|
-
const MAJOR = [0, 2, 4, 5, 7, 9, 11];
|
|
45
|
-
const MINOR = [0, 2, 3, 5, 7, 8, 10];
|
|
46
33
|
|
|
47
34
|
export type TrackRole = "drums" | "bass" | "keys" | "lead";
|
|
48
35
|
|
|
@@ -141,27 +128,6 @@ export function musicalFingerprint(score: TrackScore): Fingerprint {
|
|
|
141
128
|
};
|
|
142
129
|
}
|
|
143
130
|
|
|
144
|
-
/** Best-fitting major or minor scale for a pitch-class histogram, or null. */
|
|
145
|
-
export function estimateKey(histogram: readonly number[]): string | null {
|
|
146
|
-
const total = histogram.reduce((sum, value) => sum + value, 0);
|
|
147
|
-
if (total <= 0) return null;
|
|
148
|
-
let best: { key: string; score: number } | undefined;
|
|
149
|
-
for (let tonic = 0; tonic < 12; tonic += 1) {
|
|
150
|
-
for (const [mode, scale] of [
|
|
151
|
-
["major", MAJOR],
|
|
152
|
-
["minor", MINOR],
|
|
153
|
-
] as const) {
|
|
154
|
-
let fit = 0;
|
|
155
|
-
for (const step of scale) fit += histogram[(tonic + step) % 12]!;
|
|
156
|
-
// Tonic and fifth weigh extra so relative keys separate.
|
|
157
|
-
fit += histogram[tonic]! * 0.75 + histogram[(tonic + 7) % 12]! * 0.25;
|
|
158
|
-
if (!best || fit > best.score + 1e-9)
|
|
159
|
-
best = { key: `${NOTE_NAMES[tonic]} ${mode}`, score: fit };
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return best!.key;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
131
|
/** Deterministic offline name, e.g. "a minor bass groove" or "128 bpm drums + keys". */
|
|
166
132
|
export function localName(fingerprint: Fingerprint): string {
|
|
167
133
|
const roles = fingerprint.roles;
|