@hraness/dawg 0.6.1 → 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 (253) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/DAWG.md +343 -116
  3. package/README.md +27 -25
  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 +182 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/instruments.ts +19 -0
  11. package/core/keys.ts +3 -3
  12. package/core/loop.ts +5 -0
  13. package/core/lyrics.ts +297 -0
  14. package/core/master.ts +3 -3
  15. package/core/resonators.ts +16 -2
  16. package/core/routing.ts +165 -0
  17. package/core/score.ts +832 -13
  18. package/core/sdk/eval-child.ts +7 -2
  19. package/core/sdk/eval.ts +35 -6
  20. package/core/sdk/print.ts +276 -3
  21. package/core/sdk/sync-lyrics.ts +49 -0
  22. package/core/sdk/v1.ts +1849 -42
  23. package/core/sections.ts +292 -26
  24. package/core/sing.ts +815 -0
  25. package/core/style-provenance.ts +80 -0
  26. package/core/styles/africa-mena-southasia.ts +2893 -0
  27. package/core/styles/americas.ts +3810 -0
  28. package/core/styles/art.ts +4993 -0
  29. package/core/styles/base.ts +123 -0
  30. package/core/styles/cycles.ts +106 -0
  31. package/core/styles/electronic.ts +2723 -0
  32. package/core/styles/europe-asia-pacific.ts +2838 -0
  33. package/core/styles/excerpt.ts +29 -0
  34. package/core/styles/gamelan.ts +283 -0
  35. package/core/styles/generate.ts +2199 -0
  36. package/core/styles/index.ts +515 -0
  37. package/core/styles/parts.ts +106 -0
  38. package/core/styles/pop.ts +3189 -0
  39. package/core/styles/rock.ts +2993 -0
  40. package/core/styles/roots.ts +4175 -0
  41. package/core/styles/schema.ts +429 -0
  42. package/core/styles/taxonomy.ts +940 -0
  43. package/core/styles/validate.ts +528 -0
  44. package/core/tempo.ts +32 -2
  45. package/core/tuning.ts +19 -3
  46. package/core/vocoder.ts +524 -0
  47. package/guides/agent.md +29 -0
  48. package/guides/arrange.md +30 -0
  49. package/guides/audition.md +20 -14
  50. package/guides/automation.md +12 -7
  51. package/guides/chords.md +15 -15
  52. package/guides/effects.md +17 -16
  53. package/guides/faders.md +20 -16
  54. package/guides/files.md +13 -9
  55. package/guides/getting-started.md +15 -11
  56. package/guides/keys.md +19 -15
  57. package/guides/media.md +17 -12
  58. package/guides/mix.md +12 -6
  59. package/guides/music.md +23 -8
  60. package/guides/notes.md +15 -9
  61. package/guides/performance.md +15 -13
  62. package/guides/play.md +21 -13
  63. package/guides/project.md +25 -8
  64. package/guides/providers.md +20 -14
  65. package/guides/resample.md +15 -9
  66. package/guides/rhythm.md +17 -13
  67. package/guides/sessions.md +17 -7
  68. package/guides/show-me.md +31 -0
  69. package/guides/sound.md +25 -9
  70. package/guides/sounds.md +15 -13
  71. package/guides/styles.md +31 -0
  72. package/guides/tempo.md +15 -10
  73. package/guides/tracks.md +15 -10
  74. package/guides/tuning.md +32 -0
  75. package/guides/voice.md +31 -0
  76. package/guides/web-search.md +18 -8
  77. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  78. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  79. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  80. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  81. package/native/prebuilt/manifest.json +21 -0
  82. package/package.json +5 -2
  83. package/src/agent/agent.ts +126 -14
  84. package/src/agent/calibration-tools.ts +53 -0
  85. package/src/agent/clip-tools.ts +453 -0
  86. package/src/agent/command-agent.ts +369 -0
  87. package/src/agent/drum-tools.ts +2 -2
  88. package/src/agent/expression-tools.ts +1 -1
  89. package/src/agent/gateway.ts +246 -60
  90. package/src/agent/models.ts +53 -12
  91. package/src/agent/ops.ts +12 -1
  92. package/src/agent/pack-tools.ts +1 -1
  93. package/src/agent/planner.ts +13 -0
  94. package/src/agent/portable-schema.ts +80 -0
  95. package/src/agent/preview-tool.ts +4 -1
  96. package/src/agent/provider.ts +22 -8
  97. package/src/agent/rhythm-tools.ts +1 -1
  98. package/src/agent/section-tools.ts +1 -1
  99. package/src/agent/show-me.ts +497 -0
  100. package/src/agent/steer.ts +15 -0
  101. package/src/agent/style-tools.ts +217 -0
  102. package/src/agent/tool-error.ts +12 -0
  103. package/src/agent/tools.ts +108 -23
  104. package/src/agent/usage.ts +2 -2
  105. package/src/agent/voice-tools.ts +925 -0
  106. package/src/agent/xcb-agent.ts +11 -7
  107. package/src/argv.ts +38 -0
  108. package/src/audio/analysis.ts +253 -0
  109. package/src/audio/arrange.ts +37 -3
  110. package/src/audio/autotune-engine.ts +101 -0
  111. package/src/audio/autotune.ts +640 -0
  112. package/src/audio/clips.ts +240 -0
  113. package/src/audio/doctor.ts +86 -0
  114. package/src/audio/dsp/bandbank.ts +138 -0
  115. package/src/audio/dsp/envelope.ts +10 -0
  116. package/src/audio/dsp/follow.ts +120 -0
  117. package/src/audio/dsp/formant.ts +427 -0
  118. package/src/audio/dsp/glottal.ts +243 -0
  119. package/src/audio/dsp/interp.ts +7 -2
  120. package/src/audio/dsp/lpc.ts +50 -0
  121. package/src/audio/dsp/periodicity.ts +59 -0
  122. package/src/audio/dsp/pitch.ts +995 -0
  123. package/src/audio/dsp/psola.ts +199 -0
  124. package/src/audio/effects/chain.ts +3 -1
  125. package/src/audio/effects/common.ts +43 -0
  126. package/src/audio/effects/convolution.ts +7 -4
  127. package/src/audio/effects/filter.ts +48 -69
  128. package/src/audio/effects/formant.ts +263 -0
  129. package/src/audio/engine.ts +160 -28
  130. package/src/audio/fit.ts +35 -3
  131. package/src/audio/instrument-check.ts +59 -43
  132. package/src/audio/instruments.ts +4 -0
  133. package/src/audio/keys/calibration.ts +56 -0
  134. package/src/audio/keys/electric.ts +8 -1
  135. package/src/audio/keys/engine.ts +13 -1
  136. package/src/audio/keys/piano.ts +22 -2
  137. package/src/audio/kits.ts +135 -6
  138. package/src/audio/live.ts +114 -20
  139. package/src/audio/native.ts +615 -0
  140. package/src/audio/preview.ts +30 -2
  141. package/src/audio/render-worker.ts +2 -0
  142. package/src/audio/renderer.ts +2 -0
  143. package/src/audio/resample.ts +2 -1
  144. package/src/audio/sampler.ts +85 -4
  145. package/src/audio/samples.ts +20 -2
  146. package/src/audio/sing/analysis.ts +193 -0
  147. package/src/audio/sing/engine.ts +949 -0
  148. package/src/audio/strings/bow.ts +48 -5
  149. package/src/audio/strings/engine.ts +8 -2
  150. package/src/audio/synth/oscillators.ts +31 -21
  151. package/src/audio/synth/voice.ts +34 -1
  152. package/src/audio/vocoder/bank.ts +314 -0
  153. package/src/audio/vocoder/carrier.ts +165 -0
  154. package/src/audio/vocoder/control.ts +68 -0
  155. package/src/audio/vocoder/detect.ts +50 -0
  156. package/src/audio/vocoder/index.ts +304 -0
  157. package/src/audio/vocoder/talkbox.ts +143 -0
  158. package/src/audio/wav.ts +500 -77
  159. package/src/audio/winds/engine.ts +5 -1
  160. package/src/audio/winds/trim.ts +28 -4
  161. package/src/audio/winds/trims1.ts +297 -0
  162. package/src/audio/winds/voice.ts +15 -2
  163. package/src/auth/cli.ts +38 -36
  164. package/src/auth/credentials.ts +30 -1
  165. package/src/auth/login.ts +15 -9
  166. package/src/auth/tui.ts +19 -10
  167. package/src/commands/arrange.ts +44 -29
  168. package/src/commands/autotune.ts +421 -0
  169. package/src/commands/calibration.ts +74 -0
  170. package/src/commands/clips.ts +887 -0
  171. package/src/commands/drums.ts +3 -2
  172. package/src/commands/edit.ts +11 -4
  173. package/src/commands/expression.ts +1 -1
  174. package/src/commands/formant.ts +221 -0
  175. package/src/commands/fx.ts +101 -32
  176. package/src/commands/grammar.ts +558 -0
  177. package/src/commands/help.ts +610 -380
  178. package/src/commands/history.ts +18 -0
  179. package/src/commands/keys.ts +8 -8
  180. package/src/commands/modal.ts +1 -1
  181. package/src/commands/music.ts +1 -1
  182. package/src/commands/nearest.ts +53 -0
  183. package/src/commands/pack.ts +9 -2
  184. package/src/commands/param-range.ts +56 -0
  185. package/src/commands/parses.ts +130 -0
  186. package/src/commands/progression.ts +170 -0
  187. package/src/commands/rhythm.ts +3 -0
  188. package/src/commands/rig.ts +3 -24
  189. package/src/commands/sing.ts +478 -0
  190. package/src/commands/strum.ts +13 -1
  191. package/src/commands/style.ts +415 -0
  192. package/src/commands/time.ts +6 -3
  193. package/src/commands/tuning.ts +3 -3
  194. package/src/commands/vocal-pitch.ts +616 -0
  195. package/src/commands/vocal.ts +147 -0
  196. package/src/commands/vocoder.ts +627 -0
  197. package/src/commands/wind.ts +2 -2
  198. package/src/fs/durable.ts +50 -0
  199. package/src/lang/glossary.ts +493 -0
  200. package/src/launch-args.ts +163 -0
  201. package/src/main.ts +1397 -253
  202. package/src/media/cli.ts +20 -3
  203. package/src/media/import.ts +3 -1
  204. package/src/project/check.ts +21 -2
  205. package/src/project/clip-pins.ts +72 -0
  206. package/src/project/init.ts +23 -8
  207. package/src/project/sync.ts +418 -86
  208. package/src/render.ts +20 -1
  209. package/src/session/daemon.ts +3 -0
  210. package/src/session/meta.ts +14 -0
  211. package/src/session/origin.ts +154 -0
  212. package/src/session/port.ts +22 -4
  213. package/src/session/presence.ts +34 -4
  214. package/src/session/protocol.ts +5 -1
  215. package/src/session/rebase.ts +18 -5
  216. package/src/session/receipt.ts +258 -0
  217. package/src/session/store.ts +65 -43
  218. package/src/tui/arrange-menu.ts +65 -31
  219. package/src/tui/audition.ts +1 -1
  220. package/src/tui/euclid.ts +18 -13
  221. package/src/tui/fader.ts +228 -41
  222. package/src/tui/granular-menu.ts +2 -4
  223. package/src/tui/menu-clips.ts +297 -0
  224. package/src/tui/menu-time.ts +20 -13
  225. package/src/tui/menu-voice.ts +405 -0
  226. package/src/tui/menu.ts +759 -177
  227. package/src/tui/modal-menu.ts +6 -6
  228. package/src/tui/performance-menu.ts +5 -2
  229. package/src/tui/play-chords.ts +4 -2
  230. package/src/tui/play-mode.ts +15 -1
  231. package/src/tui/play-session.ts +47 -7
  232. package/src/tui/sing-menu.ts +278 -0
  233. package/src/tui/style-menu.ts +104 -0
  234. package/src/tui/vocoder-menu.ts +244 -0
  235. package/src/tui/wind-menu.ts +3 -3
  236. package/src/version.ts +8 -0
  237. package/src/web/fetch.ts +115 -29
  238. package/tui/activity.ts +180 -9
  239. package/tui/app.ts +274 -40
  240. package/tui/clip-row.ts +132 -0
  241. package/tui/delight.ts +144 -0
  242. package/tui/drawer.ts +70 -22
  243. package/tui/frame-gate.ts +76 -0
  244. package/tui/grammar.ts +112 -63
  245. package/tui/guide.ts +42 -4
  246. package/tui/highway.ts +269 -25
  247. package/tui/hints.ts +192 -0
  248. package/tui/input.ts +60 -9
  249. package/tui/keys.ts +1 -1
  250. package/tui/play-strip.ts +68 -14
  251. package/tui/prompt.ts +1 -1
  252. package/tui/screen.ts +144 -14
  253. package/tui/theme.ts +27 -0
@@ -0,0 +1,558 @@
1
+ /**
2
+ * The one command grammar (design §3): `[/]verb [noun|target] [args]`, where
3
+ * the slash is optional, synonyms map to the canonical word, and every noun
4
+ * answers `remove|rm|delete` and `list|presets|ls` the same way.
5
+ *
6
+ * The parsers keep their own spellings; this module rewrites a line that no
7
+ * parser took into the spellings they do take (`recover`), so a line that
8
+ * works today runs exactly as before and only a line that failed gets a
9
+ * second reading. `commandParses` (src/commands/parses.ts) and the prompt bar
10
+ * (src/main.ts submit) share it, so an alias that runs also counts as a
11
+ * command for typo fixes, show-me and the agent's command mode.
12
+ */
13
+
14
+ import { HELP_SECTIONS, USAGE } from "./help.ts";
15
+ import { nearest } from "./nearest.ts";
16
+ import { parseTuningCommand } from "./tuning.ts";
17
+
18
+ /** Words that remove a thing: `section rm verse` ≡ `section remove verse`. */
19
+ export const REMOVE_WORDS: readonly string[] = ["remove", "rm", "delete"];
20
+ /** Words that list a noun's presets: `fx ls` ≡ `fx list` ≡ `fx`. */
21
+ export const LIST_WORDS: readonly string[] = ["list", "presets", "ls"];
22
+
23
+ /** `/formant 3` → `formant 3`; `formant 3` → `/formant 3`. */
24
+ export function toggleSlash(line: string): string {
25
+ const text = line.trim();
26
+ return text.startsWith("/") ? text.slice(1) : `/${text}`;
27
+ }
28
+
29
+ /** One leading `/` removed (only one: `//x` stays a slash word). */
30
+ export function stripSlash(line: string): string {
31
+ const text = line.trim();
32
+ return text.startsWith("/") ? text.slice(1) : text;
33
+ }
34
+
35
+ /** The first word, slash and case folded (`/FX` → `fx`). */
36
+ export function verbOf(line: string): string {
37
+ return stripSlash(line).split(/\s+/)[0]?.toLowerCase() ?? "";
38
+ }
39
+
40
+ /**
41
+ * Canonical forms that are new names for old grammar, rewritten to the
42
+ * spelling the parser takes. Each entry maps a bare line to its rewrite, or
43
+ * undefined when the entry does not apply. Canonical first, old alias kept:
44
+ * `groove house` runs the parser's `/pattern house`.
45
+ */
46
+ const REWRITES: readonly ((words: readonly string[]) => string | undefined)[] =
47
+ [
48
+ // key ≡ scale for showing and listing (`key A minor` already parses).
49
+ (w) =>
50
+ w[0] === "key" &&
51
+ (w.length === 1 || (w.length === 2 && LIST_WORDS.includes(w[1]!)))
52
+ ? w.length === 1
53
+ ? "scale"
54
+ : "scale list"
55
+ : undefined,
56
+ // key <mode> keeps the tonic (`key dorian`, `key hijaz`), as scale does.
57
+ (w) => {
58
+ if (w[0] !== "key" || w.length < 2) return undefined;
59
+ const scale = ["scale", ...w.slice(1)].join(" ");
60
+ const parsed = parseTuningCommand(scale);
61
+ return parsed?.type === "scale-set" && parsed.scale !== undefined
62
+ ? scale
63
+ : undefined;
64
+ },
65
+ // chords idiom <name> ≡ chords style <name>.
66
+ (w) =>
67
+ w[0] === "chords" && w[1] === "idiom"
68
+ ? ["chords", "style", ...w.slice(2)].join(" ")
69
+ : undefined,
70
+ // synth filter <hz> ≡ synth lpf <hz> (also lowpass, cutoff).
71
+ (w) =>
72
+ w[0] === "synth" && /^(filter|lowpass|cutoff)$/.test(w[1] ?? "")
73
+ ? ["synth", "lpf", ...w.slice(2)].join(" ")
74
+ : undefined,
75
+ // fx filter cutoff <hz> ≡ fx filter lpf <hz>; fx lowpass ≡ fx filter.
76
+ (w) =>
77
+ w[0] === "fx" && w[1] === "filter" && w[2] === "cutoff"
78
+ ? ["fx", "filter", "lpf", ...w.slice(3)].join(" ")
79
+ : w[0] === "fx" && w[1] === "lowpass"
80
+ ? ["fx", "filter", ...w.slice(2)].join(" ")
81
+ : undefined,
82
+ // lowpass <hz> ≡ filter <hz>.
83
+ (w) =>
84
+ w[0] === "lowpass" ? ["filter", ...w.slice(1)].join(" ") : undefined,
85
+ // genre <id> ≡ style <id>.
86
+ (w) =>
87
+ w[0] === "genre" || w[0] === "genres"
88
+ ? ["style", ...w.slice(1)].join(" ")
89
+ : undefined,
90
+ // bpm <n> ≡ tempo <n> (bare `/bpm` stays a sample's own tempo).
91
+ (w) =>
92
+ w[0] === "bpm" && w.length === 2 && /^\d+(\.\d+)?$/.test(w[1]!)
93
+ ? `tempo ${w[1]}`
94
+ : undefined,
95
+ // render|bounce|wav <file>.wav [stems] ≡ export <file>.wav [stems].
96
+ (w) =>
97
+ /^(render|bounce|wav)$/.test(w[0] ?? "") &&
98
+ /\.wav$/i.test(w[1] ?? "") &&
99
+ (w.length === 2 || (w.length === 3 && w[2] === "stems"))
100
+ ? ["export", ...w.slice(1)].join(" ")
101
+ : undefined,
102
+ // remove|rm|delete note <id> ≡ remove <id>.
103
+ (w) =>
104
+ REMOVE_WORDS.includes(w[0] ?? "") && w[1] === "note" && w.length === 3
105
+ ? `remove ${w[2]}`
106
+ : undefined,
107
+ // rm <id> ≡ remove <id>.
108
+ (w) =>
109
+ w[0] === "rm" && w.length >= 2
110
+ ? ["remove", ...w.slice(1)].join(" ")
111
+ : undefined,
112
+ ];
113
+
114
+ /**
115
+ * Verbs whose slash and bare spellings are different commands: bare `play`
116
+ * is the transport, `/play` is play mode. The grammar never swaps them.
117
+ */
118
+ export const DISTINCT_SLASH: ReadonlySet<string> = new Set(["play"]);
119
+
120
+ /**
121
+ * Every other reading of `line`, most likely first: the canonical rewrite,
122
+ * the other slash spelling, and the remove and list word swaps, each with
123
+ * and without the slash. The line itself is not included.
124
+ */
125
+ export function candidates(line: string): string[] {
126
+ const text = line.trim().replace(/\s+/g, " ");
127
+ if (!text || text.length > 1_024) return [];
128
+ const bare = stripSlash(text);
129
+ if (!bare || bare.startsWith("/")) return [];
130
+ // `play` and `/play` are two commands (transport vs play mode).
131
+ if (DISTINCT_SLASH.has(verbOf(text))) return [];
132
+ const words = bare.split(" ");
133
+ const lower = words.map((word) => word.toLowerCase());
134
+ const out: string[] = [];
135
+ const push = (candidate: string | undefined): void => {
136
+ if (candidate === undefined) return;
137
+ for (const form of [candidate, toggleSlash(candidate)])
138
+ if (form !== text && !out.includes(form)) out.push(form);
139
+ };
140
+ for (const rewrite of REWRITES) push(rewrite(lower));
141
+ push(bare);
142
+ // remove|rm|delete in the first three words: try each spelling.
143
+ for (let index = 0; index < Math.min(3, words.length); index += 1) {
144
+ if (!REMOVE_WORDS.includes(lower[index]!)) continue;
145
+ for (const word of REMOVE_WORDS)
146
+ if (word !== lower[index])
147
+ push(
148
+ [...words.slice(0, index), word, ...words.slice(index + 1)].join(" "),
149
+ );
150
+ }
151
+ // <noun> list|presets|ls, or bare <noun>: try every listing spelling.
152
+ const last = lower.at(-1)!;
153
+ if (words.length >= 2 && LIST_WORDS.includes(last)) {
154
+ const head = words.slice(0, -1);
155
+ push(head.join(" "));
156
+ for (const word of LIST_WORDS)
157
+ if (word !== last) push([...head, word].join(" "));
158
+ } else if (words.length === 1)
159
+ for (const word of LIST_WORDS) push(`${words[0]} ${word}`);
160
+ return out;
161
+ }
162
+
163
+ /** The first reading of `line` that `accepts`, or undefined. */
164
+ export function recover(
165
+ line: string,
166
+ accepts: (candidate: string) => boolean,
167
+ ): string | undefined {
168
+ return candidates(line).find(accepts);
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Export and loop: forms the prompt bar runs itself
173
+
174
+ export type ExportCommand = Readonly<{
175
+ path: string;
176
+ format: "json" | "mid" | "wav";
177
+ stems: boolean;
178
+ }>;
179
+
180
+ export const EXPORT_USAGE =
181
+ "export <file>.track.json|.mid|.wav [stems] · export song.wav · export mix.wav stems";
182
+
183
+ /**
184
+ * `export song.wav`, `export song.wav stems`, `export loop.track.json`,
185
+ * `export song.mid` (slash optional). Stems are a WAV per track.
186
+ */
187
+ export function parseExportCommand(line: string): ExportCommand | undefined {
188
+ const match = line.trim().match(/^\/?export\s+(\S+)(?:\s+(stems))?\s*$/i);
189
+ if (!match) return undefined;
190
+ const path = match[1]!;
191
+ // `export list` is a listing spelling, never a file named `list`.
192
+ if (LIST_WORDS.includes(path.toLowerCase())) return undefined;
193
+ const format = /\.wav$/i.test(path)
194
+ ? "wav"
195
+ : /\.midi?$/i.test(path)
196
+ ? "mid"
197
+ : "json";
198
+ const stems = match[2] !== undefined;
199
+ if (stems && format !== "wav") return undefined;
200
+ return { path, format, stems };
201
+ }
202
+
203
+ /**
204
+ * Whether the agent may write `path`: relative, inside the workspace, with
205
+ * no `..` segment and no home or drive prefix.
206
+ */
207
+ export function workspaceRelative(path: string): boolean {
208
+ if (!path || path.length > 256) return false;
209
+ if (/^([/\\~]|[a-z]:)/i.test(path)) return false;
210
+ return !path.split(/[/\\]/).some((segment) => segment === "..");
211
+ }
212
+
213
+ export type LoopCommand =
214
+ | Readonly<{ type: "loop-off" }>
215
+ | Readonly<{ type: "loop-bars"; from: number; to: number }>
216
+ | Readonly<{ type: "loop-section"; name: string }>
217
+ | Readonly<{ type: "loop-show" }>;
218
+
219
+ export const LOOP_USAGE =
220
+ "loop <a>-<b> | <section> | off · loop 1-4 · loop chorus · loop off";
221
+
222
+ /**
223
+ * `loop 1-4` (bars, 1-based inclusive), `loop 1 4`, `loop chorus`, `loop
224
+ * off`, bare `loop` (what loops now). Slash optional.
225
+ */
226
+ export function parseLoopCommand(line: string): LoopCommand | undefined {
227
+ const words = stripSlash(line).trim().split(/\s+/);
228
+ if (words[0]?.toLowerCase() !== "loop") return undefined;
229
+ const rest = words.slice(1);
230
+ if (rest.length === 0) return { type: "loop-show" };
231
+ if (rest.length === 1 && /^(off|none|song)$/i.test(rest[0]!))
232
+ return { type: "loop-off" };
233
+ const range = rest
234
+ .join(" ")
235
+ .match(/^(\d{1,4})\s*(?:-|\.\.|–|\s)\s*(\d{1,4})$/u);
236
+ if (range) {
237
+ const from = Number(range[1]);
238
+ const to = Number(range[2]);
239
+ return from >= 1 && to >= from
240
+ ? { type: "loop-bars", from, to }
241
+ : undefined;
242
+ }
243
+ if (rest.length === 1 && /^\d{1,4}$/.test(rest[0]!)) {
244
+ const bar = Number(rest[0]);
245
+ return bar >= 1 ? { type: "loop-bars", from: bar, to: bar } : undefined;
246
+ }
247
+ const name = rest.join(" ");
248
+ return /^[\p{L}\p{N}][\p{L}\p{N} _\x27.-]{0,31}$/u.test(name)
249
+ ? { type: "loop-section", name }
250
+ : undefined;
251
+ }
252
+
253
+ /** `track remove|rm|delete <name>` and `track move <name> <n>`. */
254
+ export function parseTrackEdit(
255
+ line: string,
256
+ ): Readonly<{ verb: "rm" | "move"; rest: string }> | undefined {
257
+ const match = line
258
+ .trim()
259
+ .match(/^\/?track\s+(rm|remove|delete|move)\s+(.{1,64}?)\s*$/i);
260
+ if (!match) return undefined;
261
+ return {
262
+ verb: match[1]!.toLowerCase() === "move" ? "move" : "rm",
263
+ rest: match[2]!,
264
+ };
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Window verbs and the agent boundary
269
+
270
+ /**
271
+ * Window verbs whose argument is free text: bare they print this hint and do
272
+ * nothing, so `rename` alone (or a sentence starting with it, offline) never
273
+ * renames or forks a session by accident.
274
+ */
275
+ export const FREE_TEXT_HINTS: Readonly<Record<string, string>> = {
276
+ rename: "rename · /rename <name> names this session · /rename --auto",
277
+ fork: "fork · /fork [<name>] copies this session",
278
+ resume: "resume · /resume [<n>|<name>] · /sessions lists them",
279
+ login:
280
+ "model key · model key [gateway|openrouter|codex|claude] adds an agent", // login is an alias
281
+ };
282
+
283
+ /**
284
+ * Words people reach for that dawg keeps elsewhere: the hint names where,
285
+ * so `swing` never reads as a typo of `sing`.
286
+ */
287
+ export const ELSEWHERE_HINTS: Readonly<Record<string, string>> = {
288
+ swing: "swing lives on a rhythm row · euclid hat swing 0.1 · help rhythm",
289
+ };
290
+
291
+ /** The hint for a line whose verb dawg keeps elsewhere, if any. */
292
+ export function elsewhereHint(line: string): string | undefined {
293
+ const verb = verbOf(line);
294
+ return Object.prototype.hasOwnProperty.call(ELSEWHERE_HINTS, verb)
295
+ ? ELSEWHERE_HINTS[verb]
296
+ : undefined;
297
+ }
298
+
299
+ /**
300
+ * Window verbs that run the same with or without the slash. The prompt bar
301
+ * retries the other spelling of these when the typed one is not handled.
302
+ */
303
+ export const WINDOW_VERBS: ReadonlySet<string> = new Set([
304
+ "help",
305
+ "guide",
306
+ "guides",
307
+ "menu",
308
+ "tracks",
309
+ "track",
310
+ "undo",
311
+ "redo",
312
+ "export",
313
+ "import",
314
+ "status",
315
+ "theme",
316
+ "motion",
317
+ "showme",
318
+ "model",
319
+ "sessions",
320
+ "logout",
321
+ "auth",
322
+ "quit",
323
+ "exit",
324
+ "view",
325
+ "transcript",
326
+ "log",
327
+ "click",
328
+ "count-in",
329
+ "grid",
330
+ "try",
331
+ "sample",
332
+ "samples",
333
+ "len",
334
+ "fitmode",
335
+ "euclid",
336
+ "style",
337
+ "styles",
338
+ "calibration",
339
+ ]);
340
+
341
+ /** `✗ no agent · …`: what prose gets when no provider is signed in. */
342
+ export const NO_AGENT = "no agent · try style deep-house · model key adds one";
343
+
344
+ /**
345
+ * Command words that are also everyday English: a sentence starting with
346
+ * one (`add a walking bass`, `build tension`) is a request for the agent.
347
+ * Every other known verb is grammar only and never reaches the agent.
348
+ */
349
+ export const EVERYDAY_VERBS: ReadonlySet<string> = new Set([
350
+ "add",
351
+ "put",
352
+ "move",
353
+ "make",
354
+ "build",
355
+ "drop",
356
+ "fill",
357
+ "form",
358
+ "play",
359
+ "remove",
360
+ "delete",
361
+ "clear",
362
+ "extend",
363
+ "length",
364
+ "try",
365
+ "help",
366
+ "sing",
367
+ "pan",
368
+ "track",
369
+ "chords",
370
+ "key",
371
+ "scale",
372
+ "style",
373
+ "view",
374
+ "log",
375
+ "bend",
376
+ "glide",
377
+ "fade",
378
+ "shift",
379
+ "loop",
380
+ "groove",
381
+ "hit",
382
+ "export",
383
+ "import",
384
+ "rename",
385
+ "fork",
386
+ "resume",
387
+ "login", // alias of model key
388
+ ]);
389
+
390
+ /**
391
+ * `✗ <line> · did you mean <x>? · <usage>`: one card for a known verb whose
392
+ * arguments did not parse, the same for slash and bare input, in the order
393
+ * what failed · nearest match · where to look.
394
+ */
395
+ export function usageCard(line: string, usage: string, near?: string): string {
396
+ return near
397
+ ? `${line} · did you mean ${near}? · ${usage}`
398
+ : `${line} · ${usage}`;
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // One vocabulary matcher, one error template
403
+
404
+ export { nearest } from "./nearest.ts";
405
+
406
+ /** Every verb help and the usage table know, bare (`fx`, `tempo`, …). */
407
+ export function knownVerbs(): ReadonlySet<string> {
408
+ knownCache ??= new Set(
409
+ [
410
+ ...HELP_SECTIONS.flatMap((section) =>
411
+ section.group === "keys"
412
+ ? []
413
+ : section.entries.map((entry) => entry.command.split(/[\s|[]/)[0]!),
414
+ ),
415
+ ...Object.keys(USAGE),
416
+ ...Object.values(USAGE).map((usage) => usage.split(/[\s|[]/)[0]!),
417
+ ...WINDOW_VERBS,
418
+ "groove",
419
+ "rig",
420
+ "loop",
421
+ "export",
422
+ "remove",
423
+ "rm",
424
+ "delete",
425
+ ]
426
+ .map((verb) => verb.replace(/^\//, "").toLowerCase())
427
+ .filter((verb) => /^[a-z][\w-]*$/.test(verb)),
428
+ );
429
+ return knownCache;
430
+ }
431
+ let knownCache: Set<string> | undefined;
432
+
433
+ /** A value's allowed range, unit and a working example. */
434
+ export type ValueRange = Readonly<{
435
+ command: string;
436
+ min: number;
437
+ max: number;
438
+ unit: string;
439
+ example: string;
440
+ }>;
441
+
442
+ /**
443
+ * Range tables for the values people type, keyed by command word, with the
444
+ * core schema keys they guard (`tempoBpm`) mapped to the same rows, so a
445
+ * core message never reaches a card raw.
446
+ */
447
+ export const RANGES: Readonly<Record<string, ValueRange>> = Object.freeze({
448
+ tempo: {
449
+ command: "tempo",
450
+ min: 20,
451
+ max: 300,
452
+ unit: "BPM",
453
+ example: "tempo 128",
454
+ },
455
+ bars: { command: "bars", min: 1, max: 256, unit: "bars", example: "bars 8" },
456
+ meter: {
457
+ command: "meter",
458
+ min: 1,
459
+ max: 16,
460
+ unit: "beats per bar",
461
+ example: "meter 3",
462
+ },
463
+ volume: {
464
+ command: "volume",
465
+ min: 0,
466
+ max: 1,
467
+ unit: "",
468
+ example: "volume 0.8",
469
+ },
470
+ pan: { command: "pan", min: -1, max: 1, unit: "", example: "pan -0.3" },
471
+ });
472
+
473
+ /** Core schema keys and the command row that explains each. */
474
+ const CORE_KEYS: Readonly<Record<string, string>> = Object.freeze({
475
+ tempoBpm: "tempo",
476
+ bars: "bars",
477
+ beatsPerBar: "meter",
478
+ "track volume": "volume",
479
+ "track pan": "pan",
480
+ });
481
+
482
+ /**
483
+ * `✗ tempo 900 · tempo takes 20…300 BPM · tempo 128`: the one template for
484
+ * a value out of range (the `✗` is the card's marker).
485
+ */
486
+ export function usageError(input: string, range: ValueRange): string {
487
+ const unit = range.unit ? ` ${range.unit}` : "";
488
+ return `${clip(input)} · ${range.command} takes ${range.min}…${range.max}${unit} · ${range.example}`;
489
+ }
490
+
491
+ /**
492
+ * A core validation message rewritten in the template, or undefined when it
493
+ * names no known key: `tempoBpm must be between 20 and 300` after `tempo
494
+ * 900` reads `tempo 900 · tempo takes 20…300 BPM · tempo 128`.
495
+ */
496
+ export function friendlyCoreError(
497
+ input: string,
498
+ message: string,
499
+ ): string | undefined {
500
+ for (const [key, command] of Object.entries(CORE_KEYS)) {
501
+ if (!message.startsWith(`${key} must be`)) continue;
502
+ const range = RANGES[command];
503
+ if (range) return usageError(input, range);
504
+ }
505
+ return undefined;
506
+ }
507
+
508
+ /** `usage · <cmd> <args>`: the one usage line. */
509
+ export function usageLine(usage: string): string {
510
+ return `usage · ${usage.replace(/^usage\s*·?\s*/i, "")}`;
511
+ }
512
+
513
+ /** `no track <id> · tracks lists them`: the one missing-track line. */
514
+ export function noTrack(id: string): string {
515
+ return `no track ${clip(id)} · tracks lists them`;
516
+ }
517
+
518
+ /** `no note <id> · notes lists them`: the one missing-note line. */
519
+ export function noNote(id: string): string {
520
+ return `no note ${clip(id)} · notes lists them`;
521
+ }
522
+
523
+ function clip(value: string): string {
524
+ const line = value.replace(/\s+/g, " ").trim();
525
+ return line.length > 32 ? `${line.slice(0, 31)}…` : line;
526
+ }
527
+
528
+ // ---------------------------------------------------------------------------
529
+ // Canonical rewrites the prompt bar applies before anything else
530
+
531
+ /**
532
+ * Canonical forms that name window commands: `model key [provider]` is the
533
+ * agent-key command (`login` stays an alias), `models` is `model`, and
534
+ * `voice` opens the Voice help topic. Undefined when `line` is none.
535
+ */
536
+ export function canonicalWindowForm(line: string): string | undefined {
537
+ const text = line.trim().replace(/\s+/g, " ");
538
+ const key = text.match(/^\/?models?\s+key(?:\s+(.*))?$/i);
539
+ if (key) return `/login${key[1] ? ` ${key[1]}` : ""}`; // login: the alias it runs
540
+ const models = text.match(/^\/?models(\s+\S+)?$/i);
541
+ if (models) return `/model${models[1] ?? ""}`;
542
+ if (/^\/?voice$/i.test(text)) return "/help voice";
543
+ return undefined;
544
+ }
545
+
546
+ /**
547
+ * `exported · mix.wav · 2 stems mix-lead.wav mix-bass.wav`: the stems
548
+ * receipt names every file it wrote, counted in the right number.
549
+ */
550
+ export function stemsReceipt(path: string, stems: readonly string[]): string {
551
+ const base = (file: string) => file.split(/[\\/]/).at(-1) ?? file;
552
+ if (stems.length === 0)
553
+ return `exported · ${path} · no audible tracks for stems`;
554
+ const count = `${stems.length} stem${stems.length === 1 ? "" : "s"}`;
555
+ const names = stems.slice(0, 3).map(base);
556
+ if (stems.length > 3) names.push(`+${stems.length - 3}`);
557
+ return `exported · ${path} · ${count} ${names.join(" ")}`;
558
+ }