@hraness/dawg 0.2.0 → 0.4.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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -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
 
@@ -102,13 +89,15 @@ export function musicalFingerprint(score: TrackScore): Fingerprint {
102
89
  if (track.filter) effects.add("filter");
103
90
  if (track.delay) effects.add("delay");
104
91
  if (track.reverb) effects.add("reverb");
92
+ for (const name of Object.keys(track.fx ?? {})) effects.add(name);
105
93
  if (track.volumeAutomation.length || track.panAutomation.length)
106
94
  effects.add("automation");
107
95
  if (
108
96
  track.filterAutomation?.length ||
109
97
  track.resonanceAutomation?.length ||
110
98
  track.delayFeedbackAutomation?.length ||
111
- track.delayMixAutomation?.length
99
+ track.delayMixAutomation?.length ||
100
+ Object.keys(track.fxAutomation ?? {}).length
112
101
  )
113
102
  effects.add("sweep");
114
103
  const instrument = track.instrument.toLowerCase().slice(0, 24);
@@ -141,27 +130,6 @@ export function musicalFingerprint(score: TrackScore): Fingerprint {
141
130
  };
142
131
  }
143
132
 
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
133
  /** Deterministic offline name, e.g. "a minor bass groove" or "128 bpm drums + keys". */
166
134
  export function localName(fingerprint: Fingerprint): string {
167
135
  const roles = fingerprint.roles;
@@ -56,6 +56,8 @@ export type WindowPlayer = {
56
56
  /** Start at `beat`, or swap a playing loop in place (gapless engine). */
57
57
  play(score: TrackScore, beat?: number): Promise<void>;
58
58
  stop(): void;
59
+ /** Stops and releases the render worker; the player is done for good. */
60
+ dispose(): Promise<void>;
59
61
  };
60
62
 
61
63
  /**
@@ -65,6 +67,8 @@ export type WindowPlayer = {
65
67
  */
66
68
  export interface SessionPort<T> {
67
69
  readonly mode: "daemon" | "file";
70
+ /** This window's presence id, to exclude itself from `presence()`. */
71
+ readonly clientId: string;
68
72
  /** Human-readable status for the activity line. */
69
73
  readonly status: string;
70
74
  readonly player: WindowPlayer;
@@ -157,10 +161,12 @@ export async function openSessionPort<T>(
157
161
  const silentPlayer: WindowPlayer = {
158
162
  play: async () => undefined,
159
163
  stop: () => undefined,
164
+ dispose: async () => undefined,
160
165
  };
161
166
 
162
167
  class DaemonPort<T> implements SessionPort<T> {
163
168
  public readonly mode = "daemon" as const;
169
+ public readonly clientId: string;
164
170
  public readonly player = silentPlayer;
165
171
  public status: string;
166
172
  public sync: SyncStatus = "synced";
@@ -168,6 +174,7 @@ class DaemonPort<T> implements SessionPort<T> {
168
174
 
169
175
  public constructor(private readonly client: DaemonClient) {
170
176
  this.status = `dawgd pid ${client.daemonPid}`;
177
+ this.clientId = client.clientId;
171
178
  }
172
179
 
173
180
  private setSync(sync: SyncStatus): void {
@@ -303,17 +310,24 @@ class DaemonPort<T> implements SessionPort<T> {
303
310
 
304
311
  class FilePort<T> implements SessionPort<T> {
305
312
  public readonly mode = "file" as const;
313
+ public readonly clientId = randomUUID();
306
314
  public status = "file session";
307
315
  public readonly sync = "local" as const;
308
316
  public readonly player: WindowPlayer;
309
317
  private readonly presenceStore: FilePresence;
318
+ private readonly statusListeners = new Set<(update: PortUpdate<T>) => void>();
310
319
 
311
320
  private constructor(private readonly options: OpenPortOptions) {
312
321
  this.player = new AudioEngine({
313
322
  lockPath: `${options.paths.record}.audio.lock`,
323
+ projectRoot: dirname(options.paths.root),
324
+ onStatus: (status) => {
325
+ for (const listener of this.statusListeners)
326
+ listener({ type: "status", message: status.message });
327
+ },
314
328
  });
315
329
  this.presenceStore = new FilePresence(options.paths, {
316
- clientId: randomUUID(),
330
+ clientId: this.clientId,
317
331
  pid: process.pid,
318
332
  label: options.label.slice(0, 128),
319
333
  focusedTrackId: options.focusedTrackId,
@@ -393,13 +407,17 @@ class FilePort<T> implements SessionPort<T> {
393
407
  });
394
408
  };
395
409
  const recordName = basename(this.options.paths.record);
410
+ // macOS coalesces the temp write and the rename into events named after
411
+ // the temp file (`<record>.<pid>.tmp`), so match that too, but not the
412
+ // sibling `.audio.lock` / `.daemon.log` files that share the prefix.
413
+ const recordFile = new RegExp(
414
+ `^${recordName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\.\\d+\\.tmp)?$`,
415
+ );
396
416
  let watcher: FSWatcher | undefined;
397
417
  try {
398
418
  watcher = watch(dirname(this.options.paths.record), (_event, file) => {
399
- // macOS coalesces the temp write and the rename into events named
400
- // after the temp file (`<record>.<pid>.tmp`), so match the prefix and
401
- // re-check shortly after in case the event preceded the rename.
402
- if (file !== null && !String(file).startsWith(recordName)) return;
419
+ // Re-check shortly after in case the event preceded the rename.
420
+ if (file !== null && !recordFile.test(String(file))) return;
403
421
  check();
404
422
  setTimeout(check, 40).unref?.();
405
423
  });
@@ -412,9 +430,11 @@ class FilePort<T> implements SessionPort<T> {
412
430
  watcher = undefined;
413
431
  }
414
432
  const timer = setInterval(check, watcher ? WATCHED_POLL_MS : POLL_MS);
433
+ this.statusListeners.add(listener);
415
434
  check();
416
435
  return () => {
417
436
  closed = true;
437
+ this.statusListeners.delete(listener);
418
438
  clearInterval(timer);
419
439
  watcher?.close();
420
440
  };
@@ -455,7 +475,7 @@ class FilePort<T> implements SessionPort<T> {
455
475
  }
456
476
 
457
477
  public async close(): Promise<void> {
458
- this.player.stop();
478
+ void this.player.dispose();
459
479
  await this.presenceStore.stop();
460
480
  }
461
481
  }
@@ -15,6 +15,7 @@ import {
15
15
  type ScoreOperation,
16
16
  type TrackScore,
17
17
  } from "../../core/score.ts";
18
+ import { rewindComposition, type Rewind } from "./delta.ts";
18
19
 
19
20
  /** How far behind a base may be and still be rebased. */
20
21
  export const MAX_REBASE_DISTANCE = 64;
@@ -105,6 +106,30 @@ export function rebaseOperations(
105
106
  if (base.bars !== current.bars)
106
107
  return { ok: false, reason: "loop length changed" };
107
108
  break;
109
+ case "setKey":
110
+ if (base.key !== current.key)
111
+ return { ok: false, reason: "key changed" };
112
+ break;
113
+ case "setMeter":
114
+ if (base.beatsPerBar !== current.beatsPerBar)
115
+ return { ok: false, reason: "meter changed" };
116
+ break;
117
+ case "removeTrack": {
118
+ if (added.tracks.has(operation.trackId)) break;
119
+ if (
120
+ !settingsUnchanged(operation.trackId) ||
121
+ !notesUnchanged(operation.trackId)
122
+ )
123
+ return { ok: false, reason: `track ${operation.trackId} changed` };
124
+ break;
125
+ }
126
+ case "moveTrack": {
127
+ const order = (score: TrackScore) =>
128
+ score.tracks.map((track) => track.id).join("\u0000");
129
+ if (order(base) !== order(current))
130
+ return { ok: false, reason: "track order changed" };
131
+ break;
132
+ }
108
133
  default:
109
134
  return { ok: false, reason: "unsupported operation" };
110
135
  }
@@ -123,16 +148,21 @@ export function rebaseOperations(
123
148
  }
124
149
 
125
150
  /**
126
- * The composition at revision `revision`, recovered from the `before` of the
127
- * event that replaced it. Undefined when the log cannot vouch for it.
151
+ * The composition at revision `revision`, recovered by rewinding the current
152
+ * composition through every later event's `rewind`. Undefined when the log
153
+ * cannot vouch for it (a gap in revisions, or a rewind compacted away).
128
154
  */
129
155
  export function compositionAt(
130
- events: readonly { revision: number; payload: unknown }[],
156
+ record: {
157
+ composition: unknown;
158
+ events: readonly { revision: number; rewind?: Rewind | undefined }[];
159
+ },
131
160
  revision: number,
132
161
  ): unknown {
133
- const event = events[revision];
134
- if (event === undefined || event.revision !== revision + 1) return undefined;
135
- const payload = event.payload;
136
- if (typeof payload !== "object" || payload === null) return undefined;
137
- return (payload as { before?: unknown }).before;
162
+ const { events } = record;
163
+ if (!Number.isSafeInteger(revision) || revision < 0) return undefined;
164
+ if (revision >= events.length) return undefined;
165
+ for (let index = revision; index < events.length; index += 1)
166
+ if (events[index]!.revision !== index + 1) return undefined;
167
+ return rewindComposition(record.composition, events, revision);
138
168
  }