@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.
- package/CHANGELOG.md +201 -0
- package/DAWG.md +762 -11
- package/README.md +79 -30
- package/core/chords.ts +1724 -0
- package/core/diff.ts +261 -0
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/key.ts +43 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/pitch.ts +60 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +1054 -14
- package/core/sdk/eval-child.ts +125 -0
- package/core/sdk/eval.ts +260 -0
- package/core/sdk/print.ts +675 -0
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +4159 -0
- package/core/slug.ts +19 -0
- package/core/synth.ts +1001 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +317 -16
- package/src/agent/brief.ts +50 -5
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +55 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +705 -14
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +35 -14
- package/src/agent/xcb.ts +233 -21
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +337 -0
- package/src/audio/engine.ts +430 -33
- package/src/audio/kits.ts +200 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +420 -0
- package/src/audio/samples.ts +1025 -0
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +550 -334
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- 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/drums.ts +299 -0
- package/src/commands/edit.ts +192 -0
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +535 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +34 -15
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +462 -0
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +2136 -170
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +186 -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 +397 -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/media/wavetable.ts +202 -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 +49 -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 +6 -38
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +2125 -0
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +984 -0
- package/src/tui/sketch.ts +108 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +684 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +398 -33
- package/tui/drums.ts +44 -0
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +24 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +188 -0
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
|
|
|
@@ -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;
|
package/src/session/port.ts
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
400
|
-
|
|
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.
|
|
478
|
+
void this.player.dispose();
|
|
459
479
|
await this.presenceStore.stop();
|
|
460
480
|
}
|
|
461
481
|
}
|
package/src/session/rebase.ts
CHANGED
|
@@ -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
|
|
127
|
-
*
|
|
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
|
-
|
|
156
|
+
record: {
|
|
157
|
+
composition: unknown;
|
|
158
|
+
events: readonly { revision: number; rewind?: Rewind | undefined }[];
|
|
159
|
+
},
|
|
131
160
|
revision: number,
|
|
132
161
|
): unknown {
|
|
133
|
-
const
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
}
|