@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
package/src/render.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  sectionCues,
37
37
  } from "./audio/arrange.ts";
38
38
  import { SampleLibrary, hasSamplerTracks } from "./audio/samples.ts";
39
+ import { withClipLengths } from "./audio/clips.ts";
39
40
  import {
40
41
  PackStore,
41
42
  creditsLine,
@@ -118,6 +119,14 @@ export async function runRenderCommand(
118
119
  );
119
120
  return 1;
120
121
  }
122
+ // Clips crossing the section's edges are cut against their files' real
123
+ // ends, so read their lengths first (load problems are reported below).
124
+ if (score.tracks.some((track) => track.clips)) {
125
+ const bank = await new SampleLibrary({ projectRoot: workspace }).load(
126
+ score,
127
+ );
128
+ score = withClipLengths(score, bank);
129
+ }
121
130
  // One section as it plays when looped: its mutes and variations, no form.
122
131
  score = withNoteCap(BAKED_NOTE_CAP, () =>
123
132
  sectionScore(bakeTrackTime(score), section),
@@ -226,7 +235,13 @@ export async function runRenderCommand(
226
235
  const sha = createHash("sha256").update(wav).digest("hex");
227
236
  const seconds = audio.frames / audio.sampleRate;
228
237
  stdout.write(
229
- `rendered · ${target} · ${seconds.toFixed(2)} s · ${wav.byteLength} bytes · ${sha}\n`,
238
+ `rendered · ${target} · ${seconds.toFixed(2)} s · ${audio.sampleRate} Hz${
239
+ // A plain song keeps 0.4's 22.05 kHz (byte-identical renders); say
240
+ // so, since its ceiling is 11 kHz and cymbal air is lost.
241
+ rateArg === undefined && !score.master
242
+ ? " (--rate 48000 for full band)"
243
+ : ""
244
+ } · ${wav.byteLength} bytes · ${sha}\n`,
230
245
  );
231
246
  if (credits) stdout.write(`credits · ${credits}\n`);
232
247
  const clipped = clippedSamples(audio.pcm);
@@ -238,6 +253,10 @@ export async function runRenderCommand(
238
253
  : "lower track volumes or add a master with a limiter (master streaming, or master: { limiter: {} } in song.ts)"
239
254
  }\n`,
240
255
  );
256
+ if (audio.nonFinite)
257
+ stderr.write(
258
+ `warning · ${audio.nonFinite.samples} non-finite ${audio.nonFinite.samples === 1 ? "sample" : "samples"} zeroed · from ${audio.nonFinite.tracks.join(", ")} · an effect or voice setting is unstable\n`,
259
+ );
241
260
  if (options.has("--measure"))
242
261
  stdout.write(
243
262
  `loudness · ${measurementLine(measureRendered(audio, false).mix, audio.master)}\n`,
@@ -8,8 +8,14 @@ import {
8
8
  LineDecoder,
9
9
  MAX_SERVER_FRAME_BYTES,
10
10
  parseServerMessage,
11
+ PROTOCOL_MAX,
12
+ PROTOCOL_MIN,
13
+ ProtocolError,
11
14
  type ApplyResult,
12
15
  type ClientMessage,
16
+ type LiveMessage,
17
+ type LiveStatus,
18
+ type PaneView,
13
19
  type PresenceEntry,
14
20
  type ServerMessage,
15
21
  type TransportAction,
@@ -34,6 +40,13 @@ export type DaemonClientOptions = {
34
40
  sessionId: string;
35
41
  label?: string;
36
42
  clientId?: string;
43
+ /** Who is at this window; see src/identity/actor.ts. */
44
+ actor?: { id: string; name: string };
45
+ /**
46
+ * Protocol range to offer. Defaults to this build's full range; tests pin
47
+ * `{vMin: 1, vMax: 1}` to act as an old client.
48
+ */
49
+ versions?: { vMin: number; vMax: number };
37
50
  focusedTrackId?: string | null;
38
51
  /** Spawn a detached daemon when none answers. Defaults to true. */
39
52
  spawn?: boolean;
@@ -74,6 +87,11 @@ export class DaemonClient {
74
87
  public transport!: TransportState;
75
88
  public presence: PresenceEntry[] = [];
76
89
  public daemonPid = 0;
90
+ /** Negotiated revision: 1 until a v2 daemon says otherwise. */
91
+ public protocol = 1;
92
+ public caps: readonly string[] = [];
93
+ /** Last intent seq this client issued; retries resend the same one. */
94
+ private seq = 0;
77
95
  private socket: Socket | undefined;
78
96
  private decoder = new LineDecoder(MAX_SERVER_FRAME_BYTES);
79
97
  private readonly pending = new Map<string, Pending>();
@@ -82,6 +100,9 @@ export class DaemonClient {
82
100
  private reconnecting: Promise<void> | undefined;
83
101
  private focusedTrackId: string | null;
84
102
  private welcomed: (() => void) | undefined;
103
+ private view: ClientMessage | undefined;
104
+ private monitoring = false;
105
+ private click: LiveMessage | undefined;
85
106
 
86
107
  private constructor(private readonly options: DaemonClientOptions) {
87
108
  this.paths = sessionPaths(options.workspace, options.sessionId);
@@ -113,6 +134,8 @@ export class DaemonClient {
113
134
  kind: string;
114
135
  payload: unknown;
115
136
  key?: string;
137
+ /** Reuse to retry the same intent; a fresh one is drawn otherwise. */
138
+ seq?: number;
116
139
  operations?: unknown[];
117
140
  composition?: unknown;
118
141
  }): Promise<ApplyResult> {
@@ -125,6 +148,7 @@ export class DaemonClient {
125
148
  kind: intent.kind,
126
149
  payload: intent.payload,
127
150
  };
151
+ if (this.protocol >= 2) message.seq = intent.seq ?? this.nextSeq();
128
152
  if (intent.operations) message.operations = intent.operations;
129
153
  else message.composition = intent.composition;
130
154
  return this.result(await this.request(message));
@@ -209,6 +233,51 @@ export class DaemonClient {
209
233
  );
210
234
  }
211
235
 
236
+ /** True when dawgd runs live notes and the click (`live` capability). */
237
+ public get sharedLive(): boolean {
238
+ return this.caps.includes("live");
239
+ }
240
+
241
+ /** This pane's own presence entry, as dawgd last broadcast it. */
242
+ public get self(): PresenceEntry | undefined {
243
+ return this.presence.find((entry) => entry.clientId === this.clientId);
244
+ }
245
+
246
+ /** Reports this pane's view fields; resent after a reconnect. */
247
+ public setView(view: PaneView): void {
248
+ this.view = { v: 1, type: "view", ...view };
249
+ if (this.connected) this.socket!.write(encodeFrame(this.view));
250
+ }
251
+
252
+ /** Turns this pane's share of the shared live engine on or off. */
253
+ public async liveMonitor(on: boolean): Promise<LiveStatus> {
254
+ this.monitoring = on;
255
+ const reply = await this.request({
256
+ v: 1,
257
+ type: "live",
258
+ action: "monitor",
259
+ id: randomUUID(),
260
+ on,
261
+ });
262
+ if (reply.type !== "liveStatus")
263
+ throw new Error("dawgd did not answer live monitor");
264
+ const { v: _v, type: _type, id: _id, ...status } = reply;
265
+ return status;
266
+ }
267
+
268
+ /** Fire-and-forget live note or click; the click is resent on reconnect. */
269
+ public live(message: Exclude<LiveMessage, { action: "monitor" }>): void {
270
+ if (message.action === "click")
271
+ this.click = message.on || message.countIn ? message : undefined;
272
+ if (this.connected) this.socket!.write(encodeFrame(message));
273
+ }
274
+
275
+ /** The next per-(actor, client) intent number. */
276
+ public nextSeq(): number {
277
+ this.seq += 1;
278
+ return this.seq;
279
+ }
280
+
212
281
  public close(): void {
213
282
  this.closed = true;
214
283
  for (const [id, pending] of this.pending) {
@@ -334,7 +403,21 @@ export class DaemonClient {
334
403
  socket.destroy();
335
404
  this.onClose(socket);
336
405
  });
337
- // Resend everything that was unanswered on the previous socket.
406
+ // Resend everything that was unanswered on the previous socket,
407
+ // after this pane's view and live state (a respawned dawgd knows
408
+ // neither).
409
+ if (this.view) socket.write(encodeFrame(this.view));
410
+ if (this.monitoring)
411
+ socket.write(
412
+ encodeFrame({
413
+ v: 1,
414
+ type: "live",
415
+ action: "monitor",
416
+ id: randomUUID(),
417
+ on: true,
418
+ }),
419
+ );
420
+ if (this.click) socket.write(encodeFrame(this.click));
338
421
  for (const pending of this.pending.values())
339
422
  socket.write(pending.frame);
340
423
  resolve();
@@ -348,6 +431,17 @@ export class DaemonClient {
348
431
  label: (this.options.label ?? "dawg").slice(0, 128),
349
432
  clientId: this.clientId,
350
433
  focusedTrackId: this.focusedTrackId,
434
+ ...(this.options.versions ?? {
435
+ vMin: PROTOCOL_MIN,
436
+ vMax: PROTOCOL_MAX,
437
+ }),
438
+ caps: ["actor", "seq", "quantum", "panes", "live"],
439
+ ...(this.options.actor
440
+ ? {
441
+ actorId: this.options.actor.id,
442
+ actorName: this.options.actor.name,
443
+ }
444
+ : {}),
351
445
  }),
352
446
  );
353
447
  });
@@ -368,7 +462,11 @@ export class DaemonClient {
368
462
  let message: ServerMessage;
369
463
  try {
370
464
  message = parseServerMessage(line);
371
- } catch {
465
+ } catch (error) {
466
+ // A newer daemon's message types are skipped, not fatal; a malformed
467
+ // frame of a known type still drops the connection.
468
+ if (error instanceof ProtocolError && error.code === "unknown-type")
469
+ continue;
372
470
  socket.destroy();
373
471
  return;
374
472
  }
@@ -379,6 +477,8 @@ export class DaemonClient {
379
477
  private onMessage(message: ServerMessage): void {
380
478
  if (message.type === "welcome") {
381
479
  this.daemonPid = message.pid;
480
+ this.protocol = message.protocol ?? 1;
481
+ this.caps = message.caps ?? [];
382
482
  this.record = message.record;
383
483
  this.digest = message.digest;
384
484
  this.transport = message.transport;
@@ -443,7 +543,11 @@ export class DaemonClient {
443
543
  this.emit({
444
544
  type: "status",
445
545
  connected: this.connected,
446
- message: `dawgd error · ${message.code}`,
546
+ // Audio notices (a lost output, a dying player) carry their words.
547
+ message:
548
+ message.code === "audio"
549
+ ? `audio · ${message.message}`
550
+ : `dawgd error · ${message.code}`,
447
551
  });
448
552
  return;
449
553
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The per-pane range clipboard on disk (op1-ux §6.3): it lives in `.dawg`
3
+ * next to the session, one file per session and pane letter, so it survives
4
+ * a restart of that pane and is never in the score file.
5
+ */
6
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
7
+ import { dirname, join } from "node:path";
8
+ import type { RangeClipboard } from "../commands/range.ts";
9
+ import { stateDir } from "./store.ts";
10
+
11
+ const SAFE = /^[A-Za-z0-9-]{1,64}$/;
12
+
13
+ /** `.dawg/clipboard/<session>-<pane>.json`; `solo` without a pane letter. */
14
+ export function clipboardPath(
15
+ workspace: string,
16
+ sessionId: string,
17
+ pane: string | undefined,
18
+ ): string | undefined {
19
+ const who = pane ?? "solo";
20
+ if (!SAFE.test(sessionId) || !SAFE.test(who)) return undefined;
21
+ return join(stateDir(workspace), "clipboard", `${sessionId}-${who}.json`);
22
+ }
23
+
24
+ function isBarRange(value: unknown): boolean {
25
+ if (!value || typeof value !== "object") return false;
26
+ const range = value as Record<string, unknown>;
27
+ return (
28
+ Number.isInteger(range.startBar) &&
29
+ Number.isInteger(range.bars) &&
30
+ (range.startBar as number) >= 0 &&
31
+ (range.bars as number) >= 1
32
+ );
33
+ }
34
+
35
+ /** A clipboard read back from disk, or undefined when absent or malformed. */
36
+ export function parseClipboard(text: string): RangeClipboard | undefined {
37
+ try {
38
+ const value = JSON.parse(text) as Record<string, unknown>;
39
+ const clip = value.clip as Record<string, unknown> | undefined;
40
+ if (
41
+ typeof value.source !== "string" ||
42
+ !isBarRange(value.range) ||
43
+ !clip ||
44
+ typeof clip.all !== "boolean" ||
45
+ !Number.isInteger(clip.bars) ||
46
+ !Array.isArray(clip.tracks) ||
47
+ !Array.isArray(clip.barTicks)
48
+ )
49
+ return undefined;
50
+ return value as unknown as RangeClipboard;
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ export async function loadClipboard(
57
+ path: string | undefined,
58
+ ): Promise<RangeClipboard | undefined> {
59
+ if (!path) return undefined;
60
+ try {
61
+ return parseClipboard(await readFile(path, "utf8"));
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ /** Writes (or, for undefined, removes) the pane's clipboard. */
68
+ export async function saveClipboard(
69
+ path: string | undefined,
70
+ board: RangeClipboard | undefined,
71
+ ): Promise<void> {
72
+ if (!path) return;
73
+ if (!board) {
74
+ await rm(path, { force: true });
75
+ return;
76
+ }
77
+ await mkdir(dirname(path), { recursive: true });
78
+ await writeFile(path, `${JSON.stringify(board)}\n`);
79
+ }
@@ -6,9 +6,17 @@ import {
6
6
  type ScoreOperation,
7
7
  type TrackScore,
8
8
  } from "../../core/score.ts";
9
+ import { DiffError, diffScores } from "../../core/diff.ts";
9
10
  import { TransportClock, transportMapFor } from "../audio/clock.ts";
10
11
  import { AudioEngine } from "../audio/engine.ts";
12
+ import {
13
+ SampleLibrary,
14
+ hasSamplerTracks,
15
+ type SampleBank,
16
+ } from "../audio/samples.ts";
17
+ import { PackStore } from "../audio/packs.ts";
11
18
  import { acquireSessionLock } from "./lock.ts";
19
+ import { LiveHost } from "./live-host.ts";
12
20
  import {
13
21
  compositionAt,
14
22
  MAX_REBASE_DISTANCE,
@@ -22,8 +30,12 @@ import {
22
30
  encodeFrame,
23
31
  LineDecoder,
24
32
  MAX_CLIENT_FRAME_BYTES,
33
+ DAEMON_CAPS,
25
34
  monotonicEpochMs,
35
+ negotiateProtocol,
26
36
  parseClientMessage,
37
+ PROTOCOL_MAX,
38
+ PROTOCOL_MIN,
27
39
  ProtocolError,
28
40
  type ApplyResult,
29
41
  chooseTrack,
@@ -39,6 +51,7 @@ import {
39
51
  SessionConflictError,
40
52
  sessionPaths,
41
53
  updateSessionMeta,
54
+ type EventActor,
42
55
  type SessionPaths,
43
56
  type SessionRecord,
44
57
  } from "./store.ts";
@@ -56,8 +69,24 @@ type Client = {
56
69
  decoder: LineDecoder;
57
70
  ready: boolean;
58
71
  presence?: PresenceEntry;
72
+ /** Negotiated protocol revision (1 for clients that sent no range). */
73
+ protocol?: number;
59
74
  };
60
75
 
76
+ /** The lowest free pane letter (A, B, C in join order, reused when freed). */
77
+ export function freePaneLetter(taken: ReadonlySet<string>): string {
78
+ for (let code = 65; code <= 90; code += 1) {
79
+ const letter = String.fromCharCode(code);
80
+ if (!taken.has(letter)) return letter;
81
+ }
82
+ return "Z";
83
+ }
84
+
85
+ /** Retry-dedupe key for an intent: (actor, client, seq). */
86
+ function seqKey(actorId: string | undefined, clientId: string, seq: number) {
87
+ return `${actorId ?? "-"}\u0000${clientId}\u0000${seq}`;
88
+ }
89
+
61
90
  export type DaemonOptions = {
62
91
  workspace: string;
63
92
  sessionId: string;
@@ -75,8 +104,13 @@ export class DawgDaemon {
75
104
  private readonly graceMs: number;
76
105
  private readonly clients = new Set<Client>();
77
106
  private readonly keys = new Map<string, number>();
107
+ /** (actor, client, seq) to the revision it produced; see `seqKey`. */
108
+ private readonly seqs = new Map<string, number>();
78
109
  private readonly clock = new TransportClock();
79
110
  private readonly audio: AudioEngine;
111
+ private readonly live: LiveHost;
112
+ private samples: SampleBank | undefined;
113
+ private sampleLibrary: SampleLibrary | undefined;
80
114
  private record!: SessionRecord<Composition>;
81
115
  private score!: TrackScore;
82
116
  private digest = "";
@@ -111,6 +145,37 @@ export class DawgDaemon {
111
145
  });
112
146
  },
113
147
  });
148
+ this.workspace = options.workspace;
149
+ this.live = new LiveHost({
150
+ sink: this.audio,
151
+ score: () => this.score,
152
+ samples: () => this.samples,
153
+ transportBeatAt: (ms) =>
154
+ this.clock.playing ? this.clock.beatAt(ms) : undefined,
155
+ toMonotonic: (epochMs) => epochMs - performance.timeOrigin,
156
+ log: (line) => void this.log(line),
157
+ });
158
+ }
159
+
160
+ private readonly workspace: string;
161
+
162
+ /** Notes each client played through the shared engine (tests read it). */
163
+ public get livePlayed(): ReadonlyMap<string, number> {
164
+ return this.live.played;
165
+ }
166
+
167
+ /** Decodes sampler voices for live notes, once per score that needs them. */
168
+ private async loadSamples(): Promise<void> {
169
+ if (!hasSamplerTracks(this.score)) return;
170
+ this.sampleLibrary ??= new SampleLibrary({
171
+ projectRoot: this.workspace,
172
+ packs: new PackStore(),
173
+ });
174
+ try {
175
+ this.samples = await this.sampleLibrary.load(this.score);
176
+ } catch (error) {
177
+ await this.log(`live samples: ${String(error)}`);
178
+ }
114
179
  }
115
180
 
116
181
  /** Returns false when another live daemon already owns this session. */
@@ -229,7 +294,10 @@ export class DawgDaemon {
229
294
  const drop = () => {
230
295
  if (!this.clients.delete(client)) return;
231
296
  socket.destroy();
232
- if (client.presence) this.broadcastPresence();
297
+ if (client.presence) {
298
+ void this.live.drop(client.presence.clientId);
299
+ this.broadcastPresence();
300
+ }
233
301
  if (this.clients.size === 0) this.armGrace();
234
302
  };
235
303
  socket.on("end", drop);
@@ -246,13 +314,31 @@ export class DawgDaemon {
246
314
 
247
315
  private handle(client: Client, message: ClientMessage): void {
248
316
  if (message.type === "hello") {
317
+ const protocol = negotiateProtocol(message.vMin, message.vMax);
318
+ if (protocol === undefined) {
319
+ this.fail(
320
+ client,
321
+ new ProtocolError(
322
+ "version",
323
+ `dawgd speaks protocol ${PROTOCOL_MIN}-${PROTOCOL_MAX}`,
324
+ ),
325
+ );
326
+ return;
327
+ }
249
328
  client.ready = true;
329
+ client.protocol = protocol;
330
+ const letters = new Set<string>();
331
+ for (const other of this.clients)
332
+ if (other !== client && other.presence?.pane)
333
+ letters.add(other.presence.pane);
250
334
  client.presence = {
251
335
  clientId: message.clientId,
252
336
  pid: message.pid,
253
337
  label: message.label,
254
338
  focusedTrackId: message.focusedTrackId,
339
+ pane: freePaneLetter(letters),
255
340
  };
341
+ if (message.actorId) client.presence.actorId = message.actorId;
256
342
  this.send(client, {
257
343
  v: 1,
258
344
  type: "welcome",
@@ -261,6 +347,8 @@ export class DawgDaemon {
261
347
  record: this.record,
262
348
  digest: this.digest,
263
349
  transport: this.transportState(),
350
+ protocol,
351
+ caps: [...DAEMON_CAPS],
264
352
  });
265
353
  this.broadcastPresence();
266
354
  return;
@@ -379,6 +467,39 @@ export class DawgDaemon {
379
467
  });
380
468
  return;
381
469
  }
470
+ if (message.type === "view") {
471
+ const presence = client.presence!;
472
+ const { v: _v, type: _type, ...view } = message;
473
+ for (const key of [
474
+ "screen",
475
+ "param",
476
+ "recording",
477
+ "playing",
478
+ "pinned",
479
+ "follow",
480
+ ] as const)
481
+ delete presence[key];
482
+ Object.assign(presence, view);
483
+ this.broadcastPresence();
484
+ return;
485
+ }
486
+ if (message.type === "live") {
487
+ const clientId = client.presence!.clientId;
488
+ if (message.action === "monitor")
489
+ void (async () => {
490
+ if (message.on) await this.loadSamples();
491
+ const status = await this.live.handle(clientId, message);
492
+ if (status)
493
+ this.send(client, {
494
+ v: 1,
495
+ type: "liveStatus",
496
+ id: message.id,
497
+ ...status,
498
+ });
499
+ })();
500
+ else void this.live.handle(clientId, message);
501
+ return;
502
+ }
382
503
  if (message.type === "transport") {
383
504
  this.enqueue(async () => {
384
505
  this.transport(message.action, message.beat, message.bpm);
@@ -393,7 +514,7 @@ export class DawgDaemon {
393
514
  return;
394
515
  }
395
516
  this.enqueue(async () => {
396
- const result = await this.apply(message);
517
+ const result = await this.apply(message, client);
397
518
  this.send(client, { v: 1, type: "result", id: message.id, ...result });
398
519
  });
399
520
  }
@@ -407,15 +528,35 @@ export class DawgDaemon {
407
528
 
408
529
  private async apply(
409
530
  message: Extract<ClientMessage, { type: "apply" }>,
531
+ client?: Client,
410
532
  ): Promise<ApplyResult> {
411
533
  const seen = this.keys.get(message.key);
412
534
  if (seen !== undefined) return { status: "duplicate", revision: seen };
535
+ // The authority stamps who made the event from the connection, never
536
+ // from the intent body.
537
+ const actor: EventActor | undefined = client?.presence
538
+ ? {
539
+ clientId: client.presence.clientId,
540
+ ...(client.presence.actorId
541
+ ? { actorId: client.presence.actorId }
542
+ : {}),
543
+ ...(message.seq !== undefined ? { seq: message.seq } : {}),
544
+ }
545
+ : undefined;
546
+ const retryKey =
547
+ actor?.seq !== undefined
548
+ ? seqKey(actor.actorId, actor.clientId, actor.seq)
549
+ : undefined;
550
+ const retried = retryKey ? this.seqs.get(retryKey) : undefined;
551
+ if (retried !== undefined)
552
+ return { status: "duplicate", revision: retried };
553
+ let operations: ScoreOperation[] | undefined;
413
554
  const stale = message.base !== this.record.revision;
414
555
  let rebased = false;
415
556
  let next: TrackScore;
416
557
  try {
417
558
  if (message.operations) {
418
- const operations = message.operations.map(parseOperation);
559
+ operations = message.operations.map(parseOperation);
419
560
  if (stale) {
420
561
  // Operation intents from an older base replay on the current score
421
562
  // when nothing they touch changed in between.
@@ -435,6 +576,13 @@ export class DawgDaemon {
435
576
  else next = scoreFromJSON(message.composition);
436
577
  // Round-trip through the parser so only canonical score data is stored.
437
578
  next = scoreFromJSON(next.toJSON());
579
+ // Composition intents still log as ops, so the log replays as ops.
580
+ operations ??= derivedOperations(
581
+ this.score,
582
+ next,
583
+ MAX_DERIVED_OPS_BYTES -
584
+ Buffer.byteLength(JSON.stringify(message.payload ?? null), "utf8"),
585
+ );
438
586
  } catch (error) {
439
587
  return {
440
588
  status: "rejected",
@@ -459,7 +607,13 @@ export class DawgDaemon {
459
607
  this.record = await appendSessionEvent(
460
608
  this.paths,
461
609
  this.record,
462
- { kind: message.kind, payload, id: message.key },
610
+ {
611
+ kind: message.kind,
612
+ payload,
613
+ id: message.key,
614
+ ...(actor ? { actor } : {}),
615
+ ...(operations ? { ops: operations } : {}),
616
+ },
463
617
  next.toJSON(),
464
618
  );
465
619
  } catch (error) {
@@ -488,6 +642,7 @@ export class DawgDaemon {
488
642
  this.score = next;
489
643
  this.digest = compositionDigest(this.record.composition);
490
644
  this.keys.set(message.key, this.record.revision);
645
+ if (retryKey) this.seqs.set(retryKey, this.record.revision);
491
646
  this.diskMtime = await this.recordMtime();
492
647
  const event = this.record.events[this.record.events.length - 1]!;
493
648
  this.broadcast({
@@ -578,6 +733,7 @@ export class DawgDaemon {
578
733
  beat: this.clock.beatAt(),
579
734
  bpm: this.score.tempoBpm,
580
735
  atMs,
736
+ quantum: this.score.beatsPerBar,
581
737
  };
582
738
  }
583
739
 
@@ -621,8 +777,15 @@ export class DawgDaemon {
621
777
  this.score = scoreFromJSON(this.record.composition);
622
778
  this.digest = compositionDigest(this.record.composition);
623
779
  this.keys.clear();
624
- for (const event of this.record.events)
780
+ this.seqs.clear();
781
+ for (const event of this.record.events) {
625
782
  this.keys.set(event.id, event.revision);
783
+ if (event.actor?.seq !== undefined)
784
+ this.seqs.set(
785
+ seqKey(event.actor.actorId, event.actor.clientId, event.actor.seq),
786
+ event.revision,
787
+ );
788
+ }
626
789
  this.diskMtime = await this.recordMtime();
627
790
  }
628
791
 
@@ -704,10 +867,41 @@ const OPERATION_TYPES = new Set([
704
867
  "setTime",
705
868
  "setTuning",
706
869
  "setMaster",
870
+ "setCalibration",
871
+ "setStyle",
707
872
  "setSections",
873
+ "setLoop",
874
+ "setClips",
708
875
  ]);
709
876
 
710
877
  /** Shape gate before the reducer, which validates every field it reads. */
878
+ /**
879
+ * Serialized budget for payload plus derived ops; the store bounds the two
880
+ * together at 64 KiB per event.
881
+ */
882
+ const MAX_DERIVED_OPS_BYTES = 60 * 1024;
883
+
884
+ /**
885
+ * The operations turning `before` into `after`, or undefined (a snapshot
886
+ * event) when the change has no op form or is too large to log as ops.
887
+ */
888
+ export function derivedOperations(
889
+ before: TrackScore,
890
+ after: TrackScore,
891
+ budgetBytes = MAX_DERIVED_OPS_BYTES,
892
+ ): ScoreOperation[] | undefined {
893
+ let ops: readonly ScoreOperation[];
894
+ try {
895
+ ops = diffScores(before, after);
896
+ } catch (error) {
897
+ if (error instanceof DiffError) return undefined;
898
+ throw error;
899
+ }
900
+ if (Buffer.byteLength(JSON.stringify(ops), "utf8") > budgetBytes)
901
+ return undefined;
902
+ return [...ops];
903
+ }
904
+
711
905
  function parseOperation(value: unknown): ScoreOperation {
712
906
  if (
713
907
  typeof value !== "object" ||