@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
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Musical receipts: one line that says what changed in the song, built from
3
+ * the score operations a change applied (`diffScores(before, after)`).
4
+ *
5
+ * ◆ 96 BPM · +2 notes on bass (C4 E4) · reverb 0.4
6
+ *
7
+ * Receipts name tempo in BPM, notes by pitch, and track settings by their
8
+ * musical value; revision numbers and validator counts stay in the ctrl-o
9
+ * log. The builder is pure, so the agent turn, undo and redo share it.
10
+ */
11
+ import { diffScores } from "../../core/diff.ts";
12
+ import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
13
+ import { midiToPitch } from "../../core/pitch.ts";
14
+ import type { ScoreOperation, TrackScore } from "../../core/score.ts";
15
+
16
+ /** Parts kept before the rest collapse to `+N more`. */
17
+ const MAX_PARTS = 5;
18
+ /** Pitch names listed after a note count. */
19
+ const MAX_PITCHES = 4;
20
+
21
+ function plural(count: number, word: string): string {
22
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
23
+ }
24
+
25
+ function round(value: number, places = 2): string {
26
+ const scale = 10 ** places;
27
+ return String(Math.round(value * scale) / scale);
28
+ }
29
+
30
+ function noteName(pitch: number, drum: boolean): string {
31
+ return drum ? drumVoiceForPitch(pitch) : midiToPitch(pitch);
32
+ }
33
+
34
+ type NoteTally = { added: number[]; removed: number[]; changed: number };
35
+
36
+ /** One field of a track patch as a musical phrase, or undefined to skip. */
37
+ function trackField(field: string, value: unknown): string | undefined {
38
+ if (value === null || value === undefined) {
39
+ if (field === "volumeAutomation" || field.endsWith("Automation"))
40
+ return `${field.replace(/Automation$/, "")} automation off`;
41
+ return `${field} off`;
42
+ }
43
+ switch (field) {
44
+ case "volume":
45
+ return typeof value === "number" ? `volume ${round(value)}` : undefined;
46
+ case "pan":
47
+ return typeof value === "number" ? `pan ${round(value)}` : undefined;
48
+ case "muted":
49
+ return value === true ? "muted" : "unmuted";
50
+ case "solo":
51
+ return value === true ? "solo" : "solo off";
52
+ case "instrument":
53
+ return typeof value === "string" ? `→ ${value}` : undefined;
54
+ case "name":
55
+ return typeof value === "string" ? `renamed ${value}` : undefined;
56
+ case "reverb":
57
+ case "delay": {
58
+ const mix = (value as { mix?: unknown }).mix;
59
+ return typeof mix === "number" ? `${field} ${round(mix)}` : field;
60
+ }
61
+ case "filter": {
62
+ const cutoff = (value as { cutoff?: unknown }).cutoff;
63
+ return typeof cutoff === "number"
64
+ ? `filter ${Math.round(cutoff)} Hz`
65
+ : "filter";
66
+ }
67
+ case "fx": {
68
+ const names = Object.keys(value as Record<string, unknown>);
69
+ return names.length ? `fx ${names.join(" ")}` : "fx off";
70
+ }
71
+ default:
72
+ if (Array.isArray(value) && field.endsWith("Automation"))
73
+ return value.length === 0
74
+ ? `${field.replace(/Automation$/, "")} automation off`
75
+ : `${field.replace(/Automation$/, "")} automation`;
76
+ return field;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * The receipt parts for `operations` applied to `before`. Tracks are named
82
+ * only when the song has more than one, so a single-track edit reads
83
+ * `reverb 0.4` and a band edit `bass reverb 0.4`.
84
+ */
85
+ export function receiptParts(
86
+ before: TrackScore,
87
+ operations: readonly ScoreOperation[],
88
+ ): string[] {
89
+ const parts: string[] = [];
90
+ // Track settings follow the notes: what you hear first reads first.
91
+ const settings: string[] = [];
92
+ const instruments = new Map(
93
+ before.tracks.map((track) => [track.id, track.instrument]),
94
+ );
95
+ const noteTrack = new Map(
96
+ before.notes.map((note) => [note.id, note.trackId]),
97
+ );
98
+ const notePitch = new Map(
99
+ before.notes.map((note) => [
100
+ note.id,
101
+ typeof note.pitch === "number" ? note.pitch : Number.NaN,
102
+ ]),
103
+ );
104
+ const tallies = new Map<string, NoteTally>();
105
+ const tally = (trackId: string): NoteTally => {
106
+ let found = tallies.get(trackId);
107
+ if (!found) {
108
+ found = { added: [], removed: [], changed: 0 };
109
+ tallies.set(trackId, found);
110
+ }
111
+ return found;
112
+ };
113
+ const added = new Set<string>();
114
+ const removed = new Set<string>();
115
+ const trackCount = new Set([
116
+ ...before.tracks.map((track) => track.id),
117
+ ...operations.flatMap((op) =>
118
+ op.type === "addTrack" ? [op.track.id ?? "track"] : [],
119
+ ),
120
+ ]).size;
121
+ const prefix = (trackId: string) => (trackCount > 1 ? `${trackId} ` : "");
122
+ // Notes are summarised per track after the song-level parts.
123
+ for (const op of operations) {
124
+ switch (op.type) {
125
+ case "setTempo":
126
+ parts.push(`${round(op.tempoBpm, 1)} BPM`);
127
+ break;
128
+ case "setKey":
129
+ parts.push(op.key ? `key ${op.key}` : "no key");
130
+ break;
131
+ case "setBars":
132
+ parts.push(plural(op.bars, "bar"));
133
+ break;
134
+ case "setMeter":
135
+ parts.push(`${op.beatsPerBar} beats a bar`);
136
+ break;
137
+ case "setTime":
138
+ parts.push(op.time ? "tempo map" : "steady tempo");
139
+ break;
140
+ case "setTuning":
141
+ parts.push(
142
+ op.tuning ? `tuning ${op.tuning.name ?? ""}`.trim() : "12-TET",
143
+ );
144
+ break;
145
+ case "setMaster":
146
+ parts.push(op.master ? "master" : "master off");
147
+ break;
148
+ case "setStyle":
149
+ if (op.style) parts.push(op.style.id);
150
+ break;
151
+ case "setSections":
152
+ parts.push(plural(op.sections.length, "section"));
153
+ break;
154
+ case "setCalibration":
155
+ break;
156
+ case "addTrack": {
157
+ const id = op.track.id ?? "track";
158
+ const instrument = op.track.instrument;
159
+ added.add(id);
160
+ instruments.set(id, instrument ?? "");
161
+ parts.push(
162
+ `+${id}${instrument && instrument !== id ? ` (${instrument})` : ""}`,
163
+ );
164
+ break;
165
+ }
166
+ case "removeTrack":
167
+ removed.add(op.trackId);
168
+ parts.push(`−${op.trackId}`);
169
+ break;
170
+ case "moveTrack":
171
+ parts.push(`moved ${op.trackId}`);
172
+ break;
173
+ case "clearTrack":
174
+ parts.push(`cleared ${op.trackId}`);
175
+ break;
176
+ case "setClips":
177
+ settings.push(
178
+ `${prefix(op.trackId)}${op.clips && op.clips.length ? plural(op.clips.length, "clip") : "no clips"}`,
179
+ );
180
+ break;
181
+ case "setAutomation":
182
+ settings.push(`${prefix(op.trackId)}${op.parameter} automation`);
183
+ break;
184
+ case "updateTrack": {
185
+ if (added.has(op.trackId)) break;
186
+ const fields = Object.entries(op.patch)
187
+ .map(([field, value]) => trackField(field, value))
188
+ .filter((text): text is string => text !== undefined);
189
+ if (fields.length === 0) break;
190
+ const name = op.patch.name;
191
+ const label =
192
+ typeof name === "string" ? op.trackId : prefix(op.trackId).trim();
193
+ const text = fields.slice(0, 2).join(" ");
194
+ settings.push(label ? `${label} ${text}` : text);
195
+ break;
196
+ }
197
+ case "addNote": {
198
+ const entry = tally(op.note.trackId);
199
+ entry.added.push(
200
+ typeof op.note.pitch === "number" ? op.note.pitch : Number.NaN,
201
+ );
202
+ break;
203
+ }
204
+ case "removeNote": {
205
+ const trackId = noteTrack.get(op.noteId);
206
+ if (trackId && !removed.has(trackId))
207
+ tally(trackId).removed.push(notePitch.get(op.noteId) ?? Number.NaN);
208
+ break;
209
+ }
210
+ case "updateNote": {
211
+ const trackId = noteTrack.get(op.noteId);
212
+ if (trackId) tally(trackId).changed += 1;
213
+ break;
214
+ }
215
+ }
216
+ }
217
+ for (const [trackId, entry] of tallies) {
218
+ const drum = isDrumInstrument(instruments.get(trackId));
219
+ const listed = (pitches: readonly number[]): string => {
220
+ const names = pitches
221
+ .filter((pitch) => Number.isFinite(pitch))
222
+ .slice(0, MAX_PITCHES)
223
+ .map((pitch) => noteName(pitch, drum));
224
+ const more = pitches.length > MAX_PITCHES ? " …" : "";
225
+ return names.length ? ` (${names.join(" ")}${more})` : "";
226
+ };
227
+ if (entry.added.length > 0)
228
+ parts.push(
229
+ `+${plural(entry.added.length, "note")} on ${trackId}${listed(entry.added)}`,
230
+ );
231
+ if (entry.removed.length > 0)
232
+ parts.push(
233
+ `−${plural(entry.removed.length, "note")} on ${trackId}${listed(entry.removed)}`,
234
+ );
235
+ if (entry.changed > 0)
236
+ parts.push(`${plural(entry.changed, "note")} edited on ${trackId}`);
237
+ }
238
+ parts.push(...settings);
239
+ if (parts.length > MAX_PARTS)
240
+ return [
241
+ ...parts.slice(0, MAX_PARTS - 1),
242
+ `+${parts.length - (MAX_PARTS - 1)} more`,
243
+ ];
244
+ return parts;
245
+ }
246
+
247
+ /** `96 BPM · +2 notes on bass (C4 E4) · reverb 0.4`, or "" for no change. */
248
+ export function musicalReceipt(before: TrackScore, after: TrackScore): string {
249
+ if (before === after) return "";
250
+ let operations: readonly ScoreOperation[];
251
+ try {
252
+ operations = diffScores(before, after);
253
+ } catch {
254
+ // A resolution change cannot diff: say the song changed.
255
+ return "song changed";
256
+ }
257
+ return receiptParts(before, operations).join(" · ");
258
+ }
@@ -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;
@@ -56,6 +61,13 @@ export type SessionRecord<T> = {
56
61
  updatedAt: string;
57
62
  composition: T;
58
63
  events: SessionEvent[];
64
+ /**
65
+ * Optional (0.7): how many of the oldest events were folded away when the
66
+ * log filled up. `revision === (folded ?? 0) + events.length`, and
67
+ * `events[i].revision === (folded ?? 0) + i + 1`. Absent on records that
68
+ * never filled, so older records load and print unchanged.
69
+ */
70
+ folded?: number;
59
71
  /** Name and lineage. Versioned separately so renames keep the revision. */
60
72
  meta: SessionMeta;
61
73
  };
@@ -207,10 +219,9 @@ export async function appendSessionEvent<T>(
207
219
  try {
208
220
  const disk = await readRecord<T>(paths.record);
209
221
  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`);
222
+ // A full log folds its oldest events away instead of refusing the edit.
223
+ const fold = disk.events.length >= MAX_EVENTS ? FOLD_BATCH : 0;
224
+ const folded = (disk.folded ?? 0) + fold;
214
225
  const revision = disk.revision + 1;
215
226
  const at = new Date().toISOString();
216
227
  const appended: SessionEvent = {
@@ -228,7 +239,8 @@ export async function appendSessionEvent<T>(
228
239
  revision,
229
240
  updatedAt: at,
230
241
  composition,
231
- events: [...disk.events, appended],
242
+ events: [...disk.events.slice(fold), appended],
243
+ ...(folded > 0 ? { folded } : {}),
232
244
  // Metadata always comes from disk so a rename written by another window
233
245
  // between this caller's read and its write is never reverted.
234
246
  meta: disk.meta,
@@ -245,8 +257,10 @@ export async function appendSessionEvent<T>(
245
257
  /**
246
258
  * Keeps the record under its size cap by dropping the oldest events'
247
259
  * 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.
260
+ * contiguous run. The appended (last) event's rewind is dropped only when
261
+ * nothing else is left to drop, so undo of the edit just made keeps working
262
+ * whenever its rewind fits on its own. Only the composition and the bounded
263
+ * payloads are irreducible; a record that still does not fit is rejected.
250
264
  */
251
265
  function compactRecord<T>(record: SessionRecord<T>): {
252
266
  record: SessionRecord<T>;
@@ -254,23 +268,27 @@ function compactRecord<T>(record: SessionRecord<T>): {
254
268
  } {
255
269
  let current = record;
256
270
  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);
271
+ let bytes = Buffer.byteLength(json, "utf8");
272
+ if (bytes <= MAX_RECORD_BYTES) return { record: current, json };
273
+ const events = [...current.events];
274
+ const last = events.length - 1;
275
+ // Measured sizes let the loop strip exactly as many rewinds as needed with
276
+ // one final stringify instead of one per batch.
277
+ // The appended event comes last, after every older rewind is gone.
278
+ for (let index = 0; index <= last && bytes > MAX_RECORD_BYTES; index += 1) {
279
+ const event = events[index]!;
280
+ if (event.rewind === undefined) continue;
281
+ const { rewind, ...rest } = event;
282
+ // `,"rewind":<json>` is what dropping the field saves.
283
+ bytes -= Buffer.byteLength(JSON.stringify(rewind), "utf8") + 10;
284
+ events[index] = rest;
273
285
  }
286
+ current = { ...current, events };
287
+ json = JSON.stringify(current);
288
+ if (Buffer.byteLength(json, "utf8") > MAX_RECORD_BYTES)
289
+ throw new SessionValidationError(
290
+ `session record exceeds ${MAX_RECORD_BYTES} bytes`,
291
+ );
274
292
  return { record: current, json };
275
293
  }
276
294
 
@@ -383,8 +401,13 @@ export async function inheritedEvents(
383
401
  } catch {
384
402
  break;
385
403
  }
386
- if (!parent || fork.revision > parent.events.length) break;
387
- const prefix = parent.events.slice(0, fork.revision);
404
+ if (!parent || fork.revision > parent.revision) break;
405
+ // Events folded out of the parent are gone; what remains still ends at
406
+ // the fork point.
407
+ const prefix = parent.events.slice(
408
+ 0,
409
+ Math.max(0, fork.revision - (parent.folded ?? 0)),
410
+ );
388
411
  chunks.unshift(prefix.slice(-(MAX_INHERITED_EVENTS - total)));
389
412
  total += prefix.length;
390
413
  fork = parent.meta.forkOf;
@@ -413,20 +436,10 @@ async function writeAtomic(path: string, contents: string): Promise<void> {
413
436
  throw new SessionValidationError(
414
437
  `session record exceeds ${MAX_RECORD_BYTES} bytes`,
415
438
  );
416
- const temporary = `${path}.${process.pid}.tmp`;
417
439
  // 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);
440
+ // rename publishes it, and the directory is synced so the rename survives
441
+ // a power loss too.
442
+ await durableWrite(path, `${contents}\n`, { mkdir: false });
430
443
  }
431
444
 
432
445
  async function readRecord<T>(path: string): Promise<SessionRecord<T>> {
@@ -472,7 +485,15 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
472
485
  throw new SessionValidationError(
473
486
  `session has more than ${MAX_EVENTS} events`,
474
487
  );
475
- if (record.revision !== record.events.length)
488
+ const folded = record.folded ?? 0;
489
+ if (
490
+ !Number.isSafeInteger(folded) ||
491
+ (folded as number) < 0 ||
492
+ (record.folded !== undefined && folded === 0)
493
+ )
494
+ throw new SessionValidationError("session folded count is invalid");
495
+ const base = folded as number;
496
+ if (record.revision !== base + record.events.length)
476
497
  throw new SessionValidationError(
477
498
  "session revision does not match its event log",
478
499
  );
@@ -487,7 +508,7 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
487
508
  assertSessionId(candidate.id);
488
509
  if (
489
510
  !Number.isSafeInteger(candidate.revision) ||
490
- candidate.revision !== index + 1
511
+ candidate.revision !== base + index + 1
491
512
  )
492
513
  throw new SessionValidationError("session event revisions are invalid");
493
514
  if (
@@ -546,6 +567,7 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
546
567
  updatedAt: record.updatedAt,
547
568
  composition: record.composition as T,
548
569
  events,
570
+ ...(base > 0 ? { folded: base } : {}),
549
571
  meta,
550
572
  };
551
573
  }
@@ -8,6 +8,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
10
  import type { MenuContext, MenuNode } from "./menu.ts";
11
+ import { styleMenuDetail, styleMenuNodes } from "./style-menu.ts";
11
12
 
12
13
  /** `3 sections · form A×2 B · loop chorus`, or how to start. */
13
14
  export function arrangeDetail(context: MenuContext): string {
@@ -20,41 +21,26 @@ export function arrangeDetail(context: MenuContext): string {
20
21
  return parts.join(" · ");
21
22
  }
22
23
 
24
+ /**
25
+ * Arrange's rows after tracks (§4a): sections (each section, mark bars,
26
+ * add section), then form and the style browser.
27
+ */
23
28
  export function arrangeNodes(context: MenuContext): MenuNode[] {
24
29
  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
- }));
33
- nodes.push(
34
- {
35
- kind: "entry",
36
- label: "mark bars",
37
- 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",
41
- 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;
46
- },
47
- },
30
+ const count = score.sections.length;
31
+ const nodes: MenuNode[] = [
48
32
  {
49
- kind: "entry",
50
- label: "add section",
51
- 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(),
33
+ kind: "menu",
34
+ id: "sections",
35
+ label: "sections",
36
+ detail:
37
+ count === 0
38
+ ? "none yet · mark bars"
39
+ : `${count} · ${score.sections.map((section) => section.name).join(", ")}`,
40
+ help: "mark bars, add a section, then loop, jump, mute or vary one",
41
+ build: sectionListNodes,
56
42
  },
57
- );
43
+ ];
58
44
  if (score.sections.length > 0) {
59
45
  nodes.push({
60
46
  kind: "entry",
@@ -80,6 +66,15 @@ export function arrangeNodes(context: MenuContext): MenuNode[] {
80
66
  help: "play the whole song (or form) again",
81
67
  });
82
68
  }
69
+ // The style browser: a whole song from a style (src/tui/style-menu.ts).
70
+ nodes.push({
71
+ kind: "menu",
72
+ id: "style",
73
+ label: "style",
74
+ detail: styleMenuDetail(context),
75
+ help: "make a whole song from a style: the style tree, find and blend",
76
+ build: styleMenuNodes,
77
+ });
83
78
  return nodes;
84
79
  }
85
80
 
@@ -388,3 +383,42 @@ export function fillNodes(section: Section): MenuNode[] {
388
383
  },
389
384
  ];
390
385
  }
386
+
387
+ /** Arrange › sections: one row per section, then mark bars and add section. */
388
+ function sectionListNodes(context: MenuContext): MenuNode[] {
389
+ const score = context.score;
390
+ const nodes: MenuNode[] = score.sections.map((section) => ({
391
+ kind: "menu",
392
+ id: `section:${section.name}`,
393
+ label: section.name,
394
+ detail: sectionDetail(context, section),
395
+ help: "loop, jump, mute, vary, move, rename, transitions",
396
+ build: (next) => sectionNodes(next, section.name),
397
+ }));
398
+ nodes.push(
399
+ {
400
+ kind: "entry",
401
+ label: "mark bars",
402
+ value: "",
403
+ placeholder: "name and bars, e.g. verse 1-8",
404
+ example: "section verse 1-8",
405
+ help: "name a bar range; re-marking a name moves its marker",
406
+ command: (text) => {
407
+ const trimmed = text.trim();
408
+ return /\S\s+\d{1,4}(?:[-–]\d{1,4})?$/u.test(trimmed)
409
+ ? `section ${trimmed}`
410
+ : undefined;
411
+ },
412
+ },
413
+ {
414
+ kind: "entry",
415
+ label: "add section",
416
+ value: "",
417
+ placeholder: "name and bars, e.g. chorus 8 (empty: next name, 8 bars)",
418
+ example: "section add chorus 8",
419
+ help: "a new section after the last one (extends the song)",
420
+ command: (text) => `section add ${text.trim()}`.trim(),
421
+ },
422
+ );
423
+ return nodes;
424
+ }
@@ -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|modal|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)\s+\S|pack\s+use\s+\S|(?:piano|grand|upright|felt|honkytonk|prepared|ballad)(?:\s+piano)?\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. */
package/src/tui/euclid.ts CHANGED
@@ -18,7 +18,18 @@
18
18
  * `a` flips A/B, `c` solo ↔ in context, Enter keeps the staged changes as
19
19
  * one undo step and Esc reverts them.
20
20
  */
21
- import { HINTS } from "../../tui/grammar.ts";
21
+ import {
22
+ HINTS,
23
+ KEY_BACKSPACE,
24
+ KEY_BACKTAB,
25
+ KEY_DOWN,
26
+ KEY_ENTER,
27
+ KEY_LEFT,
28
+ KEY_RESET,
29
+ KEY_RIGHT,
30
+ KEY_TAB,
31
+ KEY_UP,
32
+ } from "../../tui/grammar.ts";
22
33
  import { DRUM_VOICES, isDrumInstrument } from "../../core/drums.ts";
23
34
  import {
24
35
  DIVISIONS,
@@ -284,15 +295,9 @@ export function ringText(row: RhythmRow | undefined): string {
284
295
  .join("");
285
296
  }
286
297
 
287
- const KEY_UP = new Set(["\u001b[A", "\u001bOA", "k"]);
288
- const KEY_DOWN = new Set(["\u001b[B", "\u001bOB", "j"]);
289
- const KEY_LEFT = new Set(["\u001b[D", "\u001bOD", "h", "-", "_"]);
290
- const KEY_RIGHT = new Set(["\u001b[C", "\u001bOC", "l", "+", "="]);
291
- const KEY_NEXT_PARAM = new Set(["\t", "]"]);
292
- const KEY_PREV_PARAM = new Set(["\u001b[Z", "["]);
293
- const KEY_ENTER = new Set(["\r", "\n"]);
294
- const KEY_BACKSPACE = new Set(["\u007f", "\b"]);
295
- const KEY_OFF = new Set(["x", "\u001b[3~"]);
298
+ /** Tab ] next field, shift-tab [ previous (the brackets are the old keys). */
299
+ const isNextField = (value: string) => KEY_TAB.has(value) || value === "]";
300
+ const isPrevField = (value: string) => KEY_BACKTAB.has(value) || value === "[";
296
301
 
297
302
  export class EuclidEditor {
298
303
  private visible = false;
@@ -394,8 +399,8 @@ export class EuclidEditor {
394
399
  lanes.length;
395
400
  return { type: "handled" };
396
401
  }
397
- if (KEY_NEXT_PARAM.has(value) || KEY_PREV_PARAM.has(value)) {
398
- const direction = KEY_NEXT_PARAM.has(value) ? 1 : -1;
402
+ if (isNextField(value) || isPrevField(value)) {
403
+ const direction = isNextField(value) ? 1 : -1;
399
404
  this.param =
400
405
  (this.param + direction + EUCLID_PARAMS.length) % EUCLID_PARAMS.length;
401
406
  return { type: "handled" };
@@ -437,7 +442,7 @@ export class EuclidEditor {
437
442
  this.entry = "";
438
443
  return { type: "handled" };
439
444
  }
440
- if (KEY_OFF.has(value))
445
+ if (KEY_RESET.has(value))
441
446
  return lane.row
442
447
  ? { type: "run", command: `euclid ${lane.voice} off` }
443
448
  : { type: "handled" };