@hraness/dawg 0.6.0 → 0.7.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 (275) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +655 -141
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +759 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -99
  8. package/core/expression.ts +158 -6
  9. package/core/fx.ts +354 -32
  10. package/core/granular.ts +91 -0
  11. package/core/instruments.ts +180 -0
  12. package/core/keys.ts +573 -4
  13. package/core/loop.ts +5 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/resonators.ts +306 -4
  17. package/core/routing.ts +165 -0
  18. package/core/score.ts +1181 -16
  19. package/core/sdk/eval-child.ts +7 -2
  20. package/core/sdk/eval.ts +35 -6
  21. package/core/sdk/print.ts +354 -6
  22. package/core/sdk/sync-lyrics.ts +49 -0
  23. package/core/sdk/v1.ts +3160 -69
  24. package/core/sections.ts +298 -24
  25. package/core/sing.ts +815 -0
  26. package/core/strings.ts +237 -2
  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/core/winds.ts +652 -0
  50. package/guides/agent.md +29 -0
  51. package/guides/arrange.md +30 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -6
  54. package/guides/chords.md +16 -14
  55. package/guides/effects.md +17 -15
  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 +12 -6
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/performance.md +15 -10
  65. package/guides/play.md +21 -13
  66. package/guides/project.md +25 -8
  67. package/guides/providers.md +20 -14
  68. package/guides/resample.md +32 -0
  69. package/guides/rhythm.md +17 -13
  70. package/guides/sessions.md +17 -7
  71. package/guides/show-me.md +31 -0
  72. package/guides/sound.md +25 -9
  73. package/guides/sounds.md +15 -12
  74. package/guides/styles.md +31 -0
  75. package/guides/tempo.md +15 -10
  76. package/guides/tracks.md +15 -10
  77. package/guides/tuning.md +32 -0
  78. package/guides/voice.md +31 -0
  79. package/guides/web-search.md +18 -8
  80. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  81. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  82. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  83. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  84. package/native/prebuilt/manifest.json +21 -0
  85. package/package.json +5 -2
  86. package/src/agent/agent.ts +131 -13
  87. package/src/agent/calibration-tools.ts +53 -0
  88. package/src/agent/chord-tools.ts +153 -1
  89. package/src/agent/clip-tools.ts +453 -0
  90. package/src/agent/command-agent.ts +369 -0
  91. package/src/agent/drum-tools.ts +2 -2
  92. package/src/agent/expression-tools.ts +91 -0
  93. package/src/agent/gateway.ts +246 -60
  94. package/src/agent/models.ts +53 -12
  95. package/src/agent/ops.ts +15 -1
  96. package/src/agent/pack-tools.ts +1 -1
  97. package/src/agent/planner.ts +13 -0
  98. package/src/agent/portable-schema.ts +80 -0
  99. package/src/agent/preview-tool.ts +5 -1
  100. package/src/agent/provider.ts +22 -8
  101. package/src/agent/resample-tool.ts +131 -0
  102. package/src/agent/rhythm-tools.ts +1 -1
  103. package/src/agent/section-tools.ts +1 -1
  104. package/src/agent/show-me.ts +497 -0
  105. package/src/agent/steer.ts +15 -0
  106. package/src/agent/style-tools.ts +217 -0
  107. package/src/agent/tool-error.ts +12 -0
  108. package/src/agent/tools.ts +307 -37
  109. package/src/agent/usage.ts +2 -2
  110. package/src/agent/voice-tools.ts +925 -0
  111. package/src/agent/xcb-agent.ts +16 -6
  112. package/src/argv.ts +38 -0
  113. package/src/audio/analysis.ts +253 -0
  114. package/src/audio/arrange.ts +58 -3
  115. package/src/audio/autotune-engine.ts +101 -0
  116. package/src/audio/autotune.ts +640 -0
  117. package/src/audio/clips.ts +240 -0
  118. package/src/audio/doctor.ts +86 -0
  119. package/src/audio/dsp/bandbank.ts +138 -0
  120. package/src/audio/dsp/envelope.ts +171 -0
  121. package/src/audio/dsp/follow.ts +120 -0
  122. package/src/audio/dsp/formant.ts +427 -0
  123. package/src/audio/dsp/glottal.ts +243 -0
  124. package/src/audio/dsp/interp.ts +7 -2
  125. package/src/audio/dsp/lpc.ts +50 -0
  126. package/src/audio/dsp/periodicity.ts +59 -0
  127. package/src/audio/dsp/pitch.ts +995 -0
  128. package/src/audio/dsp/psola.ts +199 -0
  129. package/src/audio/dsp/shift.ts +256 -0
  130. package/src/audio/effects/chain.ts +21 -4
  131. package/src/audio/effects/common.ts +57 -0
  132. package/src/audio/effects/convolution.ts +113 -5
  133. package/src/audio/effects/filter.ts +48 -69
  134. package/src/audio/effects/formant.ts +263 -0
  135. package/src/audio/effects/gaze.ts +354 -0
  136. package/src/audio/engine.ts +206 -33
  137. package/src/audio/fit.ts +80 -3
  138. package/src/audio/granular.ts +352 -21
  139. package/src/audio/instrument-check.ts +67 -47
  140. package/src/audio/instruments.ts +36 -3
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +427 -0
  143. package/src/audio/keys/engine.ts +156 -20
  144. package/src/audio/keys/organ.ts +1335 -0
  145. package/src/audio/keys/piano.ts +72 -8
  146. package/src/audio/keys/sympathetic.ts +127 -0
  147. package/src/audio/kits.ts +135 -6
  148. package/src/audio/live-worker.ts +3 -1
  149. package/src/audio/live.ts +247 -26
  150. package/src/audio/native.ts +615 -0
  151. package/src/audio/preview.ts +45 -3
  152. package/src/audio/render-worker.ts +2 -0
  153. package/src/audio/renderer.ts +2 -0
  154. package/src/audio/resample.ts +286 -0
  155. package/src/audio/resonators.ts +10 -2
  156. package/src/audio/sampler.ts +252 -24
  157. package/src/audio/samples.ts +46 -3
  158. package/src/audio/sing/analysis.ts +193 -0
  159. package/src/audio/sing/engine.ts +949 -0
  160. package/src/audio/strings/body.ts +29 -16
  161. package/src/audio/strings/bow.ts +699 -0
  162. package/src/audio/strings/engine.ts +274 -22
  163. package/src/audio/strings/measure.test-helpers.ts +2 -0
  164. package/src/audio/synth/oscillators.ts +31 -21
  165. package/src/audio/synth/voice.ts +34 -1
  166. package/src/audio/vocoder/bank.ts +314 -0
  167. package/src/audio/vocoder/carrier.ts +165 -0
  168. package/src/audio/vocoder/control.ts +68 -0
  169. package/src/audio/vocoder/detect.ts +50 -0
  170. package/src/audio/vocoder/index.ts +304 -0
  171. package/src/audio/vocoder/talkbox.ts +143 -0
  172. package/src/audio/wav.ts +608 -85
  173. package/src/audio/winds/engine.ts +306 -0
  174. package/src/audio/winds/filters.ts +153 -0
  175. package/src/audio/winds/pitch.ts +104 -0
  176. package/src/audio/winds/trim.ts +104 -0
  177. package/src/audio/winds/trims.ts +917 -0
  178. package/src/audio/winds/trims1.ts +297 -0
  179. package/src/audio/winds/voice.ts +492 -0
  180. package/src/auth/cli.ts +38 -36
  181. package/src/auth/credentials.ts +30 -1
  182. package/src/auth/login.ts +15 -9
  183. package/src/auth/tui.ts +19 -10
  184. package/src/commands/arrange.ts +44 -29
  185. package/src/commands/autotune.ts +421 -0
  186. package/src/commands/calibration.ts +74 -0
  187. package/src/commands/clips.ts +887 -0
  188. package/src/commands/drums.ts +3 -2
  189. package/src/commands/edit.ts +11 -4
  190. package/src/commands/expression.ts +104 -23
  191. package/src/commands/formant.ts +221 -0
  192. package/src/commands/fx.ts +105 -33
  193. package/src/commands/grammar.ts +558 -0
  194. package/src/commands/granular.ts +7 -2
  195. package/src/commands/help.ts +652 -339
  196. package/src/commands/history.ts +18 -0
  197. package/src/commands/keys.ts +293 -17
  198. package/src/commands/modal.ts +67 -4
  199. package/src/commands/music.ts +1 -1
  200. package/src/commands/nearest.ts +53 -0
  201. package/src/commands/pack.ts +9 -2
  202. package/src/commands/param-range.ts +56 -0
  203. package/src/commands/parses.ts +130 -0
  204. package/src/commands/progression.ts +170 -0
  205. package/src/commands/resample.ts +281 -0
  206. package/src/commands/rhythm.ts +3 -0
  207. package/src/commands/rig.ts +8 -25
  208. package/src/commands/sample.ts +49 -2
  209. package/src/commands/shift.ts +119 -0
  210. package/src/commands/sing.ts +478 -0
  211. package/src/commands/string.ts +28 -2
  212. package/src/commands/strum.ts +485 -0
  213. package/src/commands/style.ts +415 -0
  214. package/src/commands/time.ts +6 -3
  215. package/src/commands/tuning.ts +3 -3
  216. package/src/commands/vocal-pitch.ts +616 -0
  217. package/src/commands/vocal.ts +147 -0
  218. package/src/commands/vocoder.ts +627 -0
  219. package/src/commands/wind.ts +244 -0
  220. package/src/fs/durable.ts +50 -0
  221. package/src/lang/glossary.ts +493 -0
  222. package/src/launch-args.ts +163 -0
  223. package/src/main.ts +1521 -255
  224. package/src/media/cli.ts +20 -3
  225. package/src/media/import.ts +3 -1
  226. package/src/project/check.ts +26 -3
  227. package/src/project/clip-pins.ts +72 -0
  228. package/src/project/init.ts +23 -8
  229. package/src/project/sync.ts +418 -86
  230. package/src/render.ts +36 -1
  231. package/src/session/daemon.ts +3 -0
  232. package/src/session/meta.ts +14 -0
  233. package/src/session/origin.ts +154 -0
  234. package/src/session/port.ts +22 -4
  235. package/src/session/presence.ts +50 -7
  236. package/src/session/protocol.ts +5 -1
  237. package/src/session/rebase.ts +18 -5
  238. package/src/session/receipt.ts +258 -0
  239. package/src/session/store.ts +65 -43
  240. package/src/tui/arrange-menu.ts +65 -31
  241. package/src/tui/audition.ts +1 -1
  242. package/src/tui/euclid.ts +18 -13
  243. package/src/tui/fader.ts +228 -41
  244. package/src/tui/granular-menu.ts +2 -4
  245. package/src/tui/menu-clips.ts +297 -0
  246. package/src/tui/menu-time.ts +20 -13
  247. package/src/tui/menu-voice.ts +405 -0
  248. package/src/tui/menu.ts +1377 -193
  249. package/src/tui/modal-menu.ts +38 -11
  250. package/src/tui/performance-menu.ts +46 -1
  251. package/src/tui/play-chords.ts +108 -5
  252. package/src/tui/play-mode.ts +16 -1
  253. package/src/tui/play-session.ts +81 -16
  254. package/src/tui/sing-menu.ts +278 -0
  255. package/src/tui/style-menu.ts +104 -0
  256. package/src/tui/vocoder-menu.ts +244 -0
  257. package/src/tui/wind-menu.ts +144 -0
  258. package/src/version.ts +8 -0
  259. package/src/web/fetch.ts +115 -29
  260. package/tui/activity.ts +180 -9
  261. package/tui/app.ts +274 -40
  262. package/tui/clip-row.ts +132 -0
  263. package/tui/delight.ts +144 -0
  264. package/tui/drawer.ts +70 -22
  265. package/tui/frame-gate.ts +76 -0
  266. package/tui/grammar.ts +112 -63
  267. package/tui/guide.ts +42 -4
  268. package/tui/highway.ts +269 -25
  269. package/tui/hints.ts +192 -0
  270. package/tui/input.ts +60 -9
  271. package/tui/keys.ts +1 -1
  272. package/tui/play-strip.ts +68 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +144 -14
  275. package/tui/theme.ts +27 -0
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,9 +235,28 @@ 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`);
247
+ const clipped = clippedSamples(audio.pcm);
248
+ if (clipped > 0)
249
+ stderr.write(
250
+ `warning · ${clipped} ${clipped === 1 ? "sample clips" : "samples clip"} at full scale · ${
251
+ audio.master
252
+ ? "add master limiter or lower the master gain"
253
+ : "lower track volumes or add a master with a limiter (master streaming, or master: { limiter: {} } in song.ts)"
254
+ }\n`,
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
+ );
232
260
  if (options.has("--measure"))
233
261
  stdout.write(
234
262
  `loudness · ${measurementLine(measureRendered(audio, false).mix, audio.master)}\n`,
@@ -238,6 +266,13 @@ export async function runRenderCommand(
238
266
  return 0;
239
267
  }
240
268
 
269
+ /** Samples at or past 16-bit full scale: the mix bus hit the rails. */
270
+ export function clippedSamples(pcm: Int16Array): number {
271
+ let count = 0;
272
+ for (const value of pcm) if (value >= 32_767 || value <= -32_768) count += 1;
273
+ return count;
274
+ }
275
+
241
276
  async function loadScore(
242
277
  workspace: string,
243
278
  options: ReadonlyMap<string, string>,
@@ -704,7 +704,10 @@ const OPERATION_TYPES = new Set([
704
704
  "setTime",
705
705
  "setTuning",
706
706
  "setMaster",
707
+ "setCalibration",
708
+ "setStyle",
707
709
  "setSections",
710
+ "setClips",
708
711
  ]);
709
712
 
710
713
  /** Shape gate before the reducer, which validates every field it reads. */
@@ -30,6 +30,11 @@ export type SessionMeta = {
30
30
  namedFingerprint?: string;
31
31
  /** Track-and-instrument-set hash at the last auto name. */
32
32
  namedStructure?: string;
33
+ /**
34
+ * Optional (0.7): the song's loop has wrapped once, so the one-shot
35
+ * `↻ first loop` moment never repeats. Session state, never the score.
36
+ */
37
+ heardLoop?: true;
33
38
  };
34
39
 
35
40
  /** Fields a client may change. `null` clears an optional field. */
@@ -38,6 +43,8 @@ export type MetaPatch = {
38
43
  nameSource?: NameSource;
39
44
  namedFingerprint?: string | null;
40
45
  namedStructure?: string | null;
46
+ /** Only ever set; the first loop is heard once. */
47
+ heardLoop?: true;
41
48
  };
42
49
 
43
50
  /** A conditional write applies only when every given field still matches. */
@@ -125,6 +132,7 @@ export function parseSessionMeta(
125
132
  if (fingerprint) result.namedFingerprint = fingerprint;
126
133
  const structure = optionalFingerprint(meta.namedStructure);
127
134
  if (structure) result.namedStructure = structure;
135
+ if (meta.heardLoop === true) result.heardLoop = true;
128
136
  return result;
129
137
  }
130
138
 
@@ -144,6 +152,11 @@ export function parseMetaPatch(value: unknown): MetaPatch {
144
152
  if (raw[key] === undefined) continue;
145
153
  patch[key] = raw[key] === null ? null : requireFingerprint(raw[key]);
146
154
  }
155
+ if (raw.heardLoop !== undefined) {
156
+ if (raw.heardLoop !== true)
157
+ throw new MetaValidationError("heardLoop is invalid");
158
+ patch.heardLoop = true;
159
+ }
147
160
  if (Object.keys(patch).length === 0)
148
161
  throw new MetaValidationError("meta patch is empty");
149
162
  return patch;
@@ -184,6 +197,7 @@ export function applyMetaPatch(
184
197
  };
185
198
  if (patch.name !== undefined) next.name = patch.name;
186
199
  if (patch.nameSource !== undefined) next.nameSource = patch.nameSource;
200
+ if (patch.heardLoop) next.heardLoop = true;
187
201
  for (const key of ["namedFingerprint", "namedStructure"] as const) {
188
202
  if (patch[key] === null) delete next[key];
189
203
  else if (patch[key] !== undefined) next[key] = patch[key];
@@ -0,0 +1,154 @@
1
+ import { WRITTEN_REVISION, type SessionPort } from "./port.ts";
2
+ import type { SessionEvent, SessionRecord } from "./store.ts";
3
+
4
+ /**
5
+ * Remembers the events this window wrote, so the "synced" card shows only
6
+ * for revisions that came from another client (design C12). Session events
7
+ * carry no writer id, so the tracker records the id of the event each of
8
+ * this window's appends produced, and holds a decision while a write is in
9
+ * flight (a watcher update can land before the append resolves).
10
+ */
11
+ export class OwnWrites {
12
+ private readonly ids = new Set<string>();
13
+ private inflight = 0;
14
+ private waiters: (() => void)[] = [];
15
+
16
+ /** Wraps one append: records the event it produced. */
17
+ public async track<T>(
18
+ write: Promise<SessionRecord<T>>,
19
+ ): Promise<SessionRecord<T>> {
20
+ this.inflight++;
21
+ try {
22
+ const record = await write;
23
+ const revision = WRITTEN_REVISION.get(record);
24
+ const own =
25
+ revision === undefined
26
+ ? record.events[record.events.length - 1]
27
+ : record.events.find((event) => event.revision === revision);
28
+ if (own) this.remember(own.id);
29
+ return record;
30
+ } finally {
31
+ this.inflight--;
32
+ if (this.inflight === 0) {
33
+ const waiters = this.waiters;
34
+ this.waiters = [];
35
+ for (const wake of waiters) wake();
36
+ }
37
+ }
38
+ }
39
+
40
+ public remember(id: string): void {
41
+ this.ids.add(id);
42
+ // Bounded: only recent ids matter for the next watcher update.
43
+ if (this.ids.size > 512) {
44
+ const oldest = this.ids.values().next().value;
45
+ if (oldest !== undefined) this.ids.delete(oldest);
46
+ }
47
+ }
48
+
49
+ public isOwn(id: string): boolean {
50
+ return this.ids.has(id);
51
+ }
52
+
53
+ /** Resolves once no append from this window is in flight. */
54
+ public settled(): Promise<void> {
55
+ if (this.inflight === 0) return Promise.resolve();
56
+ return new Promise((resolve) => this.waiters.push(resolve));
57
+ }
58
+
59
+ /**
60
+ * Patches a port's append methods in place so every write it commits is
61
+ * tracked. Idempotent per port.
62
+ */
63
+ public attach<T>(port: SessionPort<T>): void {
64
+ const marked = port as SessionPort<T> & { [TRACKED]?: true };
65
+ if (marked[TRACKED]) return;
66
+ marked[TRACKED] = true;
67
+ const append = port.append.bind(port);
68
+ const appendOperations = port.appendOperations.bind(port);
69
+ port.append = (...args) => this.track(append(...args));
70
+ port.appendOperations = (...args) => this.track(appendOperations(...args));
71
+ }
72
+ }
73
+
74
+ const TRACKED = Symbol("dawg.ownWrites");
75
+
76
+ /** Events in `latest` past `fromRevision` that this window did not write. */
77
+ export function foreignEvents(
78
+ latest: Pick<SessionRecord<unknown>, "events">,
79
+ fromRevision: number,
80
+ own: Pick<OwnWrites, "isOwn">,
81
+ ): SessionEvent[] {
82
+ return latest.events.filter(
83
+ (event) => event.revision > fromRevision && !own.isOwn(event.id),
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Names the other window for the sync card by the track it has open, the
89
+ * word that window's own header shows (`synced · lead window`), never by a
90
+ * number taken from the presence list's order. With several other windows
91
+ * the one whose track the edit touched is named; before presence arrives
92
+ * the edited track names it. Otherwise `another window`.
93
+ */
94
+ export function otherWindowName(
95
+ clients: readonly { clientId: string; focusedTrackId?: string | null }[],
96
+ selfId: string,
97
+ options: {
98
+ /** The one track the foreign revision changed, when exactly one. */
99
+ editedTrackId?: string | undefined;
100
+ /** Display name for a track id. */
101
+ trackName?: (trackId: string) => string;
102
+ } = {},
103
+ ): string {
104
+ const name = options.trackName ?? ((trackId: string) => trackId);
105
+ const others = clients.filter(
106
+ (client) => client.clientId !== selfId && client.focusedTrackId,
107
+ );
108
+ const edited = options.editedTrackId;
109
+ const owner =
110
+ (edited && others.find((client) => client.focusedTrackId === edited)) ||
111
+ (others.length === 1 ? others[0] : undefined);
112
+ if (owner?.focusedTrackId) return `${name(owner.focusedTrackId)} window`;
113
+ if (edited) return `${name(edited)} window`;
114
+ return "another window";
115
+ }
116
+
117
+ /** The single track whose notes or settings differ, or undefined. */
118
+ export function editedTrack(
119
+ before: {
120
+ tracks: readonly { id: string }[];
121
+ notes: readonly { trackId: string }[];
122
+ },
123
+ after: {
124
+ tracks: readonly { id: string }[];
125
+ notes: readonly { trackId: string }[];
126
+ },
127
+ ): string | undefined {
128
+ const changed = new Set<string>();
129
+ const byTrack = (value: typeof before) => {
130
+ const map = new Map<string, string[]>();
131
+ for (const note of value.notes) {
132
+ const list = map.get(note.trackId) ?? [];
133
+ list.push(JSON.stringify(note));
134
+ map.set(note.trackId, list);
135
+ }
136
+ return map;
137
+ };
138
+ const left = byTrack(before);
139
+ const right = byTrack(after);
140
+ const ids = new Set([
141
+ ...before.tracks.map((track) => track.id),
142
+ ...after.tracks.map((track) => track.id),
143
+ ]);
144
+ for (const id of ids) {
145
+ const a = before.tracks.find((track) => track.id === id);
146
+ const b = after.tracks.find((track) => track.id === id);
147
+ if (
148
+ JSON.stringify(a) !== JSON.stringify(b) ||
149
+ (left.get(id) ?? []).join("\n") !== (right.get(id) ?? []).join("\n")
150
+ )
151
+ changed.add(id);
152
+ }
153
+ return changed.size === 1 ? [...changed][0] : undefined;
154
+ }
@@ -129,6 +129,11 @@ export type OpenPortOptions = {
129
129
  /** Set false for one-shot commands (demo, export) that must not spawn dawgd. */
130
130
  daemon?: boolean;
131
131
  daemonArgs?: string[];
132
+ /**
133
+ * File sessions: the backstop poll while fs.watch is live (default 1 s).
134
+ * Tests raise it to prove a change arrived through the watcher.
135
+ */
136
+ watchedPollMs?: number;
132
137
  };
133
138
 
134
139
  /** Connects to (or starts) dawgd, falling back to the file-lock path. */
@@ -225,7 +230,11 @@ class DaemonPort<T> implements SessionPort<T> {
225
230
  }
226
231
  if (result.status === "accepted" || result.status === "duplicate") {
227
232
  this.setSync("synced");
228
- return this.record();
233
+ // The client's record can already hold a later write from another
234
+ // window; mark which revision is this one's (OwnWrites reads it).
235
+ const record = { ...this.record() };
236
+ WRITTEN_REVISION.set(record, result.revision);
237
+ return record;
229
238
  }
230
239
  if (result.status === "rebase") {
231
240
  this.setSync("conflict");
@@ -308,6 +317,12 @@ class DaemonPort<T> implements SessionPort<T> {
308
317
  }
309
318
  }
310
319
 
320
+ /**
321
+ * The revision an append produced, for records whose last event may be a
322
+ * newer write from elsewhere (a daemon window's record follows the log).
323
+ */
324
+ export const WRITTEN_REVISION = new WeakMap<object, number>();
325
+
311
326
  class FilePort<T> implements SessionPort<T> {
312
327
  public readonly mode = "file" as const;
313
328
  public readonly clientId = randomUUID();
@@ -408,10 +423,10 @@ class FilePort<T> implements SessionPort<T> {
408
423
  };
409
424
  const recordName = basename(this.options.paths.record);
410
425
  // macOS coalesces the temp write and the rename into events named after
411
- // the temp file (`<record>.<pid>.tmp`), so match that too, but not the
426
+ // the temp file (`<record>.<pid>.<n>.tmp`), so match that too, but not the
412
427
  // sibling `.audio.lock` / `.daemon.log` files that share the prefix.
413
428
  const recordFile = new RegExp(
414
- `^${recordName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\.\\d+\\.tmp)?$`,
429
+ `^${recordName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:(?:\\.\\d+)+\\.tmp)?$`,
415
430
  );
416
431
  let watcher: FSWatcher | undefined;
417
432
  try {
@@ -429,7 +444,10 @@ class FilePort<T> implements SessionPort<T> {
429
444
  } catch {
430
445
  watcher = undefined;
431
446
  }
432
- const timer = setInterval(check, watcher ? WATCHED_POLL_MS : POLL_MS);
447
+ const timer = setInterval(
448
+ check,
449
+ watcher ? (this.options.watchedPollMs ?? WATCHED_POLL_MS) : POLL_MS,
450
+ );
433
451
  this.statusListeners.add(listener);
434
452
  check();
435
453
  return () => {
@@ -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";
@@ -32,6 +33,9 @@ export class FilePresence {
32
33
  private readonly lock: string;
33
34
  private entry: PresenceEntry;
34
35
  private timer: ReturnType<typeof setInterval> | undefined;
36
+ private writing: Promise<void> = Promise.resolve();
37
+ private writes = 0;
38
+ private stopped = false;
35
39
 
36
40
  public constructor(paths: SessionPaths, entry: PresenceEntry) {
37
41
  this.dir = `${paths.record}.presence`;
@@ -40,6 +44,7 @@ export class FilePresence {
40
44
  }
41
45
 
42
46
  public async start(): Promise<void> {
47
+ this.stopped = false;
43
48
  await this.write();
44
49
  this.timer = setInterval(
45
50
  () => void this.write().catch(() => {}),
@@ -49,14 +54,19 @@ export class FilePresence {
49
54
  }
50
55
 
51
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;
52
60
  if (this.timer) clearInterval(this.timer);
53
61
  this.timer = undefined;
62
+ await this.writing;
54
63
  await rm(this.file(), { force: true });
55
64
  }
56
65
 
57
66
  public async focus(trackId: string | null): Promise<void> {
58
67
  this.entry = { ...this.entry, focusedTrackId: trackId };
59
- await this.write();
68
+ // Presence is advisory: a failed heartbeat write never refuses a focus change.
69
+ await this.write().catch(() => undefined);
60
70
  }
61
71
 
62
72
  public async claim(
@@ -98,14 +108,17 @@ export class FilePresence {
98
108
 
99
109
  /** Live entries; stale or dead-process heartbeats are removed. */
100
110
  public async list(now = Date.now()): Promise<PresenceEntry[]> {
101
- let names: string[];
111
+ let all: string[];
102
112
  try {
103
- names = (await readdir(this.dir)).filter((name) =>
104
- name.endsWith(".json"),
105
- );
113
+ all = await readdir(this.dir);
106
114
  } catch {
107
115
  return [];
108
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"));
109
122
  const entries: PresenceEntry[] = [];
110
123
  for (const name of names.slice(0, MAX_PRESENCE_FILES)) {
111
124
  const path = join(this.dir, name);
@@ -133,18 +146,48 @@ export class FilePresence {
133
146
  return join(this.dir, `${this.entry.clientId}.json`);
134
147
  }
135
148
 
136
- private async write(): Promise<void> {
149
+ /** Writes are serialized so the heartbeat and focus() never share a rename. */
150
+ private write(): Promise<void> {
151
+ const next = this.writing.then(
152
+ () => this.writeNow(),
153
+ () => this.writeNow(),
154
+ );
155
+ this.writing = next.catch(() => undefined);
156
+ return next;
157
+ }
158
+
159
+ private async writeNow(): Promise<void> {
160
+ if (this.stopped) return;
137
161
  await mkdir(this.dir, { recursive: true });
138
- const temporary = `${this.file()}.${process.pid}.tmp`;
162
+ const temporary = `${this.file()}.${process.pid}.${++this.writes}.tmp`;
139
163
  await writeFile(
140
164
  temporary,
141
165
  JSON.stringify({ ...this.entry, at: Date.now() }),
142
166
  "utf8",
143
167
  );
168
+ if (this.stopped) {
169
+ await rm(temporary, { force: true });
170
+ return;
171
+ }
144
172
  await rename(temporary, this.file());
145
173
  }
146
174
  }
147
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
+
148
191
  function alive(pid: number): boolean {
149
192
  try {
150
193
  process.kill(pid, 0);
@@ -551,7 +551,11 @@ function requireRecord(value: unknown): SessionRecord<unknown> {
551
551
  !Number.isSafeInteger(record.revision) ||
552
552
  typeof record.updatedAt !== "string" ||
553
553
  !Array.isArray(record.events) ||
554
- record.events.length !== record.revision ||
554
+ (record.folded !== undefined &&
555
+ (!Number.isSafeInteger(record.folded) ||
556
+ (record.folded as number) < 1)) ||
557
+ record.events.length + ((record.folded as number | undefined) ?? 0) !==
558
+ record.revision ||
555
559
  record.composition === undefined
556
560
  )
557
561
  throw new ProtocolError("invalid", "record is invalid");
@@ -84,6 +84,7 @@ export function rebaseOperations(
84
84
  break;
85
85
  }
86
86
  case "updateTrack":
87
+ case "setClips":
87
88
  case "setAutomation": {
88
89
  if (added.tracks.has(operation.trackId)) break;
89
90
  if (!settingsUnchanged(operation.trackId))
@@ -123,10 +124,18 @@ export function rebaseOperations(
123
124
  if (!same(base.tuning ?? null, current.tuning ?? null))
124
125
  return { ok: false, reason: "tuning changed" };
125
126
  break;
127
+ case "setCalibration":
128
+ if ((base.calibration ?? 0) !== (current.calibration ?? 0))
129
+ return { ok: false, reason: "calibration changed" };
130
+ break;
126
131
  case "setMaster":
127
132
  if (!deepEqual(base.master, current.master))
128
133
  return { ok: false, reason: "master changed" };
129
134
  break;
135
+ case "setStyle":
136
+ if (!deepEqual(base.style, current.style))
137
+ return { ok: false, reason: "style changed" };
138
+ break;
130
139
  case "setSections":
131
140
  if (
132
141
  JSON.stringify(base.sections) !== JSON.stringify(current.sections) ||
@@ -177,13 +186,17 @@ export function compositionAt(
177
186
  record: {
178
187
  composition: unknown;
179
188
  events: readonly { revision: number; rewind?: Rewind | undefined }[];
189
+ /** Events folded out of the front of the log (see `SessionRecord`). */
190
+ folded?: number | undefined;
180
191
  },
181
192
  revision: number,
182
193
  ): unknown {
183
194
  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);
195
+ const base = record.folded ?? 0;
196
+ if (!Number.isSafeInteger(revision) || revision < base) return undefined;
197
+ const start = revision - base;
198
+ if (start >= events.length) return undefined;
199
+ for (let index = start; index < events.length; index += 1)
200
+ if (events[index]!.revision !== base + index + 1) return undefined;
201
+ return rewindComposition(record.composition, events, start);
189
202
  }