@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
package/src/main.ts CHANGED
@@ -1,11 +1,43 @@
1
1
  #!/usr/bin/env bun
2
+ import { TOPIC_ALIASES } from "./lang/glossary.ts";
3
+ import { isGuideInstrument, vocalChainPatch } from "../core/clips.ts";
4
+ import { isSingWord } from "../core/sing.ts";
5
+ import { commandParses, parseExact } from "./commands/parses.ts";
6
+ import { paramRangeError } from "./commands/param-range.ts";
7
+ import { noteName as midiNoteName } from "./media/notes.ts";
2
8
  import {
9
+ EVERYDAY_VERBS,
10
+ EXPORT_USAGE,
11
+ elsewhereHint,
12
+ FREE_TEXT_HINTS,
13
+ noTrack,
14
+ stemsReceipt,
15
+ LOOP_USAGE,
16
+ WINDOW_VERBS,
17
+ NO_AGENT,
18
+ RANGES,
19
+ canonicalWindowForm,
20
+ friendlyCoreError,
21
+ knownVerbs,
22
+ nearest,
23
+ noNote,
24
+ parseExportCommand,
25
+ parseLoopCommand,
26
+ recover,
27
+ usageCard,
28
+ usageError,
29
+ verbOf,
30
+ } from "./commands/grammar.ts";
31
+ import {
32
+ instrumentListLines,
3
33
  isUnknownInstrument,
4
34
  plainSineAdvice,
35
+ unknownInstrumentMessage,
5
36
  } from "./audio/instrument-check.ts";
6
37
  import { randomUUID } from "node:crypto";
7
38
  import { readFileSync, writeSync } from "node:fs";
8
- import { readFile, stat, writeFile } from "node:fs/promises";
39
+ import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
40
+ import { tmpdir } from "node:os";
9
41
  import { join, relative, resolve } from "node:path";
10
42
  import { stdin, stdout } from "node:process";
11
43
  import {
@@ -14,6 +46,12 @@ import {
14
46
  type SessionRecord,
15
47
  } from "./session/store.ts";
16
48
  import { openSessionPort } from "./session/port.ts";
49
+ import {
50
+ OwnWrites,
51
+ editedTrack,
52
+ foreignEvents,
53
+ otherWindowName,
54
+ } from "./session/origin.ts";
17
55
  import { compositionDigest, monotonicEpochMs } from "./session/protocol.ts";
18
56
  import {
19
57
  formatSessionLine,
@@ -69,11 +107,31 @@ import {
69
107
  rigTrackFields,
70
108
  rigWordPatch,
71
109
  } from "./commands/rig.ts";
110
+ import {
111
+ applyProgressionCommand,
112
+ parseProgressionCommand,
113
+ } from "./commands/progression.ts";
114
+ import {
115
+ applyGuitarCommand,
116
+ applyStrumCommand,
117
+ parseGuitarCommand,
118
+ parseStrumCommand,
119
+ } from "./commands/strum.ts";
72
120
  import {
73
121
  applyModalCommand,
74
122
  modalListLines,
75
123
  parseModalCommand,
76
124
  } from "./commands/modal.ts";
125
+ import {
126
+ applyWindCommand,
127
+ parseWindCommand,
128
+ windListLines,
129
+ } from "./commands/wind.ts";
130
+ import {
131
+ applySingCommand,
132
+ parseSingCommand,
133
+ singListLines,
134
+ } from "./commands/sing.ts";
77
135
  import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
78
136
  import { applyStringCommand, parseStringCommand } from "./commands/string.ts";
79
137
  import { instrumentPatchForWord, isModalWord } from "../core/resonators.ts";
@@ -94,26 +152,38 @@ import {
94
152
  parseTuningCommand,
95
153
  type TuningCommand,
96
154
  } from "./commands/tuning.ts";
155
+ import {
156
+ applyCalibrationCommand,
157
+ parseCalibrationCommand,
158
+ } from "./commands/calibration.ts";
97
159
  import { TuningError, displayTag, resolveTuning } from "../core/tuning.ts";
98
160
  import {
99
161
  applyMasterCommand,
100
162
  measurementLine,
101
163
  parseMasterCommand,
102
164
  } from "./commands/master.ts";
165
+ import { applyStyleCommand, parseStyleCommand } from "./commands/style.ts";
103
166
  import { exportSampleRate, measureScoreOffThread } from "./audio/measure.ts";
104
167
  import {
105
168
  applySectionCommand,
169
+ loopSpan,
106
170
  parseSectionCommand,
107
171
  } from "./commands/arrange.ts";
108
172
  import {
109
- HELP_TOPICS,
173
+ helpMiss,
110
174
  helpText,
175
+ helpTitle,
111
176
  helpTopicLines,
112
177
  nearestCommand,
113
178
  typoFix,
114
179
  usageHint,
115
180
  } from "./commands/help.ts";
116
- import { historyTarget, REDO_KIND, UNDO_KIND } from "./commands/history.ts";
181
+ import {
182
+ historyReceipt,
183
+ historyTarget,
184
+ REDO_KIND,
185
+ UNDO_KIND,
186
+ } from "./commands/history.ts";
117
187
 
118
188
  import {
119
189
  SamplePlacementError,
@@ -127,6 +197,32 @@ import {
127
197
  voiceNameFrom,
128
198
  } from "./commands/sample.ts";
129
199
  import { applyFitCommand, fitVoice, parseFitCommand } from "./commands/fit.ts";
200
+ import { applyShiftCommand, parseShiftCommand } from "./commands/shift.ts";
201
+ import { parseVocalCommand, runVocalCommand } from "./commands/vocal.ts";
202
+ import {
203
+ applyFormantCommand,
204
+ parseFormantCommand,
205
+ parseVowelCommand,
206
+ } from "./commands/formant.ts";
207
+ import {
208
+ applyLyrics,
209
+ parseClipCommand,
210
+ parseLyricsCommand,
211
+ runClipCommand,
212
+ setClipImportDeps,
213
+ } from "./commands/clips.ts";
214
+ import { pitchTraceFor } from "./commands/vocal-pitch.ts";
215
+ import {
216
+ applyVocoderCommand,
217
+ parseVocoderCommand,
218
+ vocoderListLines,
219
+ } from "./commands/vocoder.ts";
220
+ import {
221
+ applyAutotuneCommand,
222
+ autotuneListLines,
223
+ parseAutotuneCommand,
224
+ } from "./commands/autotune.ts";
225
+ import { parseResampleCommand, runResample } from "./commands/resample.ts";
130
226
  import { suggestFitMode } from "./audio/dsp/onset.ts";
131
227
  import {
132
228
  SampleLibrary,
@@ -135,7 +231,14 @@ import {
135
231
  type SampleBank,
136
232
  type SampleProblem,
137
233
  } from "./audio/samples.ts";
234
+ import {
235
+ ANALYSIS_CACHE_BYTES,
236
+ analysisCacheStatus,
237
+ analysisDir,
238
+ pruneAnalysisCache,
239
+ } from "./audio/analysis.ts";
138
240
  import { drumSnapshotFields, samplerSnapshotFields } from "../tui/drums.ts";
241
+ import { clipSnapshots, loadClipPeaks } from "../tui/clip-row.ts";
139
242
  import { highwayLayers } from "../tui/layers.ts";
140
243
  import { drumVoicePitch, isDrumInstrument } from "../core/drums.ts";
141
244
  import {
@@ -144,6 +247,7 @@ import {
144
247
  type AgentEvent,
145
248
  type AgentHost,
146
249
  } from "./agent/agent.ts";
250
+ import { routeDuringTurn } from "./agent/steer.ts";
147
251
  import {
148
252
  isApiSelection,
149
253
  providerFingerprint,
@@ -162,8 +266,24 @@ import {
162
266
  spendLine as formatSpendLine,
163
267
  webHostFor,
164
268
  } from "./agent/usage.ts";
165
- import { configDir } from "./auth/credentials.ts";
166
- import { runAuthCommand, runFirstRunLogin, runTuiLogin } from "./auth/cli.ts";
269
+ import { configDir, readConfig, writeConfig } from "./auth/credentials.ts";
270
+ import type { CommandOutcome } from "./agent/command-agent.ts";
271
+ import {
272
+ GLIDE_STEP_MS,
273
+ NoteScheduler,
274
+ finishHint,
275
+ gestureFor,
276
+ glideValues,
277
+ agentPathsAllowed,
278
+ isAgentCommand,
279
+ isBrokenCommand,
280
+ brokenCommandReceipt,
281
+ parseShowMe,
282
+ toolCaption,
283
+ type ShowMeLevel,
284
+ } from "./agent/show-me.ts";
285
+ import { firstRunCard, runAuthCommand, runTuiLogin } from "./auth/cli.ts";
286
+ import { musicalReceipt } from "./session/receipt.ts";
167
287
  import {
168
288
  modelPickerItems,
169
289
  tuiAuthCommand,
@@ -179,10 +299,16 @@ import {
179
299
  PlaySession,
180
300
  type LiveEngine,
181
301
  } from "./tui/play-session.ts";
182
- import { EditMenu, MENU_SECTIONS, type MenuContext } from "./tui/menu.ts";
302
+ import {
303
+ EditMenu,
304
+ menuUsage,
305
+ menuSectionPath,
306
+ type MenuContext,
307
+ } from "./tui/menu.ts";
183
308
  import {
184
309
  drawerView,
185
310
  faderChoose,
311
+ faderCommand,
186
312
  faderKeyPress,
187
313
  faderSetPosition,
188
314
  faderStep,
@@ -204,7 +330,15 @@ import {
204
330
  isStageable,
205
331
  } from "./tui/audition.ts";
206
332
  import { EuclidEditor, type EuclidContext } from "./tui/euclid.ts";
207
- import { HINTS, KEYS, keyLines, type KeySection } from "../tui/grammar.ts";
333
+ import {
334
+ ARROW_DOWN,
335
+ ARROW_UP,
336
+ closesKeys,
337
+ HINTS,
338
+ KEYS,
339
+ keyLines,
340
+ type KeySection,
341
+ } from "../tui/grammar.ts";
208
342
  import { renderAudition } from "./audio/audition.ts";
209
343
  import { exportScore, playbackTime, scoreBeatAt } from "./audio/arrange.ts";
210
344
  import { formatForm } from "../core/sections.ts";
@@ -252,6 +386,7 @@ import {
252
386
  applyScoreOperation,
253
387
  createScore,
254
388
  isSamplerInstrument,
389
+ isTrackAudible,
255
390
  PACK_PREFIX,
256
391
  scoreFromJSON,
257
392
  SCORE_LIMITS,
@@ -264,8 +399,19 @@ import { decodeLoop, encodeLoop } from "../core/loop.ts";
264
399
  import { scoreToMidi } from "../core/midi.ts";
265
400
  import type { TrackScoreSnapshot } from "../tui/render.ts";
266
401
  import { PromptModel } from "../tui/prompt.ts";
267
- import { TerminalInputDecoder } from "../tui/input.ts";
268
402
  import {
403
+ ESCAPE_FLUSH_MS,
404
+ INPUT_FLUSH,
405
+ PASTE_FLUSH_MS,
406
+ TerminalInputDecoder,
407
+ } from "../tui/input.ts";
408
+ import {
409
+ FrameGate,
410
+ IDLE_HEARTBEAT_MS,
411
+ MIN_FRAME_GAP_MS,
412
+ } from "../tui/frame-gate.ts";
413
+ import {
414
+ CARD_GLOW_MS,
269
415
  composeFrame,
270
416
  TuiApp,
271
417
  type AppView,
@@ -273,13 +419,19 @@ import {
273
419
  type SyncState,
274
420
  type TypesIndicator,
275
421
  } from "../tui/app.ts";
276
- import { fail, ok, toneOf, warn, type Receipt } from "../tui/activity.ts";
422
+ import { fail, note, ok, toneOf, warn, type Receipt } from "../tui/activity.ts";
277
423
  import { systemRunner } from "./auth/runner.ts";
278
424
  import type { MediaServices } from "./media/types.ts";
279
425
  import { encodeBuffer } from "../tui/screen.ts";
280
426
  import { parseThemeName } from "../tui/theme.ts";
281
427
  import { formatDiagnostic } from "../core/sdk/eval.ts";
282
428
  import { isProject } from "./project/init.ts";
429
+ import {
430
+ parseLaunchArgs,
431
+ parseSimpleArgv,
432
+ resolveTrackArg,
433
+ } from "./launch-args.ts";
434
+ import { RENDER_USAGE, runRenderCommand } from "./render.ts";
283
435
  import { typecheckProject } from "./project/typecheck.ts";
284
436
  import {
285
437
  startProjectSync,
@@ -287,107 +439,100 @@ import {
287
439
  type SyncHost,
288
440
  } from "./project/sync.ts";
289
441
 
442
+ /** Set while `submit` runs a grammar reading, so it reads only once. */
443
+ let recovering = false;
444
+
445
+ /** Slash words `submit` handles itself (no parser in commandParses). */
446
+ const SLASH_HANDLED: ReadonlySet<string> = new Set([
447
+ ...WINDOW_VERBS,
448
+ "chords",
449
+ "click",
450
+ "help",
451
+ ]);
452
+
290
453
  /** Whether a bare command parses (no side effects): for typo suggestions. */
291
454
  function parsesLocally(text: string): boolean {
292
- return [
293
- parsePrompt,
294
- parseMusicCommand,
295
- parseEditCommand,
296
- parseRhythmCommand,
297
- parseFxCommand,
298
- parseSynthCommand,
299
- parseStringCommand,
300
- parseGranularCommand,
301
- parseKeysCommand,
302
- parseExpressionCommand,
303
- parseMasterCommand,
304
- (value: string) => parseSectionCommand(value, score),
305
- parsePatternCommand,
306
- parseKitCommand,
307
- parsePackCommand,
308
- parseSampleCommand,
309
- parseFitCommand,
310
- parseWavetableCommand,
311
- parseTimeCommand,
312
- parseTuningCommand,
313
- parseRigCommand,
314
- parseModalCommand,
315
- ].some((parse) => parse(text) !== undefined);
455
+ return commandParses(text, score);
316
456
  }
317
457
 
318
458
  const ESC = "\u001b[";
319
459
  /** `/sessions` rows shown in the overlay. */
320
460
  const MAX_LISTED_SESSIONS = 64;
321
461
 
322
- const SUBCOMMANDS = [
323
- "login",
324
- "logout",
325
- "auth",
326
- "sessions",
327
- "render",
328
- "init",
329
- "check",
330
- ];
331
- const VALUE_FLAGS = ["--session", "--track", "--import", "--export", "--theme"];
332
- const FLAGS = [
333
- ...VALUE_FLAGS,
334
- "--new",
335
- "--demo",
336
- "--help",
337
- "-h",
338
- "--version",
339
- "-v",
340
- "--reduce-motion",
341
- "--no-mouse",
342
- ];
462
+ /** Wraps a usage line at spaces before `width`, indenting continuations. */
463
+ function wrapUsage(text: string, width: number, indent: string): string {
464
+ const lines: string[] = [];
465
+ let line = "";
466
+ for (const word of text.split(" ")) {
467
+ const prefix = lines.length === 0 ? " " : indent;
468
+ if (line && prefix.length + line.length + 1 + word.length > width) {
469
+ lines.push(line);
470
+ line = word;
471
+ } else line = line ? `${line} ${word}` : word;
472
+ }
473
+ lines.push(line);
474
+ return lines.join(`\n${indent}`);
475
+ }
343
476
  const HELP_TEXT = `dawg · local-first terminal music workstation
344
477
 
345
478
  Usage:
346
479
  dawg [--new] [--session <name|id>] [--track <name>]
347
- dawg --import <file> --export <file>
480
+ dawg --import <file> --export <file> convert a loop file (no session)
348
481
  dawg sessions
349
- dawg render <out.wav|out.mid> [--session <name|id>] [--import <file>]
350
- [--normalize <lufs|streaming|club|loud|…>] [--measure]
351
- dawg init [dir] project files: song.ts, tracks/<slug>/track.ts, .dawg/sdk
482
+ ${wrapUsage(RENDER_USAGE.replace(/^usage: /, ""), 76, " ")}
483
+ dawg init [dir] song.ts, tracks/<slug>/track.ts and .dawg/sdk
352
484
  dawg check typecheck + evaluate the project; exit 1 on problems
353
- dawg media doctor|download|stems|analyze|notes|sample|lyrics … (dawg media --help)
485
+ dawg <command> --help usage: sessions render init check media model
486
+ dawg doctor audio backend, native sink and devices
487
+ dawg media doctor|download|stems|analyze|notes|sample|lyrics (--help)
354
488
  dawg --version
355
489
 
356
- Usage flags:
490
+ Display options:
357
491
  --reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
358
492
  --theme <name> default | high-contrast | mono (NO_COLOR forces mono)
359
493
  --no-mouse keys only; no click/wheel reporting (also DAWG_MOUSE=0)
360
494
 
361
495
  Prompt:
362
- Enter submit · Shift-Enter newline · Alt-Enter queue · Ctrl-Q toggle queue
363
- Ctrl-Z undo · Ctrl-Y redo · Ctrl-O transcript · Esc cancel/close · Ctrl-C exit
496
+ Enter submit · Shift-Enter newline · Alt-Enter next · Ctrl-Q now / next
497
+ Ctrl-Z undo · Ctrl-Y redo · Ctrl-O transcript · Esc close · Ctrl-C exit
364
498
  Space on an empty prompt toggles playback
365
499
 
366
500
  Commands (bare music words; app commands take a slash):
367
501
  ${helpText()}
368
502
 
369
- Sign in (the choice is saved and reused until you log out):
370
- dawg login find existing setups and pick a provider
371
- dawg login gateway|openrouter|codex|claude
372
- dawg model [alias] pick a model, with the estimated cost per prompt
503
+ Agent (optional; the choice is saved and reused until you log out):
504
+ dawg model key find existing setups and pick a provider
505
+ dawg model key gateway|openrouter|codex|claude
506
+ dawg model [alias] pick a model, with the cost per prompt
373
507
  dawg logout [provider] · dawg auth status [--check]
374
508
  Unrecognized requests go to the agent once a provider is configured
375
509
  (DAWG_PROVIDER=gateway|openrouter|codex|claude|auto, DAWG_MODEL=<alias>;
376
510
  DAWG_AI=0 disables the agent).`;
377
511
  const args = new Set(process.argv.slice(2));
378
- const requestedSession = optionValue("--session");
379
- const explicitTrack = optionValue("--track");
512
+ /** One parse for every launch flag; problems are reported below. */
513
+ const launch = parseLaunchArgs(process.argv.slice(2));
514
+ const launchArgs = launch.ok ? launch.args : undefined;
515
+ const requestedSession = launchArgs?.session;
516
+ /** `--track` normalized as `/track` does; matched to an existing track below. */
517
+ let explicitTrack = launchArgs?.track?.id;
380
518
  /** The focused track; claimed at startup unless `--track` is given. */
381
519
  let requestedTrack = explicitTrack ?? "main";
382
520
  const initialInstrument = isDrumInstrument(requestedTrack) ? "kit" : "sine";
383
- const importPath = optionValue("--import");
384
- const exportPath = optionValue("--export");
521
+ const importPath = launchArgs?.importPath;
522
+ const exportPath = launchArgs?.exportPath;
523
+ // `dawg model key [provider]` is the canonical spelling of `dawg login`.
524
+ if (process.argv[2] === "model" && process.argv[3] === "key")
525
+ process.exit(await runAuthCommand(["login", ...process.argv.slice(4)]));
385
526
  if (["login", "logout", "auth", "model"].includes(process.argv[2] ?? ""))
386
527
  process.exit(await runAuthCommand(process.argv.slice(2)));
387
528
  {
388
529
  // An unknown DAWG_MODEL is an error, never a silent fallback.
389
530
  const fromEnv = process.env.DAWG_MODEL?.trim();
390
- if (fromEnv && !resolveModelChoice("gateway", fromEnv)) {
531
+ if (
532
+ fromEnv &&
533
+ !resolveModelChoice("gateway", fromEnv) &&
534
+ !resolveModelChoice("openrouter", fromEnv)
535
+ ) {
391
536
  process.stderr.write(
392
537
  `dawg: unknown DAWG_MODEL "${fromEnv.slice(0, 40)}"; use one of ${MODEL_CATALOG.map((row) => row.alias).join(", ")} or a vendor/model ID\n`,
393
538
  );
@@ -395,10 +540,14 @@ if (["login", "logout", "auth", "model"].includes(process.argv[2] ?? ""))
395
540
  }
396
541
  }
397
542
  if (process.argv[2] === "sessions") {
398
- if (args.has("--help") || args.has("-h"))
399
- stdout.write(
400
- "usage: dawg sessions · lists this workspace's sessions, newest first (* marks the current one)\n",
401
- );
543
+ const usage =
544
+ "usage: dawg sessions · lists this workspace's sessions, newest first (* marks the current one)";
545
+ const parsed = parseSimpleArgv(process.argv.slice(3), 0);
546
+ if (parsed.kind === "error") {
547
+ process.stderr.write(`${parsed.problem} · ${usage}\n`);
548
+ process.exit(2);
549
+ }
550
+ if (parsed.kind === "help") stdout.write(`${usage}\n`);
402
551
  else await printSessions(process.cwd(), stdout);
403
552
  process.exit(0);
404
553
  }
@@ -411,6 +560,10 @@ if (process.argv[2] === "init" || process.argv[2] === "check") {
411
560
  await command(process.argv.slice(2), process.cwd(), stdout, process.stderr),
412
561
  );
413
562
  }
563
+ if (process.argv[2] === "doctor") {
564
+ const { runDoctorCommand } = await import("./audio/doctor.ts");
565
+ process.exit(await runDoctorCommand(process.argv.slice(3), stdout));
566
+ }
414
567
  if (process.argv[2] === "media") {
415
568
  const { runMediaCommand } = await import("./media/cli.ts");
416
569
  process.exit(
@@ -443,18 +596,37 @@ if (args.has("--help") || args.has("-h")) {
443
596
  }
444
597
  // Argument mistakes are rejected here, before `.dawg/` could be created.
445
598
  {
446
- const problem = argumentProblem(process.argv.slice(2));
447
- if (problem) {
448
- process.stderr.write(`${problem} · dawg --help\n`);
599
+ if (!launch.ok) {
600
+ process.stderr.write(`${launch.problem} · dawg --help\n`);
449
601
  process.exit(2);
450
602
  }
451
603
  }
604
+ // `--import X --export Y` converts one loop file to another: no session.
605
+ if (importPath && exportPath) {
606
+ try {
607
+ const converted = decodeLoop(await readLoopFile(importPath));
608
+ await writeFile(resolve(exportPath), encodeLoop(converted), "utf8");
609
+ } catch (error) {
610
+ const reason = error instanceof Error ? error.message : String(error);
611
+ const midi = /\.midi?$/i.test(importPath)
612
+ ? " · MIDI files are not imported; --import takes a .track.json loop"
613
+ : "";
614
+ process.stderr.write(
615
+ `dawg: cannot import ${importPath} · ${reason.split("\n")[0]!.slice(0, 160)}${midi}\n`,
616
+ );
617
+ process.exit(1);
618
+ }
619
+ stdout.write(`converted ${importPath} → ${exportPath}\n`);
620
+ process.exit(0);
621
+ }
452
622
  const demo =
453
623
  args.has("--demo") || process.env.DAWG_DEMO === "1" || !stdin.isTTY;
454
- // First run with no provider (or a saved one that stopped working): the
455
- // sign-in picker, in the shell, before the TUI takes the screen.
456
- if (!demo && process.env.DAWG_AI !== "0" && stdout.isTTY)
457
- await runFirstRunLogin();
624
+ // Music first: the TUI opens directly. The first session with no provider
625
+ // (or a saved one that stopped working) gets one card instead of a picker.
626
+ const launchCard =
627
+ !demo && process.env.DAWG_AI !== "0" && stdout.isTTY
628
+ ? firstRunCard().catch(() => undefined)
629
+ : undefined;
458
630
 
459
631
  const initial = createScore({
460
632
  tracks: [
@@ -487,7 +659,32 @@ const freshWorkspace = !(await stat(join(process.cwd(), ".dawg")).then(
487
659
  () => true,
488
660
  () => false,
489
661
  ));
490
- const session = await ensureSession(initial.toJSON(), sessionOptions);
662
+ // Decode the import before a session exists, so a bad file is one line.
663
+ let importedScore: TrackScore | undefined;
664
+ if (importPath) {
665
+ try {
666
+ importedScore = decodeLoop(await readLoopFile(importPath));
667
+ } catch (error) {
668
+ const reason = error instanceof Error ? error.message : String(error);
669
+ const midi = /\.midi?$/i.test(importPath)
670
+ ? " · MIDI files are not imported; --import takes a .track.json loop"
671
+ : "";
672
+ process.stderr.write(
673
+ `dawg: cannot import ${importPath} · ${reason.split("\n")[0]!.slice(0, 160)}${midi}\n`,
674
+ );
675
+ process.exit(1);
676
+ }
677
+ }
678
+ // A demo frame only renders: with no workspace yet (and nothing imported),
679
+ // it runs on a throwaway session so the cwd gets no `.dawg/`.
680
+ const ephemeralWorkspace =
681
+ demo && freshWorkspace && !importPath && selectedSession === undefined
682
+ ? await mkdtemp(join(tmpdir(), "dawg-demo-"))
683
+ : undefined;
684
+ const session = await ensureSession(initial.toJSON(), {
685
+ ...sessionOptions,
686
+ ...(ephemeralWorkspace ? { workspace: ephemeralWorkspace } : {}),
687
+ });
491
688
  // dawgd when connected, the file-lock path otherwise (see src/session/port.ts).
492
689
  let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
493
690
  paths: session.paths,
@@ -499,9 +696,14 @@ let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
499
696
  let record: SessionRecord<ReturnType<TrackScore["toJSON"]>> =
500
697
  port.mode === "daemon" ? await port.load() : session.record;
501
698
  let score = scoreFromJSON(record.composition);
502
- if (importPath) {
503
- const imported = decodeLoop(await readLoopFile(importPath));
504
- score = imported;
699
+ if (importPath && importedScore) score = importedScore;
700
+ // `--track Bass` focuses an existing `bass` (or a track named "Bass").
701
+ if (launchArgs?.track) {
702
+ explicitTrack = resolveTrackArg(score.tracks, launchArgs.track);
703
+ requestedTrack = explicitTrack;
704
+ }
705
+ if (importPath && importedScore) {
706
+ score = importedScore;
505
707
  record = await port.append(
506
708
  record,
507
709
  {
@@ -540,7 +742,7 @@ const tui = new TuiApp({
540
742
  rows: () => stdout.rows ?? 24,
541
743
  },
542
744
  prompt,
543
- theme: parseThemeName(optionValue("--theme") ?? process.env.DAWG_THEME),
745
+ theme: launchArgs?.theme ?? parseThemeName(process.env.DAWG_THEME),
544
746
  reducedMotion:
545
747
  args.has("--reduce-motion") || process.env.DAWG_REDUCE_MOTION === "1",
546
748
  });
@@ -597,8 +799,25 @@ let requestFrame: () => void = () => undefined;
597
799
  let runPromptLater: (command: string) => void = () => undefined;
598
800
  /** The fader drawer's focus and typing, while one is open over the menu. */
599
801
  let fader: FaderState | undefined;
600
- const KEY_UP = "\u001b[A";
601
- const KEY_DOWN = "\u001b[B";
802
+ /** Show-me state (see the show-me section below). */
803
+ const showMe: {
804
+ level: ShowMeLevel;
805
+ ghost?: string | undefined;
806
+ caption?: string | undefined;
807
+ commands: string[];
808
+ notes: NoteScheduler;
809
+ clear?: ReturnType<typeof setTimeout> | undefined;
810
+ } = {
811
+ level: parseShowMe(process.env.DAWG_SHOWME ?? "") ?? "on",
812
+ commands: [],
813
+ notes: new NoteScheduler(() => score.tempoBpm),
814
+ };
815
+ if (!process.env.DAWG_SHOWME)
816
+ void readConfig({ dir: configDir() })
817
+ .then((config) => {
818
+ if (config.showMe) showMe.level = config.showMe;
819
+ })
820
+ .catch(() => undefined);
602
821
  /** The fader bar a left-button drag started on. */
603
822
  let dragging: { field: number; left: number; width: number } | undefined;
604
823
  /** The open drawer came from a command, not the menu. */
@@ -631,7 +850,8 @@ let handoff: <T>(flow: () => Promise<T>) => Promise<T> = (flow) => flow();
631
850
  function spendLine(): string {
632
851
  const width = stdout.columns ?? 80;
633
852
  if (!providerName || providerName === "offline")
634
- return width >= 40 ? "no model · dawg login" : "";
853
+ // A state, not a nag: the first session's card already named /login.
854
+ return width >= 40 ? formatSpendLine({ kind: "offline" }) : "";
635
855
  const [model, provider] = providerName.split(" · ");
636
856
  return formatSpendLine(
637
857
  {
@@ -656,6 +876,10 @@ if (demo) {
656
876
  "add C4 at 0 for 1",
657
877
  0,
658
878
  );
879
+ if (ephemeralWorkspace) {
880
+ await port.close().catch(() => undefined);
881
+ await rm(ephemeralWorkspace, { recursive: true, force: true });
882
+ }
659
883
  process.exit(0);
660
884
  }
661
885
 
@@ -666,36 +890,8 @@ if (exportPath) {
666
890
 
667
891
  await runInteractive();
668
892
 
669
- /** Describes an unknown subcommand or option in `argv`, or undefined. */
670
- function argumentProblem(argv: readonly string[]): string | undefined {
671
- if (argv[0] && !argv[0].startsWith("-") && !SUBCOMMANDS.includes(argv[0]))
672
- return `unknown command · ${argv[0]}`;
673
- if (argv[0] && SUBCOMMANDS.includes(argv[0])) return undefined;
674
- for (let index = 0; index < argv.length; index += 1) {
675
- const arg = argv[index]!;
676
- if (VALUE_FLAGS.includes(arg)) {
677
- index += 1;
678
- continue;
679
- }
680
- if (arg.startsWith("-") && !FLAGS.includes(arg))
681
- return `unknown option · ${arg}`;
682
- if (!arg.startsWith("-")) return `unknown command · ${arg}`;
683
- }
684
- return undefined;
685
- }
686
-
687
893
  async function packageVersion(): Promise<string> {
688
- const raw = await readFile(
689
- new URL("../package.json", import.meta.url),
690
- "utf8",
691
- );
692
- return (JSON.parse(raw) as { version?: string }).version ?? "0.0.0";
693
- }
694
-
695
- function optionValue(name: string): string | undefined {
696
- const index = process.argv.indexOf(name);
697
- const value = index >= 0 ? process.argv[index + 1] : undefined;
698
- return value && !value.startsWith("--") ? value : undefined;
894
+ return (await import("./version.ts")).VERSION;
699
895
  }
700
896
 
701
897
  function seedDemo(value: TrackScore, trackId: string): TrackScore {
@@ -785,16 +981,24 @@ function snapshot(
785
981
  muted: focused?.muted,
786
982
  ...(tag?.cents !== undefined ? { cents: tag.cents } : {}),
787
983
  ...(tag?.name !== undefined ? { centsFrom: tag.name } : {}),
984
+ ...(note.lyric !== undefined ? { lyric: note.lyric } : {}),
788
985
  };
789
986
  });
987
+ // 0.7 clips: the clip row; peaks load off the frame path, then redraw.
988
+ const clips = clipSnapshots(value, requestedTrack);
989
+ if (clips)
990
+ void loadClipPeaks(process.cwd(), focused).then((changed) => {
991
+ if (changed) requestFrame();
992
+ });
790
993
  return {
791
994
  notes,
995
+ ...(clips ? { clips } : {}),
792
996
  trackName:
793
997
  value.tracks.find((track) => track.id === requestedTrack)?.name ??
794
998
  requestedTrack,
795
999
  trackId: requestedTrack,
796
1000
  sessionId: record.sessionId,
797
- revision: record.revision,
1001
+ revision: shownRevision(record),
798
1002
  bpm: value.time?.tempo
799
1003
  ? bpmAtTick(value, beat * value.ticksPerBeat)
800
1004
  : value.tempoBpm,
@@ -813,6 +1017,7 @@ function snapshot(
813
1017
  currentBeat: scoreBeatAt(value, beat),
814
1018
  playing: clock.playing,
815
1019
  activity,
1020
+ ...(focused?.instrument === "vocal" ? { vocal: true } : {}),
816
1021
  ...drumSnapshotFields(
817
1022
  value.tracks.find((track) => track.id === requestedTrack)?.instrument,
818
1023
  notes,
@@ -824,6 +1029,7 @@ function snapshot(
824
1029
  ...(table && table.linear && table.size !== 12
825
1030
  ? { tuningPeriod: { size: table.size, root: table.root } }
826
1031
  : {}),
1032
+ pitchTrace: pitchTraceFor(requestedTrack, value),
827
1033
  layers:
828
1034
  tui.highwayView === "all"
829
1035
  ? highwayLayers(
@@ -865,6 +1071,10 @@ function appView(value: TrackScore, beat: number): AppView {
865
1071
  providerName && providerName !== "offline" ? providerName : undefined,
866
1072
  spend: spendLine(),
867
1073
  agentOffline: providerName === "offline" || process.env.DAWG_AI === "0",
1074
+ showMe:
1075
+ showMe.ghost || showMe.caption
1076
+ ? { ghost: showMe.ghost, caption: showMe.caption }
1077
+ : undefined,
868
1078
  sync: syncState,
869
1079
  sessionName: record.meta.name,
870
1080
  windows: windowCount,
@@ -968,17 +1178,20 @@ function receipt(result: string | Receipt, base?: Baseline): void {
968
1178
  tui.activity.pushError(message);
969
1179
  return;
970
1180
  }
1181
+ // A receipt that names its own undo key needs no second hint.
971
1182
  const hint =
972
- scoreChanged && undoHintsShown < MAX_UNDO_HINTS
1183
+ scoreChanged &&
1184
+ undoHintsShown < MAX_UNDO_HINTS &&
1185
+ !message.endsWith("ctrl-z undo")
973
1186
  ? message.startsWith("undid")
974
- ? "^y redo"
975
- : "^z undo"
1187
+ ? "ctrl-y redo"
1188
+ : "ctrl-z undo"
976
1189
  : undefined;
977
1190
  if (hint) undoHintsShown += 1;
978
1191
  tui.activity.pushCard(message, {
979
1192
  tone,
980
- baseRevision: changed ? base.revision : undefined,
981
- resultRevision: changed ? record.revision : undefined,
1193
+ baseRevision: changed ? shownRevision(record, base.revision) : undefined,
1194
+ resultRevision: changed ? shownRevision(record) : undefined,
982
1195
  hint,
983
1196
  trackId: requestedTrack,
984
1197
  });
@@ -990,6 +1203,10 @@ function describeError(command: string, error: unknown): string {
990
1203
  const message = error instanceof Error ? error.message : String(error);
991
1204
  if (detail?.code === "ENOENT" && typeof detail.path === "string")
992
1205
  return `no such file · ${relative(process.cwd(), detail.path)}`;
1206
+ // `tempo 900` → `tempo 900 · tempo takes 20…300 BPM · tempo 128`: no raw
1207
+ // core keys (`tempoBpm`) on a card.
1208
+ const friendly = friendlyCoreError(truncateForCard(command), message);
1209
+ if (friendly) return friendly;
993
1210
  const verb = command.trim().split(/\s+/)[0]?.replace(/^\//, "") || "command";
994
1211
  return `${verb} failed · ${message}`;
995
1212
  }
@@ -1016,25 +1233,108 @@ function mouseOn(): string {
1016
1233
  return mouseEnabled() ? MOUSE_ON : "";
1017
1234
  }
1018
1235
 
1236
+ /**
1237
+ * Mouse off, plain colors, plain paste, cursor shown, main screen. A
1238
+ * function, not a const: `await runInteractive()` runs above this line.
1239
+ */
1240
+ function terminalRestore(): string {
1241
+ return `${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`;
1242
+ }
1243
+
1244
+ /** `dawg: <stack>` for a crash printed after leaving the alternate screen. */
1245
+ function crashText(error: unknown): string {
1246
+ const text =
1247
+ error instanceof Error ? (error.stack ?? error.message) : String(error);
1248
+ return `dawg: ${text}\n`;
1249
+ }
1250
+
1019
1251
  async function runInteractive(): Promise<void> {
1020
- // A crash or a stray process.exit must not leave the terminal reporting
1021
- // mouse events into the shell.
1022
- process.once("exit", () => {
1023
- if (mouseEnabled()) writeSync(1, MOUSE_OFF);
1024
- });
1025
- // Nor may a kill or a closed terminal: restore it, then exit as killed.
1252
+ // Every way out (quit, a failing teardown step, a crash, a kill, a stray
1253
+ // process.exit) restores the whole terminal, once, synchronously: the
1254
+ // shell must not stay on the alternate screen with paste brackets on.
1255
+ let terminalRestored = false;
1256
+ const restoreTerminal = () => {
1257
+ if (terminalRestored) return;
1258
+ terminalRestored = true;
1259
+ try {
1260
+ stdin.setRawMode?.(false);
1261
+ } catch {
1262
+ // stdin may already be closed.
1263
+ }
1264
+ try {
1265
+ writeSync(1, terminalRestore());
1266
+ } catch {
1267
+ // The terminal may already be gone (SIGHUP).
1268
+ }
1269
+ };
1270
+ process.once("exit", restoreTerminal);
1271
+ // A kill or a closed terminal: restore it, then exit as killed.
1026
1272
  for (const [signal, code] of [
1027
1273
  ["SIGTERM", 143],
1028
1274
  ["SIGHUP", 129],
1029
1275
  ] as const)
1030
1276
  process.once(signal, () => {
1277
+ restoreTerminal();
1278
+ process.exit(code);
1279
+ });
1280
+ // An exception nothing caught leaves the editor in an unknown state: leave
1281
+ // the alternate screen first, so the stack lands in the shell, then exit.
1282
+ const onUncaught = (error: unknown) => {
1283
+ restoreTerminal();
1284
+ try {
1285
+ writeSync(2, crashText(error));
1286
+ } catch {
1287
+ // Nowhere left to report it.
1288
+ }
1289
+ process.exit(1);
1290
+ };
1291
+ // A rejected fire-and-forget promise is one failed action, not a broken
1292
+ // editor: report it on the activity strip and repaint over anything the
1293
+ // runtime wrote.
1294
+ const onRejection = (error: unknown) => {
1295
+ if (terminalRestored || screenSuspended) {
1031
1296
  try {
1032
- writeSync(1, `${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1297
+ writeSync(2, crashText(error));
1033
1298
  } catch {
1034
- // The terminal may already be gone (SIGHUP).
1299
+ // Nowhere left to report it.
1035
1300
  }
1036
- process.exit(code);
1037
- });
1301
+ return;
1302
+ }
1303
+ tui.activity.pushError(
1304
+ `failed · ${error instanceof Error ? error.message : String(error)}`,
1305
+ );
1306
+ tui.invalidate();
1307
+ requestFrame();
1308
+ };
1309
+ // Runtime warnings and console output would land on top of the frame; the
1310
+ // writer never repaints rows it did not change. Route them to the
1311
+ // transcript instead, and repaint.
1312
+ const onWarning = (warning: Error) => {
1313
+ if (screenSuspended || terminalRestored) return;
1314
+ tui.activity.pushNote(`warning · ${warning.message}`, "error");
1315
+ tui.invalidate();
1316
+ requestFrame();
1317
+ };
1318
+ const consoleError = console.error;
1319
+ const consoleWarn = console.warn;
1320
+ const consoleToTranscript =
1321
+ (original: (...data: unknown[]) => void) =>
1322
+ (...data: unknown[]) => {
1323
+ if (screenSuspended || terminalRestored) return original(...data);
1324
+ tui.activity.pushNote(
1325
+ data
1326
+ .map((item) => (item instanceof Error ? item.message : String(item)))
1327
+ .join(" "),
1328
+ "error",
1329
+ );
1330
+ tui.invalidate();
1331
+ requestFrame();
1332
+ };
1333
+ process.on("uncaughtException", onUncaught);
1334
+ process.on("unhandledRejection", onRejection);
1335
+ process.on("warning", onWarning);
1336
+ console.error = consoleToTranscript(consoleError);
1337
+ console.warn = consoleToTranscript(consoleWarn);
1038
1338
  stdin.setRawMode?.(true);
1039
1339
  stdin.resume();
1040
1340
  // Alternate screen, hidden cursor, bracketed paste.
@@ -1045,7 +1345,8 @@ async function runInteractive(): Promise<void> {
1045
1345
  const runPrompt = async (text: string): Promise<void> => {
1046
1346
  const ui = tui.command(text);
1047
1347
  if (ui !== undefined) {
1048
- tui.activity.pushCard(ui, { tone: "info" });
1348
+ if (typeof ui === "string") tui.activity.pushCard(ui, { tone: "info" });
1349
+ else tui.activity.pushCard(ui.text, { tone: toneOf(ui) });
1049
1350
  return;
1050
1351
  }
1051
1352
  tui.activity.pushRequest(text);
@@ -1069,6 +1370,16 @@ async function runInteractive(): Promise<void> {
1069
1370
  }
1070
1371
  };
1071
1372
  let agentReported = false;
1373
+ /** A typed command during an agent turn; leaves the turn's receipt alone. */
1374
+ const runLocalBesideAgent = async (text: string): Promise<void> => {
1375
+ tui.activity.pushRequest(text);
1376
+ const base = baseline();
1377
+ try {
1378
+ receipt(await submit(text), base);
1379
+ } catch (error) {
1380
+ tui.activity.pushError(describeError(text, error));
1381
+ }
1382
+ };
1072
1383
  const drainQueue = async (): Promise<void> => {
1073
1384
  if (processingQueue) return;
1074
1385
  processingQueue = true;
@@ -1089,17 +1400,64 @@ async function runInteractive(): Promise<void> {
1089
1400
  tui.activity.setQueueDepth(queuedPrompts.length);
1090
1401
  }
1091
1402
  };
1403
+ // Builds a frame only when something can have changed (tui/frame-gate.ts):
1404
+ // an idle editor no longer rebuilds the whole view 30 times a second.
1405
+ const frameGate = new FrameGate(IDLE_HEARTBEAT_MS, MIN_FRAME_GAP_MS);
1406
+ const unwatchActivity = tui.activity.subscribe(() => frameGate.markDirty());
1407
+ const animating = (): boolean => {
1408
+ if (clock.playing || play?.on || auditionLoop?.looping) return true;
1409
+ const activity = tui.activity;
1410
+ if (activity.spinner || activity.streaming) return true;
1411
+ if (tui.delight.animating(Date.now())) return true;
1412
+ const latest = activity.latest;
1413
+ return (
1414
+ !tui.ui.reducedMotion &&
1415
+ latest !== undefined &&
1416
+ Date.now() - latest.atMs < CARD_GLOW_MS
1417
+ );
1418
+ };
1092
1419
  const tick = (force = false) => {
1093
1420
  if (screenSuspended) return;
1421
+ if (
1422
+ !frameGate.shouldBuild({
1423
+ nowMs: Date.now(),
1424
+ force,
1425
+ animating: animating(),
1426
+ keys: [
1427
+ score,
1428
+ record.revision,
1429
+ record.sessionId,
1430
+ record.meta.name,
1431
+ syncState,
1432
+ windowCount,
1433
+ typesIndicator,
1434
+ menu.open,
1435
+ euclid.open,
1436
+ tui.ui.overlay,
1437
+ ],
1438
+ })
1439
+ )
1440
+ return;
1094
1441
  followCommitted();
1442
+ tui.delight.song(record.sessionId, record.meta.heardLoop === true);
1095
1443
  play?.tick();
1096
1444
  // Values in the menu follow the score as edits land.
1097
1445
  if (menu.open) refreshMenu();
1098
1446
  if (euclid.open) refreshEuclid();
1099
1447
  refreshAuditionPicker();
1100
- tui.render(appView(score, clock.beatAt()), { force });
1448
+ // The gate already paces frames; the app's own throttle would drop an
1449
+ // approved frame that lands just after a forced one, and the gate would
1450
+ // not ask again until the heartbeat.
1451
+ tui.render(appView(score, clock.beatAt()), { force: true });
1101
1452
  };
1102
1453
  requestFrame = () => tick(true);
1454
+ // The first wrap is remembered per song in .dawg metadata, never the score.
1455
+ tui.onFirstLoop = () => {
1456
+ void port
1457
+ .updateMeta({ heardLoop: true })
1458
+ .then((result) => adoptMeta(result.meta))
1459
+ .catch(() => undefined);
1460
+ };
1103
1461
  runPromptLater = (command) => {
1104
1462
  queuedPrompts.unshift(command);
1105
1463
  void drainQueue();
@@ -1109,6 +1467,16 @@ async function runInteractive(): Promise<void> {
1109
1467
  };
1110
1468
  agentEventSink = (event) => {
1111
1469
  if (event.type === "usage") return;
1470
+ if (event.type === "command-typing") {
1471
+ showMeTyping(event.text);
1472
+ return;
1473
+ }
1474
+ if (event.type === "command") return;
1475
+ if (event.type === "tool-start") {
1476
+ const caption = toolCaption(event.name);
1477
+ if (caption) showMeCaption(caption);
1478
+ }
1479
+ if (event.type === "done" || event.type === "error") showMeFinish();
1112
1480
  tui.activity.applyAgentEvent(event);
1113
1481
  if (event.type === "done" || event.type === "error") agentReported = true;
1114
1482
  };
@@ -1119,13 +1487,21 @@ async function runInteractive(): Promise<void> {
1119
1487
  tick(true);
1120
1488
  };
1121
1489
  stdout.on("resize", onResize);
1490
+ const ownWrites = new OwnWrites();
1491
+ let presenceClients: readonly {
1492
+ clientId: string;
1493
+ focusedTrackId?: string | null;
1494
+ }[] = [];
1122
1495
  let applying: Promise<void> = Promise.resolve();
1123
1496
  const applyLatest = (latest: typeof record): Promise<void> =>
1124
1497
  (applying = applying.then(() => applyRecord(latest)));
1125
1498
  const applyRecord = async (latest: typeof record): Promise<void> => {
1126
1499
  try {
1500
+ // Queued before a /fork or /resume swapped the session: stale.
1501
+ if (latest.sessionId !== record.sessionId) return;
1127
1502
  if (latest.revision > record.revision) {
1128
1503
  const previousRevision = record.revision;
1504
+ const before = score;
1129
1505
  record = latest;
1130
1506
  if (stageCapture)
1131
1507
  stageCapture.committed = scoreFromJSON(record.composition);
@@ -1162,11 +1538,27 @@ async function runInteractive(): Promise<void> {
1162
1538
  else if (payload.action === "toggle") await setTransport("toggle");
1163
1539
  }
1164
1540
  projectSync?.scoreChanged(score);
1165
- tui.activity.pushCard("synced from another window", {
1166
- tone: "info",
1167
- baseRevision: previousRevision,
1168
- resultRevision: record.revision,
1169
- });
1541
+ // Another window's play/pause is followed, not announced, and this
1542
+ // window's own writes (which can echo back before the append
1543
+ // resolves) are never "synced".
1544
+ const from = shownRevision(record, previousRevision);
1545
+ const to = shownRevision(record);
1546
+ await ownWrites.settled();
1547
+ const foreign = foreignEvents(latest, previousRevision, ownWrites).some(
1548
+ (event) => event.kind !== "transport",
1549
+ );
1550
+ if (to !== from && foreign)
1551
+ tui.activity.pushCard(
1552
+ `synced · ${otherWindowName(presenceClients, port.clientId, {
1553
+ editedTrackId: editedTrack(
1554
+ before,
1555
+ scoreFromJSON(latest.composition),
1556
+ ),
1557
+ trackName: (id) =>
1558
+ score.tracks.find((track) => track.id === id)?.name ?? id,
1559
+ })}`,
1560
+ { tone: "info", baseRevision: from, resultRevision: to },
1561
+ );
1170
1562
  }
1171
1563
  } catch {
1172
1564
  // An invalid composition is skipped; the next update retries.
@@ -1177,8 +1569,10 @@ async function runInteractive(): Promise<void> {
1177
1569
  void applyLatest(update.record);
1178
1570
  adoptMeta(update.record.meta);
1179
1571
  } else if (update.type === "meta") adoptMeta(update.meta);
1180
- else if (update.type === "presence") windowCount = update.clients.length;
1181
- else if (update.type === "sync") syncState = update.sync;
1572
+ else if (update.type === "presence") {
1573
+ presenceClients = update.clients;
1574
+ windowCount = update.clients.length;
1575
+ } else if (update.type === "sync") syncState = update.sync;
1182
1576
  else if (update.type === "transport") {
1183
1577
  // Every window renders the same hit line from dawgd's timestamp.
1184
1578
  const { playing, beat, bpm, atMs } = update.transport;
@@ -1192,11 +1586,23 @@ async function runInteractive(): Promise<void> {
1192
1586
  });
1193
1587
  }
1194
1588
  };
1195
- let unsubscribe = port.subscribe(onUpdate);
1589
+ // Each subscription answers only while its port is the live one: /fork and
1590
+ // /resume swap the port, and a load the old port started before the swap
1591
+ // could otherwise land afterwards and pull the old session's name and
1592
+ // score into the new one.
1593
+ const subscribeLive = (): (() => void) => {
1594
+ const bound = port;
1595
+ ownWrites.attach(bound);
1596
+ return bound.subscribe((update) => {
1597
+ if (bound === port) onUpdate(update);
1598
+ });
1599
+ };
1600
+ let unsubscribe = subscribeLive();
1196
1601
  const refreshPresence = () =>
1197
1602
  void port
1198
1603
  .presence()
1199
1604
  .then((clients) => {
1605
+ presenceClients = clients;
1200
1606
  windowCount = Math.max(1, clients.length);
1201
1607
  })
1202
1608
  .catch(() => undefined);
@@ -1208,12 +1614,13 @@ async function runInteractive(): Promise<void> {
1208
1614
  rebindPort = () => {
1209
1615
  unsubscribe();
1210
1616
  syncState = port.sync;
1211
- unsubscribe = port.subscribe(onUpdate);
1617
+ unsubscribe = subscribeLive();
1212
1618
  refreshPresence();
1213
1619
  };
1214
1620
  if (freshWorkspace)
1215
1621
  tui.activity.pushCard("created .dawg/ · add it to .gitignore", {
1216
1622
  tone: "info",
1623
+ once: true,
1217
1624
  });
1218
1625
  if (attachNotice)
1219
1626
  tui.activity.pushCard(attachNotice, {
@@ -1223,6 +1630,9 @@ async function runInteractive(): Promise<void> {
1223
1630
  });
1224
1631
  if (port.status !== "file session")
1225
1632
  tui.activity.pushCard(port.status, { tone: "info" });
1633
+ void launchCard?.then((card) => {
1634
+ if (card) tui.activity.pushCard(card.text, { tone: card.tone, once: true });
1635
+ });
1226
1636
  if (await isProject(process.cwd()))
1227
1637
  projectSync = startProjectSync(syncHost());
1228
1638
  void currentProvider().then(() => tick(true));
@@ -1244,8 +1654,28 @@ async function runInteractive(): Promise<void> {
1244
1654
  stdin.on("end", onEnd);
1245
1655
  async function* chunks(): AsyncGenerator<string> {
1246
1656
  for (;;) {
1247
- while (inbox.length === 0)
1248
- await new Promise<void>((resolve) => (wake = resolve));
1657
+ while (inbox.length === 0) {
1658
+ // A partial escape (Alt+[, a cut-off paste) is flushed after a short
1659
+ // idle so it never holds back the keys behind it.
1660
+ const held = inputDecoder.pending();
1661
+ const idle =
1662
+ held === "paste"
1663
+ ? PASTE_FLUSH_MS
1664
+ : held === "escape"
1665
+ ? ESCAPE_FLUSH_MS
1666
+ : undefined;
1667
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1668
+ const timedOut = await new Promise<boolean>((resolve) => {
1669
+ wake = () => resolve(false);
1670
+ if (idle !== undefined)
1671
+ timeout = setTimeout(() => resolve(true), idle);
1672
+ });
1673
+ clearTimeout(timeout);
1674
+ if (timedOut && inbox.length === 0) {
1675
+ wake = undefined;
1676
+ yield INPUT_FLUSH;
1677
+ }
1678
+ }
1249
1679
  wake = undefined;
1250
1680
  const next = inbox.shift()!;
1251
1681
  if (next === "\u0000eof") return;
@@ -1260,7 +1690,7 @@ async function runInteractive(): Promise<void> {
1260
1690
  stdin.pause();
1261
1691
  stdin.setRawMode?.(false);
1262
1692
  // Leave the alternate screen; restore the cursor and plain paste.
1263
- stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1693
+ stdout.write(terminalRestore());
1264
1694
  try {
1265
1695
  return await flow();
1266
1696
  } finally {
@@ -1279,10 +1709,13 @@ async function runInteractive(): Promise<void> {
1279
1709
  let exiting = false;
1280
1710
  for await (const text of chunks()) {
1281
1711
  // A read that is exactly ESC is the Esc key, not the start of a sequence.
1282
- const values = [
1283
- ...inputDecoder.push(text),
1284
- ...(text === "\u001b" ? inputDecoder.flush() : []),
1285
- ];
1712
+ const values =
1713
+ text === INPUT_FLUSH
1714
+ ? inputDecoder.flush()
1715
+ : [
1716
+ ...inputDecoder.push(text),
1717
+ ...(text === "\u001b" ? inputDecoder.flush() : []),
1718
+ ];
1286
1719
  while (values.length) {
1287
1720
  const value = values.shift()!;
1288
1721
  // A mouse report acts on what the last frame painted under it; some
@@ -1308,9 +1741,9 @@ async function runInteractive(): Promise<void> {
1308
1741
  continue;
1309
1742
  }
1310
1743
  }
1311
- // The `?` panel closes on any key (Ctrl-C still quits).
1744
+ // The `?` panel closes on esc or ?; other keys wait (Ctrl-C quits).
1312
1745
  if (tui.ui.keys && typeof value === "string" && value !== "\u0003") {
1313
- tui.closeKeys();
1746
+ if (closesKeys(value)) tui.closeKeys();
1314
1747
  tick(true);
1315
1748
  continue;
1316
1749
  }
@@ -1441,12 +1874,7 @@ async function runInteractive(): Promise<void> {
1441
1874
  // Never await a daemon round trip here: the key loop must stay
1442
1875
  // live for Esc, quit and redraws while the toggle is in flight.
1443
1876
  void toggleTransport()
1444
- .catch((error: unknown) => {
1445
- tui.activity.pushCard(
1446
- `transport failed · ${error instanceof Error ? error.message : String(error)}`,
1447
- { tone: "error" },
1448
- );
1449
- })
1877
+ .catch((error: unknown) => transportFailed(error))
1450
1878
  .finally(() => tick(true));
1451
1879
  } else {
1452
1880
  const input = tui.input(value);
@@ -1475,7 +1903,9 @@ async function runInteractive(): Promise<void> {
1475
1903
  if (auditionLoop?.looping && isStageable(input.value))
1476
1904
  hoverItem(input.value, `picker:${input.picker}`);
1477
1905
  else if (input.picker === "pattern")
1478
- previewPattern(input.value.replace(/^\/pattern\s+/, ""));
1906
+ previewPattern(
1907
+ input.value.replace(/^\/(?:pattern|groove)\s+/, ""),
1908
+ );
1479
1909
  } else if (input.type === "pick-audition") {
1480
1910
  pickerAuditionKey(input.key);
1481
1911
  } else if (input.type === "pick-cancel") {
@@ -1527,6 +1957,16 @@ async function runInteractive(): Promise<void> {
1527
1957
  else if (action?.kind === "cancel" && agentTurn) {
1528
1958
  agentTurn.controller.abort();
1529
1959
  tui.activity.setSpinner("cancelling");
1960
+ } else if (
1961
+ action?.kind === "submit" &&
1962
+ action.value &&
1963
+ agentTurn &&
1964
+ routeDuringTurn(action.value, parsesLocally) === "local"
1965
+ ) {
1966
+ // A typed command never waits on the agent: it commits its own
1967
+ // revision now, and the agent's next call re-reads the score.
1968
+ const value = action.value;
1969
+ void runLocalBesideAgent(value).finally(() => tick(true));
1530
1970
  } else if (action?.kind === "submit" && action.value && agentTurn) {
1531
1971
  // Enter during a turn steers it; Alt-Enter still queues a follow-up.
1532
1972
  agentTurn.steering.push(action.value);
@@ -1557,17 +1997,36 @@ async function runInteractive(): Promise<void> {
1557
1997
  stdin.off("end", onEnd);
1558
1998
  clearInterval(timer);
1559
1999
  clearInterval(presenceTimer);
1560
- await projectSync?.stop();
1561
- namer.dispose();
1562
2000
  stdout.off("resize", onResize);
1563
- unsubscribe();
1564
- await play?.exit().catch(() => undefined);
1565
- await monitorEngine?.dispose().catch(() => undefined);
1566
- audio.stop();
1567
- await port.close();
1568
- stdin.setRawMode?.(false);
2001
+ // The terminal first: a teardown step that throws (a read-only .dawg,
2002
+ // a dead engine) must not leave the shell on the alternate screen.
2003
+ restoreTerminal();
1569
2004
  stdin.pause();
1570
- stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
2005
+ console.error = consoleError;
2006
+ console.warn = consoleWarn;
2007
+ process.off("warning", onWarning);
2008
+ unwatchActivity();
2009
+ // Each step on its own: one failure does not skip the rest.
2010
+ const failures: unknown[] = [];
2011
+ const steps: Array<() => unknown> = [
2012
+ () => projectSync?.stop(),
2013
+ () => namer.dispose(),
2014
+ () => unsubscribe(),
2015
+ () => play?.exit(),
2016
+ () => monitorEngine?.dispose(),
2017
+ () => audio.stop(),
2018
+ () => port.close(),
2019
+ ];
2020
+ for (const step of steps) {
2021
+ try {
2022
+ await step();
2023
+ } catch (error) {
2024
+ failures.push(error);
2025
+ }
2026
+ }
2027
+ process.off("unhandledRejection", onRejection);
2028
+ process.off("uncaughtException", onUncaught);
2029
+ if (failures.length > 0) throw failures[0];
1571
2030
  }
1572
2031
  }
1573
2032
 
@@ -1582,23 +2041,23 @@ function unknownCommand(command: string): string {
1582
2041
 
1583
2042
  async function submit(prompt: string): Promise<string | Receipt> {
1584
2043
  const command = prompt.trim();
2044
+ // Canonical names for window commands: `model key` (login), `models`,
2045
+ // `voice` (the Voice topic).
2046
+ const canonical = canonicalWindowForm(command);
2047
+ if (canonical) return submit(canonical);
1585
2048
  const helpCommand = command.match(/^\/?(?:help|\?)(?:\s+(\S+))?$/i);
1586
2049
  if (helpCommand) {
1587
2050
  const topic = helpCommand[1];
2051
+ // The panel's inner width (tui/app.ts paintText): rows clip there with
2052
+ // an ellipsis, the same as every other panel.
2053
+ const columns = stdout.columns ?? 80;
1588
2054
  const lines = helpTopicLines(
1589
2055
  topic,
1590
- Math.max(40, (stdout.columns ?? 80) - 10),
1591
- );
1592
- if (!lines)
1593
- return fail(
1594
- `no help topic ${topic} · /help all · ${HELP_TOPICS.join(" ")}`,
1595
- );
1596
- tui.openText(topic ? `help · ${topic.toLowerCase()}` : "help", lines);
1597
- return ok(
1598
- topic
1599
- ? `help · ${topic.toLowerCase()}`
1600
- : "help · /help all for every command",
2056
+ Math.max(10, columns - (columns >= 60 ? 8 : 4)),
1601
2057
  );
2058
+ if (!lines) return fail(helpMiss(topic!));
2059
+ tui.openText(helpTitle(topic), lines);
2060
+ return ok(topic ? helpTitle(topic) : "help · help all for every command");
1602
2061
  }
1603
2062
  if (/^\/?tracks$/i.test(command)) {
1604
2063
  const problems = await sampleProblems(score);
@@ -1630,7 +2089,32 @@ async function submit(prompt: string): Promise<string | Receipt> {
1630
2089
  items.findIndex((item) => item.current),
1631
2090
  ),
1632
2091
  });
1633
- return ok(`${items.length} track${items.length === 1 ? "" : "s"}`);
2092
+ return note(`${items.length} track${items.length === 1 ? "" : "s"}`);
2093
+ }
2094
+ if (/^\/?(?:instruments|instrument\s+(?:list|ls|presets))$/i.test(command)) {
2095
+ // `instrument list`: every word `instrument <name>` takes, by family.
2096
+ const columns = stdout.columns ?? 80;
2097
+ const lines = instrumentListLines(
2098
+ Math.max(10, columns - (columns >= 60 ? 8 : 4)),
2099
+ );
2100
+ tui.openText("instruments", lines);
2101
+ return note("instruments · instrument <name> on the focused track");
2102
+ }
2103
+ if (/^\/?notes(?:\s+(?:list|ls))?$/i.test(command)) {
2104
+ // `notes`: the focused track's note ids, the names `remove <id>` takes.
2105
+ const notes = score.notes
2106
+ .filter((entry) => entry.trackId === requestedTrack)
2107
+ .slice()
2108
+ .sort((a, b) => a.startTick - b.startTick);
2109
+ const lines = notes.map(
2110
+ (entry) =>
2111
+ `${entry.id} · ${midiNoteName(entry.pitch)} · beat ${+(entry.startTick / score.ticksPerBeat).toFixed(2)}`,
2112
+ );
2113
+ if (lines.length === 0) return note(`notes · ${requestedTrack} has none`);
2114
+ tui.openText(`notes · ${requestedTrack}`, lines);
2115
+ return note(
2116
+ `${lines.length} note${lines.length === 1 ? "" : "s"} · ${requestedTrack} · remove <id>`,
2117
+ );
1634
2118
  }
1635
2119
  const playCommand = command.match(
1636
2120
  /^\/play(?:\s+(on|off|degrees|in-key|chromatic))?$/i,
@@ -1656,9 +2140,31 @@ async function submit(prompt: string): Promise<string | Receipt> {
1656
2140
  }
1657
2141
  const chordsCommand = command.match(/^\/chords(?:\s+(.+))?$/i);
1658
2142
  if (chordsCommand) {
1659
- const result = applyChordsCommand(chordSettings, chordsCommand[1] ?? "");
2143
+ const result = applyChordsCommand(
2144
+ chordSettings,
2145
+ chordsCommand[1] ?? "",
2146
+ score.tempoBpm,
2147
+ );
1660
2148
  return result.ok ? ok(result.message) : fail(result.message);
1661
2149
  }
2150
+ const showMeCommand = command.match(/^\/show-?me(?:\s+(\S+))?$/i);
2151
+ if (showMeCommand) {
2152
+ if (!showMeCommand[1])
2153
+ return ok(`show me · ${showMe.level} · /showme on|quiet|off`);
2154
+ const level = parseShowMe(showMeCommand[1]);
2155
+ if (!level) return fail("usage · /showme on|quiet|off");
2156
+ showMe.level = level;
2157
+ void writeConfig({ dir: configDir() }, { showMe: level }).catch(
2158
+ () => undefined,
2159
+ );
2160
+ return ok(
2161
+ level === "off"
2162
+ ? "show me off · the agent edits with tools"
2163
+ : level === "quiet"
2164
+ ? "show me quiet · the agent types commands, no captions"
2165
+ : "show me on · the agent types commands, slides faders, plays keys",
2166
+ );
2167
+ }
1662
2168
  const countIn = command.match(/^\/count-?in\s+([0-2])$/i);
1663
2169
  if (countIn) return ok(playSession().setCountIn(Number(countIn[1])));
1664
2170
  const euclidCommand = command.match(/^\/euclid(?:\s+(\S+))?$/i);
@@ -1673,21 +2179,45 @@ async function submit(prompt: string): Promise<string | Receipt> {
1673
2179
  }
1674
2180
  const tryCommand = command.match(/^\/try(?:\s+(.+))?$/i);
1675
2181
  if (tryCommand) return tryPrompt(tryCommand[1]?.trim() ?? "");
1676
- // A bare parameter (`volume`, `pan`, `fx filter`, `fx reverb mix`)
1677
- // opens the fader drawer on it, with its related params stacked below.
1678
- if (/^\/?(?:volume|pan|fx\s+\S+(?:\s+\S+)?)$/i.test(command)) {
1679
- const label = menu.showFader(menuContext(), command);
2182
+ // A bare scalar (`volume`, `pan`, `fx reverb mix`, and the song's
2183
+ // `tempo`, `bars` and `meter`) opens the fader drawer on it, with its
2184
+ // related params stacked below.
2185
+ if (
2186
+ /^\/?(?:volume|pan|tempo|bpm|bars|meter|fx\s+\S+(?:\s+\S+)?)$/i.test(
2187
+ command,
2188
+ )
2189
+ ) {
2190
+ const context = menuContext();
2191
+ const label =
2192
+ menu.showFader(context, command) ?? songFader(context, command);
1680
2193
  if (label) {
2194
+ const field = menu
2195
+ .faderFields(context)
2196
+ .find((candidate) => candidate.label === label);
2197
+ // The receipt names the value it opened on: `tempo · 120 BPM · …`.
2198
+ const value =
2199
+ field?.kind === "number"
2200
+ ? field.value !== undefined
2201
+ ? field.format(field.value)
2202
+ : field.off
2203
+ : undefined;
1681
2204
  openFader(label, true);
1682
2205
  refreshMenu();
1683
- return ok(`${label} · ←→ adjust · enter keep · esc revert`);
2206
+ return ok(
2207
+ `${label}${value ? ` · ${value}` : ""} · ←→ adjust · enter keep · esc revert`,
2208
+ );
1684
2209
  }
1685
2210
  }
1686
2211
  const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
1687
2212
  if (menuCommand) {
1688
2213
  const section = menuCommand[1]?.toLowerCase();
1689
- if (section && !(MENU_SECTIONS as readonly string[]).includes(section))
1690
- return fail(`/menu [${MENU_SECTIONS.join("|")}]`);
2214
+ // §4: the keys topic has no menu page; it opens the ? panel.
2215
+ if (section && (section === "keys" || TOPIC_ALIASES[section] === "keys")) {
2216
+ tui.showKeys("keys", keyLines(KEYS.prompt));
2217
+ return ok("keys");
2218
+ }
2219
+ if (section && !menuSectionPath(menuContext(), section))
2220
+ return fail(menuUsage(menuContext(), section));
1691
2221
  openMenu(section);
1692
2222
  return ok("menu");
1693
2223
  }
@@ -1717,6 +2247,73 @@ async function submit(prompt: string): Promise<string | Receipt> {
1717
2247
  if (requestedTrack !== grainTrack[1]!.toLowerCase()) return focused;
1718
2248
  return submit(grainTrack[2]!);
1719
2249
  }
2250
+ // `/track rm <name>` (aliases remove, delete) and `/track move <name>
2251
+ // <position>`: the human surface for the removeTrack and moveTrack
2252
+ // operations. Removing a track also drops any vocoder src or autotune from
2253
+ // that named it; ^z brings everything back.
2254
+ const trackEdit = command.match(
2255
+ /^\/?track\s+(rm|remove|delete|move)\s+(.{1,64}?)\s*$/i,
2256
+ );
2257
+ if (trackEdit) {
2258
+ const verb = trackEdit[1]!.toLowerCase() === "move" ? "move" : "rm";
2259
+ let rest = trackEdit[2]!.trim();
2260
+ let position: number | undefined;
2261
+ if (verb === "move") {
2262
+ const tail = rest.match(/^(.+?)\s+(\d{1,3})$/);
2263
+ if (!tail)
2264
+ return fail(`usage · /track move <name> <1..${score.tracks.length}>`);
2265
+ rest = tail[1]!;
2266
+ position = Number(tail[2]);
2267
+ }
2268
+ const wanted = rest
2269
+ .replace(/^["']|["']$/g, "")
2270
+ .trim()
2271
+ .replace(/\s+/g, " ")
2272
+ .toLowerCase();
2273
+ const found = score.tracks.find(
2274
+ (track) =>
2275
+ track.id.toLowerCase() === wanted ||
2276
+ track.id.toLowerCase() === wanted.replace(/ /g, "-") ||
2277
+ (track.name ?? "").toLowerCase() === wanted,
2278
+ );
2279
+ if (!found) return fail(noTrack(rest));
2280
+ if (verb === "move") {
2281
+ if (position! < 1 || position! > score.tracks.length)
2282
+ return fail(`usage · /track move <name> <1..${score.tracks.length}>`);
2283
+ const next = applyScoreOperation(score, {
2284
+ type: "moveTrack",
2285
+ trackId: found.id,
2286
+ index: position! - 1,
2287
+ });
2288
+ await commitScore(next, "track.move", { trackId: found.id });
2289
+ await projectSync?.flushScore();
2290
+ return ok(`moved ${found.id} to position ${position}`);
2291
+ }
2292
+ if (score.tracks.length <= 1)
2293
+ return fail("the last track stays · /clear empties it");
2294
+ const next = applyScoreOperation(score, {
2295
+ type: "removeTrack",
2296
+ trackId: found.id,
2297
+ });
2298
+ const dropped = score.tracks
2299
+ .filter((track) => track.id !== found.id)
2300
+ .filter((track) => {
2301
+ const after = next.tracks.find((t) => t.id === track.id);
2302
+ return (
2303
+ (track.vocoder?.src !== undefined &&
2304
+ after?.vocoder?.src === undefined) ||
2305
+ (track.autotune?.from !== undefined &&
2306
+ after?.autotune?.from === undefined)
2307
+ );
2308
+ })
2309
+ .map((track) => track.id);
2310
+ await commitScore(next, "track.remove", { trackId: found.id });
2311
+ await projectSync?.flushScore();
2312
+ if (found.id === requestedTrack) await focusTrack(next.tracks[0]!.id);
2313
+ return ok(
2314
+ `removed ${found.id}${dropped.length ? ` · dropped references on ${dropped.join(", ")}` : ""} · ctrl-z undoes`,
2315
+ );
2316
+ }
1720
2317
  // `/track piano b`: a name with spaces focuses the track of that name, or
1721
2318
  // creates `piano-b` named "piano b".
1722
2319
  const namedTrack = command.match(/^\/track\s+([a-z0-9._ -]{1,64})$/i);
@@ -1731,12 +2328,100 @@ async function submit(prompt: string): Promise<string | Receipt> {
1731
2328
  if (sample) return sampleCommand(sample);
1732
2329
  const fit = parseFitCommand(command);
1733
2330
  if (fit) return fitCommand(fit);
2331
+ const shift = parseShiftCommand(command);
2332
+ if (shift) {
2333
+ const result = applyShiftCommand(score, requestedTrack, shift);
2334
+ if (!result.ok) return fail(result.message);
2335
+ await commitScore(result.next, "sample.set", { trackId: requestedTrack });
2336
+ await projectSync?.flushScore();
2337
+ return ok(result.message);
2338
+ }
2339
+ const resample = parseResampleCommand(command);
2340
+ if (resample) return resampleCommand(resample);
2341
+ // 0.7 clips: `/clip` edits and `/lyrics` on the focused track.
2342
+ const clipCommand = parseClipCommand(command);
2343
+ if (clipCommand) {
2344
+ if ("error" in clipCommand) return fail(clipCommand.error);
2345
+ const result = await runClipCommand(clipCommand, {
2346
+ score,
2347
+ trackId: requestedTrack,
2348
+ cwd: process.cwd(),
2349
+ });
2350
+ if (!result.ok) return fail(result.message);
2351
+ if (result.next && result.kind) {
2352
+ await commitScore(result.next, result.kind, {
2353
+ trackId: requestedTrack,
2354
+ ...result.payload,
2355
+ });
2356
+ await projectSync?.flushScore();
2357
+ }
2358
+ return ok(result.message);
2359
+ }
2360
+ const lyricsCommand = parseLyricsCommand(command);
2361
+ if (lyricsCommand) {
2362
+ const result = applyLyrics(score, requestedTrack, lyricsCommand);
2363
+ if (!result.ok) return fail(result.message);
2364
+ if (result.next && result.kind) {
2365
+ await commitScore(result.next, result.kind, {
2366
+ trackId: requestedTrack,
2367
+ ...result.payload,
2368
+ });
2369
+ await projectSync?.flushScore();
2370
+ }
2371
+ return ok(result.message);
2372
+ }
2373
+ // 0.7 autotune: `/autotune …` (and `/tune <preset>` pointing here).
2374
+ // `/vocal autotune …` takes the same path (presets panel, draft, commit).
2375
+ const autotune = parseAutotuneCommand(
2376
+ command.replace(/^\/?vocal\s+(?=autotune\b)/i, ""),
2377
+ );
2378
+ if (autotune) {
2379
+ if (autotune.type === "autotune-list")
2380
+ tui.openText("autotune presets", autotuneListLines());
2381
+ if (autotune.type !== "autotune-usage" && autotune.type !== "autotune-list")
2382
+ await materializeDraft();
2383
+ const result = applyAutotuneCommand(score, requestedTrack, autotune);
2384
+ if (result.next && result.kind)
2385
+ await commitScore(result.next, result.kind, result.payload);
2386
+ return result.ok ? ok(result.message) : fail(result.message);
2387
+ }
2388
+ // 0.7 Voice: `/vocal <verb>`; lanes register verbs in VOCAL_VERBS.
2389
+ setClipImportDeps({
2390
+ media: {
2391
+ ...mediaServices(),
2392
+ signal: new AbortController().signal,
2393
+ progress: () => undefined,
2394
+ },
2395
+ });
2396
+ const vocal = parseVocalCommand(command);
2397
+ if (vocal) {
2398
+ if (vocal.kind === "verb") await materializeDraft();
2399
+ const result = await runVocalCommand(vocal, {
2400
+ score,
2401
+ trackId: requestedTrack,
2402
+ cwd: process.cwd(),
2403
+ });
2404
+ if (!result.ok) return fail(result.message);
2405
+ if (result.next && result.kind) {
2406
+ await commitScore(result.next, result.kind, {
2407
+ trackId: requestedTrack,
2408
+ ...result.payload,
2409
+ });
2410
+ await projectSync?.flushScore();
2411
+ }
2412
+ // A verb that made or chose another track (a vocoder carrier): focus it.
2413
+ if (result.trackId && result.trackId !== requestedTrack) {
2414
+ await port.focus(result.trackId);
2415
+ requestedTrack = result.trackId;
2416
+ }
2417
+ return ok(result.message);
2418
+ }
1734
2419
  const pack = parsePackCommand(command);
1735
2420
  if (pack) return packCommand(pack);
1736
2421
  const pattern = parsePatternCommand(command);
1737
2422
  if (pattern) return patternCommand(pattern);
1738
2423
  const kit = parseKitCommand(command);
1739
- if (kit) return kitCommand(kit, /^\/kit\s*$/i.test(command.trim()));
2424
+ if (kit) return kitCommand(kit, /^\/?kits?\s*$/i.test(command.trim()));
1740
2425
  const wavetable = parseWavetableCommand(command);
1741
2426
  if (wavetable) return wavetableCommand(wavetable);
1742
2427
  const sessionReply = await sessionCommand(command);
@@ -1765,11 +2450,52 @@ async function submit(prompt: string): Promise<string | Receipt> {
1765
2450
  await commitScore(result.next, result.kind, result.payload);
1766
2451
  return result.ok ? ok(result.message) : fail(result.message);
1767
2452
  }
2453
+ const guitar = parseGuitarCommand(command);
2454
+ if (guitar) {
2455
+ if (guitar.type !== "guitar-show" && guitar.type !== "guitar-hint")
2456
+ await materializeDraft();
2457
+ const result = applyGuitarCommand(score, requestedTrack, guitar);
2458
+ if (result.next && result.kind)
2459
+ await commitScore(result.next, result.kind, result.payload);
2460
+ return result.ok ? ok(result.message) : fail(result.message);
2461
+ }
2462
+ const progression = parseProgressionCommand(command);
2463
+ if (progression) {
2464
+ if (progression.type === "progression") await materializeDraft();
2465
+ const result = applyProgressionCommand(
2466
+ score,
2467
+ requestedTrack,
2468
+ progression,
2469
+ () => randomUUID().slice(0, 12),
2470
+ );
2471
+ if (result.next && result.kind)
2472
+ await commitScore(result.next, result.kind, result.payload);
2473
+ return result.ok ? ok(result.message) : fail(result.message);
2474
+ }
2475
+ const strum = parseStrumCommand(command);
2476
+ if (strum) {
2477
+ if (strum.type === "strum") await materializeDraft();
2478
+ const result = applyStrumCommand(score, requestedTrack, strum, () =>
2479
+ randomUUID().slice(0, 12),
2480
+ );
2481
+ if (result.next && result.kind)
2482
+ await commitScore(result.next, result.kind, result.payload);
2483
+ return result.ok ? ok(result.message) : fail(result.message);
2484
+ }
1768
2485
  const rig = parseRigCommand(command);
1769
2486
  if (rig) {
1770
2487
  if (rig.type !== "rig-show" && rig.type !== "rig-hint")
1771
2488
  await materializeDraft();
1772
2489
  const result = applyRigCommand(score, requestedTrack, rig);
2490
+ if (result.next && result.kind)
2491
+ await commitScore(result.next, result.kind, result.payload);
2492
+ return readOrDone(result);
2493
+ }
2494
+ // 0.7 `/formant` and `/vowel`: short forms of `fx formant|vowel`.
2495
+ const formant = parseFormantCommand(command) ?? parseVowelCommand(command);
2496
+ if (formant) {
2497
+ if (formant.type === "fx") await materializeDraft();
2498
+ const result = applyFormantCommand(score, requestedTrack, formant);
1773
2499
  if (result.next && result.kind)
1774
2500
  await commitScore(result.next, result.kind, result.payload);
1775
2501
  return result.ok ? ok(result.message) : fail(result.message);
@@ -1791,6 +2517,8 @@ async function submit(prompt: string): Promise<string | Receipt> {
1791
2517
  const result = applyFxCommand(score, requestedTrack, fx, pinnedIr);
1792
2518
  if (result.next && result.kind)
1793
2519
  await commitScore(result.next, result.kind, result.payload);
2520
+ // Bare `fx` reads the chain (`•`); it changes nothing.
2521
+ if (result.ok && fx.type === "fx-list") return note(result.message);
1794
2522
  return result.ok ? ok(result.message) : fail(result.message);
1795
2523
  }
1796
2524
  const unknownFx = unknownFxMessage(command);
@@ -1806,6 +2534,13 @@ async function submit(prompt: string): Promise<string | Receipt> {
1806
2534
  }
1807
2535
  const tuning = parseTuningCommand(command);
1808
2536
  if (tuning) return tuningCommand(tuning);
2537
+ const calibration = parseCalibrationCommand(command);
2538
+ if (calibration) {
2539
+ const result = applyCalibrationCommand(score, calibration);
2540
+ if (result.next && result.kind)
2541
+ await commitScore(result.next, result.kind, result.payload);
2542
+ return result.ok ? ok(result.message) : fail(result.message);
2543
+ }
1809
2544
  const keysCommand = parseKeysCommand(command);
1810
2545
  if (keysCommand) {
1811
2546
  const reads =
@@ -1827,13 +2562,54 @@ async function submit(prompt: string): Promise<string | Receipt> {
1827
2562
  await commitScore(result.next, result.kind, result.payload);
1828
2563
  return result.ok ? ok(result.message) : fail(result.message);
1829
2564
  }
2565
+ const vocoderCommand = parseVocoderCommand(command);
2566
+ if (vocoderCommand) {
2567
+ if (vocoderCommand.type === "vocoder-list")
2568
+ tui.openText("vocoder presets", vocoderListLines());
2569
+ if (
2570
+ vocoderCommand.type !== "vocoder-list" &&
2571
+ vocoderCommand.type !== "vocoder-usage"
2572
+ )
2573
+ await materializeDraft();
2574
+ const result = applyVocoderCommand(score, requestedTrack, vocoderCommand);
2575
+ if (result.next && result.kind)
2576
+ await commitScore(result.next, result.kind, result.payload);
2577
+ // `/vocoder` on a vocal makes a carrier track: focus it.
2578
+ if (result.trackId && result.trackId !== requestedTrack) {
2579
+ await port.focus(result.trackId);
2580
+ requestedTrack = result.trackId;
2581
+ }
2582
+ return result.ok ? ok(result.message) : fail(result.message);
2583
+ }
2584
+ const windCommand = parseWindCommand(command);
2585
+ if (windCommand) {
2586
+ if (windCommand.type === "wind-list")
2587
+ tui.openText("wind presets", windListLines());
2588
+ if (windCommand.type !== "wind-show" && windCommand.type !== "wind-list")
2589
+ await materializeDraft();
2590
+ const result = applyWindCommand(score, requestedTrack, windCommand);
2591
+ if (result.next && result.kind)
2592
+ await commitScore(result.next, result.kind, result.payload);
2593
+ return result.ok ? ok(result.message) : fail(result.message);
2594
+ }
2595
+ const singCommand = parseSingCommand(command);
2596
+ if (singCommand) {
2597
+ if (singCommand.type === "sing-list")
2598
+ tui.openText("sing presets", singListLines());
2599
+ if (singCommand.type !== "sing-show" && singCommand.type !== "sing-list")
2600
+ await materializeDraft();
2601
+ const result = applySingCommand(score, requestedTrack, singCommand);
2602
+ if (result.next && result.kind)
2603
+ await commitScore(result.next, result.kind, result.payload);
2604
+ return result.ok ? ok(result.message) : fail(result.message);
2605
+ }
1830
2606
  const synth = parseSynthCommand(command);
1831
2607
  if (synth) {
1832
2608
  if (synth.type !== "synth-list") await materializeDraft();
1833
2609
  const result = applySynthCommand(score, requestedTrack, synth);
1834
2610
  if (result.next && result.kind)
1835
2611
  await commitScore(result.next, result.kind, result.payload);
1836
- return result.ok ? ok(result.message) : fail(result.message);
2612
+ return readOrDone(result);
1837
2613
  }
1838
2614
  const stringCommand = parseStringCommand(command);
1839
2615
  if (stringCommand) {
@@ -1845,7 +2621,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1845
2621
  const result = applyStringCommand(score, requestedTrack, stringCommand);
1846
2622
  if (result.next && result.kind)
1847
2623
  await commitScore(result.next, result.kind, result.payload);
1848
- return result.ok ? ok(result.message) : fail(result.message);
2624
+ return readOrDone(result);
1849
2625
  }
1850
2626
  const grainHint = grainSrcHint(command);
1851
2627
  if (grainHint) return fail(grainHint);
@@ -1858,6 +2634,50 @@ async function submit(prompt: string): Promise<string | Receipt> {
1858
2634
  await commitScore(result.next, result.kind, result.payload);
1859
2635
  return result.ok ? ok(result.message) : fail(result.message);
1860
2636
  }
2637
+ const styleCommand = parseStyleCommand(
2638
+ /^\/?styles?\s+presets$/i.test(command) ? "style list" : command,
2639
+ );
2640
+ if (styleCommand) {
2641
+ const writes =
2642
+ styleCommand.type === "style-apply" ||
2643
+ styleCommand.type === "style-again";
2644
+ // Bare `style` opens Arrange › style; `style list|ls|presets` lists.
2645
+ if (
2646
+ styleCommand.type === "style-families" &&
2647
+ /^\/?styles?$/i.test(command)
2648
+ ) {
2649
+ openMenu("style");
2650
+ return ok(
2651
+ "style · ↑/↓ browse · enter makes the song · style list prints it",
2652
+ );
2653
+ }
2654
+ if (writes) await materializeDraft();
2655
+ const result = applyStyleCommand(score, styleCommand);
2656
+ if (result.next && result.kind)
2657
+ await commitScore(result.next, result.kind, result.payload);
2658
+ if (result.log) tui.activity.pushNote(result.log);
2659
+ if (!result.ok) return fail(result.message);
2660
+ // A multi-line listing opens as text; the card keeps its first line
2661
+ // so lines never run together on the one-line strip.
2662
+ if (!writes && result.message.includes("\n")) {
2663
+ const [head = "styles", ...rest] = result.message.split("\n");
2664
+ tui.openText(head, rest);
2665
+ return note(head);
2666
+ }
2667
+ // A new song: focus its first melodic track, so play and the menu
2668
+ // land on something tonal instead of a track the style replaced.
2669
+ const melodic = writes
2670
+ ? result.next?.tracks.find((track) => !isDrumInstrument(track.instrument))
2671
+ : undefined;
2672
+ if (melodic && melodic.id !== requestedTrack) {
2673
+ await port.focus(melodic.id);
2674
+ requestedTrack = melodic.id;
2675
+ draftTrack = false;
2676
+ }
2677
+ return ok(
2678
+ melodic ? `${result.message} · focused ${melodic.id}` : result.message,
2679
+ );
2680
+ }
1861
2681
  const masterCommand = parseMasterCommand(command);
1862
2682
  if (masterCommand) {
1863
2683
  if (masterCommand.type === "master-measure")
@@ -1870,7 +2690,33 @@ async function submit(prompt: string): Promise<string | Receipt> {
1870
2690
  const result = applyMasterCommand(score, masterCommand);
1871
2691
  if (result.next && result.kind)
1872
2692
  await commitScore(result.next, result.kind, result.payload);
1873
- return result.ok ? ok(result.message) : fail(result.message);
2693
+ return readOrDone(result);
2694
+ }
2695
+ // `loop <section> | <a>-<b> | off`: what playback loops, a section.
2696
+ const loop = parseLoopCommand(command);
2697
+ if (loop) {
2698
+ if (loop.type === "loop-show")
2699
+ return note(
2700
+ score.loopSection
2701
+ ? `loop · ${score.loopSection} · loop off plays the song`
2702
+ : `loop · the song · ${LOOP_USAGE}`,
2703
+ );
2704
+ if (loop.type === "loop-off") return submit("section loop off");
2705
+ if (loop.type === "loop-section")
2706
+ return submit(`section loop ${loop.name}`);
2707
+ const span = score.sections.find(
2708
+ (section) =>
2709
+ section.startBar + 1 === loop.from &&
2710
+ section.startBar + section.bars === loop.to,
2711
+ );
2712
+ if (span) return submit(`section loop ${span.name}`);
2713
+ // No section spans the bars: mark (or move) the section named `loop`
2714
+ // and loop it, as one revision.
2715
+ await materializeDraft();
2716
+ const result = loopSpan(score, requestedTrack, loop.from, loop.to);
2717
+ if (!result.ok) return fail(result.message);
2718
+ await commitScore(result.next, "score.sections", { loop: result.name });
2719
+ return ok(result.message);
1874
2720
  }
1875
2721
  const arrange = parseSectionCommand(command, score);
1876
2722
  if (arrange) {
@@ -1884,7 +2730,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1884
2730
  if (result.next && result.kind)
1885
2731
  await commitScore(result.next, result.kind, result.payload);
1886
2732
  if (result.seekBeat !== undefined) await seekTransport(result.seekBeat);
1887
- return result.ok ? ok(result.message) : fail(result.message);
2733
+ return readOrDone(result);
1888
2734
  }
1889
2735
  const music = parseMusicCommand(command);
1890
2736
  if (music) {
@@ -1896,20 +2742,21 @@ async function submit(prompt: string): Promise<string | Receipt> {
1896
2742
  await commitScore(result.next, result.kind, result.payload);
1897
2743
  return result.ok ? ok(result.message) : fail(result.message);
1898
2744
  }
1899
- const exportCommand = command.match(/^\/?export\s+([^\s]+)$/i);
2745
+ const exportCommand = parseExportCommand(command);
1900
2746
  if (exportCommand) {
1901
- const path = resolve(exportCommand[1]!);
1902
- if (/\.midi?$/i.test(path)) {
2747
+ const path = resolve(exportCommand.path);
2748
+ if (exportCommand.format === "mid") {
1903
2749
  await writeFile(path, scoreToMidi(exportScore(score)));
1904
- return `exported midi · ${exportCommand[1]}`;
2750
+ return `exported midi · ${exportCommand.path}`;
1905
2751
  }
1906
- // Audio comes from the offline renderer, never JSON under an audio name.
1907
- if (/\.(wav|aiff?|flac|mp3|ogg|m4a)$/i.test(path))
1908
- return fail(
1909
- `/export writes .track.json or .mid · render audio with: dawg render ${exportCommand[1]}`,
1910
- );
2752
+ // Audio goes through the `dawg render` path, the same bytes as the CLI.
2753
+ if (exportCommand.format === "wav")
2754
+ return exportWav(exportCommand.path, exportCommand.stems);
2755
+ // Other audio names are not formats dawg writes.
2756
+ if (/\.(aiff?|flac|mp3|ogg|m4a)$/i.test(path))
2757
+ return fail(`export · ${exportCommand.path} · ${EXPORT_USAGE}`);
1911
2758
  await writeFile(path, encodeLoop(score), "utf8");
1912
- return `exported · ${exportCommand[1]}`;
2759
+ return `exported · ${exportCommand.path}`;
1913
2760
  }
1914
2761
  const importCommand = command.match(/^\/?import\s+([^\s]+)$/i);
1915
2762
  if (importCommand) {
@@ -1944,14 +2791,14 @@ async function submit(prompt: string): Promise<string | Receipt> {
1944
2791
  tui.activity.setSpinner(undefined);
1945
2792
  }
1946
2793
  }
1947
- const receiptText = await tuiSetModel(modelMatch[1]);
2794
+ const result = await tuiSetModel(modelMatch[1]);
1948
2795
  provider = undefined;
1949
2796
  await currentProvider();
1950
- return receiptText;
2797
+ return result.ok ? result.text : fail(result.text);
1951
2798
  }
1952
2799
  if (/^\/login\b/i.test(prompt.trim())) {
1953
2800
  const parsed = tuiLoginArgs(prompt.trim());
1954
- if (typeof parsed === "string") return `login · ${parsed}`;
2801
+ if (typeof parsed === "string") return `model key · ${parsed}`;
1955
2802
  await handoff(() =>
1956
2803
  runTuiLogin(
1957
2804
  parsed.target === "auto" ? "pick" : parsed.target,
@@ -1961,8 +2808,8 @@ async function submit(prompt: string): Promise<string | Receipt> {
1961
2808
  provider = undefined;
1962
2809
  await currentProvider();
1963
2810
  return providerName === "offline"
1964
- ? "login · not signed in · direct commands still work"
1965
- : `signed in · ${providerName}`;
2811
+ ? "no agent key · model key adds one · direct commands still work"
2812
+ : `agent key · ${providerName}`;
1966
2813
  }
1967
2814
  if (/^\/(logout|auth)\b/i.test(prompt.trim())) {
1968
2815
  tui.activity.setSpinner(prompt.trim().split(/\s+/)[0]!.slice(1));
@@ -1977,8 +2824,46 @@ async function submit(prompt: string): Promise<string | Receipt> {
1977
2824
  tui.activity.setSpinner(undefined);
1978
2825
  }
1979
2826
  }
2827
+ // The grammar's second reading (design §3): a line no handler took runs
2828
+ // in its other spellings — `/x` ≡ `x`, aliases, `rm|remove|delete`,
2829
+ // `list|presets|ls` — so only lines that failed before get one.
2830
+ // A line the core prompt parser takes is already understood.
2831
+ if (!recovering && !parsePrompt(prompt)) {
2832
+ const retry = recover(
2833
+ command,
2834
+ (candidate) =>
2835
+ parseExact(candidate, score) !== undefined ||
2836
+ // Window verbs get only the slash toggle, never a listing word.
2837
+ (candidate.startsWith("/") &&
2838
+ SLASH_HANDLED.has(verbOf(candidate)) &&
2839
+ candidate.split(/\s+/).length === command.trim().split(/\s+/).length),
2840
+ );
2841
+ if (retry !== undefined) {
2842
+ recovering = true;
2843
+ try {
2844
+ return await submit(retry);
2845
+ } finally {
2846
+ recovering = false;
2847
+ }
2848
+ }
2849
+ }
2850
+ // Bare `rename`, `fork`, `resume`, `login` take free text: a hint only.
2851
+ const freeText = FREE_TEXT_HINTS[command.toLowerCase()];
2852
+ if (freeText !== undefined) return note(freeText);
2853
+ const elsewhere = elsewhereHint(command);
2854
+ if (elsewhere !== undefined) return note(elsewhere);
1980
2855
  // `/model` belongs to its own handler above; every other slash word that
1981
2856
  // reached here is unknown or misused, and never a question for the agent.
2857
+ // `/instrument aah` is `instrument aah`; `instrument sing choir` is
2858
+ // `instrument choir`.
2859
+ const singWord = command.match(/^\/?instrument\s+sing\s+([a-z]+)$/i);
2860
+ if (singWord && isSingWord(singWord[1]!.toLowerCase()))
2861
+ return submit(`instrument ${singWord[1]!.toLowerCase()}`);
2862
+ if (/^\/instrument\s/i.test(command) && parsePrompt(command.slice(1)))
2863
+ return submit(command.slice(1));
2864
+ // A synth or effect number out of range names its range.
2865
+ const range = paramRangeError(command);
2866
+ if (range) return fail(range);
1982
2867
  if (command.startsWith("/") && !/^\/model\b/i.test(command)) {
1983
2868
  const hint = usageHint(command);
1984
2869
  return fail(
@@ -1993,6 +2878,10 @@ async function submit(prompt: string): Promise<string | Receipt> {
1993
2878
  // A one-letter slip on a command whose arguments parse stays local.
1994
2879
  const fix = typoFix(command, parsesLocally);
1995
2880
  if (fix) return fail(`${truncateForCard(command)} · did you mean ${fix}?`);
2881
+ // A grammar verb (not everyday English) never reaches the agent.
2882
+ const verb = verbOf(command);
2883
+ if (knownVerbs().has(verb) && !EVERYDAY_VERBS.has(verb))
2884
+ return fail(usageCard(truncateForCard(command), `/help ${verb}`));
1996
2885
  if (process.env.DAWG_AI === "0")
1997
2886
  return fail(`unrecognized · ${truncateForCard(command)} · /help`);
1998
2887
  await materializeDraft();
@@ -2022,6 +2911,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
2022
2911
  return parsed.action;
2023
2912
  }
2024
2913
  if (parsed.type === "set-tempo") {
2914
+ const range = RANGES.tempo!;
2915
+ if (parsed.tempoBpm < range.min || parsed.tempoBpm > range.max)
2916
+ return fail(usageError(truncateForCard(command), range));
2025
2917
  const next = score.withTempo(parsed.tempoBpm);
2026
2918
  await commitScore(next, "score.tempo", { tempoBpm: parsed.tempoBpm });
2027
2919
  clock.follow(next);
@@ -2049,15 +2941,10 @@ async function submit(prompt: string): Promise<string | Receipt> {
2049
2941
  if (parsed.type === "track") {
2050
2942
  // An unknown word would store and play a plain sine: say so instead.
2051
2943
  const word = parsed.patch.instrument;
2052
- if (word !== undefined && isUnknownInstrument(word)) {
2053
- const advice = plainSineAdvice(word) ?? "";
2054
- const near = advice.match(/did you mean (\S+)\?/)?.[1];
2055
- return fail(
2056
- `instrument ${truncateForCard(word)} · not a dawg instrument${near ? ` · did you mean ${near}?` : ""} · /menu sounds lists them`,
2057
- );
2058
- }
2944
+ if (word !== undefined && isUnknownInstrument(word))
2945
+ return fail(unknownInstrumentMessage(word));
2059
2946
  // `instrument jangle`: a guitar alias also loads its rig.
2060
- const patch = parsed.word
2947
+ const rigged = parsed.word
2061
2948
  ? {
2062
2949
  ...parsed.patch,
2063
2950
  ...rigWordPatch(
@@ -2066,6 +2953,11 @@ async function submit(prompt: string): Promise<string | Receipt> {
2066
2953
  ),
2067
2954
  }
2068
2955
  : parsed.patch;
2956
+ // `instrument vocal` (0.7): the vocal chain fills unset effects.
2957
+ const chain = isGuideInstrument(parsed.patch.instrument)
2958
+ ? vocalChainPatch(score.tracks.find((t) => t.id === requestedTrack))
2959
+ : {};
2960
+ const patch = { ...chain, ...rigged };
2069
2961
  const next = applyScoreOperation(score, {
2070
2962
  type: "updateTrack",
2071
2963
  trackId: requestedTrack,
@@ -2078,11 +2970,21 @@ async function submit(prompt: string): Promise<string | Receipt> {
2078
2970
  // Plain `marimba` keeps the legacy tone; point at the mallet engine.
2079
2971
  if (parsed.patch.instrument === "marimba" && !("modal" in parsed.patch))
2080
2972
  return `track · ${requestedTrack} · marimba (legacy tone) · modal marimba for the mallet engine`;
2973
+ // Plain `wind` keeps the legacy tone; point at the wind engine.
2974
+ if (parsed.patch.instrument === "wind" && !("wind" in parsed.patch))
2975
+ return `track · ${requestedTrack} · wind (legacy tone) · wind flute (or sax, trumpet …) for the wind engine`;
2081
2976
  const sine =
2082
2977
  word !== undefined && !("string" in parsed.patch)
2083
2978
  ? plainSineAdvice(word)
2084
2979
  : undefined;
2085
2980
  if (sine) return `track · ${requestedTrack} · ${sine}`;
2981
+ const added = [
2982
+ chain.filter ? "hpf 90 Hz" : "",
2983
+ chain.fx?.compressor ? "compressor 3:1" : "",
2984
+ chain.reverb ? "plate 0.14" : "",
2985
+ ].filter(Boolean);
2986
+ if (added.length)
2987
+ return `track · ${requestedTrack} · vocal · added ${added.join(", ")} · fx … off to remove`;
2086
2988
  return `track · ${requestedTrack}`;
2087
2989
  }
2088
2990
  if (parsed.type === "automation") {
@@ -2127,6 +3029,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
2127
3029
  return `cleared · ${requestedTrack}`;
2128
3030
  }
2129
3031
  if (parsed.type === "remove-note") {
3032
+ // A receipt never claims a removal that did not happen.
3033
+ if (!score.notes.some((note) => note.id === parsed.noteId))
3034
+ return fail(noNote(parsed.noteId));
2130
3035
  const next = applyScoreOperation(score, {
2131
3036
  type: "removeNote",
2132
3037
  noteId: parsed.noteId,
@@ -2244,6 +3149,10 @@ async function focusTrack(trackId: string): Promise<Receipt> {
2244
3149
  ...newPianoTrack(trackId),
2245
3150
  // `track vibes`: a 0.6 modal word names the track and its preset.
2246
3151
  ...(isModalWord(trackId) ? instrumentPatchForWord(trackId) : {}),
3152
+ // `track vocal` (0.7): a clip track with the vocal chain.
3153
+ ...(isGuideInstrument(trackId)
3154
+ ? { instrument: "vocal", ...vocalChainPatch(undefined) }
3155
+ : {}),
2247
3156
  },
2248
3157
  });
2249
3158
  await commitScore(next, "track.create", { trackId });
@@ -2375,6 +3284,36 @@ async function fitCommand(
2375
3284
  );
2376
3285
  }
2377
3286
 
3287
+ async function resampleCommand(
3288
+ command: NonNullable<ReturnType<typeof parseResampleCommand>>,
3289
+ ): Promise<Receipt> {
3290
+ await materializeDraft();
3291
+ // The source's own samples must be loaded for the render.
3292
+ if (hasSamplerTracks(score)) await sampleProblems(score);
3293
+ const result = await runResample({
3294
+ projectRoot: process.cwd(),
3295
+ score,
3296
+ command,
3297
+ ...(liveSampleBank ? { samples: liveSampleBank } : {}),
3298
+ });
3299
+ if (!result.ok) return fail(result.message);
3300
+ const problems = (await sampleProblems(result.next)).filter(
3301
+ (problem) =>
3302
+ problem.trackId === result.trackId && problem.level === "error",
3303
+ );
3304
+ if (problems.length > 0) return fail(`resample · ${problems[0]!.message}`);
3305
+ await commitScore(result.next, "resample", {
3306
+ trackId: result.trackId,
3307
+ src: result.src,
3308
+ sha256: result.sha256,
3309
+ });
3310
+ await port.focus(result.trackId);
3311
+ requestedTrack = result.trackId;
3312
+ draftTrack = false;
3313
+ await projectSync?.flushScore();
3314
+ return ok(result.message);
3315
+ }
3316
+
2378
3317
  async function sampleCommand(
2379
3318
  command: NonNullable<ReturnType<typeof parseSampleCommand>>,
2380
3319
  ): Promise<Receipt> {
@@ -2514,11 +3453,19 @@ async function packCommand(command: PackCommand): Promise<Receipt> {
2514
3453
  const store = packs();
2515
3454
  const a = await store.pruneCache(to(store.maxFileCacheBytes));
2516
3455
  const b = await library.pruneCache(to(library.maxCacheBytes));
2517
- freed = { files: a.removed + b.removed, bytes: a.freed + b.freed };
3456
+ const c = await pruneAnalysisCache(
3457
+ analysisDir(process.cwd()),
3458
+ to(ANALYSIS_CACHE_BYTES),
3459
+ );
3460
+ freed = {
3461
+ files: a.removed + b.removed + c.removed,
3462
+ bytes: a.freed + b.freed + c.freed,
3463
+ };
2518
3464
  }
2519
3465
  const lines = cacheLines({
2520
3466
  packs: await packs().cacheStatus(),
2521
3467
  assets: await library.cacheStatus(),
3468
+ analysis: await analysisCacheStatus(process.cwd()),
2522
3469
  ...(freed ? { freed } : {}),
2523
3470
  });
2524
3471
  tui.openText("cache", lines);
@@ -2593,27 +3540,38 @@ async function packCommand(command: PackCommand): Promise<Receipt> {
2593
3540
  }
2594
3541
  }
2595
3542
 
3543
+ /**
3544
+ * A command result as a receipt: a refusal is ✗, a change is ✓, and a read
3545
+ * that changed nothing (a listing or a show) is •.
3546
+ */
3547
+ function readOrDone(
3548
+ result: Readonly<{ ok: boolean; message: string; next?: unknown }>,
3549
+ ): Receipt {
3550
+ if (!result.ok) return fail(result.message);
3551
+ return result.next ? ok(result.message) : note(result.message);
3552
+ }
3553
+
2596
3554
  async function patternCommand(command: PatternCommand): Promise<Receipt> {
2597
3555
  if (command.kind === "list") {
2598
- tui.openText("patterns", DRUM_PATTERNS.map(patternLine));
2599
- return ok(`patterns · ${DRUM_PATTERNS.length} · /pattern <name>`);
3556
+ tui.openText("grooves", DRUM_PATTERNS.map(patternLine));
3557
+ return note(`grooves · ${DRUM_PATTERNS.length} · groove <name>`);
2600
3558
  }
2601
3559
  if (command.kind === "browse") {
2602
3560
  tui.openPicker({
2603
3561
  id: "pattern",
2604
- title: "drum patterns",
3562
+ title: "grooves",
2605
3563
  hint: HINTS.audition,
2606
3564
  audition: true,
2607
3565
  items: DRUM_PATTERNS.map((entry) => ({
2608
3566
  label: `${entry.label.padEnd(22)} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
2609
- value: `/pattern ${entry.name}`,
3567
+ value: `/groove ${entry.name}`,
2610
3568
  })),
2611
3569
  filterable: true,
2612
3570
  index: 0,
2613
3571
  });
2614
3572
  patternPreview = undefined;
2615
3573
  previewPattern(DRUM_PATTERNS[0]?.name);
2616
- return ok("patterns · ↑/↓ preview · Enter applies on the focused track");
3574
+ return note("grooves · ↑/↓ preview · Enter applies on the focused track");
2617
3575
  }
2618
3576
  await materializeDraft();
2619
3577
  const result = applyDrumPattern(
@@ -2674,7 +3632,7 @@ function openKitPicker(): Receipt {
2674
3632
  }));
2675
3633
  tui.openPicker({
2676
3634
  id: "kit",
2677
- title: "drum kits",
3635
+ title: "kits",
2678
3636
  hint: HINTS.audition,
2679
3637
  audition: true,
2680
3638
  items,
@@ -2706,7 +3664,7 @@ async function kitCommand(command: KitCommand, bare = false): Promise<Receipt> {
2706
3664
  .map((entry) => `${entry.name} · synth · ${entry.detail}`);
2707
3665
  lines.push(...kitListLines(await packs().bankAliases(ALIASED_PACK)));
2708
3666
  tui.openText("kits", lines);
2709
- return ok("kits · /kit <name or nickname> on the focused drum track");
3667
+ return note("kits · /kit <name or nickname> on the focused drum track");
2710
3668
  }
2711
3669
  await materializeDraft();
2712
3670
  const trackId = kitTarget(score, requestedTrack);
@@ -2817,12 +3775,10 @@ function adoptMeta(meta: typeof record.meta): void {
2817
3775
  record = { ...record, meta };
2818
3776
  if (meta.name !== announcedName) {
2819
3777
  announcedName = meta.name;
2820
- tui.activity.pushCard(
2821
- meta.nameSource === "auto"
2822
- ? `${meta.name} (auto-named) · rename with /rename <name>`
2823
- : `session · ${meta.name}`,
2824
- { tone: "info" },
2825
- );
3778
+ // The auto-name joins the receipt that earned it, as a quiet suffix.
3779
+ if (meta.nameSource === "auto")
3780
+ tui.activity.attachNote(`named “${meta.name}” · /rename`);
3781
+ else tui.activity.pushCard(`session · ${meta.name}`, { tone: "info" });
2826
3782
  }
2827
3783
  }
2828
3784
 
@@ -2986,11 +3942,13 @@ function menuContext(): MenuContext {
2986
3942
  committedChords: chordSettings,
2987
3943
  },
2988
3944
  trackId: requestedTrack,
3945
+ sessionName: record.meta.name,
2989
3946
  playing: clock.playing,
2990
3947
  grid: session?.grid ?? DEFAULT_GRID,
2991
3948
  grids: GRIDS.map((grid) => grid.label),
2992
3949
  clickOn: session?.clickOn ?? false,
2993
3950
  countInBars: session?.countInBars ?? 1,
3951
+ showMe: showMe.level,
2994
3952
  chords: stagedChordSettings() ?? session?.chords.settings ?? chordSettings,
2995
3953
  projectRoot: process.cwd(),
2996
3954
  };
@@ -3167,7 +4125,11 @@ function stagedChordSettings(): ChordSettings | undefined {
3167
4125
  if (chords.length === 0) return undefined;
3168
4126
  const settings = { ...chordSettings };
3169
4127
  for (const command of chords)
3170
- applyChordsCommand(settings, command.match(CHORDS_COMMAND)![1]!);
4128
+ applyChordsCommand(
4129
+ settings,
4130
+ command.match(CHORDS_COMMAND)![1]!,
4131
+ score.tempoBpm,
4132
+ );
3171
4133
  return settings;
3172
4134
  }
3173
4135
 
@@ -3224,7 +4186,11 @@ async function applyStaged(
3224
4186
  const chords = command.trim().match(CHORDS_COMMAND);
3225
4187
  if (chords) {
3226
4188
  // Window state: checked on a copy; kept settings apply on Enter.
3227
- const result = applyChordsCommand({ ...chordSettings }, chords[1]!);
4189
+ const result = applyChordsCommand(
4190
+ { ...chordSettings },
4191
+ chords[1]!,
4192
+ base.tempoBpm,
4193
+ );
3228
4194
  return { next: base, ok: result.ok, message: result.message };
3229
4195
  }
3230
4196
  await prefetchStaged(base, command);
@@ -3272,7 +4238,7 @@ async function startAuditionLoop(): Promise<void> {
3272
4238
  loop.focus(requestedTrack);
3273
4239
  // One sound at a time: the song stops while the loop plays.
3274
4240
  if (clock.playing) await setTransport("pause");
3275
- previewEngine().setLeadMs(PLAY_LEAD_MS);
4241
+ previewEngine().setLeadMs(previewEngine().playLeadMs);
3276
4242
  loop.start();
3277
4243
  }
3278
4244
 
@@ -3417,7 +4383,11 @@ async function keepStaged(): Promise<Receipt> {
3417
4383
  CHORDS_COMMAND.test(command),
3418
4384
  );
3419
4385
  for (const command of chordCommands)
3420
- applyChordsCommand(chordSettings, command.match(CHORDS_COMMAND)![1]!);
4386
+ applyChordsCommand(
4387
+ chordSettings,
4388
+ command.match(CHORDS_COMMAND)![1]!,
4389
+ score.tempoBpm,
4390
+ );
3421
4391
  const scoreCommands = taken.commands.filter(
3422
4392
  (command) => !CHORDS_COMMAND.test(command),
3423
4393
  );
@@ -3484,12 +4454,17 @@ function refreshMenu(): void {
3484
4454
  if (fader) {
3485
4455
  const fields = menu.faderFields(context);
3486
4456
  if (fields.length === 0) closeFader();
3487
- else
4457
+ else {
4458
+ const focusedField = fields[focusIndex(fader, fields)];
3488
4459
  tui.drawer = drawerView(fader, fields, menu.faderCommitted(context), {
3489
4460
  title: menu.crumbs,
3490
4461
  dirty: context.audition?.dirty ?? false,
3491
4462
  status: context.audition?.status,
4463
+ atOnce:
4464
+ focusedField !== undefined &&
4465
+ !stageableNow(faderCommand(focusedField)),
3492
4466
  });
4467
+ }
3493
4468
  }
3494
4469
  const view = menu.view(context);
3495
4470
  tui.openPicker({
@@ -3507,6 +4482,73 @@ function refreshMenu(): void {
3507
4482
  // ── the fader drawer ─────────────────────────────────────────────────
3508
4483
 
3509
4484
  /** Enter on a number row (or a bare `volume`, `fx filter`): the drawer. */
4485
+ /**
4486
+ * `export song.wav [stems]`: render the song as `dawg render song.wav` would
4487
+ * (the offline renderer, deterministic), then with `stems` one WAV per
4488
+ * audible track beside it (`song-bass.wav`), each that track soloed.
4489
+ */
4490
+ async function exportWav(path: string, stems: boolean): Promise<Receipt> {
4491
+ const directory = await mkdtemp(join(tmpdir(), "dawg-export-"));
4492
+ const errors: string[] = [];
4493
+ const sink = { write: (text: string) => errors.push(text.trim()) };
4494
+ const quiet = { write: () => undefined };
4495
+ const render = async (song: TrackScore, target: string): Promise<boolean> => {
4496
+ const file = join(directory, "song.track.json");
4497
+ await writeFile(file, encodeLoop(song), "utf8");
4498
+ const code = await runRenderCommand(
4499
+ ["render", target, "--import", file],
4500
+ process.cwd(),
4501
+ quiet,
4502
+ sink,
4503
+ );
4504
+ return code === 0;
4505
+ };
4506
+ tui.activity.setSpinner(`export ${path}`);
4507
+ try {
4508
+ if (!(await render(score, path)))
4509
+ return fail(errors.at(-1) ?? `export failed · ${path}`);
4510
+ if (!stems) return ok(`exported · ${path}`);
4511
+ const written: string[] = [];
4512
+ const base = path.replace(/\.wav$/i, "");
4513
+ for (const track of score.tracks) {
4514
+ if (!isTrackAudible(score, track.id)) continue;
4515
+ let solo = score;
4516
+ for (const other of score.tracks)
4517
+ solo = applyScoreOperation(solo, {
4518
+ type: "updateTrack",
4519
+ trackId: other.id,
4520
+ patch: { solo: other.id === track.id },
4521
+ });
4522
+ const target = `${base}-${track.id}.wav`;
4523
+ if (!(await render(solo, target)))
4524
+ return fail(errors.at(-1) ?? `export failed · ${target}`);
4525
+ written.push(target);
4526
+ }
4527
+ return ok(stemsReceipt(path, written));
4528
+ } finally {
4529
+ tui.activity.setSpinner(undefined);
4530
+ await rm(directory, { recursive: true, force: true });
4531
+ }
4532
+ }
4533
+
4534
+ /** `tempo`, `bars`, `meter`: open Project and name its fader, if it has one. */
4535
+ function songFader(context: MenuContext, command: string): string | undefined {
4536
+ // Bare song scalars and the Project fader each opens.
4537
+ const faders: Readonly<Record<string, string>> = {
4538
+ tempo: "tempo",
4539
+ bpm: "tempo",
4540
+ bars: "loop length",
4541
+ meter: "beats per bar",
4542
+ };
4543
+ const label = faders[command.replace(/^\//, "").toLowerCase()];
4544
+ if (!label) return undefined;
4545
+ menu.show(context, "project");
4546
+ if (menu.faderFields(context).some((field) => field.label === label))
4547
+ return label;
4548
+ menu.close();
4549
+ return undefined;
4550
+ }
4551
+
3510
4552
  /**
3511
4553
  * Open the drawer on `label`. `standalone` drawers came from a command or a
3512
4554
  * click, not from inside the menu, so closing one closes the menu too.
@@ -3609,7 +4651,7 @@ function mouseInput(event: MouseEvent): string[] {
3609
4651
  return [];
3610
4652
  }
3611
4653
  if (fader) return [];
3612
- if (tui.ui.overlay) return [event.delta < 0 ? KEY_UP : KEY_DOWN];
4654
+ if (tui.ui.overlay) return [event.delta < 0 ? ARROW_UP : ARROW_DOWN];
3613
4655
  return [];
3614
4656
  }
3615
4657
  // A drag keeps moving the fader it started on, even past the bar's ends.
@@ -3699,7 +4741,9 @@ function mouseInput(event: MouseEvent): string[] {
3699
4741
  }
3700
4742
  case "transport":
3701
4743
  if (tui.ui.overlay) return [];
3702
- void toggleTransport().finally(() => requestFrame());
4744
+ void toggleTransport()
4745
+ .catch((error: unknown) => transportFailed(error))
4746
+ .finally(() => requestFrame());
3703
4747
  return [];
3704
4748
  case "tracks":
3705
4749
  if (tui.ui.overlay) return [];
@@ -3730,13 +4774,13 @@ function playSession(): PlaySession {
3730
4774
  }
3731
4775
 
3732
4776
  async function enterPlay(): Promise<Receipt> {
3733
- if (play?.on) return ok(`play · ${play.track} · esc leaves`);
4777
+ if (play?.on) return ok(`play mode · ${play.track} · esc leaves`);
3734
4778
  await materializeDraft();
3735
4779
  const session = playSession();
3736
4780
  await session.enter();
3737
4781
  if (hasSamplerTracks(score)) void sampleProblems(score);
3738
4782
  return ok(
3739
- `play · ${session.track} · ${session.keyboard.range} · ? keys · esc leaves`,
4783
+ `play mode · ${session.track} · ${session.layout.drums ? "kit" : session.keyboard.range} · ? keys · esc leaves`,
3740
4784
  );
3741
4785
  }
3742
4786
 
@@ -3766,9 +4810,7 @@ function playKey(value: string): boolean {
3766
4810
  else if (result.command === "transport") {
3767
4811
  if (!session.startWithCountIn())
3768
4812
  void toggleTransport().catch((error: unknown) =>
3769
- tui.activity.pushError(
3770
- `transport failed · ${error instanceof Error ? error.message : String(error)}`,
3771
- ),
4813
+ transportFailed(error),
3772
4814
  );
3773
4815
  } else if (result.command === "menu") return false;
3774
4816
  return true;
@@ -3830,13 +4872,18 @@ function playHost() {
3830
4872
  async stopTransport(): Promise<void> {
3831
4873
  await setTransport("pause");
3832
4874
  },
4875
+ ghost(pitch: number) {
4876
+ tui.delight.ghost(pitch, Date.now());
4877
+ requestFrame();
4878
+ },
3833
4879
  card(text: string, tone: "info" | "success" | "warning" | "error") {
3834
4880
  if (tone === "error") tui.activity.pushError(text);
3835
4881
  else
3836
4882
  tui.activity.pushCard(text, {
3837
4883
  tone,
3838
4884
  trackId: requestedTrack,
3839
- resultRevision: tone === "success" ? record.revision : undefined,
4885
+ resultRevision:
4886
+ tone === "success" ? shownRevision(record) : undefined,
3840
4887
  });
3841
4888
  },
3842
4889
  newNoteId: () => randomUUID().slice(0, 12),
@@ -3883,6 +4930,9 @@ function timingChanged(previous: TrackScore, next: TrackScore): boolean {
3883
4930
  function syncHost(): SyncHost {
3884
4931
  return {
3885
4932
  project: process.cwd(),
4933
+ // The files belong to one session at a time; another session's window
4934
+ // stays detached instead of reprinting over them.
4935
+ sessionId: () => record.sessionId,
3886
4936
  current: () => score,
3887
4937
  async commit(plan, summary) {
3888
4938
  const baseRevision = record.revision;
@@ -3908,7 +4958,8 @@ function syncHost(): SyncHost {
3908
4958
  tui.activity.pushCard(text, {
3909
4959
  tone,
3910
4960
  hint,
3911
- resultRevision: tone === "success" ? record.revision : undefined,
4961
+ resultRevision:
4962
+ tone === "success" ? shownRevision(record) : undefined,
3912
4963
  });
3913
4964
  },
3914
4965
  types(state) {
@@ -3939,10 +4990,17 @@ async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
3939
4990
  },
3940
4991
  restored.toJSON(),
3941
4992
  );
4993
+ // Compare with what was undone, which another window may have written.
4994
+ const before = scoreFromJSON(latest.composition);
3942
4995
  score = restored;
3943
4996
  if (clock.playing) void audio.play(score);
3944
4997
  return ok(
3945
- `${direction === "undo" ? "undid" : "redid"} · rev ${target.revision}`,
4998
+ historyReceipt(
4999
+ direction,
5000
+ before,
5001
+ restored,
5002
+ shownRevision(record, target.revision),
5003
+ ),
3946
5004
  );
3947
5005
  } catch (error) {
3948
5006
  if (error instanceof SessionConflictError)
@@ -3953,7 +5011,36 @@ async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
3953
5011
  }
3954
5012
  }
3955
5013
 
3956
- /** The space-bar toggle: flip the transport, then record it for other windows. */
5014
+ /** Space, the menu and a header click all report a failed toggle the same way. */
5015
+ function transportFailed(error: unknown): void {
5016
+ tui.activity.pushCard(
5017
+ `transport failed · ${error instanceof Error ? error.message : String(error)}`,
5018
+ { tone: "error" },
5019
+ );
5020
+ }
5021
+
5022
+ /**
5023
+ * The revision dawg shows (header, receipts, undo): edits to the song.
5024
+ * Play and pause are logged so other windows follow them, but they are not
5025
+ * edits, so they never move the number: a revision shows as itself minus
5026
+ * the transport events at or before it.
5027
+ */
5028
+ function shownRevision(
5029
+ value: typeof record,
5030
+ revision = value.revision,
5031
+ ): number {
5032
+ let transports = 0;
5033
+ for (const event of value.events)
5034
+ if (event.revision <= revision && event.kind === "transport")
5035
+ transports += 1;
5036
+ return revision - transports;
5037
+ }
5038
+
5039
+ /**
5040
+ * The space-bar toggle: flip the transport, then record it for other
5041
+ * windows. The log entry keeps windows in step; the header's revision
5042
+ * (`shownRevision`) skips it, since play/pause is not an edit.
5043
+ */
3957
5044
  async function toggleTransport(): Promise<void> {
3958
5045
  await setTransport("toggle");
3959
5046
  try {
@@ -4023,11 +5110,11 @@ async function runAgent(text: string): Promise<string | Receipt> {
4023
5110
  if (agentTurn) return warn("agent busy · Esc cancels");
4024
5111
  const selection = await currentProvider();
4025
5112
  if (selection.kind === "offline")
4026
- return fail(
4027
- `unrecognized · ${truncateForCard(text)} · ${selection.reason}`,
4028
- );
5113
+ return fail(`${truncateForCard(text)} · ${NO_AGENT}`);
4029
5114
  const turn = { controller: new AbortController(), steering: [] as string[] };
4030
5115
  agentTurn = turn;
5116
+ // The turn ends on one musical receipt of what it changed.
5117
+ const before = score;
4031
5118
  reportAgentActivity(`${providerName} · thinking…`);
4032
5119
  // xcb admits a pending account on its first call, which takes longer.
4033
5120
  let admitting = selection.kind === "xcb" && selection.admissionPending;
@@ -4060,6 +5147,8 @@ async function runAgent(text: string): Promise<string | Receipt> {
4060
5147
  }
4061
5148
  return;
4062
5149
  }
5150
+ if (event.type === "done")
5151
+ tui.activity.setTurnReceipt(musicalReceipt(before, score));
4063
5152
  agentEventSink(event);
4064
5153
  },
4065
5154
  });
@@ -4091,7 +5180,7 @@ function currentProvider(): Promise<ProviderSelection> {
4091
5180
  provider = selectProvider().catch((): ProviderSelection => ({
4092
5181
  kind: "offline",
4093
5182
  choice: "auto",
4094
- reason: "provider unavailable; run `dawg login`",
5183
+ reason: "provider unavailable; run `dawg model key`",
4095
5184
  }));
4096
5185
  }
4097
5186
  return provider.then((selection) => {
@@ -4103,9 +5192,9 @@ function currentProvider(): Promise<ProviderSelection> {
4103
5192
  !invalidNoticeShown
4104
5193
  ) {
4105
5194
  invalidNoticeShown = true;
4106
- tui.activity.pushCard(`sign-in stopped working · ${selection.reason}`, {
5195
+ tui.activity.pushCard(`agent key stopped working · ${selection.reason}`, {
4107
5196
  tone: "warning",
4108
- hint: "/login",
5197
+ hint: "/model key",
4109
5198
  });
4110
5199
  }
4111
5200
  providerStamp.offline = selection.kind === "offline";
@@ -4180,6 +5269,180 @@ function agentPreviewHost(): PreviewHost {
4180
5269
  };
4181
5270
  }
4182
5271
 
5272
+ // ── show-me ──────────────────────────────────────────────────────────
5273
+
5274
+ /**
5275
+ * Show-me (docs/show-me.md): with an API provider the agent writes prompt
5276
+ * commands, streamed. The line being written is ghost text in the empty
5277
+ * prompt bar at the model's own speed; each complete line runs through
5278
+ * `submit()`, the path a typed Enter takes, at once. A parameter value
5279
+ * glides there over ~150 ms while the loop plays (you hear it), notes and
5280
+ * hits sound as they arrive (in time when the stream is ahead of the tempo,
5281
+ * as step entry when it is behind), and the caption names the key or fader
5282
+ * a person would use. Nothing waits for the turn to end.
5283
+ */
5284
+
5285
+ function showMeTyping(text: string): void {
5286
+ showMe.ghost = text || undefined;
5287
+ if (text && showMe.level === "on") showMe.caption = undefined;
5288
+ requestFrame();
5289
+ }
5290
+
5291
+ function showMeCaption(text: string | undefined): void {
5292
+ if (showMe.level !== "on") return;
5293
+ showMe.caption = text;
5294
+ if (showMe.clear) clearTimeout(showMe.clear);
5295
+ showMe.clear = undefined;
5296
+ requestFrame();
5297
+ }
5298
+
5299
+ /** End of a turn: the ghost goes, the caption becomes the do-it-yourself hint. */
5300
+ function showMeFinish(): void {
5301
+ showMe.ghost = undefined;
5302
+ showMe.notes.reset();
5303
+ const hint = finishHint(showMe.commands);
5304
+ showMe.commands = [];
5305
+ if (hint && showMe.level === "on") {
5306
+ showMeCaption(hint);
5307
+ showMe.clear = setTimeout(() => {
5308
+ showMe.caption = undefined;
5309
+ showMe.clear = undefined;
5310
+ requestFrame();
5311
+ }, 8_000);
5312
+ } else {
5313
+ showMe.caption = undefined;
5314
+ requestFrame();
5315
+ }
5316
+ }
5317
+
5318
+ /**
5319
+ * Slide a fader to its new value the way the drawer does: each eased step is
5320
+ * the field's own command, staged on a scratch score and played, so the loop
5321
+ * moves through the values. Only while the loop plays (otherwise nothing is
5322
+ * heard and the glide would only cost time); never longer than ~150 ms.
5323
+ */
5324
+ async function glideFader(param: string, target: number): Promise<void> {
5325
+ if (!clock.playing || !stdout.isTTY) return;
5326
+ const scratch = new EditMenu();
5327
+ const context = { ...menuContext(), score };
5328
+ const label = scratch.showFader(context, param);
5329
+ if (!label) return;
5330
+ const field = scratch
5331
+ .faderFields(context)
5332
+ .find((candidate) => candidate.label === label);
5333
+ scratch.close();
5334
+ if (field?.kind !== "number") return;
5335
+ const from = field.value ?? field.start ?? target;
5336
+ const steps = glideValues(from, target).slice(0, -1);
5337
+ for (const value of steps) {
5338
+ const staged = await applyStaged(score, field.command(value));
5339
+ if (staged.next && clock.playing) void audio.play(staged.next);
5340
+ await Bun.sleep(GLIDE_STEP_MS);
5341
+ }
5342
+ }
5343
+
5344
+ /** Sound one streamed note on the audition voice (the loop is stopped). */
5345
+ function soundStreamedNote(
5346
+ value: TrackScore,
5347
+ trackId: string,
5348
+ pitch: number,
5349
+ startBeat: number,
5350
+ ): void {
5351
+ if (clock.playing) return; // The loop itself plays it.
5352
+ const engine = liveEngine();
5353
+ if (!engine?.canMonitor) return;
5354
+ const tick = Math.round(startBeat * value.ticksPerBeat);
5355
+ const note = value.notes.find(
5356
+ (candidate) =>
5357
+ candidate.trackId === trackId &&
5358
+ candidate.pitch === pitch &&
5359
+ candidate.startTick === tick,
5360
+ );
5361
+ if (!note) return;
5362
+ try {
5363
+ const json = value.toJSON() as unknown as Record<string, unknown>;
5364
+ const single = scoreFromJSON({
5365
+ ...json,
5366
+ notes: [{ ...note, startTick: 0 }],
5367
+ });
5368
+ const pcm = renderAudition({
5369
+ score: single,
5370
+ trackId,
5371
+ sampleRate: engine.sampleRate,
5372
+ ...(liveSampleBank ? { samples: liveSampleBank } : {}),
5373
+ });
5374
+ if (!pcm) return;
5375
+ void engine
5376
+ .monitor(true)
5377
+ .then(() => engine.noteOn(AUDITION_VOICE, pcm))
5378
+ .catch(() => undefined);
5379
+ } catch {
5380
+ // A note that cannot render is still in the score.
5381
+ }
5382
+ }
5383
+
5384
+ /** The command host the show-me agent loop runs lines through. */
5385
+ function showMeCommandHost(): AgentHost["commands"] {
5386
+ return {
5387
+ isCommand: (line) =>
5388
+ isAgentCommand(line, score) || isBrokenCommand(line, score),
5389
+ async run(line): Promise<CommandOutcome> {
5390
+ if (!agentPathsAllowed(line)) {
5391
+ const revision = shownRevision(record);
5392
+ return {
5393
+ ok: false,
5394
+ message: `${line} · the agent works only inside this folder`,
5395
+ baseRevision: revision,
5396
+ resultRevision: revision,
5397
+ };
5398
+ }
5399
+ if (isBrokenCommand(line, score)) {
5400
+ // A command that does not parse is a red receipt, never prose.
5401
+ const base = baseline();
5402
+ receipt(fail(brokenCommandReceipt(line)), base);
5403
+ return {
5404
+ ok: false,
5405
+ message: brokenCommandReceipt(line),
5406
+ baseRevision: base.revision,
5407
+ resultRevision: base.revision,
5408
+ };
5409
+ }
5410
+ const gesture = gestureFor(line, { score, trackId: requestedTrack });
5411
+ showMeCaption(gesture.caption);
5412
+ showMe.commands.push(line);
5413
+ if (gesture.kind === "fader" && showMe.level === "on")
5414
+ await glideFader(gesture.param, gesture.value);
5415
+ const base = baseline();
5416
+ tui.activity.pushNote(`agent › ${line}`, "request");
5417
+ let result: string | Receipt;
5418
+ try {
5419
+ result = await submit(line);
5420
+ } catch (error) {
5421
+ result = fail(describeError(line, error));
5422
+ }
5423
+ receipt(result, base);
5424
+ if (gesture.kind === "keys" && showMe.level === "on") {
5425
+ const trackId = requestedTrack;
5426
+ const after = score;
5427
+ for (const note of gesture.notes) {
5428
+ const { atMs } = showMe.notes.schedule(note.start, performance.now());
5429
+ const delay = Math.max(0, atMs - performance.now());
5430
+ setTimeout(
5431
+ () => soundStreamedNote(after, trackId, note.pitch, note.start),
5432
+ delay,
5433
+ );
5434
+ }
5435
+ }
5436
+ return {
5437
+ ok: toneOf(result) !== "error",
5438
+ message: typeof result === "string" ? result : result.text,
5439
+ baseRevision: shownRevision(record, base.revision),
5440
+ resultRevision: shownRevision(record),
5441
+ };
5442
+ },
5443
+ };
5444
+ }
5445
+
4183
5446
  function agentHost(
4184
5447
  turn: { steering: string[] },
4185
5448
  selection: ProviderSelection,
@@ -4254,6 +5517,9 @@ function agentHost(
4254
5517
  }
4255
5518
  },
4256
5519
  takeSteering: () => turn.steering.splice(0),
5520
+ ...(showMe.level !== "off" && stdout.isTTY
5521
+ ? { commands: showMeCommandHost() }
5522
+ : {}),
4257
5523
  workspace: { root: process.cwd() },
4258
5524
  // A written project source is applied before the tool result returns,
4259
5525
  // so the model reads the outcome and any type errors in the same step.