@hraness/dawg 0.2.0 → 0.4.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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -0,0 +1,535 @@
1
+ /**
2
+ * The command reference, in one place: `/help` renders it in the overlay,
3
+ * `dawg --help` prints it, and the docs mirror it. Music words are bare;
4
+ * app commands take a slash (bare aliases keep working but are listed once,
5
+ * in their canonical form).
6
+ */
7
+
8
+ export type HelpGroup = "music" | "session" | "window" | "keys";
9
+
10
+ export type HelpEntry = Readonly<{
11
+ /** Canonical form, e.g. `pan <-1..1>`. */
12
+ command: string;
13
+ /** What it does, short enough for one overlay row. */
14
+ summary: string;
15
+ }>;
16
+
17
+ export type HelpSection = Readonly<{
18
+ group: HelpGroup;
19
+ entries: readonly HelpEntry[];
20
+ }>;
21
+
22
+ export const HELP_SECTIONS: readonly HelpSection[] = [
23
+ {
24
+ group: "music",
25
+ entries: [
26
+ { command: "play", summary: "start the transport" },
27
+ { command: "pause", summary: "stop the transport" },
28
+ { command: "tempo <bpm>", summary: "20–300 BPM" },
29
+ { command: "add <note> at <beat> [for <beats>]", summary: "add C4 at 0" },
30
+ { command: "remove <id>", summary: "delete a note" },
31
+ { command: "move <id> to <beat>", summary: "shift a note" },
32
+ { command: "length <id> <beats>", summary: "resize a note" },
33
+ { command: "velocity <id> <0..1>", summary: "note loudness" },
34
+ { command: "bars <count>", summary: "loop length, 1–256" },
35
+ { command: "extend <count> bars", summary: "lengthen the loop" },
36
+ {
37
+ command: "instrument <name>",
38
+ summary: "sine piano pluck bass saw square triangle wavetable kit",
39
+ },
40
+ { command: "volume <0..1>", summary: "track level" },
41
+ { command: "pan <-1..1>", summary: "left … right" },
42
+ { command: "mute", summary: "silence this track" },
43
+ { command: "unmute", summary: "hear it again" },
44
+ { command: "solo", summary: "only this track" },
45
+ { command: "unsolo", summary: "every track again" },
46
+ { command: "clear", summary: "remove this track's notes" },
47
+ {
48
+ command: "wt <table> | wt <0..1> | wt list",
49
+ summary: "wavetable synth · wt basic · wt wt_digital:2 · wt 0.5",
50
+ },
51
+ {
52
+ command: "wtenv|wtattack|wtdecay|wtrate|wtdepth|warp <n>",
53
+ summary: "scan the table · warpmode bendp",
54
+ },
55
+ {
56
+ command: "fx <effect> <param> <value> | on | off | preset <name>",
57
+ summary: "effects · fx delay mix 0.3 · fx reverb on · fx lists them",
58
+ },
59
+ {
60
+ command: "synth <param> <value> | preset <name>",
61
+ summary: "synth voice · synth lpf 1200 · synth lists every param",
62
+ },
63
+ { command: "filter <hz> [res]", summary: "low-pass · filter off" },
64
+ { command: "delay <beats> [fb] [mix]", summary: "ping-pong · delay off" },
65
+ { command: "reverb <mix> [size]", summary: "room · reverb off" },
66
+ {
67
+ command: "automate <lane> at <beat> <value>",
68
+ summary: "volume pan filter resonance delay-feedback delay-mix wt",
69
+ },
70
+ {
71
+ command: "automate <lane> points <b:v>...",
72
+ summary: "several points · automate pan points 0:-1 4:1",
73
+ },
74
+ {
75
+ command: "automate <lane> remove <beat>",
76
+ summary: "drop one point",
77
+ },
78
+ { command: "clear [<lane>] automation", summary: "drop a lane's points" },
79
+ { command: "track name <text>", summary: "rename this track" },
80
+ { command: "meter <1..16>", summary: "beats per bar" },
81
+ {
82
+ command: "key <tonic> <mode> | none",
83
+ summary: "song key · key A minor · key F# dorian",
84
+ },
85
+ {
86
+ command: "hit <voice> at <beat>",
87
+ summary: "kit tracks · hit kick at 0",
88
+ },
89
+ {
90
+ command: "pattern <voice> <beats...> | every <step>",
91
+ summary: "pattern kick every 1",
92
+ },
93
+ { command: "clear <voice>", summary: "remove one drum voice" },
94
+ {
95
+ command: "euclid <voice> <pulses> [<steps>] [rotate <n>]",
96
+ summary: "generated rhythm · euclid hat 7 16 rotate 2",
97
+ },
98
+ {
99
+ command: "euclid <voice> <field> <value> | off | freeze",
100
+ summary: "repeats pace accent prob swing …",
101
+ },
102
+ { command: "grid <voice> <x.X.>", summary: "explicit steps · X accent" },
103
+ { command: "undo", summary: "step back · Ctrl-Z" },
104
+ { command: "redo", summary: "step forward · Ctrl-Y" },
105
+ ],
106
+ },
107
+ {
108
+ group: "session",
109
+ entries: [
110
+ { command: "/sessions", summary: "list sessions in this workspace" },
111
+ { command: "/resume [<n>|<name>|<id>]", summary: "switch session" },
112
+ { command: "/rename <name>|--auto", summary: "name this session" },
113
+ { command: "/fork [<name>]", summary: "copy into a new session" },
114
+ { command: "/status", summary: "name · revision · digest · storage" },
115
+ { command: "/export <file>", summary: "write track.loop/v1 JSON" },
116
+ { command: "/import <file>", summary: "replace the score from a file" },
117
+ {
118
+ command: "/sample [<path> [as <voice>]]",
119
+ summary: "add a sample voice · list voices",
120
+ },
121
+ ],
122
+ },
123
+ {
124
+ group: "window",
125
+ entries: [
126
+ {
127
+ command: "/track <name>",
128
+ summary: "focus a track, creating it if new",
129
+ },
130
+ { command: "/tracks", summary: "list tracks" },
131
+ { command: "/view focus|all", summary: "one track or every track" },
132
+ { command: "/transcript", summary: "scrollable log · Ctrl-O" },
133
+ { command: "/theme default|high-contrast|mono", summary: "colors" },
134
+ { command: "/motion on|off", summary: "animation" },
135
+ { command: "/model [alias]", summary: "pick a model · cost per prompt" },
136
+ {
137
+ command: "/login [gateway|openrouter|codex|claude]",
138
+ summary: "sign in to a provider",
139
+ },
140
+ {
141
+ command: "/logout [provider]",
142
+ summary: "forget keys and the saved choice",
143
+ },
144
+ { command: "/auth [--check]", summary: "provider and audio status" },
145
+ { command: "/play [on|off]", summary: "keyboard play mode · Ctrl-P" },
146
+ {
147
+ command: "/pattern [name]",
148
+ summary: "drum groove picker · moving previews",
149
+ },
150
+ {
151
+ command: "/kit [name]",
152
+ summary: "drum kit picker · synth kits, then samples",
153
+ },
154
+ {
155
+ command: "/pack list|info|use|add",
156
+ summary: "sample packs · /pack use 909/bd",
157
+ },
158
+ {
159
+ command: "/euclid [voice]",
160
+ summary: "T-1 style rhythm editor · Rhythm in /menu",
161
+ },
162
+ {
163
+ command: "/menu [section]",
164
+ summary: "every setting by hand · Ctrl-K",
165
+ },
166
+ {
167
+ command: "/try <sound command>",
168
+ summary: "hear it on a loop first · a A/B · enter keep",
169
+ },
170
+ {
171
+ command: "/click on|off|<volume>",
172
+ summary: "metronome · /count-in 0-2 · /grid 1/16",
173
+ },
174
+ {
175
+ command: "/chords auto|manual|off",
176
+ summary: "play-mode chords · /chords for settings",
177
+ },
178
+ {
179
+ command: "/help [topic]",
180
+ summary: "start here · /help all for everything",
181
+ },
182
+ ],
183
+ },
184
+ {
185
+ group: "keys",
186
+ entries: [
187
+ { command: "Enter", summary: "submit" },
188
+ { command: "Shift-Enter", summary: "newline" },
189
+ { command: "Alt-Enter", summary: "queue the request" },
190
+ { command: "Ctrl-Q", summary: "toggle queue mode" },
191
+ { command: "Ctrl-Z / Ctrl-Y", summary: "undo / redo" },
192
+ { command: "Ctrl-O", summary: "transcript" },
193
+ { command: "Esc", summary: "cancel the agent · back one level" },
194
+ { command: "?", summary: "keys for the screen you are on" },
195
+ { command: "Space", summary: "play/pause on an empty prompt" },
196
+ {
197
+ command: "Ctrl-P",
198
+ summary: "play mode · Z/X octave · R record · Q chords",
199
+ },
200
+ {
201
+ command: "Ctrl-K",
202
+ summary: "menu · ←→ adjust · / filter · x reset",
203
+ },
204
+ { command: "Ctrl-C", summary: "exit" },
205
+ ],
206
+ },
207
+ ];
208
+
209
+ /**
210
+ * `/help` with no topic: a short, task-first guide. Each row is something to
211
+ * type or press, then what it does. The full reference is `/help all` (or one
212
+ * group: `/help music`).
213
+ */
214
+ export const HELP_GUIDE: readonly HelpSection[] = [
215
+ {
216
+ group: "start here" as HelpGroup,
217
+ entries: [
218
+ { command: "type a request", summary: "“add a walking bass in A minor”" },
219
+ { command: "ctrl-p", summary: "play notes on the computer keyboard" },
220
+ {
221
+ command: "ctrl-k",
222
+ summary: "menu: every sound, effect, rhythm, chord",
223
+ },
224
+ { command: "?", summary: "keys for the screen you are on" },
225
+ {
226
+ command: "ctrl-z · ctrl-y",
227
+ summary: "undo · redo (every change is one)",
228
+ },
229
+ ],
230
+ },
231
+ {
232
+ group: "play notes" as HelpGroup,
233
+ entries: [
234
+ {
235
+ command: "ctrl-p, a s d f …",
236
+ summary: "piano keys · z x octave · esc leave",
237
+ },
238
+ {
239
+ command: "r, then space",
240
+ summary: "record over the loop (one undo a bar)",
241
+ },
242
+ ],
243
+ },
244
+ {
245
+ group: "make drums" as HelpGroup,
246
+ entries: [
247
+ { command: "/pattern", summary: "pick a groove · moving previews it" },
248
+ { command: "/kit", summary: "pick a drum kit" },
249
+ { command: "/euclid", summary: "rhythm editor: pulses, steps, rotation" },
250
+ ],
251
+ },
252
+ {
253
+ group: "shape the sound" as HelpGroup,
254
+ entries: [
255
+ {
256
+ command: "ctrl-k › Sound",
257
+ summary: "instrument, envelope, filter, wavetable",
258
+ },
259
+ { command: "ctrl-k › Effects", summary: "delay, reverb, distortion …" },
260
+ { command: "fx delay mix 0.3", summary: "the same from the prompt" },
261
+ { command: "/try fx reverb mix 0.6", summary: "hear it before keeping" },
262
+ ],
263
+ },
264
+ {
265
+ group: "chords" as HelpGroup,
266
+ entries: [
267
+ { command: "key A minor", summary: "set the song key" },
268
+ {
269
+ command: "ctrl-p, then q",
270
+ summary: "chord mode: one key plays a chord",
271
+ },
272
+ {
273
+ command: "1–4 · 5–8 · n",
274
+ summary: "chord type · extension · next chord",
275
+ },
276
+ ],
277
+ },
278
+ {
279
+ group: "more" as HelpGroup,
280
+ entries: [
281
+ { command: "/help all", summary: "every command and key" },
282
+ { command: "/help music", summary: "or session, window, keys" },
283
+ ],
284
+ },
285
+ ];
286
+
287
+ /** Topics `/help <topic>` takes, besides `all`. */
288
+ export const HELP_TOPICS = ["music", "session", "window", "keys"] as const;
289
+
290
+ /**
291
+ * Rows for `/help [topic]`: the guide with no topic, the full reference for
292
+ * `all`, one group for its name; undefined for an unknown topic.
293
+ */
294
+ export function helpTopicLines(
295
+ topic: string | undefined,
296
+ width = 80,
297
+ ): string[] | undefined {
298
+ const name = topic?.trim().toLowerCase().replace(/^\//, "");
299
+ if (!name) return sectionLines(HELP_GUIDE, width, 20);
300
+ if (name === "all" || name === "commands" || name === "reference")
301
+ return helpLines(width);
302
+ const group = HELP_SECTIONS.find((section) => section.group === name);
303
+ return group ? sectionLines([group], width) : undefined;
304
+ }
305
+
306
+ function sectionLines(
307
+ sections: readonly HelpSection[],
308
+ width: number,
309
+ fixedColumn?: number,
310
+ ): string[] {
311
+ const lines: string[] = [];
312
+ const column =
313
+ fixedColumn ??
314
+ Math.min(
315
+ 40,
316
+ Math.max(
317
+ ...sections.flatMap((s) => s.entries.map((e) => e.command.length)),
318
+ ) + 2,
319
+ );
320
+ for (const section of sections) {
321
+ if (lines.length > 0) lines.push("");
322
+ lines.push(`── ${section.group}`);
323
+ for (const entry of section.entries) {
324
+ const pad = Math.max(1, column - entry.command.length);
325
+ lines.push(
326
+ `${entry.command}${" ".repeat(pad)}${entry.summary}`.slice(0, width),
327
+ );
328
+ }
329
+ }
330
+ return lines;
331
+ }
332
+
333
+ /** Overlay rows: a heading per group, then `command summary` lines. */
334
+ export function helpLines(width = 80): string[] {
335
+ const lines: string[] = [];
336
+ const column = Math.min(
337
+ 40,
338
+ Math.max(
339
+ ...HELP_SECTIONS.flatMap((s) => s.entries.map((e) => e.command.length)),
340
+ ) + 2,
341
+ );
342
+ for (const section of HELP_SECTIONS) {
343
+ if (lines.length > 0) lines.push("");
344
+ lines.push(`── ${section.group}`);
345
+ for (const entry of section.entries) {
346
+ const pad = Math.max(1, column - entry.command.length);
347
+ lines.push(
348
+ `${entry.command}${" ".repeat(pad)}${entry.summary}`.slice(0, width),
349
+ );
350
+ }
351
+ }
352
+ return lines;
353
+ }
354
+
355
+ /** The `Commands:` block of `dawg --help`. */
356
+ export function helpText(): string {
357
+ return HELP_SECTIONS.filter((section) => section.group !== "keys")
358
+ .map(
359
+ (section) =>
360
+ `${section.group}:\n${section.entries
361
+ .map((entry) => ` ${entry.command}`)
362
+ .join("\n")}`,
363
+ )
364
+ .join("\n");
365
+ }
366
+
367
+ /** Usage for a known verb, shown instead of sending a near-miss to the agent. */
368
+ const USAGE: Readonly<Record<string, string>> = {
369
+ click: "/click on|off|<volume> · /click 50%",
370
+ "count-in": "/count-in 0|1|2",
371
+ chords:
372
+ "/chords auto|manual|off · voicing <n> · spread · bass · perform · rate · octaves · sevenths · preset · style",
373
+ key: "key <tonic> <mode> | none · key A minor",
374
+ grid: "/grid 1/4|1/8|1/8T|1/16|1/16T|1/32",
375
+ tempo: "tempo takes 20…300 · tempo 120",
376
+ bpm: "tempo takes 20…300 · tempo 120",
377
+ add: "add <note> at <beat> [for <beats>] · add C4 at 0",
378
+ put: "add <note> at <beat> [for <beats>] · add C4 at 0",
379
+ remove: "remove <id> · ids show in the transcript",
380
+ delete: "remove <id> · ids show in the transcript",
381
+ move: "move <id> to <beat>",
382
+ length: "length <id> <beats>",
383
+ velocity: "velocity <id> <0..1>",
384
+ vel: "velocity <id> <0..1>",
385
+ bars: "bars takes 1…256 · bars 8",
386
+ extend: "extend <count> bars · extend 4 bars",
387
+ instrument:
388
+ "instrument <name> · sine piano pluck bass saw square triangle wavetable kit · synth: sawtooth supersaw pulse white pink z_square…",
389
+ volume: "volume takes 0…1 · volume 0.8",
390
+ vol: "volume takes 0…1 · volume 0.8",
391
+ pan: "pan takes -1…1 · pan -0.5",
392
+ wt: "wt <table> | wt <0..1> | wt list · wt basic · wt wt_digital:2",
393
+ wavetable: "wt <table> | wt <0..1> | wt list · wt basic · wt wt_digital:2",
394
+ warpmode: "warpmode none|asym|bendp|bendm|bendmp|sync|quant",
395
+ filter: "filter <hz> [res] · filter 800 0.3 · filter off",
396
+ delay: "delay <beats> [fb] [mix] · delay 0.75 0.4 0.3 · delay off",
397
+ reverb: "reverb <mix> [size] · reverb 0.3 0.6 · reverb off",
398
+ automate: "automate <lane> at <beat> <value> · automate volume at 0 0.5",
399
+ automation: "automate <lane> at <beat> <value> · automate volume at 0 0.5",
400
+ hit: "hit <voice> at <beat> · hit kick at 0",
401
+ pattern: "pattern <voice> <beats...> | every <step> · pattern kick every 1",
402
+ clear: "clear · clear <voice> · clear [<lane>] automation",
403
+ track: "/track <name> · /track drums",
404
+ tracks: "/tracks",
405
+ sessions: "/sessions",
406
+ resume: "/resume [<n>|<name>|<id>]",
407
+ rename: "/rename <name> | --auto",
408
+ fork: "/fork [<name>]",
409
+ status: "/status",
410
+ export: "/export <file> · /export loop.track.json",
411
+ import: "/import <file> · /import loop.track.json",
412
+ sample:
413
+ "/sample <path> [as <voice>] · /sample set <voice> <control> <value>… · /sample set brk fit on clip 1",
414
+ samples: "/sample · lists the focused track's voices",
415
+ view: "/view focus | all",
416
+ transcript: "/transcript",
417
+ log: "/transcript",
418
+ theme: "/theme default | high-contrast | mono",
419
+ motion: "/motion on | off",
420
+ model: "/model [alias | vendor/model]",
421
+ login: "/login [gateway | openrouter | codex | claude]",
422
+ logout: "/logout [provider]",
423
+ auth: "/auth [--check]",
424
+ help: "/help [topic] · /help all · /help music|session|window|keys",
425
+ fx: "fx <effect> <param> <value> | on | off | preset <name> · fx delay mix 0.3",
426
+ synth: "synth <param> <value> | preset <name> · synth lpf 1200",
427
+ pack: "/pack list | info <name> | use <pack>/<sound> | add <url>",
428
+ kit: "/kit [name] · /kit syn909",
429
+ euclid: "/euclid [voice] · euclid hat 7 16",
430
+ menu: "/menu [sound|effects|rhythm|chords|mix|project]",
431
+ try: "/try <sound command> · /try fx reverb mix 0.6",
432
+ play: "/play [on|off] · Ctrl-P",
433
+ meter: "meter <1..16> · meter 3",
434
+ undo: "undo · Ctrl-Z",
435
+ redo: "redo · Ctrl-Y",
436
+ };
437
+
438
+ /** Verbs a typo can be matched against, slash or bare as they are typed. */
439
+ const KNOWN_VERBS: readonly string[] = [
440
+ ...new Set(
441
+ [
442
+ ...HELP_SECTIONS.flatMap((section) =>
443
+ section.group === "keys"
444
+ ? []
445
+ : section.entries.map((entry) => entry.command.split(/[\s|[]/)[0]!),
446
+ ),
447
+ ...Object.values(USAGE).map((usage) => usage.split(/[\s|[]/)[0]!),
448
+ ].filter((verb) => /^\/?[a-z][\w-]*$/i.test(verb)),
449
+ ),
450
+ ];
451
+
452
+ function editDistance(a: string, b: string): number {
453
+ const row = Array.from({ length: b.length + 1 }, (_, index) => index);
454
+ for (let i = 1; i <= a.length; i += 1) {
455
+ let previous = row[0]!;
456
+ row[0] = i;
457
+ for (let j = 1; j <= b.length; j += 1) {
458
+ const current = row[j]!;
459
+ row[j] = Math.min(
460
+ row[j]! + 1,
461
+ row[j - 1]! + 1,
462
+ previous + (a[i - 1] === b[j - 1] ? 0 : 1),
463
+ );
464
+ previous = current;
465
+ }
466
+ }
467
+ return row[b.length]!;
468
+ }
469
+
470
+ /**
471
+ * The known command nearest to the first word of `command` (`/clik` →
472
+ * `/click`, `/fx` → `fx`), or undefined when nothing is close.
473
+ */
474
+ export function nearestCommand(command: string): string | undefined {
475
+ const word = command.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
476
+ const bare = word.replace(/^\//, "");
477
+ if (!bare) return undefined;
478
+ let best: { verb: string; distance: number; score: number } | undefined;
479
+ for (const verb of KNOWN_VERBS) {
480
+ const distance = editDistance(bare, verb.replace(/^\//, ""));
481
+ if (distance === 0 && verb === word) continue;
482
+ // Ties go to the form typed: `/patern` → `/pattern`, not `pattern`.
483
+ const sameForm = verb.startsWith("/") === word.startsWith("/");
484
+ const score = distance * 2 + (sameForm ? 0 : 1);
485
+ if (!best || score < best.score) best = { verb, distance, score };
486
+ }
487
+ const limit = bare.length <= 4 ? 1 : 2;
488
+ return best && best.distance <= limit ? best.verb : undefined;
489
+ }
490
+
491
+ /**
492
+ * `tempoo 90` → `tempo 90`: the first word one edit from a bare command
493
+ * whose arguments then parse (`parses` decides). Slash words and inputs
494
+ * whose rest is not that command's arguments (prose) return undefined.
495
+ */
496
+ export function typoFix(
497
+ command: string,
498
+ parses: (candidate: string) => boolean,
499
+ ): string | undefined {
500
+ const text = command.trim();
501
+ if (!text || text.startsWith("/")) return undefined;
502
+ const [first, ...rest] = text.split(/\s+/);
503
+ const word = first!.toLowerCase();
504
+ for (const verb of KNOWN_VERBS) {
505
+ if (verb.startsWith("/") || verb === word) continue;
506
+ if (editDistance(word, verb) !== 1) continue;
507
+ const candidate = [verb, ...rest].join(" ");
508
+ if (parses(candidate)) return candidate;
509
+ }
510
+ return undefined;
511
+ }
512
+
513
+ /**
514
+ * True for a sentence that merely starts with a command verb (`add a walking
515
+ * bass in A minor`, `pan the hats left`): three or more words and no number
516
+ * or note name with an octave. Those are requests for the agent, not
517
+ * malformed commands. Slash words are never prose.
518
+ */
519
+ export function looksLikeProse(command: string): boolean {
520
+ const text = command.trim();
521
+ if (text.startsWith("/")) return false;
522
+ const words = text.split(/\s+/);
523
+ return words.length >= 3 && !/\d/.test(text);
524
+ }
525
+
526
+ /**
527
+ * A usage hint when `command` starts with a known verb but did not parse
528
+ * (`pan 3`, `volume 2`, `add H4 at 0`, `/export` with no file); undefined for
529
+ * free text that should go to the agent.
530
+ */
531
+ export function usageHint(command: string): string | undefined {
532
+ if (looksLikeProse(command)) return undefined;
533
+ const verb = command.trim().toLowerCase().replace(/^\//, "").split(/\s+/)[0];
534
+ return verb ? USAGE[verb] : undefined;
535
+ }
@@ -1,22 +1,32 @@
1
1
  /**
2
2
  * Undo/redo over the shared session event log.
3
3
  *
4
- * Every score edit records `before`, the composition it replaced. Undo and
5
- * redo append ordinary events (`score.undo` / `score.redo`) whose `before` is
6
- * the composition they replaced, so history is linear, shared by every
7
- * window, and survives restarts. Replaying the log rebuilds two stacks:
4
+ * Every event carries a `rewind` (see `src/session/delta.ts`): how to turn
5
+ * the composition it produced back into the one it replaced. Undo and redo
6
+ * append ordinary events (`score.undo` / `score.redo`), so history is linear,
7
+ * shared by every window, and survives restarts. Replaying the log rebuilds
8
+ * two stacks over the events that changed the composition:
8
9
  *
9
10
  * edit -> push onto undo, clear redo
10
11
  * undo -> pop undo, push the undo event onto redo
11
12
  * redo -> pop redo, push the redo event onto undo
12
13
  *
13
- * Undoing restores the top undo entry's `before`; redoing restores the top
14
- * redo entry's `before` (the composition the undo replaced).
14
+ * Undoing restores the composition before the top undo entry; redoing
15
+ * restores the composition before the top redo entry (the one the undo
16
+ * replaced). Both are recovered by rewinding the current composition through
17
+ * every newer event. Events whose rewind was compacted away, and events that
18
+ * did not change the composition (transport), take no part.
15
19
  */
20
+ import {
21
+ isIdentityRewind,
22
+ rewindComposition,
23
+ type Rewind,
24
+ } from "../session/delta.ts";
25
+
16
26
  export type HistoryEvent = Readonly<{
17
27
  revision: number;
18
28
  kind: string;
19
- payload: unknown;
29
+ rewind?: Rewind | undefined;
20
30
  }>;
21
31
 
22
32
  export type HistoryTarget = Readonly<{
@@ -30,27 +40,30 @@ export const UNDO_KIND = "score.undo";
30
40
  export const REDO_KIND = "score.redo";
31
41
 
32
42
  export function historyTarget(
43
+ composition: unknown,
33
44
  events: readonly HistoryEvent[],
34
45
  direction: "undo" | "redo",
35
46
  ): HistoryTarget | undefined {
36
- const undo: HistoryEvent[] = [];
37
- const redo: HistoryEvent[] = [];
38
- for (const event of events) {
39
- if (!hasBefore(event.payload)) continue;
47
+ const undo: number[] = [];
48
+ const redo: number[] = [];
49
+ events.forEach((event, index) => {
50
+ if (!changesComposition(event)) return;
40
51
  if (event.kind === UNDO_KIND) {
41
- if (undo.pop()) redo.push(event);
52
+ if (undo.pop() !== undefined) redo.push(index);
42
53
  } else if (event.kind === REDO_KIND) {
43
- if (redo.pop()) undo.push(event);
54
+ if (redo.pop() !== undefined) undo.push(index);
44
55
  } else {
45
- undo.push(event);
56
+ undo.push(index);
46
57
  redo.length = 0;
47
58
  }
48
- }
59
+ });
49
60
  const top = (direction === "undo" ? undo : redo).at(-1);
50
- if (!top || !hasBefore(top.payload)) return undefined;
51
- return { revision: top.revision, composition: top.payload.before };
61
+ if (top === undefined) return undefined;
62
+ const restored = rewindComposition(composition, events, top);
63
+ if (restored === undefined) return undefined;
64
+ return { revision: events[top]!.revision, composition: restored };
52
65
  }
53
66
 
54
- function hasBefore(payload: unknown): payload is { before: unknown } {
55
- return typeof payload === "object" && payload !== null && "before" in payload;
67
+ function changesComposition(event: HistoryEvent): boolean {
68
+ return event.rewind !== undefined && !isIdentityRewind(event.rewind);
56
69
  }