@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.
Files changed (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. 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 (or a `track.loop/v1` file) to
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\n${RENDER_USAGE}\n`);
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}\n${RENDER_USAGE}\n`);
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
- const wav = renderScoreWav(score);
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; run `dawg` first or pass --import");
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
  }
@@ -38,8 +38,8 @@ export class SessionLookupError extends Error {
38
38
  }
39
39
 
40
40
  /**
41
- * Maps `--session <name|id>` to a session id. Unknown values pass through
42
- * unchanged when they are valid ids (a new session with that id is created);
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
- if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(query)) return query;
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 = {
@@ -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.stopAsync();
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
- // Every event records the score it replaced (`before`) so undo and later
431
- // rebases can recover it; after a rebase that is the score replayed on.
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
- ...(rebased || !("before" in message.payload)
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.events, revision);
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
+ }
@@ -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;