@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,4 +1,5 @@
1
- import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
1
+ import { mkdir, readFile } from "node:fs/promises";
2
+ import { durableWrite } from "../fs/durable.ts";
2
3
  import { dirname, join } from "node:path";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { acquireSessionLock } from "./lock.ts";
@@ -20,9 +21,13 @@ const MAX_EVENT_BYTES = 64 * 1024;
20
21
  /** A rewind larger than this (a wholesale import) is not kept; the edit still lands. */
21
22
  const MAX_REWIND_BYTES = 1024 * 1024;
22
23
  const MAX_EVENTS = 2_000;
24
+ /**
25
+ * When the log is full, this many of the oldest events are folded away so a
26
+ * long session keeps accepting edits. The composition is the checkpoint;
27
+ * undo still reaches back through the events that remain.
28
+ */
29
+ const FOLD_BATCH = 200;
23
30
  export const MAX_RECORD_BYTES = 4 * 1024 * 1024;
24
- /** Oldest rewinds are dropped in batches of this size when the record fills up. */
25
- const COMPACT_BATCH = 64;
26
31
  const MAX_SESSION_ID_LENGTH = 64;
27
32
  const MAX_EVENT_KIND_LENGTH = 128;
28
33
  const MAX_TIMESTAMP_LENGTH = 64;
@@ -34,12 +39,36 @@ export class SessionValidationError extends Error {
34
39
  }
35
40
  }
36
41
 
42
+ /**
43
+ * Who made an event. Stamped by the authority from the connection's hello,
44
+ * never trusted from the intent: `actorId` is the person (absent for v1
45
+ * clients and the file fallback), `clientId` the window, `seq` the
46
+ * per-(actor, client) intent number used for retry dedupe.
47
+ */
48
+ export type EventActor = {
49
+ actorId?: string;
50
+ clientId: string;
51
+ seq?: number;
52
+ };
53
+
37
54
  export type SessionEvent = {
38
55
  id: string;
39
56
  revision: number;
57
+ /**
58
+ * Opaque to the store: any bounded string is kept and passed through, so a
59
+ * newer peer's event kinds survive an older reader.
60
+ */
40
61
  kind: string;
41
62
  payload: unknown;
42
63
  at: string;
64
+ actor?: EventActor;
65
+ /**
66
+ * The score operations this event applied: sent by an ops intent, or
67
+ * derived by the authority from a composition intent. Folding `ops` from
68
+ * the previous composition reproduces this one. Events without `ops` are
69
+ * snapshots (too large to diff, or not expressible as operations).
70
+ */
71
+ ops?: unknown[];
43
72
  /**
44
73
  * How to recover the composition this event replaced from the one it
45
74
  * produced (see `delta.ts`). Absent once compacted away: the store drops
@@ -56,6 +85,13 @@ export type SessionRecord<T> = {
56
85
  updatedAt: string;
57
86
  composition: T;
58
87
  events: SessionEvent[];
88
+ /**
89
+ * Optional (0.7): how many of the oldest events were folded away when the
90
+ * log filled up. `revision === (folded ?? 0) + events.length`, and
91
+ * `events[i].revision === (folded ?? 0) + i + 1`. Absent on records that
92
+ * never filled, so older records load and print unchanged.
93
+ */
94
+ folded?: number;
59
95
  /** Name and lineage. Versioned separately so renames keep the revision. */
60
96
  meta: SessionMeta;
61
97
  };
@@ -192,7 +228,10 @@ export async function appendSessionEvent<T>(
192
228
  // History is the store's job: a `before` composition in the payload (the
193
229
  // pre-rewind convention) is dropped rather than persisted in full.
194
230
  const payload = stripBefore(event.payload);
195
- const payloadJson = stringifyJson(payload, "session event payload");
231
+ const payloadJson = stringifyJson(
232
+ event.ops === undefined ? payload : [payload, event.ops],
233
+ "session event payload",
234
+ );
196
235
  const payloadBytes = Buffer.byteLength(payloadJson, "utf8");
197
236
  if (payloadBytes > MAX_EVENT_BYTES)
198
237
  throw new Error(`session event exceeds ${MAX_EVENT_BYTES} bytes`);
@@ -207,10 +246,9 @@ export async function appendSessionEvent<T>(
207
246
  try {
208
247
  const disk = await readRecord<T>(paths.record);
209
248
  if (disk.revision !== current.revision) throw new SessionConflictError();
210
- // Re-check the bounded event budget against the locked record. A stale
211
- // caller can pass the pre-lock check while another writer fills the log.
212
- if (disk.events.length >= MAX_EVENTS)
213
- throw new Error(`session event limit ${MAX_EVENTS} reached`);
249
+ // A full log folds its oldest events away instead of refusing the edit.
250
+ const fold = disk.events.length >= MAX_EVENTS ? FOLD_BATCH : 0;
251
+ const folded = (disk.folded ?? 0) + fold;
214
252
  const revision = disk.revision + 1;
215
253
  const at = new Date().toISOString();
216
254
  const appended: SessionEvent = {
@@ -220,6 +258,8 @@ export async function appendSessionEvent<T>(
220
258
  revision,
221
259
  at,
222
260
  };
261
+ if (event.actor !== undefined) appended.actor = { ...event.actor };
262
+ if (event.ops !== undefined) appended.ops = [...event.ops];
223
263
  const rewind = diffRewind(disk.composition, composition);
224
264
  if (Buffer.byteLength(JSON.stringify(rewind), "utf8") <= MAX_REWIND_BYTES)
225
265
  appended.rewind = rewind;
@@ -228,7 +268,8 @@ export async function appendSessionEvent<T>(
228
268
  revision,
229
269
  updatedAt: at,
230
270
  composition,
231
- events: [...disk.events, appended],
271
+ events: [...disk.events.slice(fold), appended],
272
+ ...(folded > 0 ? { folded } : {}),
232
273
  // Metadata always comes from disk so a rename written by another window
233
274
  // between this caller's read and its write is never reverted.
234
275
  meta: disk.meta,
@@ -245,8 +286,10 @@ export async function appendSessionEvent<T>(
245
286
  /**
246
287
  * Keeps the record under its size cap by dropping the oldest events'
247
288
  * rewinds, oldest first, so the rewinds that remain are always the newest
248
- * contiguous run. Only the composition and the bounded payloads are
249
- * irreducible; a record that still does not fit is rejected as before.
289
+ * contiguous run. The appended (last) event's rewind is dropped only when
290
+ * nothing else is left to drop, so undo of the edit just made keeps working
291
+ * whenever its rewind fits on its own. Only the composition and the bounded
292
+ * payloads are irreducible; a record that still does not fit is rejected.
250
293
  */
251
294
  function compactRecord<T>(record: SessionRecord<T>): {
252
295
  record: SessionRecord<T>;
@@ -254,23 +297,27 @@ function compactRecord<T>(record: SessionRecord<T>): {
254
297
  } {
255
298
  let current = record;
256
299
  let json = JSON.stringify(current);
257
- while (Buffer.byteLength(json, "utf8") > MAX_RECORD_BYTES) {
258
- const first = current.events.findIndex(
259
- (event) => event.rewind !== undefined,
260
- );
261
- if (first < 0)
262
- throw new SessionValidationError(
263
- `session record exceeds ${MAX_RECORD_BYTES} bytes`,
264
- );
265
- const events = current.events.map((event, index) => {
266
- if (index < first || index >= first + COMPACT_BATCH || !event.rewind)
267
- return event;
268
- const { rewind: _rewind, ...rest } = event;
269
- return rest;
270
- });
271
- current = { ...current, events };
272
- json = JSON.stringify(current);
300
+ let bytes = Buffer.byteLength(json, "utf8");
301
+ if (bytes <= MAX_RECORD_BYTES) return { record: current, json };
302
+ const events = [...current.events];
303
+ const last = events.length - 1;
304
+ // Measured sizes let the loop strip exactly as many rewinds as needed with
305
+ // one final stringify instead of one per batch.
306
+ // The appended event comes last, after every older rewind is gone.
307
+ for (let index = 0; index <= last && bytes > MAX_RECORD_BYTES; index += 1) {
308
+ const event = events[index]!;
309
+ if (event.rewind === undefined) continue;
310
+ const { rewind, ...rest } = event;
311
+ // `,"rewind":<json>` is what dropping the field saves.
312
+ bytes -= Buffer.byteLength(JSON.stringify(rewind), "utf8") + 10;
313
+ events[index] = rest;
273
314
  }
315
+ current = { ...current, events };
316
+ json = JSON.stringify(current);
317
+ if (Buffer.byteLength(json, "utf8") > MAX_RECORD_BYTES)
318
+ throw new SessionValidationError(
319
+ `session record exceeds ${MAX_RECORD_BYTES} bytes`,
320
+ );
274
321
  return { record: current, json };
275
322
  }
276
323
 
@@ -383,8 +430,13 @@ export async function inheritedEvents(
383
430
  } catch {
384
431
  break;
385
432
  }
386
- if (!parent || fork.revision > parent.events.length) break;
387
- const prefix = parent.events.slice(0, fork.revision);
433
+ if (!parent || fork.revision > parent.revision) break;
434
+ // Events folded out of the parent are gone; what remains still ends at
435
+ // the fork point.
436
+ const prefix = parent.events.slice(
437
+ 0,
438
+ Math.max(0, fork.revision - (parent.folded ?? 0)),
439
+ );
388
440
  chunks.unshift(prefix.slice(-(MAX_INHERITED_EVENTS - total)));
389
441
  total += prefix.length;
390
442
  fork = parent.meta.forkOf;
@@ -413,20 +465,10 @@ async function writeAtomic(path: string, contents: string): Promise<void> {
413
465
  throw new SessionValidationError(
414
466
  `session record exceeds ${MAX_RECORD_BYTES} bytes`,
415
467
  );
416
- const temporary = `${path}.${process.pid}.tmp`;
417
468
  // Durable before it is visible: the data reaches the disk before the
418
- // rename publishes it, so a crash never leaves a truncated record behind.
419
- const handle = await open(temporary, "w", 0o644);
420
- try {
421
- await handle.writeFile(`${contents}\n`, "utf8");
422
- await handle.sync();
423
- } catch (error) {
424
- await handle.close().catch(() => undefined);
425
- await rm(temporary, { force: true }).catch(() => undefined);
426
- throw error;
427
- }
428
- await handle.close();
429
- await rename(temporary, path);
469
+ // rename publishes it, and the directory is synced so the rename survives
470
+ // a power loss too.
471
+ await durableWrite(path, `${contents}\n`, { mkdir: false });
430
472
  }
431
473
 
432
474
  async function readRecord<T>(path: string): Promise<SessionRecord<T>> {
@@ -472,7 +514,15 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
472
514
  throw new SessionValidationError(
473
515
  `session has more than ${MAX_EVENTS} events`,
474
516
  );
475
- if (record.revision !== record.events.length)
517
+ const folded = record.folded ?? 0;
518
+ if (
519
+ !Number.isSafeInteger(folded) ||
520
+ (folded as number) < 0 ||
521
+ (record.folded !== undefined && folded === 0)
522
+ )
523
+ throw new SessionValidationError("session folded count is invalid");
524
+ const base = folded as number;
525
+ if (record.revision !== base + record.events.length)
476
526
  throw new SessionValidationError(
477
527
  "session revision does not match its event log",
478
528
  );
@@ -487,7 +537,7 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
487
537
  assertSessionId(candidate.id);
488
538
  if (
489
539
  !Number.isSafeInteger(candidate.revision) ||
490
- candidate.revision !== index + 1
540
+ candidate.revision !== base + index + 1
491
541
  )
492
542
  throw new SessionValidationError("session event revisions are invalid");
493
543
  if (
@@ -502,7 +552,9 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
502
552
  )
503
553
  throw new SessionValidationError("session event timestamp is invalid");
504
554
  const payloadJson = stringifyJson(
505
- candidate.payload,
555
+ candidate.ops === undefined
556
+ ? candidate.payload
557
+ : [candidate.payload, candidate.ops],
506
558
  "session event payload",
507
559
  );
508
560
  if (Buffer.byteLength(payloadJson, "utf8") > MAX_EVENT_BYTES)
@@ -527,6 +579,13 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
527
579
  at: candidate.at,
528
580
  };
529
581
  if (rewind !== undefined) parsed.rewind = rewind;
582
+ if (candidate.actor !== undefined)
583
+ parsed.actor = parseEventActor(candidate.actor);
584
+ if (candidate.ops !== undefined) {
585
+ if (!Array.isArray(candidate.ops))
586
+ throw new SessionValidationError("session event ops are invalid");
587
+ parsed.ops = candidate.ops;
588
+ }
530
589
  return parsed;
531
590
  });
532
591
  let meta: SessionMeta;
@@ -546,6 +605,7 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
546
605
  updatedAt: record.updatedAt,
547
606
  composition: record.composition as T,
548
607
  events,
608
+ ...(base > 0 ? { folded: base } : {}),
549
609
  meta,
550
610
  };
551
611
  }
@@ -579,3 +639,24 @@ function stringifyJson(value: unknown, label: string): string {
579
639
  throw new SessionValidationError(`${label} must be JSON data`);
580
640
  }
581
641
  }
642
+
643
+ function parseEventActor(value: unknown): EventActor {
644
+ if (typeof value !== "object" || value === null || Array.isArray(value))
645
+ throw new SessionValidationError("session event actor is invalid");
646
+ const actor = value as Record<string, unknown>;
647
+ const valid =
648
+ typeof actor.clientId === "string" &&
649
+ actor.clientId.length > 0 &&
650
+ actor.clientId.length <= 64 &&
651
+ (actor.actorId === undefined ||
652
+ (typeof actor.actorId === "string" &&
653
+ /^a_[a-z2-7]{22}$/.test(actor.actorId))) &&
654
+ (actor.seq === undefined ||
655
+ (Number.isSafeInteger(actor.seq) && (actor.seq as number) >= 1));
656
+ if (!valid)
657
+ throw new SessionValidationError("session event actor is invalid");
658
+ const parsed: EventActor = { clientId: actor.clientId as string };
659
+ if (actor.actorId !== undefined) parsed.actorId = actor.actorId as string;
660
+ if (actor.seq !== undefined) parsed.seq = actor.seq as number;
661
+ return parsed;
662
+ }
@@ -7,7 +7,9 @@
7
7
  import type { Section } from "../../core/score.ts";
8
8
  import { formatForm } from "../../core/sections.ts";
9
9
  import { barsLabel } from "../commands/arrange.ts";
10
+ import { rangeLabel } from "../../core/range.ts";
10
11
  import type { MenuContext, MenuNode } from "./menu.ts";
12
+ import { styleMenuDetail, styleMenuNodes } from "./style-menu.ts";
11
13
 
12
14
  /** `3 sections · form A×2 B · loop chorus`, or how to start. */
13
15
  export function arrangeDetail(context: MenuContext): string {
@@ -20,41 +22,171 @@ export function arrangeDetail(context: MenuContext): string {
20
22
  return parts.join(" · ");
21
23
  }
22
24
 
23
- export function arrangeNodes(context: MenuContext): MenuNode[] {
25
+ /** `loop 5–6 · 16 bars`, or the song length alone. */
26
+ export function rangeDetail(context: MenuContext): string {
24
27
  const score = context.score;
25
- const nodes: MenuNode[] = score.sections.map((section) => ({
26
- kind: "menu",
27
- id: `section:${section.name}`,
28
- label: section.name,
29
- detail: sectionDetail(context, section),
30
- help: "loop, jump, mute, vary, move, rename, transitions",
31
- build: (next) => sectionNodes(next, section.name),
32
- }));
28
+ const bars = `${score.bars} bar${score.bars === 1 ? "" : "s"}`;
29
+ return score.loop ? `loop ${rangeLabel(score.loop)} · ${bars}` : bars;
30
+ }
31
+
32
+ /** A bar range typed as `5-6` or `5`: kept as typed, else undefined. */
33
+ function barsText(text: string): string | undefined {
34
+ const value = text.trim().replace(/\s*(?:–|\.\.)\s*/u, "-");
35
+ return /^\d{1,4}(?:-\d{1,4})?$/u.test(value) ? value : undefined;
36
+ }
37
+
38
+ /**
39
+ * Arrange › range (op1-ux §6.4): each row runs the typed range command on
40
+ * the focused track, so the menu, the prompt and the agent share one path.
41
+ */
42
+ export function rangeNodes(context: MenuContext): MenuNode[] {
43
+ const score = context.score;
44
+ const track = context.trackId;
45
+ const nodes: MenuNode[] = [
46
+ {
47
+ kind: "entry",
48
+ label: "loop bars",
49
+ value: score.loop ? rangeLabel(score.loop) : "the song",
50
+ placeholder: "bars, e.g. 5-6",
51
+ example: "loop 5-6",
52
+ help: "loop these bars in playback; empty plays the song",
53
+ command: (text) => {
54
+ if (!text.trim()) return "loop off";
55
+ const bars = barsText(text);
56
+ return bars ? `loop ${bars}` : undefined;
57
+ },
58
+ },
59
+ ];
60
+ if (score.loop)
61
+ nodes.push(
62
+ {
63
+ kind: "action",
64
+ label: "loop next",
65
+ command: "loop next",
66
+ help: "step the loop one length later",
67
+ },
68
+ {
69
+ kind: "action",
70
+ label: "loop prev",
71
+ command: "loop prev",
72
+ help: "step the loop one length earlier",
73
+ },
74
+ );
33
75
  nodes.push(
34
76
  {
35
77
  kind: "entry",
36
- label: "mark bars",
78
+ label: "copy bars",
37
79
  value: "",
38
- placeholder: "name and bars, e.g. verse 1-8",
39
- example: "section verse 1-8",
40
- help: "name a bar range; re-marking a name moves its marker",
80
+ placeholder: "bars to bar, e.g. 5-6 to 7 x2",
81
+ example: `copy ${track} 5-6 to 7 x2`,
82
+ help: "copy this track's bars; x2 tiles, insert shifts later music",
83
+ command: (text) =>
84
+ text.trim() ? `copy ${track} ${text.trim()}` : undefined,
85
+ },
86
+ {
87
+ kind: "entry",
88
+ label: "move bars",
89
+ value: "",
90
+ placeholder: "bars to bar, e.g. 5-6 to 9",
91
+ example: `move ${track} 5-6 to 9`,
92
+ help: "move this track's bars; the source empties",
93
+ command: (text) =>
94
+ text.trim() ? `move ${track} ${text.trim()}` : undefined,
95
+ },
96
+ {
97
+ kind: "entry",
98
+ label: "clear bars",
99
+ value: "",
100
+ placeholder: "bars, e.g. 5-6",
101
+ example: `clear ${track} 5-6`,
102
+ help: "empty this track's bars; the bars stay",
41
103
  command: (text) => {
42
- const trimmed = text.trim();
43
- return /\S\s+\d{1,4}(?:[-–]\d{1,4})?$/u.test(trimmed)
44
- ? `section ${trimmed}`
45
- : undefined;
104
+ const bars = barsText(text);
105
+ return bars ? `clear ${track} ${bars}` : undefined;
46
106
  },
47
107
  },
48
108
  {
49
109
  kind: "entry",
50
- label: "add section",
110
+ label: "reverse bars",
51
111
  value: "",
52
- placeholder: "name and bars, e.g. chorus 8 (empty: next name, 8 bars)",
53
- example: "section add chorus 8",
54
- help: "a new section after the last one (extends the song)",
55
- command: (text) => `section add ${text.trim()}`.trim(),
112
+ placeholder: "bars, e.g. 5-6",
113
+ example: `reverse ${track} 5-6`,
114
+ help: "mirror this track's bars in time",
115
+ command: (text) => {
116
+ const bars = barsText(text);
117
+ return bars ? `reverse ${track} ${bars}` : undefined;
118
+ },
119
+ },
120
+ {
121
+ kind: "entry",
122
+ label: "paste at",
123
+ value: "",
124
+ placeholder: "a bar, e.g. 9",
125
+ example: "paste at 9",
126
+ help: "lay the clipboard down (copy bars without `to` fills it)",
127
+ command: (text) =>
128
+ /^\d{1,4}$/u.test(text.trim()) ? `paste at ${text.trim()}` : undefined,
129
+ },
130
+ {
131
+ kind: "entry",
132
+ label: "insert bars",
133
+ value: "",
134
+ placeholder: "count at bar, e.g. 2 at 3",
135
+ example: "bars insert 2 at 3",
136
+ help: "add empty bars; later sections, clips, automation and tempo move right",
137
+ command: (text) =>
138
+ /^\d{1,3}\s+at\s+\d{1,4}$/u.test(text.trim())
139
+ ? `bars insert ${text.trim().replace(/\s+/gu, " ")}`
140
+ : undefined,
141
+ },
142
+ {
143
+ kind: "entry",
144
+ label: "remove bars",
145
+ value: "",
146
+ placeholder: "bars, e.g. 5-6",
147
+ example: "bars remove 5-6",
148
+ help: "cut the bars out; later music moves left",
149
+ command: (text) => {
150
+ const bars = barsText(text);
151
+ return bars ? `bars remove ${bars}` : undefined;
152
+ },
153
+ },
154
+ {
155
+ kind: "entry",
156
+ label: "jump",
157
+ value: "",
158
+ placeholder: "bar or bar.beat, e.g. 5.3",
159
+ example: "jump 5.3",
160
+ help: "move the playhead",
161
+ command: (text) =>
162
+ /^\d{1,4}(?:\.\d{1,2})?$/u.test(text.trim())
163
+ ? `jump ${text.trim()}`
164
+ : undefined,
56
165
  },
57
166
  );
167
+ return nodes;
168
+ }
169
+
170
+ /**
171
+ * Arrange's rows after tracks (§4a): sections (each section, mark bars,
172
+ * add section), then form and the style browser.
173
+ */
174
+ export function arrangeNodes(context: MenuContext): MenuNode[] {
175
+ const score = context.score;
176
+ const count = score.sections.length;
177
+ const nodes: MenuNode[] = [
178
+ {
179
+ kind: "menu",
180
+ id: "sections",
181
+ label: "sections",
182
+ detail:
183
+ count === 0
184
+ ? "none yet · mark bars"
185
+ : `${count} · ${score.sections.map((section) => section.name).join(", ")}`,
186
+ help: "mark bars, add a section, then loop, jump, mute or vary one",
187
+ build: sectionListNodes,
188
+ },
189
+ ];
58
190
  if (score.sections.length > 0) {
59
191
  nodes.push({
60
192
  kind: "entry",
@@ -68,7 +200,7 @@ export function arrangeNodes(context: MenuContext): MenuNode[] {
68
200
  if (score.form.length)
69
201
  nodes.push({
70
202
  kind: "action",
71
- label: "bake form",
203
+ label: "print form to tape",
72
204
  command: "form bake",
73
205
  help: "write the form out as plain bars, then clear it",
74
206
  });
@@ -80,6 +212,37 @@ export function arrangeNodes(context: MenuContext): MenuNode[] {
80
212
  help: "play the whole song (or form) again",
81
213
  });
82
214
  }
215
+ // TAPE (op1-ux §6): every track across the bars, copy/cut/paste keys.
216
+ nodes.push({
217
+ kind: "action",
218
+ label: "tape",
219
+ command: "/tape",
220
+ help: "every track across the bars · c copy, x cut, v paste · ctrl-t",
221
+ });
222
+ // §4.7: record the loop in PLAY, one undo step per pass (r / R on TAPE).
223
+ nodes.push({
224
+ kind: "action",
225
+ label: "record the loop",
226
+ command: "keys record",
227
+ help: "play mode over the loop · each pass is one undo step · r on tape",
228
+ });
229
+ nodes.push({
230
+ kind: "menu",
231
+ id: "range",
232
+ label: "range",
233
+ detail: rangeDetail(context),
234
+ help: "loop bars, copy, move, clear, paste or reverse them, insert or remove bars",
235
+ build: rangeNodes,
236
+ });
237
+ // The style browser: a whole song from a style (src/tui/style-menu.ts).
238
+ nodes.push({
239
+ kind: "menu",
240
+ id: "style",
241
+ label: "style",
242
+ detail: styleMenuDetail(context),
243
+ help: "make a whole song from a style: the style tree, find and blend",
244
+ build: styleMenuNodes,
245
+ });
83
246
  return nodes;
84
247
  }
85
248
 
@@ -388,3 +551,42 @@ export function fillNodes(section: Section): MenuNode[] {
388
551
  },
389
552
  ];
390
553
  }
554
+
555
+ /** Arrange › sections: one row per section, then mark bars and add section. */
556
+ function sectionListNodes(context: MenuContext): MenuNode[] {
557
+ const score = context.score;
558
+ const nodes: MenuNode[] = score.sections.map((section) => ({
559
+ kind: "menu",
560
+ id: `section:${section.name}`,
561
+ label: section.name,
562
+ detail: sectionDetail(context, section),
563
+ help: "loop, jump, mute, vary, move, rename, transitions",
564
+ build: (next) => sectionNodes(next, section.name),
565
+ }));
566
+ nodes.push(
567
+ {
568
+ kind: "entry",
569
+ label: "mark bars",
570
+ value: "",
571
+ placeholder: "name and bars, e.g. verse 1-8",
572
+ example: "section verse 1-8",
573
+ help: "name a bar range; re-marking a name moves its marker",
574
+ command: (text) => {
575
+ const trimmed = text.trim();
576
+ return /\S\s+\d{1,4}(?:[-–]\d{1,4})?$/u.test(trimmed)
577
+ ? `section ${trimmed}`
578
+ : undefined;
579
+ },
580
+ },
581
+ {
582
+ kind: "entry",
583
+ label: "add section",
584
+ value: "",
585
+ placeholder: "name and bars, e.g. chorus 8 (empty: next name, 8 bars)",
586
+ example: "section add chorus 8",
587
+ help: "a new section after the last one (extends the song)",
588
+ command: (text) => `section add ${text.trim()}`.trim(),
589
+ },
590
+ );
591
+ return nodes;
592
+ }
@@ -528,7 +528,7 @@ export function auditionKey(value: string): AuditionKey | undefined {
528
528
  * before.
529
529
  */
530
530
  const STAGEABLE =
531
- /^\/?(?:(fx|effects|filter|lowpass|synth|string|bowed|modal|wind|wt|wavetable|grain|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern|euclid|art|articulation|bend|vibrato|glide|portamento|velcurve|vel-curve|pedal|sustain|humanize|tuning|tune|master|keys|piano|epiano|rhodes|wurli|wurlitzer|clav|clavinet)\s+\S|pack\s+use\s+\S|(?:piano|grand|upright|felt|honkytonk|prepared|ballad)(?:\s+piano)?\s*$|(?:epiano|rhodes|suitcase|dyno|wurli|wurlitzer|clav|clavinet|funkclav)\s*$|(?:tonewheel|hammond|b3|gospel|jazzorgan|combo|farfisa|vox|pipe|church|pipeorgan|churchorgan|flutes|cornet|reeds|celeste)(?:\s+\S.*)?$|rotary\s+(?:slow|fast|stop)\s*$)/i;
531
+ /^\/?(?:(clip|fx|effects|filter|lowpass|vocoder(?!\s+new\s*$)|formant|vowel|synth|string|bowed|modal|wind|sing|note\s+vowel|wt|wavetable|grain|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern|euclid|art|articulation|bend|vibrato|glide|portamento|velcurve|vel-curve|pedal|sustain|humanize|tuning|tune|master|keys|piano|epiano|rhodes|wurli|wurlitzer|clav|clavinet)\s+\S|pack\s+use\s+\S|(?:piano|grand|upright|felt|honkytonk|prepared|ballad)(?:\s+piano)?\s*$|(?:epiano|rhodes|suitcase|dyno|wurli|wurlitzer|clav|clavinet|funkclav)\s*$|(?:tonewheel|hammond|b3|gospel|jazzorgan|combo|farfisa|vox|pipe|church|pipeorgan|churchorgan|flutes|cornet|reeds|celeste)(?:\s+\S.*)?$|rotary\s+(?:slow|fast|stop)\s*$)/i;
532
532
  /** Subcommands that list or show instead of changing the sound. */
533
533
  const READ_ONLY = /^\/?\S+\s+(list|show|info|help|measure|meter|presets?)\s*$/i;
534
534
  /** `master <unit>` alone only shows the unit. */