@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
@@ -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
- * windows committed in the meantime, dawgd replays the operations on the
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 tracks whose
8
- * settings or contents they rewrite, the tracks they add notes to, and the
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,11 +86,43 @@ export function rebaseOperations(
83
86
  return { ok: false, reason: `note ${operation.noteId} changed` };
84
87
  break;
85
88
  }
86
- case "updateTrack":
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
+ }
87
111
  case "setAutomation": {
88
- if (added.tracks.has(operation.trackId)) break;
89
- if (!settingsUnchanged(operation.trackId))
90
- return { ok: false, reason: `track ${operation.trackId} changed` };
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
+ };
91
126
  break;
92
127
  }
93
128
  case "clearTrack": {
@@ -123,10 +158,18 @@ export function rebaseOperations(
123
158
  if (!same(base.tuning ?? null, current.tuning ?? null))
124
159
  return { ok: false, reason: "tuning changed" };
125
160
  break;
161
+ case "setCalibration":
162
+ if ((base.calibration ?? 0) !== (current.calibration ?? 0))
163
+ return { ok: false, reason: "calibration changed" };
164
+ break;
126
165
  case "setMaster":
127
166
  if (!deepEqual(base.master, current.master))
128
167
  return { ok: false, reason: "master changed" };
129
168
  break;
169
+ case "setStyle":
170
+ if (!deepEqual(base.style, current.style))
171
+ return { ok: false, reason: "style changed" };
172
+ break;
130
173
  case "setSections":
131
174
  if (
132
175
  JSON.stringify(base.sections) !== JSON.stringify(current.sections) ||
@@ -135,6 +178,10 @@ export function rebaseOperations(
135
178
  )
136
179
  return { ok: false, reason: "sections changed" };
137
180
  break;
181
+ case "setLoop":
182
+ if (!deepEqual(base.loop, current.loop))
183
+ return { ok: false, reason: "loop changed" };
184
+ break;
138
185
  case "removeTrack": {
139
186
  if (added.tracks.has(operation.trackId)) break;
140
187
  if (
@@ -177,13 +224,17 @@ export function compositionAt(
177
224
  record: {
178
225
  composition: unknown;
179
226
  events: readonly { revision: number; rewind?: Rewind | undefined }[];
227
+ /** Events folded out of the front of the log (see `SessionRecord`). */
228
+ folded?: number | undefined;
180
229
  },
181
230
  revision: number,
182
231
  ): unknown {
183
232
  const { events } = record;
184
- if (!Number.isSafeInteger(revision) || revision < 0) return undefined;
185
- if (revision >= events.length) return undefined;
186
- for (let index = revision; index < events.length; index += 1)
187
- if (events[index]!.revision !== index + 1) return undefined;
188
- return rewindComposition(record.composition, events, revision);
233
+ const base = record.folded ?? 0;
234
+ if (!Number.isSafeInteger(revision) || revision < base) return undefined;
235
+ const start = revision - base;
236
+ if (start >= events.length) return undefined;
237
+ for (let index = start; index < events.length; index += 1)
238
+ if (events[index]!.revision !== base + index + 1) return undefined;
239
+ return rewindComposition(record.composition, events, start);
189
240
  }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Musical receipts: one line that says what changed in the song, built from
3
+ * the score operations a change applied (`diffScores(before, after)`).
4
+ *
5
+ * ◆ 96 BPM · +2 notes on bass (C4 E4) · reverb 0.4
6
+ *
7
+ * Receipts name tempo in BPM, notes by pitch, and track settings by their
8
+ * musical value; revision numbers and validator counts stay in the ctrl-o
9
+ * log. The builder is pure, so the agent turn, undo and redo share it.
10
+ */
11
+ import { diffScores } from "../../core/diff.ts";
12
+ import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
13
+ import { midiToPitch } from "../../core/pitch.ts";
14
+ import type { ScoreOperation, TrackScore } from "../../core/score.ts";
15
+
16
+ /** Parts kept before the rest collapse to `+N more`. */
17
+ const MAX_PARTS = 5;
18
+ /** Pitch names listed after a note count. */
19
+ const MAX_PITCHES = 4;
20
+
21
+ function plural(count: number, word: string): string {
22
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
23
+ }
24
+
25
+ function round(value: number, places = 2): string {
26
+ const scale = 10 ** places;
27
+ return String(Math.round(value * scale) / scale);
28
+ }
29
+
30
+ function noteName(pitch: number, drum: boolean): string {
31
+ return drum ? drumVoiceForPitch(pitch) : midiToPitch(pitch);
32
+ }
33
+
34
+ type NoteTally = { added: number[]; removed: number[]; changed: number };
35
+
36
+ /** One field of a track patch as a musical phrase, or undefined to skip. */
37
+ function trackField(field: string, value: unknown): string | undefined {
38
+ if (value === null || value === undefined) {
39
+ if (field === "volumeAutomation" || field.endsWith("Automation"))
40
+ return `${field.replace(/Automation$/, "")} automation off`;
41
+ return `${field} off`;
42
+ }
43
+ switch (field) {
44
+ case "volume":
45
+ return typeof value === "number" ? `volume ${round(value)}` : undefined;
46
+ case "pan":
47
+ return typeof value === "number" ? `pan ${round(value)}` : undefined;
48
+ case "muted":
49
+ return value === true ? "muted" : "unmuted";
50
+ case "solo":
51
+ return value === true ? "solo" : "solo off";
52
+ case "instrument":
53
+ return typeof value === "string" ? `→ ${value}` : undefined;
54
+ case "name":
55
+ return typeof value === "string" ? `renamed ${value}` : undefined;
56
+ case "reverb":
57
+ case "delay": {
58
+ const mix = (value as { mix?: unknown }).mix;
59
+ return typeof mix === "number" ? `${field} ${round(mix)}` : field;
60
+ }
61
+ case "filter": {
62
+ const cutoff = (value as { cutoff?: unknown }).cutoff;
63
+ return typeof cutoff === "number"
64
+ ? `filter ${Math.round(cutoff)} Hz`
65
+ : "filter";
66
+ }
67
+ case "fx": {
68
+ const names = Object.keys(value as Record<string, unknown>);
69
+ return names.length ? `fx ${names.join(" ")}` : "fx off";
70
+ }
71
+ default:
72
+ if (Array.isArray(value) && field.endsWith("Automation"))
73
+ return value.length === 0
74
+ ? `${field.replace(/Automation$/, "")} automation off`
75
+ : `${field.replace(/Automation$/, "")} automation`;
76
+ return field;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * The receipt parts for `operations` applied to `before`. Tracks are named
82
+ * only when the song has more than one, so a single-track edit reads
83
+ * `reverb 0.4` and a band edit `bass reverb 0.4`.
84
+ */
85
+ export function receiptParts(
86
+ before: TrackScore,
87
+ operations: readonly ScoreOperation[],
88
+ ): string[] {
89
+ const parts: string[] = [];
90
+ // Track settings follow the notes: what you hear first reads first.
91
+ const settings: string[] = [];
92
+ const instruments = new Map(
93
+ before.tracks.map((track) => [track.id, track.instrument]),
94
+ );
95
+ const noteTrack = new Map(
96
+ before.notes.map((note) => [note.id, note.trackId]),
97
+ );
98
+ const notePitch = new Map(
99
+ before.notes.map((note) => [
100
+ note.id,
101
+ typeof note.pitch === "number" ? note.pitch : Number.NaN,
102
+ ]),
103
+ );
104
+ const tallies = new Map<string, NoteTally>();
105
+ const tally = (trackId: string): NoteTally => {
106
+ let found = tallies.get(trackId);
107
+ if (!found) {
108
+ found = { added: [], removed: [], changed: 0 };
109
+ tallies.set(trackId, found);
110
+ }
111
+ return found;
112
+ };
113
+ const added = new Set<string>();
114
+ const removed = new Set<string>();
115
+ const trackCount = new Set([
116
+ ...before.tracks.map((track) => track.id),
117
+ ...operations.flatMap((op) =>
118
+ op.type === "addTrack" ? [op.track.id ?? "track"] : [],
119
+ ),
120
+ ]).size;
121
+ const prefix = (trackId: string) => (trackCount > 1 ? `${trackId} ` : "");
122
+ // Notes are summarised per track after the song-level parts.
123
+ for (const op of operations) {
124
+ switch (op.type) {
125
+ case "setTempo":
126
+ parts.push(`${round(op.tempoBpm, 1)} BPM`);
127
+ break;
128
+ case "setKey":
129
+ parts.push(op.key ? `key ${op.key}` : "no key");
130
+ break;
131
+ case "setBars":
132
+ parts.push(plural(op.bars, "bar"));
133
+ break;
134
+ case "setMeter":
135
+ parts.push(`${op.beatsPerBar} beats a bar`);
136
+ break;
137
+ case "setTime":
138
+ parts.push(op.time ? "tempo map" : "steady tempo");
139
+ break;
140
+ case "setTuning":
141
+ parts.push(
142
+ op.tuning ? `tuning ${op.tuning.name ?? ""}`.trim() : "12-TET",
143
+ );
144
+ break;
145
+ case "setMaster":
146
+ parts.push(op.master ? "master" : "master off");
147
+ break;
148
+ case "setStyle":
149
+ if (op.style) parts.push(op.style.id);
150
+ break;
151
+ case "setSections":
152
+ parts.push(plural(op.sections.length, "section"));
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;
161
+ case "setCalibration":
162
+ break;
163
+ case "addTrack": {
164
+ const id = op.track.id ?? "track";
165
+ const instrument = op.track.instrument;
166
+ added.add(id);
167
+ instruments.set(id, instrument ?? "");
168
+ parts.push(
169
+ `+${id}${instrument && instrument !== id ? ` (${instrument})` : ""}`,
170
+ );
171
+ break;
172
+ }
173
+ case "removeTrack":
174
+ removed.add(op.trackId);
175
+ parts.push(`−${op.trackId}`);
176
+ break;
177
+ case "moveTrack":
178
+ parts.push(`moved ${op.trackId}`);
179
+ break;
180
+ case "clearTrack":
181
+ parts.push(`cleared ${op.trackId}`);
182
+ break;
183
+ case "setClips":
184
+ settings.push(
185
+ `${prefix(op.trackId)}${op.clips && op.clips.length ? plural(op.clips.length, "clip") : "no clips"}`,
186
+ );
187
+ break;
188
+ case "setAutomation":
189
+ settings.push(`${prefix(op.trackId)}${op.parameter} automation`);
190
+ break;
191
+ case "updateTrack": {
192
+ if (added.has(op.trackId)) break;
193
+ const fields = Object.entries(op.patch)
194
+ .map(([field, value]) => trackField(field, value))
195
+ .filter((text): text is string => text !== undefined);
196
+ if (fields.length === 0) break;
197
+ const name = op.patch.name;
198
+ const label =
199
+ typeof name === "string" ? op.trackId : prefix(op.trackId).trim();
200
+ const text = fields.slice(0, 2).join(" ");
201
+ settings.push(label ? `${label} ${text}` : text);
202
+ break;
203
+ }
204
+ case "addNote": {
205
+ const entry = tally(op.note.trackId);
206
+ entry.added.push(
207
+ typeof op.note.pitch === "number" ? op.note.pitch : Number.NaN,
208
+ );
209
+ break;
210
+ }
211
+ case "removeNote": {
212
+ const trackId = noteTrack.get(op.noteId);
213
+ if (trackId && !removed.has(trackId))
214
+ tally(trackId).removed.push(notePitch.get(op.noteId) ?? Number.NaN);
215
+ break;
216
+ }
217
+ case "updateNote": {
218
+ const trackId = noteTrack.get(op.noteId);
219
+ if (trackId) tally(trackId).changed += 1;
220
+ break;
221
+ }
222
+ }
223
+ }
224
+ for (const [trackId, entry] of tallies) {
225
+ const drum = isDrumInstrument(instruments.get(trackId));
226
+ const listed = (pitches: readonly number[]): string => {
227
+ const names = pitches
228
+ .filter((pitch) => Number.isFinite(pitch))
229
+ .slice(0, MAX_PITCHES)
230
+ .map((pitch) => noteName(pitch, drum));
231
+ const more = pitches.length > MAX_PITCHES ? " …" : "";
232
+ return names.length ? ` (${names.join(" ")}${more})` : "";
233
+ };
234
+ if (entry.added.length > 0)
235
+ parts.push(
236
+ `+${plural(entry.added.length, "note")} on ${trackId}${listed(entry.added)}`,
237
+ );
238
+ if (entry.removed.length > 0)
239
+ parts.push(
240
+ `−${plural(entry.removed.length, "note")} on ${trackId}${listed(entry.removed)}`,
241
+ );
242
+ if (entry.changed > 0)
243
+ parts.push(`${plural(entry.changed, "note")} edited on ${trackId}`);
244
+ }
245
+ parts.push(...settings);
246
+ if (parts.length > MAX_PARTS)
247
+ return [
248
+ ...parts.slice(0, MAX_PARTS - 1),
249
+ `+${parts.length - (MAX_PARTS - 1)} more`,
250
+ ];
251
+ return parts;
252
+ }
253
+
254
+ /** `96 BPM · +2 notes on bass (C4 E4) · reverb 0.4`, or "" for no change. */
255
+ export function musicalReceipt(before: TrackScore, after: TrackScore): string {
256
+ if (before === after) return "";
257
+ let operations: readonly ScoreOperation[];
258
+ try {
259
+ operations = diffScores(before, after);
260
+ } catch {
261
+ // A resolution change cannot diff: say the song changed.
262
+ return "song changed";
263
+ }
264
+ return receiptParts(before, operations).join(" · ");
265
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * A pane's handle on the machine's one live engine (design §12.3). It looks
3
+ * like the local `LiveEngine` to play mode, but notes go to dawgd as note
4
+ * events (`remoteNote`) and the click as a structured request
5
+ * (`remoteClick`): no audio crosses the socket. dawgd renders each voice
6
+ * from the score it holds and mixes every pane into one stream and one
7
+ * click.
8
+ *
9
+ * Times cross the seam on the authority's clock: local monotonic ms plus
10
+ * `performance.timeOrigin` plus the port's clock offset (0 on one machine).
11
+ */
12
+ import type { ClickBus } from "../audio/engine.ts";
13
+ import type { LiveNotePcm } from "../audio/live.ts";
14
+ import type { LiveClick, LiveMessage, LiveStatus } from "./protocol.ts";
15
+
16
+ /** What the shared engine needs from the session connection. */
17
+ export interface LiveLink {
18
+ liveMonitor(on: boolean): Promise<LiveStatus>;
19
+ live(message: Exclude<LiveMessage, { action: "monitor" }>): void;
20
+ /** Authority clock minus this machine's, in ms. */
21
+ readonly clockOffsetMs: number;
22
+ }
23
+
24
+ /** A played note as play mode hands it to a shared engine. */
25
+ export type RemoteNote = Readonly<{
26
+ trackId: string;
27
+ pitch: number;
28
+ /** 0..1. */
29
+ velocity: number;
30
+ seconds: number;
31
+ /** Transport beat of the press (unwrapped). */
32
+ beat: number;
33
+ /** Monotonic ms of the press. */
34
+ atMs: number;
35
+ }>;
36
+
37
+ /** A structured click request (a shared engine cannot take closures). */
38
+ export type RemoteClick = Readonly<{
39
+ on: boolean;
40
+ volume: number;
41
+ countIn?: Readonly<{
42
+ /** Monotonic ms the count-in starts. */
43
+ startMs: number;
44
+ startBeat: number;
45
+ beats: number;
46
+ barBeats: number;
47
+ clickBeats: number;
48
+ bpm: number;
49
+ }>;
50
+ }>;
51
+
52
+ export class SharedLiveEngine {
53
+ public sampleRate = 48_000;
54
+ public canMonitor = true;
55
+ public leadMs = 15;
56
+ public playLeadMs: number | undefined;
57
+ public audioNote: string | undefined = "shared engine";
58
+ /** Notes sent (tests and the status line read it). */
59
+ public sent = 0;
60
+
61
+ public constructor(private readonly link: LiveLink) {}
62
+
63
+ public async monitor(on: boolean): Promise<void> {
64
+ try {
65
+ const status = await this.link.liveMonitor(on);
66
+ this.sampleRate = status.sampleRate;
67
+ this.canMonitor = status.canMonitor;
68
+ this.leadMs = status.leadMs;
69
+ this.playLeadMs = status.leadMs;
70
+ this.audioNote = status.note ?? "shared engine";
71
+ } catch {
72
+ // dawgd is reconnecting; the link resends monitor once it is back.
73
+ }
74
+ }
75
+
76
+ /** The lead is dawgd's: a pane never retimes the shared engine. */
77
+ public setLeadMs(_ms: number | undefined): void {}
78
+
79
+ /** Local PCM never reaches a shared engine; play mode uses `remoteNote`. */
80
+ public noteOn(_id: number, _note: LiveNotePcm): number {
81
+ return performance.now() + this.leadMs;
82
+ }
83
+
84
+ public remoteNote(id: number, note: RemoteNote): number {
85
+ this.sent += 1;
86
+ this.link.live({
87
+ v: 1,
88
+ type: "live",
89
+ action: "on",
90
+ voice: id,
91
+ trackId: note.trackId,
92
+ pitch: note.pitch,
93
+ velocity: Math.max(0, Math.min(1, note.velocity)),
94
+ seconds: Math.max(0, Math.min(600, note.seconds)),
95
+ beat: note.beat,
96
+ atMs: this.authority(note.atMs),
97
+ });
98
+ return performance.now() + this.leadMs;
99
+ }
100
+
101
+ public noteOff(id: number): void {
102
+ this.link.live({
103
+ v: 1,
104
+ type: "live",
105
+ action: "off",
106
+ voice: id,
107
+ atMs: this.authority(performance.now()),
108
+ });
109
+ }
110
+
111
+ /** A pane cannot hand dawgd a click closure; see `remoteClick`. */
112
+ public setClick(click: ClickBus | undefined): void {
113
+ if (!click) this.remoteClick({ on: false, volume: 0 });
114
+ }
115
+
116
+ public remoteClick(click: RemoteClick): void {
117
+ const message: LiveClick = {
118
+ on: click.on,
119
+ volume: Math.max(0, Math.min(1, click.volume)),
120
+ };
121
+ if (click.countIn) {
122
+ const { startMs, ...rest } = click.countIn;
123
+ message.countIn = { ...rest, startAtMs: this.authority(startMs) };
124
+ }
125
+ this.link.live({ v: 1, type: "live", action: "click", ...message });
126
+ }
127
+
128
+ private authority(monotonicMs: number): number {
129
+ return performance.timeOrigin + monotonicMs + this.link.clockOffsetMs;
130
+ }
131
+ }