@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.
Files changed (277) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DAWG.md +454 -140
  3. package/README.md +30 -26
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +184 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/ids.ts +52 -0
  11. package/core/instruments.ts +19 -0
  12. package/core/keys.ts +3 -3
  13. package/core/loop.ts +8 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/range.ts +581 -0
  17. package/core/resonators.ts +16 -2
  18. package/core/routing.ts +165 -0
  19. package/core/score.ts +955 -14
  20. package/core/sdk/eval-child.ts +7 -2
  21. package/core/sdk/eval.ts +35 -6
  22. package/core/sdk/print.ts +287 -5
  23. package/core/sdk/sync-lyrics.ts +49 -0
  24. package/core/sdk/v1.ts +2132 -46
  25. package/core/sections.ts +480 -35
  26. package/core/sing.ts +815 -0
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/guides/agent.md +29 -0
  50. package/guides/arrange.md +31 -0
  51. package/guides/audio.md +31 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -7
  54. package/guides/chords.md +15 -15
  55. package/guides/effects.md +17 -16
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +15 -7
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/panes.md +32 -0
  65. package/guides/performance.md +15 -13
  66. package/guides/play.md +21 -13
  67. package/guides/project.md +26 -8
  68. package/guides/providers.md +20 -14
  69. package/guides/resample.md +15 -9
  70. package/guides/rhythm.md +18 -13
  71. package/guides/sessions.md +17 -7
  72. package/guides/show-me.md +31 -0
  73. package/guides/sound.md +25 -9
  74. package/guides/sounds.md +15 -13
  75. package/guides/styles.md +31 -0
  76. package/guides/tape.md +32 -0
  77. package/guides/tempo.md +15 -10
  78. package/guides/tracks.md +15 -10
  79. package/guides/tuning.md +32 -0
  80. package/guides/voice.md +31 -0
  81. package/guides/web-search.md +18 -8
  82. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  83. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  84. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  85. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  86. package/native/prebuilt/manifest.json +21 -0
  87. package/package.json +6 -2
  88. package/src/agent/agent.ts +130 -18
  89. package/src/agent/calibration-tools.ts +53 -0
  90. package/src/agent/clip-tools.ts +453 -0
  91. package/src/agent/command-agent.ts +378 -0
  92. package/src/agent/drum-tools.ts +2 -2
  93. package/src/agent/expression-tools.ts +1 -1
  94. package/src/agent/gateway.ts +246 -60
  95. package/src/agent/models.ts +53 -12
  96. package/src/agent/ops.ts +27 -1
  97. package/src/agent/pack-tools.ts +1 -1
  98. package/src/agent/planner.ts +25 -0
  99. package/src/agent/portable-schema.ts +80 -0
  100. package/src/agent/preview-tool.ts +4 -1
  101. package/src/agent/provider.ts +22 -8
  102. package/src/agent/range-tools.ts +216 -0
  103. package/src/agent/rhythm-tools.ts +1 -1
  104. package/src/agent/section-tools.ts +1 -1
  105. package/src/agent/show-me.ts +497 -0
  106. package/src/agent/steer.ts +15 -0
  107. package/src/agent/style-tools.ts +217 -0
  108. package/src/agent/tool-error.ts +12 -0
  109. package/src/agent/tools.ts +110 -23
  110. package/src/agent/usage.ts +2 -2
  111. package/src/agent/voice-tools.ts +925 -0
  112. package/src/agent/xcb-agent.ts +13 -10
  113. package/src/argv.ts +38 -0
  114. package/src/audio/analysis.ts +253 -0
  115. package/src/audio/arrange.ts +49 -5
  116. package/src/audio/audio-command.ts +218 -0
  117. package/src/audio/autotune-engine.ts +101 -0
  118. package/src/audio/autotune.ts +640 -0
  119. package/src/audio/clips.ts +240 -0
  120. package/src/audio/devices.ts +264 -0
  121. package/src/audio/doctor.ts +157 -0
  122. package/src/audio/dsp/bandbank.ts +138 -0
  123. package/src/audio/dsp/envelope.ts +10 -0
  124. package/src/audio/dsp/follow.ts +120 -0
  125. package/src/audio/dsp/formant.ts +427 -0
  126. package/src/audio/dsp/glottal.ts +243 -0
  127. package/src/audio/dsp/interp.ts +7 -2
  128. package/src/audio/dsp/lpc.ts +50 -0
  129. package/src/audio/dsp/periodicity.ts +59 -0
  130. package/src/audio/dsp/pitch.ts +995 -0
  131. package/src/audio/dsp/psola.ts +199 -0
  132. package/src/audio/effects/chain.ts +3 -1
  133. package/src/audio/effects/common.ts +43 -0
  134. package/src/audio/effects/convolution.ts +7 -4
  135. package/src/audio/effects/filter.ts +48 -69
  136. package/src/audio/effects/formant.ts +263 -0
  137. package/src/audio/engine.ts +360 -40
  138. package/src/audio/fit.ts +35 -3
  139. package/src/audio/instrument-check.ts +59 -43
  140. package/src/audio/instruments.ts +4 -0
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +8 -1
  143. package/src/audio/keys/engine.ts +13 -1
  144. package/src/audio/keys/piano.ts +22 -2
  145. package/src/audio/kits.ts +135 -6
  146. package/src/audio/live.ts +114 -20
  147. package/src/audio/native.ts +615 -0
  148. package/src/audio/preview.ts +30 -2
  149. package/src/audio/render-worker.ts +2 -0
  150. package/src/audio/renderer.ts +2 -0
  151. package/src/audio/resample.ts +2 -1
  152. package/src/audio/sampler.ts +85 -4
  153. package/src/audio/samples.ts +20 -2
  154. package/src/audio/sing/analysis.ts +193 -0
  155. package/src/audio/sing/engine.ts +949 -0
  156. package/src/audio/strings/bow.ts +48 -5
  157. package/src/audio/strings/engine.ts +8 -2
  158. package/src/audio/synth/oscillators.ts +31 -21
  159. package/src/audio/synth/voice.ts +34 -1
  160. package/src/audio/vocoder/bank.ts +314 -0
  161. package/src/audio/vocoder/carrier.ts +165 -0
  162. package/src/audio/vocoder/control.ts +68 -0
  163. package/src/audio/vocoder/detect.ts +50 -0
  164. package/src/audio/vocoder/index.ts +304 -0
  165. package/src/audio/vocoder/talkbox.ts +143 -0
  166. package/src/audio/wav.ts +500 -77
  167. package/src/audio/winds/engine.ts +5 -1
  168. package/src/audio/winds/trim.ts +28 -4
  169. package/src/audio/winds/trims1.ts +297 -0
  170. package/src/audio/winds/voice.ts +15 -2
  171. package/src/auth/cli.ts +38 -36
  172. package/src/auth/credentials.ts +30 -1
  173. package/src/auth/login.ts +15 -9
  174. package/src/auth/tui.ts +19 -10
  175. package/src/commands/arrange.ts +82 -32
  176. package/src/commands/autotune.ts +421 -0
  177. package/src/commands/calibration.ts +74 -0
  178. package/src/commands/clips.ts +887 -0
  179. package/src/commands/drums.ts +3 -2
  180. package/src/commands/edit.ts +11 -4
  181. package/src/commands/expression.ts +1 -1
  182. package/src/commands/formant.ts +221 -0
  183. package/src/commands/fx.ts +101 -32
  184. package/src/commands/grammar.ts +560 -0
  185. package/src/commands/help.ts +774 -366
  186. package/src/commands/history.ts +139 -10
  187. package/src/commands/keys.ts +25 -8
  188. package/src/commands/modal.ts +1 -1
  189. package/src/commands/music.ts +1 -1
  190. package/src/commands/nearest.ts +53 -0
  191. package/src/commands/pack.ts +9 -2
  192. package/src/commands/param-range.ts +56 -0
  193. package/src/commands/parses.ts +135 -0
  194. package/src/commands/progression.ts +170 -0
  195. package/src/commands/range.ts +763 -0
  196. package/src/commands/resample.ts +13 -10
  197. package/src/commands/rhythm.ts +3 -0
  198. package/src/commands/rig.ts +3 -24
  199. package/src/commands/sample.ts +11 -1
  200. package/src/commands/sing.ts +474 -0
  201. package/src/commands/strum.ts +13 -1
  202. package/src/commands/style.ts +415 -0
  203. package/src/commands/time.ts +6 -3
  204. package/src/commands/tuning.ts +3 -3
  205. package/src/commands/vocal-pitch.ts +617 -0
  206. package/src/commands/vocal.ts +147 -0
  207. package/src/commands/vocoder.ts +627 -0
  208. package/src/commands/wind.ts +2 -2
  209. package/src/fs/durable.ts +50 -0
  210. package/src/identity/actor.ts +127 -0
  211. package/src/lang/glossary.ts +511 -0
  212. package/src/launch-args.ts +267 -0
  213. package/src/main.ts +2508 -308
  214. package/src/media/cli.ts +20 -3
  215. package/src/media/import.ts +3 -1
  216. package/src/project/check.ts +21 -2
  217. package/src/project/clip-pins.ts +72 -0
  218. package/src/project/init.ts +23 -8
  219. package/src/project/sync.ts +418 -86
  220. package/src/render.ts +20 -1
  221. package/src/session/client.ts +107 -3
  222. package/src/session/clipboard.ts +79 -0
  223. package/src/session/daemon.ts +199 -5
  224. package/src/session/live-host.ts +232 -0
  225. package/src/session/meta.ts +14 -0
  226. package/src/session/origin.ts +174 -0
  227. package/src/session/port.ts +109 -14
  228. package/src/session/presence.ts +34 -4
  229. package/src/session/protocol.ts +365 -7
  230. package/src/session/rebase.ts +63 -12
  231. package/src/session/receipt.ts +265 -0
  232. package/src/session/shared-live.ts +131 -0
  233. package/src/session/store.ts +126 -45
  234. package/src/tui/arrange-menu.ts +225 -23
  235. package/src/tui/audition.ts +1 -1
  236. package/src/tui/euclid.ts +113 -27
  237. package/src/tui/fader.ts +282 -42
  238. package/src/tui/granular-menu.ts +2 -4
  239. package/src/tui/knob-fields.ts +107 -0
  240. package/src/tui/knob-map.ts +198 -0
  241. package/src/tui/menu-clips.ts +297 -0
  242. package/src/tui/menu-time.ts +20 -13
  243. package/src/tui/menu-voice.ts +405 -0
  244. package/src/tui/menu.ts +987 -179
  245. package/src/tui/modal-menu.ts +6 -6
  246. package/src/tui/performance-menu.ts +5 -2
  247. package/src/tui/play-chords.ts +4 -2
  248. package/src/tui/play-mode.ts +15 -1
  249. package/src/tui/play-session.ts +243 -28
  250. package/src/tui/sing-menu.ts +278 -0
  251. package/src/tui/style-menu.ts +104 -0
  252. package/src/tui/tape-mode.ts +580 -0
  253. package/src/tui/tape-view.ts +263 -0
  254. package/src/tui/vocoder-menu.ts +244 -0
  255. package/src/tui/wind-menu.ts +3 -3
  256. package/src/version.ts +8 -0
  257. package/src/web/fetch.ts +115 -29
  258. package/tui/activity.ts +180 -9
  259. package/tui/app.ts +491 -84
  260. package/tui/clip-row.ts +132 -0
  261. package/tui/delight.ts +193 -0
  262. package/tui/drawer.ts +233 -27
  263. package/tui/frame-gate.ts +76 -0
  264. package/tui/grammar.ts +143 -65
  265. package/tui/guide.ts +50 -5
  266. package/tui/highway.ts +309 -30
  267. package/tui/hints.ts +197 -0
  268. package/tui/hits.ts +7 -1
  269. package/tui/input.ts +60 -9
  270. package/tui/keys.ts +1 -1
  271. package/tui/knobs.ts +268 -0
  272. package/tui/play-strip.ts +80 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +151 -18
  275. package/tui/tape.ts +439 -0
  276. package/tui/text.ts +35 -2
  277. package/tui/theme.ts +46 -1
@@ -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 names: string[];
111
+ let all: string[];
105
112
  try {
106
- names = (await readdir(this.dir)).filter((name) =>
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);
@@ -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
- /** Daemon monotonic clock expressed on the epoch (timeOrigin + now). */
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
- return {
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
- return {
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
- return {
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.events.length !== record.revision ||
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();