@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
@@ -1,24 +1,31 @@
1
1
  /**
2
2
  * Two-way sync between the project files and the session score.
3
3
  *
4
- * files → score: a change to `song.ts` or `tracks/<slug>/track.ts` (fs.watch
5
- * plus a slow poll, debounced 150 ms) evaluates the project, adopts the
6
- * session's note ids, diffs against the current score and commits one
7
- * `files.apply` revision through the session port. A failed evaluation is
8
- * reported as a card and leaves the score alone.
4
+ * files → score: a change to any project source (`song.ts`, track files,
5
+ * helper modules they import, tuning files; fs.watch plus a slow poll,
6
+ * debounced 40 ms) evaluates the project, adopts the session's note ids,
7
+ * diffs against the current score and commits one `files.apply` revision
8
+ * through the session port. A failed evaluation is reported as a card and
9
+ * leaves the score alone.
9
10
  *
10
11
  * score → files: a new score revision reprints the project and writes only
11
- * the files whose bytes differ. Files whose last evaluation already equals
12
- * the score are never rewritten, so an author's own formatting survives.
12
+ * the files whose own slice of the score changed, so an author's formatting
13
+ * and comments in untouched files survive. A file edited since dawg last
14
+ * synced it (hash differs from `.dawg/sync.json`) is never overwritten: the
15
+ * edit wins and a card says so.
13
16
  *
14
- * Echo suppression: `.dawg/sync.json` records the hash of every file dawg
15
- * wrote; a watcher event whose file still hashes the same is ignored, in
16
- * this window and every other one.
17
+ * Echo suppression: `.dawg/sync.json` records the hash of every file as of
18
+ * the last time files and score agreed; a watcher event whose changed files
19
+ * all still hash the same is ignored, in this window and every other one.
20
+ *
21
+ * Ownership: `.dawg/sync.json` names the session the files belong to and
22
+ * the live windows syncing it. A window on a different session while the
23
+ * owner is still open never reads or writes the files.
17
24
  */
18
25
 
19
- import { createHash } from "node:crypto";
20
- import { watch, type FSWatcher } from "node:fs";
21
- import { readdir, readFile, rm, stat } from "node:fs/promises";
26
+ import { createHash, randomUUID } from "node:crypto";
27
+ import { watch, type Dirent, type FSWatcher } from "node:fs";
28
+ import { mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
22
29
  import { join } from "node:path";
23
30
  import {
24
31
  adoptNoteIds,
@@ -34,10 +41,15 @@ import {
34
41
  type EvalResult,
35
42
  } from "../../core/sdk/eval.ts";
36
43
  import { printProject } from "../../core/sdk/print.ts";
44
+ import { acquireSessionLock } from "../session/lock.ts";
37
45
  import { writeAtomic } from "./init.ts";
38
46
  import { typecheckProject, type TypecheckResult } from "./typecheck.ts";
39
47
 
40
- export const SYNC_DEBOUNCE_MS = 150;
48
+ /**
49
+ * Quiet time after a file event before evaluating: long enough to coalesce
50
+ * an editor's write-then-rename, short enough that a save feels immediate.
51
+ */
52
+ export const SYNC_DEBOUNCE_MS = 40;
41
53
  export const SYNC_POLL_MS = 1_500;
42
54
  const SYNC_STATE_FILE = ".dawg/sync.json";
43
55
  const MAX_TRACKED_FILES = 512;
@@ -73,6 +85,11 @@ export function applyFiles(
73
85
  /** What the sync needs from its window. */
74
86
  export type SyncHost = Readonly<{
75
87
  project: string;
88
+ /**
89
+ * The session this window is attached to; it may change when the window
90
+ * switches sessions. Omitted, the files are never treated as owned.
91
+ */
92
+ sessionId?: () => string | undefined;
76
93
  /** Current session score. */
77
94
  current(): TrackScore;
78
95
  /**
@@ -111,7 +128,15 @@ export type ProjectSync = Readonly<{
111
128
  stop(): Promise<void>;
112
129
  }>;
113
130
 
114
- type SyncState = { files: Record<string, string> };
131
+ type Holder = { pid: number; token: string };
132
+ type SyncOwner = { sessionId: string; holders: Holder[] };
133
+ type SyncState = { files: Record<string, string>; owner?: SyncOwner };
134
+
135
+ /** Tokens of the syncs running in this process (pids alone cannot tell them apart). */
136
+ const liveTokens = new Set<string>();
137
+ const SYNC_LOCK_FILE = ".dawg/sync.lock";
138
+ const MAX_HOLDERS = 64;
139
+ const TRACK_FILE = /^tracks\/[^/]+\/track\.ts$/;
115
140
 
116
141
  export function startProjectSync(
117
142
  host: SyncHost,
@@ -124,13 +149,24 @@ export function startProjectSync(
124
149
  const debounceMs = options.debounceMs ?? SYNC_DEBOUNCE_MS;
125
150
  const project = host.project;
126
151
  const statePath = join(project, SYNC_STATE_FILE);
127
- /** Hashes of the project files as of the last successful evaluation. */
152
+ const lockPath = join(project, SYNC_LOCK_FILE);
153
+ const token = randomUUID();
154
+ liveTokens.add(token);
155
+ /** Hashes of the project sources as of this window's last look. */
128
156
  let seen = new Map<string, string>();
129
157
  /** Outcome of the last evaluation, returned when the files did not change. */
130
158
  let lastOutcome = "files match the score";
131
159
  /** Encoded score the files were last known to equal. */
132
160
  let filesEqual = "";
161
+ /** Per file: the canonical print its current contents are known to evaluate to. */
162
+ let known = new Map<string, string>();
133
163
  let lastRejection = "";
164
+ let lastConflict = "";
165
+ /** Session id this window last claimed the files for; undefined while detached. */
166
+ let attached: string | undefined;
167
+ let detachedNotice = "";
168
+ /** The next look at the files decides who wins (startup, session switch). */
169
+ let needsStartup = true;
134
170
  let stopped = false;
135
171
  let queue: Promise<void> = Promise.resolve();
136
172
  let fileTimer: ReturnType<typeof setTimeout> | undefined;
@@ -152,25 +188,99 @@ export function startProjectSync(
152
188
  try {
153
189
  const value: unknown = JSON.parse(await readFile(statePath, "utf8"));
154
190
  if (typeof value !== "object" || value === null) return { files: {} };
155
- const files = (value as Record<string, unknown>).files;
156
- if (typeof files !== "object" || files === null) return { files: {} };
191
+ const record = value as Record<string, unknown>;
192
+ const files = record.files;
157
193
  const out: Record<string, string> = {};
158
- for (const [path, hash] of Object.entries(
159
- files as Record<string, unknown>,
160
- ))
161
- if (
162
- typeof hash === "string" &&
163
- /^[0-9a-f]{64}$/.test(hash) &&
164
- path.length < 512
165
- )
166
- out[path] = hash;
167
- return { files: out };
194
+ if (typeof files === "object" && files !== null)
195
+ for (const [path, hash] of Object.entries(
196
+ files as Record<string, unknown>,
197
+ ))
198
+ if (
199
+ typeof hash === "string" &&
200
+ /^[0-9a-f]{64}$/.test(hash) &&
201
+ path.length < 512
202
+ )
203
+ out[path] = hash;
204
+ const state: SyncState = { files: out };
205
+ const owner = parseOwner(record.owner);
206
+ if (owner) state.owner = owner;
207
+ return state;
168
208
  } catch {
169
209
  return { files: {} };
170
210
  }
171
211
  };
172
212
  const writeState = (state: SyncState) =>
173
213
  writeAtomic(statePath, `${JSON.stringify(state, null, 2)}\n`);
214
+ /** Serializes every read-modify-write of sync.json and the files across windows. */
215
+ const locked = async <T>(task: () => Promise<T>): Promise<T> => {
216
+ await mkdir(join(project, ".dawg"), { recursive: true });
217
+ const release = await acquireSessionLock(lockPath);
218
+ try {
219
+ return await task();
220
+ } finally {
221
+ await release();
222
+ }
223
+ };
224
+
225
+ /**
226
+ * Claims the files for this window's session. A different session owns
227
+ * them while any of its windows is still open: this window then stays
228
+ * detached and never touches the files. Returns whether the files are
229
+ * this window's; a changed session makes the next look a fresh startup.
230
+ */
231
+ const claim = async (): Promise<{ ok: boolean }> => {
232
+ const sessionId = host.sessionId?.();
233
+ if (sessionId === undefined) return { ok: true };
234
+ const result = await locked(async () => {
235
+ const state = await readState();
236
+ const owner = state.owner;
237
+ const others = (owner?.holders ?? []).filter(
238
+ (holder) => holder.token !== token && holderAlive(holder),
239
+ );
240
+ if (owner && owner.sessionId !== sessionId && others.length > 0)
241
+ return { ok: false, owner: owner.sessionId };
242
+ const holders =
243
+ owner?.sessionId === sessionId
244
+ ? [...others, { pid: process.pid, token }]
245
+ : [{ pid: process.pid, token }];
246
+ const next: SyncOwner = {
247
+ sessionId,
248
+ holders: holders.slice(-MAX_HOLDERS),
249
+ };
250
+ if (JSON.stringify(next) !== JSON.stringify(owner))
251
+ await writeState({ ...state, owner: next });
252
+ return { ok: true, owner: sessionId };
253
+ });
254
+ if (!result.ok) {
255
+ attached = undefined;
256
+ const notice = `files belong to session ${result.owner}`;
257
+ if (notice !== detachedNotice) {
258
+ detachedNotice = notice;
259
+ host.card(
260
+ `${notice} · not synced here`,
261
+ "warning",
262
+ "another window has them open; close it or switch to that session",
263
+ );
264
+ }
265
+ return { ok: false };
266
+ }
267
+ detachedNotice = "";
268
+ if (attached !== sessionId) needsStartup = true;
269
+ attached = sessionId;
270
+ return { ok: true };
271
+ };
272
+
273
+ const recordSynced = async (hashes: Map<string, string>): Promise<void> => {
274
+ await locked(async () => {
275
+ const state = await readState();
276
+ const files = { ...state.files };
277
+ for (const [path, hash] of hashes) files[path] = hash;
278
+ for (const path of Object.keys(files))
279
+ if (!hashes.has(path) && !(await exists(join(project, path))))
280
+ delete files[path];
281
+ await writeState({ ...state, files });
282
+ });
283
+ };
174
284
 
175
285
  /**
176
286
  * Evaluates the files. In `files-win` mode (edits) the result is committed;
@@ -178,11 +288,51 @@ export function startProjectSync(
178
288
  * the files from the score instead.
179
289
  */
180
290
  const checkFilesNow = async (
181
- mode: "files-win" | "score-wins" = "files-win",
291
+ requested: "files-win" | "score-wins" = "files-win",
182
292
  ): Promise<string> => {
293
+ let mode = requested;
183
294
  if (stopped) return lastOutcome;
295
+ const claimed = await claim();
296
+ if (!claimed.ok) return (lastOutcome = detachedNotice);
297
+ if (needsStartup) {
298
+ // First look (or this window switched sessions): files edited since
299
+ // dawg last synced them (or never recorded) win; otherwise the
300
+ // session score wins and reprints what the files lack.
301
+ needsStartup = false;
302
+ seen = new Map();
303
+ known = new Map();
304
+ filesEqual = "";
305
+ const state = await readState();
306
+ const hashes = await hashProject(project);
307
+ const edited =
308
+ hashes.size === 0 ||
309
+ [...hashes].some(([path, hash]) => state.files[path] !== hash);
310
+ mode = edited ? "files-win" : "score-wins";
311
+ }
184
312
  const current = await hashProject(project);
185
- if (mode === "files-win" && sameHashes(current, seen)) return lastOutcome;
313
+ if (mode === "files-win") {
314
+ // Nothing re-evaluated: a standing rejection still holds, anything
315
+ // else is reported as unchanged rather than repeating an old apply.
316
+ if (sameHashes(current, seen))
317
+ return lastRejection ? lastOutcome : (lastOutcome = UNCHANGED);
318
+ // An echo: every changed file is exactly what dawg (this window or
319
+ // another) last synced, so the session already holds it.
320
+ const state = await readState();
321
+ const changed = changedPaths(current, seen);
322
+ if (
323
+ seen.size > 0 &&
324
+ !lastRejection &&
325
+ changed.every(
326
+ (path) =>
327
+ current.get(path) !== undefined &&
328
+ state.files[path] === current.get(path),
329
+ )
330
+ ) {
331
+ for (const path of changed) known.delete(path);
332
+ seen = current;
333
+ return (lastOutcome = "files match the score");
334
+ }
335
+ }
186
336
  const result = await evaluate(project);
187
337
  if (!result.ok) {
188
338
  const text = result.diagnostics.map(formatDiagnostic).join("; ");
@@ -194,19 +344,23 @@ export function startProjectSync(
194
344
  "fix the file to apply",
195
345
  );
196
346
  }
347
+ seen = current;
197
348
  void runTypecheck();
198
349
  return (lastOutcome = `files rejected · ${text}`);
199
350
  }
200
351
  lastRejection = "";
201
352
  seen = current;
353
+ known = printedTexts(result.score);
202
354
  const plan = applyFiles(host.current(), result.score);
203
355
  if (plan.operations.length === 0) {
204
356
  filesEqual = encodeLoop(host.current());
357
+ await recordSynced(current);
205
358
  void runTypecheck();
206
359
  return (lastOutcome = "files match the score");
207
360
  }
208
361
  if (mode === "score-wins") {
209
362
  pendingScore = host.current();
363
+ await recordSynced(current);
210
364
  await flushScoreNow();
211
365
  void runTypecheck();
212
366
  return (lastOutcome = "files reprinted from the score");
@@ -215,6 +369,7 @@ export function startProjectSync(
215
369
  try {
216
370
  const committed = await host.commit(plan, summary);
217
371
  filesEqual = encodeLoop(committed);
372
+ await recordSynced(current);
218
373
  host.card(`applied from files · ${summary}`, "success");
219
374
  lastOutcome = `applied from files · ${summary}`;
220
375
  } catch (error) {
@@ -229,35 +384,81 @@ export function startProjectSync(
229
384
  };
230
385
 
231
386
  const flushScoreNow = async (): Promise<void> => {
387
+ if (stopped || !pendingScore) return;
388
+ const claimed = await claim();
389
+ if (!claimed.ok) return;
390
+ if (needsStartup) await checkFilesNow("files-win");
232
391
  if (stopped || !pendingScore) return;
233
392
  const score = pendingScore;
234
393
  pendingScore = undefined;
235
394
  const encoded = encodeLoop(score);
236
395
  if (encoded === filesEqual) return;
237
396
  const printed = printProject(score);
238
- const state = await readState();
239
- const files = { ...state.files };
240
- const expected = new Set(printed.files.map((file) => file.path));
241
- const hashes = new Map<string, string>();
242
- for (const file of printed.files) {
243
- const hash = sha256(file.text);
244
- hashes.set(file.path, hash);
245
- const existing = await readOptional(join(project, file.path));
246
- if (existing === file.text) continue;
247
- await writeAtomic(join(project, file.path), file.text);
248
- files[file.path] = hash;
397
+ const conflicts: string[] = [];
398
+ /** Paths this flush wrote (hash) or removed (undefined). */
399
+ const wrote = new Map<string, string | undefined>();
400
+ await locked(async () => {
401
+ const state = await readState();
402
+ const files = { ...state.files };
403
+ const expected = new Set(printed.files.map((file) => file.path));
404
+ for (const file of printed.files) {
405
+ const path = join(project, file.path);
406
+ const existing = await readOptional(path);
407
+ if (existing === file.text) {
408
+ files[file.path] = sha256(existing);
409
+ known.set(file.path, file.text);
410
+ continue;
411
+ }
412
+ // This file's slice of the score is unchanged: keep the author's text.
413
+ if (existing !== undefined && known.get(file.path) === file.text)
414
+ continue;
415
+ // Edited since dawg last synced it (or never synced): the edit wins.
416
+ if (existing !== undefined && files[file.path] !== sha256(existing)) {
417
+ conflicts.push(file.path);
418
+ continue;
419
+ }
420
+ await writeAtomic(path, file.text);
421
+ files[file.path] = sha256(file.text);
422
+ wrote.set(file.path, files[file.path]);
423
+ known.set(file.path, file.text);
424
+ }
425
+ // Track files dawg printed earlier for tracks that no longer exist.
426
+ for (const [path, hash] of Object.entries(state.files)) {
427
+ if (expected.has(path) || !TRACK_FILE.test(path)) continue;
428
+ const existing = await readOptional(join(project, path));
429
+ if (existing !== undefined && sha256(existing) !== hash) continue;
430
+ if (existing !== undefined)
431
+ await rm(join(project, path), { force: true });
432
+ wrote.set(path, undefined);
433
+ delete files[path];
434
+ known.delete(path);
435
+ }
436
+ await writeState({ ...state, files });
437
+ });
438
+ // Only our own writes are folded into `seen`: an edit the author saved
439
+ // to any other file since the last look must still be evaluated. While
440
+ // the files are rejected nothing is folded, so the next look re-evaluates.
441
+ if (!lastRejection) {
442
+ seen = new Map(seen);
443
+ for (const [path, hash] of wrote)
444
+ if (hash === undefined) seen.delete(path);
445
+ else seen.set(path, hash);
446
+ }
447
+ if (conflicts.length === 0) {
448
+ filesEqual = encoded;
449
+ lastConflict = "";
450
+ return;
249
451
  }
250
- // Track files we printed earlier for tracks that no longer exist.
251
- for (const [path, hash] of Object.entries(state.files)) {
252
- if (expected.has(path) || !path.startsWith("tracks/")) continue;
253
- const existing = await readOptional(join(project, path));
254
- if (existing !== undefined && sha256(existing) === hash)
255
- await rm(join(project, path), { force: true });
256
- delete files[path];
452
+ filesEqual = "";
453
+ const text = conflicts.join(", ");
454
+ if (text !== lastConflict) {
455
+ lastConflict = text;
456
+ host.card(
457
+ `${text} edited · not overwritten`,
458
+ "warning",
459
+ "save it to apply, or delete it to reprint",
460
+ );
257
461
  }
258
- await writeState({ files });
259
- seen = await hashProject(project);
260
- filesEqual = encoded;
261
462
  };
262
463
 
263
464
  let typecheckRunning = false;
@@ -283,12 +484,29 @@ export function startProjectSync(
283
484
  }
284
485
  };
285
486
 
487
+ /** Errors (a lock timeout, a full disk) become a card, never an unhandled rejection. */
488
+ const guarded =
489
+ <T>(task: () => Promise<T>, fallback: T) =>
490
+ async (): Promise<T> => {
491
+ try {
492
+ return await task();
493
+ } catch (error) {
494
+ host.card(
495
+ `file sync failed · ${firstLine(error instanceof Error ? error.message : String(error))}`,
496
+ "warning",
497
+ );
498
+ return fallback;
499
+ }
500
+ };
501
+ const filesWin = guarded(() => checkFilesNow("files-win"), "");
502
+ const flush = guarded(flushScoreNow, undefined);
503
+
286
504
  const scheduleFiles = () => {
287
505
  if (stopped) return;
288
506
  if (fileTimer) clearTimeout(fileTimer);
289
507
  fileTimer = setTimeout(() => {
290
508
  fileTimer = undefined;
291
- void enqueue(() => checkFilesNow("files-win"));
509
+ void enqueue(filesWin);
292
510
  }, debounceMs);
293
511
  };
294
512
 
@@ -312,21 +530,12 @@ export function startProjectSync(
312
530
  watchers = [];
313
531
  }
314
532
  poll = setInterval(
315
- () => void enqueue(() => checkFilesNow("files-win")),
533
+ () => void enqueue(filesWin),
316
534
  options.pollMs ?? SYNC_POLL_MS,
317
535
  );
318
536
  poll.unref?.();
319
537
  }
320
- // Startup: files edited since dawg last wrote them (or never recorded)
321
- // win; otherwise the session score wins and reprints what files lack.
322
- void enqueue(async () => {
323
- const state = await readState();
324
- const current = await hashProject(project);
325
- const edited =
326
- current.size === 0 ||
327
- [...current].some(([path, hash]) => state.files[path] !== hash);
328
- await checkFilesNow(edited ? "files-win" : "score-wins");
329
- });
538
+ void enqueue(filesWin);
330
539
 
331
540
  return Object.freeze({
332
541
  scoreChanged(score: TrackScore) {
@@ -335,7 +544,7 @@ export function startProjectSync(
335
544
  if (scoreTimer) clearTimeout(scoreTimer);
336
545
  scoreTimer = setTimeout(() => {
337
546
  scoreTimer = undefined;
338
- void enqueue(flushScoreNow);
547
+ void enqueue(flush);
339
548
  }, debounceMs);
340
549
  },
341
550
  checkFiles: () => enqueue(() => checkFilesNow("files-win")),
@@ -348,48 +557,171 @@ export function startProjectSync(
348
557
  watchers.forEach((watcher) => watcher.close());
349
558
  watchers = [];
350
559
  await queue.catch(() => undefined);
560
+ liveTokens.delete(token);
561
+ // Release this window's hold so another session may take the files.
562
+ await locked(async () => {
563
+ const state = await readState();
564
+ if (!state.owner?.holders.some((holder) => holder.token === token))
565
+ return;
566
+ await writeState({
567
+ ...state,
568
+ owner: {
569
+ ...state.owner,
570
+ holders: state.owner.holders.filter(
571
+ (holder) => holder.token !== token,
572
+ ),
573
+ },
574
+ });
575
+ }).catch(() => undefined);
351
576
  },
352
577
  });
353
578
  }
354
579
 
355
- /** Project-relative source paths: `song.ts` and each track file under `tracks/`. */
580
+ /** Text extensions a project's evaluation can read: modules, data and tuning. */
581
+ const SOURCE_EXTENSION = /\.(?:[cm]?[jt]sx?|json|scl|kbm)$/;
582
+ const MAX_SOURCE_DEPTH = 6;
583
+ /** Outcome when no project source changed since the last look. */
584
+ export const UNCHANGED = "files unchanged";
585
+ /** Project configuration, not evaluated: editing it never re-evaluates. */
586
+ const CONFIG_FILES = new Set([
587
+ "dawg.json",
588
+ "tsconfig.json",
589
+ "jsconfig.json",
590
+ "package.json",
591
+ ]);
592
+ /** Larger files are fingerprinted by size and mtime instead of content. */
593
+ const MAX_HASHED_BYTES = 2 * 1024 * 1024;
594
+
595
+ /**
596
+ * Project-relative source paths: `song.ts`, each `tracks/<slug>/track.ts`,
597
+ * and every other module, JSON or tuning file in the project that an
598
+ * evaluation could import or read. Dot directories (`.dawg`, `.git`) and
599
+ * `node_modules` are skipped; bounded by count and depth.
600
+ */
356
601
  export async function projectSourceFiles(project: string): Promise<string[]> {
357
- const files = ["song.ts"];
358
- let dirs: string[] = [];
359
- try {
360
- dirs = (await readdir(join(project, "tracks"), { withFileTypes: true }))
361
- .filter((entry) => entry.isDirectory())
362
- .map((entry) => entry.name)
363
- .sort();
364
- } catch {
365
- dirs = [];
366
- }
367
- for (const dir of dirs.slice(0, MAX_TRACKED_FILES)) {
368
- const path = `tracks/${dir}/track.ts`;
602
+ const out: string[] = [];
603
+ const walk = async (dir: string, depth: number): Promise<void> => {
604
+ if (depth > MAX_SOURCE_DEPTH || out.length >= MAX_TRACKED_FILES) return;
605
+ let entries: Dirent[];
369
606
  try {
370
- if ((await stat(join(project, path))).isFile()) files.push(path);
607
+ entries = await readdir(join(project, dir), { withFileTypes: true });
371
608
  } catch {
372
- // A directory without a track file (samples only) is not a source.
609
+ return;
373
610
  }
374
- }
375
- return files;
611
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
612
+ for (const entry of entries) {
613
+ if (out.length >= MAX_TRACKED_FILES) return;
614
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
615
+ const path = dir ? `${dir}/${entry.name}` : entry.name;
616
+ if (CONFIG_FILES.has(path)) continue;
617
+ if (entry.isDirectory()) await walk(path, depth + 1);
618
+ else if (entry.isFile() && SOURCE_EXTENSION.test(entry.name))
619
+ out.push(path);
620
+ }
621
+ };
622
+ await walk("", 0);
623
+ return out.sort(
624
+ (a, b) => sourceRank(a) - sourceRank(b) || (a < b ? -1 : a > b ? 1 : 0),
625
+ );
626
+ }
627
+
628
+ /** `song.ts` first, then track files, then everything else. */
629
+ function sourceRank(path: string): number {
630
+ return path === "song.ts" ? 0 : TRACK_FILE.test(path) ? 1 : 2;
376
631
  }
377
632
 
378
633
  function isProjectSource(name: string): boolean {
379
- return (
380
- name === "song.ts" || /(^|\/)track\.ts$/.test(name) || !name.includes(".")
381
- );
634
+ const base = name.split(/[\\/]/).pop() ?? name;
635
+ if (base.startsWith(".") && base.length > 0) return false;
636
+ return SOURCE_EXTENSION.test(base) || !base.includes(".");
382
637
  }
383
638
 
384
639
  async function hashProject(project: string): Promise<Map<string, string>> {
385
640
  const out = new Map<string, string>();
386
641
  for (const path of await projectSourceFiles(project)) {
387
- const text = await readOptional(join(project, path));
388
- if (text !== undefined) out.set(path, sha256(text));
642
+ const hash = await hashFile(join(project, path));
643
+ if (hash !== undefined) out.set(path, hash);
389
644
  }
390
645
  return out;
391
646
  }
392
647
 
648
+ async function hashFile(path: string): Promise<string | undefined> {
649
+ try {
650
+ const info = await stat(path);
651
+ if (info.size > MAX_HASHED_BYTES)
652
+ return sha256(`${info.size}:${info.mtimeMs}`);
653
+ return sha256(await readFile(path, "utf8"));
654
+ } catch {
655
+ return undefined;
656
+ }
657
+ }
658
+
659
+ function printedTexts(score: TrackScore): Map<string, string> {
660
+ return new Map(
661
+ printProject(score).files.map((file) => [file.path, file.text]),
662
+ );
663
+ }
664
+
665
+ function changedPaths(
666
+ a: Map<string, string>,
667
+ b: Map<string, string>,
668
+ ): string[] {
669
+ const out = new Set<string>();
670
+ for (const [path, hash] of a) if (b.get(path) !== hash) out.add(path);
671
+ for (const path of b.keys()) if (!a.has(path)) out.add(path);
672
+ return [...out];
673
+ }
674
+
675
+ function parseOwner(value: unknown): SyncOwner | undefined {
676
+ if (typeof value !== "object" || value === null) return undefined;
677
+ const record = value as Record<string, unknown>;
678
+ if (
679
+ typeof record.sessionId !== "string" ||
680
+ record.sessionId.length === 0 ||
681
+ record.sessionId.length > 128 ||
682
+ !Array.isArray(record.holders)
683
+ )
684
+ return undefined;
685
+ const holders: Holder[] = [];
686
+ for (const holder of record.holders.slice(0, MAX_HOLDERS)) {
687
+ if (typeof holder !== "object" || holder === null) continue;
688
+ const { pid, token } = holder as Record<string, unknown>;
689
+ if (
690
+ typeof pid === "number" &&
691
+ Number.isSafeInteger(pid) &&
692
+ pid > 0 &&
693
+ typeof token === "string" &&
694
+ token.length > 0 &&
695
+ token.length <= 64
696
+ )
697
+ holders.push({ pid, token });
698
+ }
699
+ return { sessionId: record.sessionId, holders };
700
+ }
701
+
702
+ function holderAlive(holder: Holder): boolean {
703
+ if (holder.pid === process.pid) return liveTokens.has(holder.token);
704
+ try {
705
+ process.kill(holder.pid, 0);
706
+ return true;
707
+ } catch (error) {
708
+ return !(
709
+ error instanceof Error &&
710
+ "code" in error &&
711
+ error.code === "ESRCH"
712
+ );
713
+ }
714
+ }
715
+
716
+ async function exists(path: string): Promise<boolean> {
717
+ try {
718
+ await stat(path);
719
+ return true;
720
+ } catch {
721
+ return false;
722
+ }
723
+ }
724
+
393
725
  function sameHashes(a: Map<string, string>, b: Map<string, string>): boolean {
394
726
  if (a.size !== b.size) return false;
395
727
  for (const [path, hash] of a) if (b.get(path) !== hash) return false;