@hraness/dawg 0.7.0 → 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 +26 -0
- package/DAWG.md +117 -30
- package/README.md +7 -5
- package/core/diff.ts +2 -0
- package/core/ids.ts +52 -0
- package/core/loop.ts +3 -0
- package/core/range.ts +581 -0
- package/core/score.ts +123 -1
- package/core/sdk/print.ts +11 -2
- package/core/sdk/v1.ts +281 -2
- package/core/sections.ts +198 -19
- package/guides/arrange.md +9 -8
- package/guides/audio.md +31 -0
- package/guides/mix.md +3 -1
- package/guides/panes.md +32 -0
- package/guides/project.md +3 -2
- package/guides/rhythm.md +2 -1
- package/guides/tape.md +32 -0
- package/package.json +2 -1
- package/src/agent/agent.ts +4 -4
- package/src/agent/command-agent.ts +19 -10
- package/src/agent/ops.ts +15 -0
- package/src/agent/planner.ts +12 -0
- package/src/agent/range-tools.ts +216 -0
- package/src/agent/show-me.ts +1 -1
- package/src/agent/tools.ts +2 -0
- package/src/agent/xcb-agent.ts +2 -3
- package/src/audio/arrange.ts +12 -2
- package/src/audio/audio-command.ts +218 -0
- package/src/audio/devices.ts +264 -0
- package/src/audio/doctor.ts +84 -13
- package/src/audio/engine.ts +216 -28
- package/src/commands/arrange.ts +60 -25
- package/src/commands/grammar.ts +3 -1
- package/src/commands/help.ts +187 -9
- package/src/commands/history.ts +122 -11
- package/src/commands/keys.ts +17 -0
- package/src/commands/parses.ts +6 -1
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +2 -6
- package/src/commands/vocal-pitch.ts +2 -1
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +18 -0
- package/src/launch-args.ts +105 -1
- package/src/main.ts +1142 -86
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +196 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/origin.ts +20 -0
- package/src/session/port.ts +87 -10
- package/src/session/protocol.ts +360 -6
- package/src/session/rebase.ts +46 -8
- package/src/session/receipt.ts +7 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +61 -2
- package/src/tui/arrange-menu.ts +169 -1
- package/src/tui/euclid.ts +95 -14
- package/src/tui/fader.ts +55 -2
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- package/src/tui/menu.ts +228 -2
- package/src/tui/play-session.ts +196 -21
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/tui/app.ts +217 -44
- package/tui/delight.ts +49 -0
- package/tui/drawer.ts +167 -9
- package/tui/grammar.ts +34 -5
- package/tui/guide.ts +8 -1
- package/tui/highway.ts +40 -5
- package/tui/hints.ts +5 -0
- package/tui/hits.ts +7 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +12 -0
- package/tui/screen.ts +7 -4
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +19 -1
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
|
/**
|
|
@@ -596,7 +934,9 @@ function requireTransport(value: unknown): TransportState {
|
|
|
596
934
|
!isFiniteNonNegative(state.beat) ||
|
|
597
935
|
!isFiniteNonNegative(state.bpm) ||
|
|
598
936
|
(state.bpm as number) <= 0 ||
|
|
599
|
-
!isFiniteNonNegative(state.atMs)
|
|
937
|
+
!isFiniteNonNegative(state.atMs) ||
|
|
938
|
+
(state.quantum !== undefined &&
|
|
939
|
+
(!isFiniteNonNegative(state.quantum) || (state.quantum as number) <= 0))
|
|
600
940
|
)
|
|
601
941
|
throw new ProtocolError("invalid", "transport is invalid");
|
|
602
942
|
return {
|
|
@@ -605,6 +945,7 @@ function requireTransport(value: unknown): TransportState {
|
|
|
605
945
|
beat: state.beat as number,
|
|
606
946
|
bpm: state.bpm as number,
|
|
607
947
|
atMs: state.atMs as number,
|
|
948
|
+
quantum: (state.quantum as number | undefined) ?? DEFAULT_QUANTUM,
|
|
608
949
|
};
|
|
609
950
|
}
|
|
610
951
|
|
|
@@ -616,6 +957,19 @@ function boundedText(value: unknown): string {
|
|
|
616
957
|
return typeof value === "string" ? value.slice(0, 512) : "";
|
|
617
958
|
}
|
|
618
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
|
+
|
|
619
973
|
/** Epoch-anchored monotonic milliseconds, comparable across local processes. */
|
|
620
974
|
export function monotonicEpochMs(): number {
|
|
621
975
|
return performance.timeOrigin + performance.now();
|
package/src/session/rebase.ts
CHANGED
|
@@ -2,17 +2,20 @@
|
|
|
2
2
|
* Server-side rebase of agent operation intents.
|
|
3
3
|
*
|
|
4
4
|
* An agent validates its operations against the score at `base`. When other
|
|
5
|
-
*
|
|
5
|
+
* panes committed in the meantime, dawgd replays the operations on the
|
|
6
6
|
* current score instead of rejecting, but only when nothing they touch
|
|
7
|
-
* changed since `base`: the notes they update or remove, the
|
|
8
|
-
*
|
|
7
|
+
* changed since `base`: the notes they update or remove, the track
|
|
8
|
+
* properties they patch (per track and field, so two panes can set
|
|
9
|
+
* different properties of one track), the tracks whose contents they clear, the tracks they add notes to, and the
|
|
9
10
|
* tempo or length they set. Anything else is a conflict and the caller gets
|
|
10
11
|
* the usual `rebase` reply. The reducer still validates every operation.
|
|
11
12
|
*/
|
|
12
13
|
import {
|
|
13
14
|
applyScoreOperation,
|
|
15
|
+
automationPoints,
|
|
14
16
|
scoreFromJSON,
|
|
15
17
|
type ScoreOperation,
|
|
18
|
+
type Track,
|
|
16
19
|
type TrackScore,
|
|
17
20
|
} from "../../core/score.ts";
|
|
18
21
|
import { rewindComposition, type Rewind } from "./delta.ts";
|
|
@@ -83,12 +86,43 @@ export function rebaseOperations(
|
|
|
83
86
|
return { ok: false, reason: `note ${operation.noteId} changed` };
|
|
84
87
|
break;
|
|
85
88
|
}
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
// Track settings conflict per (track, property): another pane's
|
|
90
|
+
// volume change never blocks this pane's pan, only its volume.
|
|
91
|
+
case "updateTrack": {
|
|
92
|
+
const id = operation.trackId;
|
|
93
|
+
if (added.tracks.has(id)) break;
|
|
94
|
+
const before = trackSettings(base, id);
|
|
95
|
+
const now = trackSettings(current, id);
|
|
96
|
+
if (!now) return { ok: false, reason: `track ${id} was removed` };
|
|
97
|
+
for (const field of Object.keys(operation.patch) as (keyof Track)[])
|
|
98
|
+
if (!same(before?.[field] ?? null, now[field] ?? null))
|
|
99
|
+
return { ok: false, reason: `${id} ${String(field)} changed` };
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
case "setClips": {
|
|
103
|
+
const id = operation.trackId;
|
|
104
|
+
if (added.tracks.has(id)) break;
|
|
105
|
+
const now = trackSettings(current, id);
|
|
106
|
+
if (!now) return { ok: false, reason: `track ${id} was removed` };
|
|
107
|
+
if (!same(trackSettings(base, id)?.clips ?? null, now.clips ?? null))
|
|
108
|
+
return { ok: false, reason: `${id} clips changed` };
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
88
111
|
case "setAutomation": {
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
112
|
+
const id = operation.trackId;
|
|
113
|
+
if (added.tracks.has(id)) break;
|
|
114
|
+
const now = trackSettings(current, id);
|
|
115
|
+
if (!now) return { ok: false, reason: `track ${id} was removed` };
|
|
116
|
+
if (
|
|
117
|
+
!same(
|
|
118
|
+
automationPoints(trackSettings(base, id), operation.parameter),
|
|
119
|
+
automationPoints(now, operation.parameter),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
reason: `${id} ${operation.parameter} automation changed`,
|
|
125
|
+
};
|
|
92
126
|
break;
|
|
93
127
|
}
|
|
94
128
|
case "clearTrack": {
|
|
@@ -144,6 +178,10 @@ export function rebaseOperations(
|
|
|
144
178
|
)
|
|
145
179
|
return { ok: false, reason: "sections changed" };
|
|
146
180
|
break;
|
|
181
|
+
case "setLoop":
|
|
182
|
+
if (!deepEqual(base.loop, current.loop))
|
|
183
|
+
return { ok: false, reason: "loop changed" };
|
|
184
|
+
break;
|
|
147
185
|
case "removeTrack": {
|
|
148
186
|
if (added.tracks.has(operation.trackId)) break;
|
|
149
187
|
if (
|
package/src/session/receipt.ts
CHANGED
|
@@ -151,6 +151,13 @@ export function receiptParts(
|
|
|
151
151
|
case "setSections":
|
|
152
152
|
parts.push(plural(op.sections.length, "section"));
|
|
153
153
|
break;
|
|
154
|
+
case "setLoop":
|
|
155
|
+
parts.push(
|
|
156
|
+
op.loop
|
|
157
|
+
? `loop ${op.loop.startBar + 1}–${op.loop.startBar + op.loop.bars}`
|
|
158
|
+
: "loop off",
|
|
159
|
+
);
|
|
160
|
+
break;
|
|
154
161
|
case "setCalibration":
|
|
155
162
|
break;
|
|
156
163
|
case "addTrack": {
|