@hraness/dawg 0.6.1 → 0.8.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 +118 -0
- package/DAWG.md +454 -140
- package/README.md +30 -26
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +184 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/ids.ts +52 -0
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +8 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/range.ts +581 -0
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +955 -14
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +287 -5
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +2132 -46
- package/core/sections.ts +480 -35
- package/core/sing.ts +815 -0
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +31 -0
- package/guides/audio.md +31 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -7
- package/guides/chords.md +15 -15
- package/guides/effects.md +17 -16
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +15 -7
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/panes.md +32 -0
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +26 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +18 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -13
- package/guides/styles.md +31 -0
- package/guides/tape.md +32 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +6 -2
- package/src/agent/agent.ts +130 -18
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +378 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +1 -1
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +27 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +25 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +4 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/range-tools.ts +216 -0
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +110 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +13 -10
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +49 -5
- package/src/audio/audio-command.ts +218 -0
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/devices.ts +264 -0
- package/src/audio/doctor.ts +157 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +10 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/effects/chain.ts +3 -1
- package/src/audio/effects/common.ts +43 -0
- package/src/audio/effects/convolution.ts +7 -4
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/engine.ts +360 -40
- package/src/audio/fit.ts +35 -3
- package/src/audio/instrument-check.ts +59 -43
- package/src/audio/instruments.ts +4 -0
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +8 -1
- package/src/audio/keys/engine.ts +13 -1
- package/src/audio/keys/piano.ts +22 -2
- package/src/audio/kits.ts +135 -6
- package/src/audio/live.ts +114 -20
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +30 -2
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +2 -1
- package/src/audio/sampler.ts +85 -4
- package/src/audio/samples.ts +20 -2
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/bow.ts +48 -5
- package/src/audio/strings/engine.ts +8 -2
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +500 -77
- package/src/audio/winds/engine.ts +5 -1
- package/src/audio/winds/trim.ts +28 -4
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +15 -2
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +82 -32
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +1 -1
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +101 -32
- package/src/commands/grammar.ts +560 -0
- package/src/commands/help.ts +774 -366
- package/src/commands/history.ts +139 -10
- package/src/commands/keys.ts +25 -8
- package/src/commands/modal.ts +1 -1
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +135 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +474 -0
- package/src/commands/strum.ts +13 -1
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +617 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +2 -2
- package/src/fs/durable.ts +50 -0
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +511 -0
- package/src/launch-args.ts +267 -0
- package/src/main.ts +2508 -308
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +21 -2
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +20 -1
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +199 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +174 -0
- package/src/session/port.ts +109 -14
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +365 -7
- package/src/session/rebase.ts +63 -12
- package/src/session/receipt.ts +265 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +126 -45
- package/src/tui/arrange-menu.ts +225 -23
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +113 -27
- package/src/tui/fader.ts +282 -42
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +987 -179
- package/src/tui/modal-menu.ts +6 -6
- package/src/tui/performance-menu.ts +5 -2
- package/src/tui/play-chords.ts +4 -2
- package/src/tui/play-mode.ts +15 -1
- package/src/tui/play-session.ts +243 -28
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +3 -3
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +491 -84
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +193 -0
- package/tui/drawer.ts +233 -27
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +143 -65
- package/tui/guide.ts +50 -5
- package/tui/highway.ts +309 -30
- package/tui/hints.ts +197 -0
- package/tui/hits.ts +7 -1
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +80 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +151 -18
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +46 -1
package/src/session/presence.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
readFile,
|
|
5
5
|
rename,
|
|
6
6
|
rm,
|
|
7
|
+
stat,
|
|
7
8
|
writeFile,
|
|
8
9
|
} from "node:fs/promises";
|
|
9
10
|
import { join } from "node:path";
|
|
@@ -34,6 +35,7 @@ export class FilePresence {
|
|
|
34
35
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
35
36
|
private writing: Promise<void> = Promise.resolve();
|
|
36
37
|
private writes = 0;
|
|
38
|
+
private stopped = false;
|
|
37
39
|
|
|
38
40
|
public constructor(paths: SessionPaths, entry: PresenceEntry) {
|
|
39
41
|
this.dir = `${paths.record}.presence`;
|
|
@@ -42,6 +44,7 @@ export class FilePresence {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
public async start(): Promise<void> {
|
|
47
|
+
this.stopped = false;
|
|
45
48
|
await this.write();
|
|
46
49
|
this.timer = setInterval(
|
|
47
50
|
() => void this.write().catch(() => {}),
|
|
@@ -51,8 +54,12 @@ export class FilePresence {
|
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
public async stop(): Promise<void> {
|
|
57
|
+
// A heartbeat already queued would otherwise rename its file back after
|
|
58
|
+
// the removal and leave a ghost entry.
|
|
59
|
+
this.stopped = true;
|
|
54
60
|
if (this.timer) clearInterval(this.timer);
|
|
55
61
|
this.timer = undefined;
|
|
62
|
+
await this.writing;
|
|
56
63
|
await rm(this.file(), { force: true });
|
|
57
64
|
}
|
|
58
65
|
|
|
@@ -101,14 +108,17 @@ export class FilePresence {
|
|
|
101
108
|
|
|
102
109
|
/** Live entries; stale or dead-process heartbeats are removed. */
|
|
103
110
|
public async list(now = Date.now()): Promise<PresenceEntry[]> {
|
|
104
|
-
let
|
|
111
|
+
let all: string[];
|
|
105
112
|
try {
|
|
106
|
-
|
|
107
|
-
name.endsWith(".json"),
|
|
108
|
-
);
|
|
113
|
+
all = await readdir(this.dir);
|
|
109
114
|
} catch {
|
|
110
115
|
return [];
|
|
111
116
|
}
|
|
117
|
+
// A writer that crashed between write and rename leaves its temp file.
|
|
118
|
+
for (const name of all.slice(0, MAX_PRESENCE_FILES))
|
|
119
|
+
if (name.endsWith(".tmp"))
|
|
120
|
+
await sweepTemporary(join(this.dir, name), now);
|
|
121
|
+
const names = all.filter((name) => name.endsWith(".json"));
|
|
112
122
|
const entries: PresenceEntry[] = [];
|
|
113
123
|
for (const name of names.slice(0, MAX_PRESENCE_FILES)) {
|
|
114
124
|
const path = join(this.dir, name);
|
|
@@ -147,6 +157,7 @@ export class FilePresence {
|
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
private async writeNow(): Promise<void> {
|
|
160
|
+
if (this.stopped) return;
|
|
150
161
|
await mkdir(this.dir, { recursive: true });
|
|
151
162
|
const temporary = `${this.file()}.${process.pid}.${++this.writes}.tmp`;
|
|
152
163
|
await writeFile(
|
|
@@ -154,10 +165,29 @@ export class FilePresence {
|
|
|
154
165
|
JSON.stringify({ ...this.entry, at: Date.now() }),
|
|
155
166
|
"utf8",
|
|
156
167
|
);
|
|
168
|
+
if (this.stopped) {
|
|
169
|
+
await rm(temporary, { force: true });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
157
172
|
await rename(temporary, this.file());
|
|
158
173
|
}
|
|
159
174
|
}
|
|
160
175
|
|
|
176
|
+
/** Removes a temp file left by a dead writer, or one older than the stale window. */
|
|
177
|
+
async function sweepTemporary(path: string, now: number): Promise<void> {
|
|
178
|
+
const pid = Number(/\.(\d+)\.\d+\.tmp$/.exec(path)?.[1]);
|
|
179
|
+
try {
|
|
180
|
+
const { mtimeMs } = await stat(path);
|
|
181
|
+
if (
|
|
182
|
+
now - mtimeMs > PRESENCE_STALE_MS ||
|
|
183
|
+
(Number.isSafeInteger(pid) && pid > 0 && !alive(pid))
|
|
184
|
+
)
|
|
185
|
+
await rm(path, { force: true });
|
|
186
|
+
} catch {
|
|
187
|
+
// Already renamed or removed by its writer.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
161
191
|
function alive(pid: number): boolean {
|
|
162
192
|
try {
|
|
163
193
|
process.kill(pid, 0);
|
package/src/session/protocol.ts
CHANGED
|
@@ -12,12 +12,38 @@ import {
|
|
|
12
12
|
} from "./meta.ts";
|
|
13
13
|
import type { SessionEvent, SessionPaths, SessionRecord } from "./store.ts";
|
|
14
14
|
|
|
15
|
+
/** Same shape as src/identity/actor.ts ACTOR_ID; kept here to stay leaf. */
|
|
16
|
+
const ACTOR_ID = /^a_[a-z2-7]{22}$/;
|
|
17
|
+
|
|
15
18
|
/**
|
|
16
19
|
* dawgd wire protocol: newline-delimited JSON over a Unix domain socket.
|
|
17
20
|
* Every frame carries `v`; every inbound frame is parsed from `unknown` and
|
|
18
21
|
* bounded before it can reach the reducer, the store, or the transport.
|
|
19
22
|
*/
|
|
20
23
|
export const PROTOCOL_VERSION = 1 as const;
|
|
24
|
+
/**
|
|
25
|
+
* Negotiated protocol revisions. Frames keep `v: 1` (the framing); `hello`
|
|
26
|
+
* offers `{vMin, vMax, caps}` and `welcome` answers with the chosen revision
|
|
27
|
+
* and the daemon's capabilities. A v1-only client sends neither and gets the
|
|
28
|
+
* original behaviour. Revision 2 adds actors, per-client `seq`, ops on
|
|
29
|
+
* events and transport `quantum`.
|
|
30
|
+
*/
|
|
31
|
+
export const PROTOCOL_MIN = 1;
|
|
32
|
+
export const PROTOCOL_MAX = 2;
|
|
33
|
+
/** What this build's dawgd offers in `welcome.caps`. */
|
|
34
|
+
export const DAEMON_CAPS = [
|
|
35
|
+
"actor",
|
|
36
|
+
"seq",
|
|
37
|
+
"ops-log",
|
|
38
|
+
"quantum",
|
|
39
|
+
"opaque-kinds",
|
|
40
|
+
"panes",
|
|
41
|
+
"live",
|
|
42
|
+
] as const;
|
|
43
|
+
const MAX_CAPS = 32;
|
|
44
|
+
const MAX_CAP_LENGTH = 32;
|
|
45
|
+
/** Default musical cycle when a transport frame predates `quantum`. */
|
|
46
|
+
export const DEFAULT_QUANTUM = 4;
|
|
21
47
|
/** Client intents carry at most one composition plus a bounded payload. */
|
|
22
48
|
export const MAX_CLIENT_FRAME_BYTES = 2 * 1024 * 1024;
|
|
23
49
|
/** Snapshots carry a full session record, which the store bounds at 4 MiB. */
|
|
@@ -38,16 +64,98 @@ export type TransportState = {
|
|
|
38
64
|
/** Beat position at `atMs`. */
|
|
39
65
|
beat: number;
|
|
40
66
|
bpm: number;
|
|
41
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* When `beat` held, on the *authority's host clock*: dawgd's monotonic
|
|
69
|
+
* clock expressed on the epoch (timeOrigin + now). On one machine every
|
|
70
|
+
* process shares it; a remote reader must convert with its per-client
|
|
71
|
+
* offset (`SessionPort` applies `clockOffsetMs`, 0 locally) before use.
|
|
72
|
+
*/
|
|
42
73
|
atMs: number;
|
|
74
|
+
/** Beats per phase cycle (bar or loop), Link-style; defaults to 4. */
|
|
75
|
+
quantum: number;
|
|
43
76
|
};
|
|
44
77
|
|
|
45
78
|
/** One live window. dawgd drops the entry when the client disconnects. */
|
|
46
79
|
export type PresenceEntry = {
|
|
47
80
|
clientId: string;
|
|
81
|
+
/** Who is at this window (src/identity/actor.ts); absent for v1 clients. */
|
|
82
|
+
actorId?: string;
|
|
83
|
+
/** Machine-local process id: display only, never identity. */
|
|
48
84
|
pid: number;
|
|
49
85
|
label: string;
|
|
50
86
|
focusedTrackId: string | null;
|
|
87
|
+
/**
|
|
88
|
+
* Pane letter (A, B, C in join order, reused when freed), assigned by
|
|
89
|
+
* dawgd. Display only: identity and per-pane undo key on `clientId`.
|
|
90
|
+
*/
|
|
91
|
+
pane?: string;
|
|
92
|
+
/** What the pane shows (see `PaneView`); absent until it reports. */
|
|
93
|
+
screen?: string;
|
|
94
|
+
/** The drawer parameter or page the pane has open. */
|
|
95
|
+
param?: string;
|
|
96
|
+
/** Recording a pass on `focusedTrackId`, and in which mode. */
|
|
97
|
+
recording?: "overdub" | "replace";
|
|
98
|
+
/** In PLAY (live keys) on `focusedTrackId`. */
|
|
99
|
+
playing?: boolean;
|
|
100
|
+
/** Locked to its screen and track (`pin`). */
|
|
101
|
+
pinned?: boolean;
|
|
102
|
+
/** Follows another pane's focus: its letter, or `*` for the latest. */
|
|
103
|
+
follow?: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The per-pane view fields a client reports with `view`. */
|
|
107
|
+
export type PaneView = Pick<
|
|
108
|
+
PresenceEntry,
|
|
109
|
+
"screen" | "param" | "recording" | "playing" | "pinned" | "follow"
|
|
110
|
+
>;
|
|
111
|
+
|
|
112
|
+
/** A live click: on while any pane wants it; count-in from the last arm. */
|
|
113
|
+
export type LiveClick = {
|
|
114
|
+
on: boolean;
|
|
115
|
+
volume: number;
|
|
116
|
+
/** A count-in before the transport starts, on the authority's clock. */
|
|
117
|
+
countIn?: {
|
|
118
|
+
startAtMs: number;
|
|
119
|
+
startBeat: number;
|
|
120
|
+
beats: number;
|
|
121
|
+
barBeats: number;
|
|
122
|
+
clickBeats: number;
|
|
123
|
+
bpm: number;
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Live notes go to the one engine on this machine. A window never sends
|
|
129
|
+
* audio: dawgd renders the voice from the score it already holds.
|
|
130
|
+
*/
|
|
131
|
+
export type LiveMessage =
|
|
132
|
+
| { v: 1; type: "live"; action: "monitor"; id: string; on: boolean }
|
|
133
|
+
| {
|
|
134
|
+
v: 1;
|
|
135
|
+
type: "live";
|
|
136
|
+
action: "on";
|
|
137
|
+
/** Window-local voice id; dawgd namespaces it by client. */
|
|
138
|
+
voice: number;
|
|
139
|
+
trackId: string;
|
|
140
|
+
pitch: number;
|
|
141
|
+
/** 0..1, as score notes store it. */
|
|
142
|
+
velocity: number;
|
|
143
|
+
/** How long the key is held (or the longest live note). */
|
|
144
|
+
seconds: number;
|
|
145
|
+
/** Transport beat of the press, unwrapped. */
|
|
146
|
+
beat: number;
|
|
147
|
+
/** When pressed, on the authority's clock (local clock + offset). */
|
|
148
|
+
atMs: number;
|
|
149
|
+
}
|
|
150
|
+
| { v: 1; type: "live"; action: "off"; voice: number; atMs: number }
|
|
151
|
+
| ({ v: 1; type: "live"; action: "click" } & LiveClick);
|
|
152
|
+
|
|
153
|
+
/** dawgd's answer to `live monitor`: how the shared engine sounds. */
|
|
154
|
+
export type LiveStatus = {
|
|
155
|
+
canMonitor: boolean;
|
|
156
|
+
sampleRate: number;
|
|
157
|
+
leadMs: number;
|
|
158
|
+
note?: string;
|
|
51
159
|
};
|
|
52
160
|
|
|
53
161
|
export type ClientMessage =
|
|
@@ -58,6 +166,12 @@ export type ClientMessage =
|
|
|
58
166
|
label: string;
|
|
59
167
|
clientId: string;
|
|
60
168
|
focusedTrackId: string | null;
|
|
169
|
+
/** Protocol revisions this client speaks; absent means 1..1. */
|
|
170
|
+
vMin?: number;
|
|
171
|
+
vMax?: number;
|
|
172
|
+
caps?: string[];
|
|
173
|
+
actorId?: string;
|
|
174
|
+
actorName?: string;
|
|
61
175
|
}
|
|
62
176
|
| { v: 1; type: "focus"; id: string; trackId: string | null }
|
|
63
177
|
/** `draft` asks dawgd to reserve a new `track-N` id when all are open. */
|
|
@@ -72,6 +186,12 @@ export type ClientMessage =
|
|
|
72
186
|
base: number;
|
|
73
187
|
kind: string;
|
|
74
188
|
payload: unknown;
|
|
189
|
+
/**
|
|
190
|
+
* Per-(actor, client) sequence number, strictly increasing per intent
|
|
191
|
+
* and reused on retry: dawgd dedupes on (actorId, clientId, seq) across
|
|
192
|
+
* restarts, the same way it dedupes on `key`.
|
|
193
|
+
*/
|
|
194
|
+
seq?: number;
|
|
75
195
|
operations?: unknown[];
|
|
76
196
|
composition?: unknown;
|
|
77
197
|
}
|
|
@@ -84,7 +204,10 @@ export type ClientMessage =
|
|
|
84
204
|
bpm?: number;
|
|
85
205
|
}
|
|
86
206
|
| { v: 1; type: "sync"; id: string }
|
|
87
|
-
| { v: 1; type: "ping"; id: string }
|
|
207
|
+
| { v: 1; type: "ping"; id: string }
|
|
208
|
+
/** Updates this pane's presence view fields; no reply. */
|
|
209
|
+
| ({ v: 1; type: "view" } & PaneView)
|
|
210
|
+
| LiveMessage;
|
|
88
211
|
|
|
89
212
|
export type ApplyResult =
|
|
90
213
|
| { status: "accepted"; revision: number }
|
|
@@ -106,6 +229,9 @@ export type ServerMessage =
|
|
|
106
229
|
record: SessionRecord<unknown>;
|
|
107
230
|
digest: string;
|
|
108
231
|
transport: TransportState;
|
|
232
|
+
/** Negotiated revision; absent from a v1-only daemon. */
|
|
233
|
+
protocol?: number;
|
|
234
|
+
caps?: string[];
|
|
109
235
|
}
|
|
110
236
|
| {
|
|
111
237
|
v: 1;
|
|
@@ -133,6 +259,7 @@ export type ServerMessage =
|
|
|
133
259
|
}
|
|
134
260
|
| ({ v: 1; type: "result"; id: string } & ApplyResult)
|
|
135
261
|
| { v: 1; type: "pong"; id: string }
|
|
262
|
+
| ({ v: 1; type: "liveStatus"; id: string } & LiveStatus)
|
|
136
263
|
| { v: 1; type: "error"; code: string; message: string };
|
|
137
264
|
|
|
138
265
|
export class ProtocolError extends Error {
|
|
@@ -192,7 +319,7 @@ export function parseClientMessage(line: string): ClientMessage {
|
|
|
192
319
|
throw new ProtocolError("invalid", "hello pid is invalid");
|
|
193
320
|
if (typeof label !== "string" || label.length > MAX_KIND_LENGTH)
|
|
194
321
|
throw new ProtocolError("invalid", "hello label is invalid");
|
|
195
|
-
|
|
322
|
+
const message: ClientMessage = {
|
|
196
323
|
v: 1,
|
|
197
324
|
type,
|
|
198
325
|
pid: pid as number,
|
|
@@ -200,6 +327,31 @@ export function parseClientMessage(line: string): ClientMessage {
|
|
|
200
327
|
clientId: requireId(value.clientId, "client id"),
|
|
201
328
|
focusedTrackId: optionalTrackId(value.focusedTrackId),
|
|
202
329
|
};
|
|
330
|
+
if (value.vMin !== undefined || value.vMax !== undefined) {
|
|
331
|
+
const vMin = value.vMin ?? 1;
|
|
332
|
+
const vMax = value.vMax ?? vMin;
|
|
333
|
+
if (
|
|
334
|
+
!Number.isSafeInteger(vMin) ||
|
|
335
|
+
!Number.isSafeInteger(vMax) ||
|
|
336
|
+
(vMin as number) < 1 ||
|
|
337
|
+
(vMax as number) < (vMin as number)
|
|
338
|
+
)
|
|
339
|
+
throw new ProtocolError("invalid", "hello versions are invalid");
|
|
340
|
+
message.vMin = vMin as number;
|
|
341
|
+
message.vMax = vMax as number;
|
|
342
|
+
}
|
|
343
|
+
if (value.caps !== undefined) message.caps = requireCaps(value.caps);
|
|
344
|
+
if (value.actorId !== undefined) {
|
|
345
|
+
if (typeof value.actorId !== "string" || !ACTOR_ID.test(value.actorId))
|
|
346
|
+
throw new ProtocolError("invalid", "hello actor is invalid");
|
|
347
|
+
message.actorId = value.actorId;
|
|
348
|
+
}
|
|
349
|
+
if (value.actorName !== undefined) {
|
|
350
|
+
if (typeof value.actorName !== "string")
|
|
351
|
+
throw new ProtocolError("invalid", "hello actor name is invalid");
|
|
352
|
+
message.actorName = value.actorName.slice(0, MAX_ID_LENGTH);
|
|
353
|
+
}
|
|
354
|
+
return message;
|
|
203
355
|
}
|
|
204
356
|
if (type === "focus")
|
|
205
357
|
return {
|
|
@@ -267,6 +419,11 @@ export function parseClientMessage(line: string): ClientMessage {
|
|
|
267
419
|
kind,
|
|
268
420
|
payload: value.payload,
|
|
269
421
|
};
|
|
422
|
+
if (value.seq !== undefined) {
|
|
423
|
+
if (!Number.isSafeInteger(value.seq) || (value.seq as number) < 1)
|
|
424
|
+
throw new ProtocolError("invalid", "apply seq is invalid");
|
|
425
|
+
message.seq = value.seq as number;
|
|
426
|
+
}
|
|
270
427
|
if (hasOperations) {
|
|
271
428
|
if (
|
|
272
429
|
!Array.isArray(value.operations) ||
|
|
@@ -304,9 +461,129 @@ export function parseClientMessage(line: string): ClientMessage {
|
|
|
304
461
|
}
|
|
305
462
|
if (type === "sync" || type === "ping")
|
|
306
463
|
return { v: 1, type, id: requireId(value.id, "request id") };
|
|
464
|
+
if (type === "view") return { v: 1, type, ...parsePaneView(value) };
|
|
465
|
+
if (type === "live") return parseLive(value);
|
|
307
466
|
throw new ProtocolError("unknown-type", "unknown message type");
|
|
308
467
|
}
|
|
309
468
|
|
|
469
|
+
const SCREEN = /^[a-z][a-z0-9-]{0,31}$/;
|
|
470
|
+
const PANE_LETTER = /^[A-Z]$/;
|
|
471
|
+
|
|
472
|
+
/** The view fields of a `view` frame or a presence entry; junk is dropped. */
|
|
473
|
+
export function parsePaneView(value: Record<string, unknown>): PaneView {
|
|
474
|
+
const view: PaneView = {};
|
|
475
|
+
if (typeof value.screen === "string" && SCREEN.test(value.screen))
|
|
476
|
+
view.screen = value.screen;
|
|
477
|
+
if (typeof value.param === "string" && value.param.length > 0)
|
|
478
|
+
view.param = value.param.slice(0, MAX_ID_LENGTH);
|
|
479
|
+
if (value.recording === "overdub" || value.recording === "replace")
|
|
480
|
+
view.recording = value.recording;
|
|
481
|
+
if (value.playing === true) view.playing = true;
|
|
482
|
+
if (value.pinned === true) view.pinned = true;
|
|
483
|
+
if (
|
|
484
|
+
typeof value.follow === "string" &&
|
|
485
|
+
(value.follow === "*" || PANE_LETTER.test(value.follow))
|
|
486
|
+
)
|
|
487
|
+
view.follow = value.follow;
|
|
488
|
+
return view;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function parseLive(value: Record<string, unknown>): LiveMessage {
|
|
492
|
+
const action = value.action;
|
|
493
|
+
if (action === "monitor") {
|
|
494
|
+
if (typeof value.on !== "boolean")
|
|
495
|
+
throw new ProtocolError("invalid", "live monitor is invalid");
|
|
496
|
+
return {
|
|
497
|
+
v: 1,
|
|
498
|
+
type: "live",
|
|
499
|
+
action,
|
|
500
|
+
id: requireId(value.id, "request id"),
|
|
501
|
+
on: value.on,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
if (action === "on") {
|
|
505
|
+
const voice = value.voice;
|
|
506
|
+
const pitch = value.pitch;
|
|
507
|
+
if (
|
|
508
|
+
!Number.isSafeInteger(voice) ||
|
|
509
|
+
!Number.isSafeInteger(pitch) ||
|
|
510
|
+
(pitch as number) < 0 ||
|
|
511
|
+
(pitch as number) > 127 ||
|
|
512
|
+
!isFiniteNonNegative(value.velocity) ||
|
|
513
|
+
(value.velocity as number) > 1 ||
|
|
514
|
+
!isFiniteNonNegative(value.seconds) ||
|
|
515
|
+
(value.seconds as number) > 600 ||
|
|
516
|
+
!Number.isFinite(value.beat) ||
|
|
517
|
+
!isFiniteNonNegative(value.atMs)
|
|
518
|
+
)
|
|
519
|
+
throw new ProtocolError("invalid", "live note is invalid");
|
|
520
|
+
return {
|
|
521
|
+
v: 1,
|
|
522
|
+
type: "live",
|
|
523
|
+
action,
|
|
524
|
+
voice: voice as number,
|
|
525
|
+
trackId: requireId(value.trackId, "track id"),
|
|
526
|
+
pitch: pitch as number,
|
|
527
|
+
velocity: value.velocity as number,
|
|
528
|
+
seconds: value.seconds as number,
|
|
529
|
+
beat: value.beat as number,
|
|
530
|
+
atMs: value.atMs as number,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
if (action === "off") {
|
|
534
|
+
if (!Number.isSafeInteger(value.voice) || !isFiniteNonNegative(value.atMs))
|
|
535
|
+
throw new ProtocolError("invalid", "live note-off is invalid");
|
|
536
|
+
return {
|
|
537
|
+
v: 1,
|
|
538
|
+
type: "live",
|
|
539
|
+
action,
|
|
540
|
+
voice: value.voice as number,
|
|
541
|
+
atMs: value.atMs as number,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
if (action === "click") {
|
|
545
|
+
if (
|
|
546
|
+
typeof value.on !== "boolean" ||
|
|
547
|
+
!isFiniteNonNegative(value.volume) ||
|
|
548
|
+
(value.volume as number) > 1
|
|
549
|
+
)
|
|
550
|
+
throw new ProtocolError("invalid", "live click is invalid");
|
|
551
|
+
const message: LiveMessage = {
|
|
552
|
+
v: 1,
|
|
553
|
+
type: "live",
|
|
554
|
+
action,
|
|
555
|
+
on: value.on,
|
|
556
|
+
volume: value.volume as number,
|
|
557
|
+
};
|
|
558
|
+
const count = value.countIn as Record<string, unknown> | undefined;
|
|
559
|
+
if (count !== undefined) {
|
|
560
|
+
if (
|
|
561
|
+
typeof count !== "object" ||
|
|
562
|
+
count === null ||
|
|
563
|
+
!isFiniteNonNegative(count.startAtMs) ||
|
|
564
|
+
!Number.isFinite(count.startBeat) ||
|
|
565
|
+
!isFiniteNonNegative(count.beats) ||
|
|
566
|
+
!isFiniteNonNegative(count.barBeats) ||
|
|
567
|
+
!isFiniteNonNegative(count.clickBeats) ||
|
|
568
|
+
!isFiniteNonNegative(count.bpm) ||
|
|
569
|
+
(count.bpm as number) <= 0 ||
|
|
570
|
+
(count.beats as number) > 64
|
|
571
|
+
)
|
|
572
|
+
throw new ProtocolError("invalid", "live count-in is invalid");
|
|
573
|
+
message.countIn = {
|
|
574
|
+
startAtMs: count.startAtMs as number,
|
|
575
|
+
startBeat: count.startBeat as number,
|
|
576
|
+
beats: count.beats as number,
|
|
577
|
+
barBeats: count.barBeats as number,
|
|
578
|
+
clickBeats: count.clickBeats as number,
|
|
579
|
+
bpm: count.bpm as number,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
return message;
|
|
583
|
+
}
|
|
584
|
+
throw new ProtocolError("invalid", "live action is invalid");
|
|
585
|
+
}
|
|
586
|
+
|
|
310
587
|
export function parseServerMessage(line: string): ServerMessage {
|
|
311
588
|
const value = parseObject(line);
|
|
312
589
|
const type = value.type;
|
|
@@ -315,7 +592,7 @@ export function parseServerMessage(line: string): ServerMessage {
|
|
|
315
592
|
throw new ProtocolError("invalid", "welcome session is invalid");
|
|
316
593
|
if (!Number.isSafeInteger(value.pid))
|
|
317
594
|
throw new ProtocolError("invalid", "welcome pid is invalid");
|
|
318
|
-
|
|
595
|
+
const message: ServerMessage = {
|
|
319
596
|
v: 1,
|
|
320
597
|
type,
|
|
321
598
|
sessionId: value.sessionId,
|
|
@@ -324,6 +601,16 @@ export function parseServerMessage(line: string): ServerMessage {
|
|
|
324
601
|
digest: requireDigest(value.digest),
|
|
325
602
|
transport: requireTransport(value.transport),
|
|
326
603
|
};
|
|
604
|
+
if (value.protocol !== undefined) {
|
|
605
|
+
if (
|
|
606
|
+
!Number.isSafeInteger(value.protocol) ||
|
|
607
|
+
(value.protocol as number) < 1
|
|
608
|
+
)
|
|
609
|
+
throw new ProtocolError("invalid", "welcome protocol is invalid");
|
|
610
|
+
message.protocol = value.protocol as number;
|
|
611
|
+
}
|
|
612
|
+
if (value.caps !== undefined) message.caps = requireCaps(value.caps);
|
|
613
|
+
return message;
|
|
327
614
|
}
|
|
328
615
|
if (type === "commit") {
|
|
329
616
|
const event = value.event;
|
|
@@ -408,6 +695,24 @@ export function parseServerMessage(line: string): ServerMessage {
|
|
|
408
695
|
}
|
|
409
696
|
if (type === "pong")
|
|
410
697
|
return { v: 1, type, id: requireId(value.id, "request id") };
|
|
698
|
+
if (type === "liveStatus") {
|
|
699
|
+
if (
|
|
700
|
+
typeof value.canMonitor !== "boolean" ||
|
|
701
|
+
!isFiniteNonNegative(value.sampleRate) ||
|
|
702
|
+
!isFiniteNonNegative(value.leadMs)
|
|
703
|
+
)
|
|
704
|
+
throw new ProtocolError("invalid", "live status is invalid");
|
|
705
|
+
const message: ServerMessage = {
|
|
706
|
+
v: 1,
|
|
707
|
+
type,
|
|
708
|
+
id: requireId(value.id, "request id"),
|
|
709
|
+
canMonitor: value.canMonitor,
|
|
710
|
+
sampleRate: value.sampleRate as number,
|
|
711
|
+
leadMs: value.leadMs as number,
|
|
712
|
+
};
|
|
713
|
+
if (typeof value.note === "string") message.note = boundedText(value.note);
|
|
714
|
+
return message;
|
|
715
|
+
}
|
|
411
716
|
if (type === "error")
|
|
412
717
|
return {
|
|
413
718
|
v: 1,
|
|
@@ -487,12 +792,45 @@ export function requirePresence(value: unknown): PresenceEntry {
|
|
|
487
792
|
const entry = value as Record<string, unknown>;
|
|
488
793
|
if (!Number.isSafeInteger(entry.pid) || (entry.pid as number) <= 0)
|
|
489
794
|
throw new ProtocolError("invalid", "presence pid is invalid");
|
|
490
|
-
|
|
795
|
+
const parsed: PresenceEntry = {
|
|
491
796
|
clientId: requireId(entry.clientId, "client id"),
|
|
492
797
|
pid: entry.pid as number,
|
|
493
798
|
label: boundedText(entry.label).slice(0, MAX_KIND_LENGTH),
|
|
494
799
|
focusedTrackId: optionalTrackId(entry.focusedTrackId),
|
|
495
800
|
};
|
|
801
|
+
if (typeof entry.actorId === "string" && ACTOR_ID.test(entry.actorId))
|
|
802
|
+
parsed.actorId = entry.actorId;
|
|
803
|
+
if (typeof entry.pane === "string" && PANE_LETTER.test(entry.pane))
|
|
804
|
+
parsed.pane = entry.pane;
|
|
805
|
+
return { ...parsed, ...parsePaneView(entry) };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Capability names: short tokens. Unknown names are kept (a peer may know
|
|
810
|
+
* more than we do); callers test membership only.
|
|
811
|
+
*/
|
|
812
|
+
function requireCaps(value: unknown): string[] {
|
|
813
|
+
if (!Array.isArray(value) || value.length > MAX_CAPS)
|
|
814
|
+
throw new ProtocolError("invalid", "caps are invalid");
|
|
815
|
+
return value.map((cap) => {
|
|
816
|
+
if (
|
|
817
|
+
typeof cap !== "string" ||
|
|
818
|
+
cap.length === 0 ||
|
|
819
|
+
cap.length > MAX_CAP_LENGTH ||
|
|
820
|
+
!/^[a-z0-9][a-z0-9.-]*$/.test(cap)
|
|
821
|
+
)
|
|
822
|
+
throw new ProtocolError("invalid", "caps are invalid");
|
|
823
|
+
return cap;
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* The revision both sides speak: the highest in the overlap of
|
|
829
|
+
* [vMin, vMax] and [PROTOCOL_MIN, PROTOCOL_MAX], or undefined when none.
|
|
830
|
+
*/
|
|
831
|
+
export function negotiateProtocol(vMin = 1, vMax = vMin): number | undefined {
|
|
832
|
+
const high = Math.min(vMax, PROTOCOL_MAX);
|
|
833
|
+
return high >= Math.max(vMin, PROTOCOL_MIN) ? high : undefined;
|
|
496
834
|
}
|
|
497
835
|
|
|
498
836
|
/**
|
|
@@ -551,7 +889,11 @@ function requireRecord(value: unknown): SessionRecord<unknown> {
|
|
|
551
889
|
!Number.isSafeInteger(record.revision) ||
|
|
552
890
|
typeof record.updatedAt !== "string" ||
|
|
553
891
|
!Array.isArray(record.events) ||
|
|
554
|
-
record.
|
|
892
|
+
(record.folded !== undefined &&
|
|
893
|
+
(!Number.isSafeInteger(record.folded) ||
|
|
894
|
+
(record.folded as number) < 1)) ||
|
|
895
|
+
record.events.length + ((record.folded as number | undefined) ?? 0) !==
|
|
896
|
+
record.revision ||
|
|
555
897
|
record.composition === undefined
|
|
556
898
|
)
|
|
557
899
|
throw new ProtocolError("invalid", "record is invalid");
|
|
@@ -592,7 +934,9 @@ function requireTransport(value: unknown): TransportState {
|
|
|
592
934
|
!isFiniteNonNegative(state.beat) ||
|
|
593
935
|
!isFiniteNonNegative(state.bpm) ||
|
|
594
936
|
(state.bpm as number) <= 0 ||
|
|
595
|
-
!isFiniteNonNegative(state.atMs)
|
|
937
|
+
!isFiniteNonNegative(state.atMs) ||
|
|
938
|
+
(state.quantum !== undefined &&
|
|
939
|
+
(!isFiniteNonNegative(state.quantum) || (state.quantum as number) <= 0))
|
|
596
940
|
)
|
|
597
941
|
throw new ProtocolError("invalid", "transport is invalid");
|
|
598
942
|
return {
|
|
@@ -601,6 +945,7 @@ function requireTransport(value: unknown): TransportState {
|
|
|
601
945
|
beat: state.beat as number,
|
|
602
946
|
bpm: state.bpm as number,
|
|
603
947
|
atMs: state.atMs as number,
|
|
948
|
+
quantum: (state.quantum as number | undefined) ?? DEFAULT_QUANTUM,
|
|
604
949
|
};
|
|
605
950
|
}
|
|
606
951
|
|
|
@@ -612,6 +957,19 @@ function boundedText(value: unknown): string {
|
|
|
612
957
|
return typeof value === "string" ? value.slice(0, 512) : "";
|
|
613
958
|
}
|
|
614
959
|
|
|
960
|
+
/**
|
|
961
|
+
* Rebases a transport anchor from the authority's host clock to this
|
|
962
|
+
* client's. `offsetMs` is (authority clock - local clock): 0 for every
|
|
963
|
+
* process on one machine, measured by a future relay for remote peers.
|
|
964
|
+
*/
|
|
965
|
+
export function toLocalTransport(
|
|
966
|
+
state: TransportState,
|
|
967
|
+
offsetMs: number,
|
|
968
|
+
): TransportState {
|
|
969
|
+
if (offsetMs === 0) return state;
|
|
970
|
+
return { ...state, atMs: Math.max(0, state.atMs - offsetMs) };
|
|
971
|
+
}
|
|
972
|
+
|
|
615
973
|
/** Epoch-anchored monotonic milliseconds, comparable across local processes. */
|
|
616
974
|
export function monotonicEpochMs(): number {
|
|
617
975
|
return performance.timeOrigin + performance.now();
|