@hraness/dawg 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DAWG.md +454 -140
  3. package/README.md +30 -26
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +184 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/ids.ts +52 -0
  11. package/core/instruments.ts +19 -0
  12. package/core/keys.ts +3 -3
  13. package/core/loop.ts +8 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/range.ts +581 -0
  17. package/core/resonators.ts +16 -2
  18. package/core/routing.ts +165 -0
  19. package/core/score.ts +955 -14
  20. package/core/sdk/eval-child.ts +7 -2
  21. package/core/sdk/eval.ts +35 -6
  22. package/core/sdk/print.ts +287 -5
  23. package/core/sdk/sync-lyrics.ts +49 -0
  24. package/core/sdk/v1.ts +2132 -46
  25. package/core/sections.ts +480 -35
  26. package/core/sing.ts +815 -0
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/guides/agent.md +29 -0
  50. package/guides/arrange.md +31 -0
  51. package/guides/audio.md +31 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -7
  54. package/guides/chords.md +15 -15
  55. package/guides/effects.md +17 -16
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +15 -7
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/panes.md +32 -0
  65. package/guides/performance.md +15 -13
  66. package/guides/play.md +21 -13
  67. package/guides/project.md +26 -8
  68. package/guides/providers.md +20 -14
  69. package/guides/resample.md +15 -9
  70. package/guides/rhythm.md +18 -13
  71. package/guides/sessions.md +17 -7
  72. package/guides/show-me.md +31 -0
  73. package/guides/sound.md +25 -9
  74. package/guides/sounds.md +15 -13
  75. package/guides/styles.md +31 -0
  76. package/guides/tape.md +32 -0
  77. package/guides/tempo.md +15 -10
  78. package/guides/tracks.md +15 -10
  79. package/guides/tuning.md +32 -0
  80. package/guides/voice.md +31 -0
  81. package/guides/web-search.md +18 -8
  82. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  83. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  84. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  85. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  86. package/native/prebuilt/manifest.json +21 -0
  87. package/package.json +6 -2
  88. package/src/agent/agent.ts +130 -18
  89. package/src/agent/calibration-tools.ts +53 -0
  90. package/src/agent/clip-tools.ts +453 -0
  91. package/src/agent/command-agent.ts +378 -0
  92. package/src/agent/drum-tools.ts +2 -2
  93. package/src/agent/expression-tools.ts +1 -1
  94. package/src/agent/gateway.ts +246 -60
  95. package/src/agent/models.ts +53 -12
  96. package/src/agent/ops.ts +27 -1
  97. package/src/agent/pack-tools.ts +1 -1
  98. package/src/agent/planner.ts +25 -0
  99. package/src/agent/portable-schema.ts +80 -0
  100. package/src/agent/preview-tool.ts +4 -1
  101. package/src/agent/provider.ts +22 -8
  102. package/src/agent/range-tools.ts +216 -0
  103. package/src/agent/rhythm-tools.ts +1 -1
  104. package/src/agent/section-tools.ts +1 -1
  105. package/src/agent/show-me.ts +497 -0
  106. package/src/agent/steer.ts +15 -0
  107. package/src/agent/style-tools.ts +217 -0
  108. package/src/agent/tool-error.ts +12 -0
  109. package/src/agent/tools.ts +110 -23
  110. package/src/agent/usage.ts +2 -2
  111. package/src/agent/voice-tools.ts +925 -0
  112. package/src/agent/xcb-agent.ts +13 -10
  113. package/src/argv.ts +38 -0
  114. package/src/audio/analysis.ts +253 -0
  115. package/src/audio/arrange.ts +49 -5
  116. package/src/audio/audio-command.ts +218 -0
  117. package/src/audio/autotune-engine.ts +101 -0
  118. package/src/audio/autotune.ts +640 -0
  119. package/src/audio/clips.ts +240 -0
  120. package/src/audio/devices.ts +264 -0
  121. package/src/audio/doctor.ts +157 -0
  122. package/src/audio/dsp/bandbank.ts +138 -0
  123. package/src/audio/dsp/envelope.ts +10 -0
  124. package/src/audio/dsp/follow.ts +120 -0
  125. package/src/audio/dsp/formant.ts +427 -0
  126. package/src/audio/dsp/glottal.ts +243 -0
  127. package/src/audio/dsp/interp.ts +7 -2
  128. package/src/audio/dsp/lpc.ts +50 -0
  129. package/src/audio/dsp/periodicity.ts +59 -0
  130. package/src/audio/dsp/pitch.ts +995 -0
  131. package/src/audio/dsp/psola.ts +199 -0
  132. package/src/audio/effects/chain.ts +3 -1
  133. package/src/audio/effects/common.ts +43 -0
  134. package/src/audio/effects/convolution.ts +7 -4
  135. package/src/audio/effects/filter.ts +48 -69
  136. package/src/audio/effects/formant.ts +263 -0
  137. package/src/audio/engine.ts +360 -40
  138. package/src/audio/fit.ts +35 -3
  139. package/src/audio/instrument-check.ts +59 -43
  140. package/src/audio/instruments.ts +4 -0
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +8 -1
  143. package/src/audio/keys/engine.ts +13 -1
  144. package/src/audio/keys/piano.ts +22 -2
  145. package/src/audio/kits.ts +135 -6
  146. package/src/audio/live.ts +114 -20
  147. package/src/audio/native.ts +615 -0
  148. package/src/audio/preview.ts +30 -2
  149. package/src/audio/render-worker.ts +2 -0
  150. package/src/audio/renderer.ts +2 -0
  151. package/src/audio/resample.ts +2 -1
  152. package/src/audio/sampler.ts +85 -4
  153. package/src/audio/samples.ts +20 -2
  154. package/src/audio/sing/analysis.ts +193 -0
  155. package/src/audio/sing/engine.ts +949 -0
  156. package/src/audio/strings/bow.ts +48 -5
  157. package/src/audio/strings/engine.ts +8 -2
  158. package/src/audio/synth/oscillators.ts +31 -21
  159. package/src/audio/synth/voice.ts +34 -1
  160. package/src/audio/vocoder/bank.ts +314 -0
  161. package/src/audio/vocoder/carrier.ts +165 -0
  162. package/src/audio/vocoder/control.ts +68 -0
  163. package/src/audio/vocoder/detect.ts +50 -0
  164. package/src/audio/vocoder/index.ts +304 -0
  165. package/src/audio/vocoder/talkbox.ts +143 -0
  166. package/src/audio/wav.ts +500 -77
  167. package/src/audio/winds/engine.ts +5 -1
  168. package/src/audio/winds/trim.ts +28 -4
  169. package/src/audio/winds/trims1.ts +297 -0
  170. package/src/audio/winds/voice.ts +15 -2
  171. package/src/auth/cli.ts +38 -36
  172. package/src/auth/credentials.ts +30 -1
  173. package/src/auth/login.ts +15 -9
  174. package/src/auth/tui.ts +19 -10
  175. package/src/commands/arrange.ts +82 -32
  176. package/src/commands/autotune.ts +421 -0
  177. package/src/commands/calibration.ts +74 -0
  178. package/src/commands/clips.ts +887 -0
  179. package/src/commands/drums.ts +3 -2
  180. package/src/commands/edit.ts +11 -4
  181. package/src/commands/expression.ts +1 -1
  182. package/src/commands/formant.ts +221 -0
  183. package/src/commands/fx.ts +101 -32
  184. package/src/commands/grammar.ts +560 -0
  185. package/src/commands/help.ts +774 -366
  186. package/src/commands/history.ts +139 -10
  187. package/src/commands/keys.ts +25 -8
  188. package/src/commands/modal.ts +1 -1
  189. package/src/commands/music.ts +1 -1
  190. package/src/commands/nearest.ts +53 -0
  191. package/src/commands/pack.ts +9 -2
  192. package/src/commands/param-range.ts +56 -0
  193. package/src/commands/parses.ts +135 -0
  194. package/src/commands/progression.ts +170 -0
  195. package/src/commands/range.ts +763 -0
  196. package/src/commands/resample.ts +13 -10
  197. package/src/commands/rhythm.ts +3 -0
  198. package/src/commands/rig.ts +3 -24
  199. package/src/commands/sample.ts +11 -1
  200. package/src/commands/sing.ts +474 -0
  201. package/src/commands/strum.ts +13 -1
  202. package/src/commands/style.ts +415 -0
  203. package/src/commands/time.ts +6 -3
  204. package/src/commands/tuning.ts +3 -3
  205. package/src/commands/vocal-pitch.ts +617 -0
  206. package/src/commands/vocal.ts +147 -0
  207. package/src/commands/vocoder.ts +627 -0
  208. package/src/commands/wind.ts +2 -2
  209. package/src/fs/durable.ts +50 -0
  210. package/src/identity/actor.ts +127 -0
  211. package/src/lang/glossary.ts +511 -0
  212. package/src/launch-args.ts +267 -0
  213. package/src/main.ts +2508 -308
  214. package/src/media/cli.ts +20 -3
  215. package/src/media/import.ts +3 -1
  216. package/src/project/check.ts +21 -2
  217. package/src/project/clip-pins.ts +72 -0
  218. package/src/project/init.ts +23 -8
  219. package/src/project/sync.ts +418 -86
  220. package/src/render.ts +20 -1
  221. package/src/session/client.ts +107 -3
  222. package/src/session/clipboard.ts +79 -0
  223. package/src/session/daemon.ts +199 -5
  224. package/src/session/live-host.ts +232 -0
  225. package/src/session/meta.ts +14 -0
  226. package/src/session/origin.ts +174 -0
  227. package/src/session/port.ts +109 -14
  228. package/src/session/presence.ts +34 -4
  229. package/src/session/protocol.ts +365 -7
  230. package/src/session/rebase.ts +63 -12
  231. package/src/session/receipt.ts +265 -0
  232. package/src/session/shared-live.ts +131 -0
  233. package/src/session/store.ts +126 -45
  234. package/src/tui/arrange-menu.ts +225 -23
  235. package/src/tui/audition.ts +1 -1
  236. package/src/tui/euclid.ts +113 -27
  237. package/src/tui/fader.ts +282 -42
  238. package/src/tui/granular-menu.ts +2 -4
  239. package/src/tui/knob-fields.ts +107 -0
  240. package/src/tui/knob-map.ts +198 -0
  241. package/src/tui/menu-clips.ts +297 -0
  242. package/src/tui/menu-time.ts +20 -13
  243. package/src/tui/menu-voice.ts +405 -0
  244. package/src/tui/menu.ts +987 -179
  245. package/src/tui/modal-menu.ts +6 -6
  246. package/src/tui/performance-menu.ts +5 -2
  247. package/src/tui/play-chords.ts +4 -2
  248. package/src/tui/play-mode.ts +15 -1
  249. package/src/tui/play-session.ts +243 -28
  250. package/src/tui/sing-menu.ts +278 -0
  251. package/src/tui/style-menu.ts +104 -0
  252. package/src/tui/tape-mode.ts +580 -0
  253. package/src/tui/tape-view.ts +263 -0
  254. package/src/tui/vocoder-menu.ts +244 -0
  255. package/src/tui/wind-menu.ts +3 -3
  256. package/src/version.ts +8 -0
  257. package/src/web/fetch.ts +115 -29
  258. package/tui/activity.ts +180 -9
  259. package/tui/app.ts +491 -84
  260. package/tui/clip-row.ts +132 -0
  261. package/tui/delight.ts +193 -0
  262. package/tui/drawer.ts +233 -27
  263. package/tui/frame-gate.ts +76 -0
  264. package/tui/grammar.ts +143 -65
  265. package/tui/guide.ts +50 -5
  266. package/tui/highway.ts +309 -30
  267. package/tui/hints.ts +197 -0
  268. package/tui/hits.ts +7 -1
  269. package/tui/input.ts +60 -9
  270. package/tui/keys.ts +1 -1
  271. package/tui/knobs.ts +268 -0
  272. package/tui/play-strip.ts +80 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +151 -18
  275. package/tui/tape.ts +439 -0
  276. package/tui/text.ts +35 -2
  277. package/tui/theme.ts +46 -1
package/src/main.ts CHANGED
@@ -1,11 +1,46 @@
1
1
  #!/usr/bin/env bun
2
+ import { TOPIC_ALIASES } from "./lang/glossary.ts";
3
+ import { isGuideInstrument, vocalChainPatch } from "../core/clips.ts";
4
+ import { newId } from "../core/ids.ts";
5
+ import { deepEqual, DiffError, diffScores } from "../core/diff.ts";
6
+ import { currentActor } from "./identity/actor.ts";
7
+ import { isSingWord } from "../core/sing.ts";
8
+ import { commandParses, parseExact } from "./commands/parses.ts";
9
+ import { paramRangeError } from "./commands/param-range.ts";
10
+ import { noteName as midiNoteName } from "./media/notes.ts";
2
11
  import {
12
+ EVERYDAY_VERBS,
13
+ EXPORT_USAGE,
14
+ elsewhereHint,
15
+ FREE_TEXT_HINTS,
16
+ noTrack,
17
+ stemsReceipt,
18
+ LOOP_USAGE,
19
+ WINDOW_VERBS,
20
+ NO_AGENT,
21
+ RANGES,
22
+ canonicalWindowForm,
23
+ friendlyCoreError,
24
+ knownVerbs,
25
+ nearest,
26
+ noNote,
27
+ parseExportCommand,
28
+ parseLoopCommand,
29
+ recover,
30
+ usageCard,
31
+ usageError,
32
+ verbOf,
33
+ } from "./commands/grammar.ts";
34
+ import {
35
+ instrumentListLines,
3
36
  isUnknownInstrument,
4
37
  plainSineAdvice,
38
+ unknownInstrumentMessage,
5
39
  } from "./audio/instrument-check.ts";
6
40
  import { randomUUID } from "node:crypto";
7
- import { readFileSync, writeSync } from "node:fs";
8
- import { readFile, stat, writeFile } from "node:fs/promises";
41
+ import { appendFileSync, readFileSync, writeSync } from "node:fs";
42
+ import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
43
+ import { tmpdir } from "node:os";
9
44
  import { join, relative, resolve } from "node:path";
10
45
  import { stdin, stdout } from "node:process";
11
46
  import {
@@ -14,7 +49,19 @@ import {
14
49
  type SessionRecord,
15
50
  } from "./session/store.ts";
16
51
  import { openSessionPort } from "./session/port.ts";
17
- import { compositionDigest, monotonicEpochMs } from "./session/protocol.ts";
52
+ import {
53
+ OwnWrites,
54
+ editedTrack,
55
+ foreignEvents,
56
+ otherWindowName,
57
+ paneLetters,
58
+ } from "./session/origin.ts";
59
+ import {
60
+ compositionDigest,
61
+ monotonicEpochMs,
62
+ type PaneView,
63
+ type PresenceEntry,
64
+ } from "./session/protocol.ts";
18
65
  import {
19
66
  formatSessionLine,
20
67
  listSessions,
@@ -49,6 +96,7 @@ import { kitCatalog } from "./audio/kits.ts";
49
96
  import { applyEditCommand, parseEditCommand } from "./commands/edit.ts";
50
97
  import { applyTimeCommand, parseTimeCommand } from "./commands/time.ts";
51
98
  import {
99
+ barAt,
52
100
  barStartTick,
53
101
  bpmAtTick,
54
102
  hasMeterChanges,
@@ -69,6 +117,10 @@ import {
69
117
  rigTrackFields,
70
118
  rigWordPatch,
71
119
  } from "./commands/rig.ts";
120
+ import {
121
+ applyProgressionCommand,
122
+ parseProgressionCommand,
123
+ } from "./commands/progression.ts";
72
124
  import {
73
125
  applyGuitarCommand,
74
126
  applyStrumCommand,
@@ -85,6 +137,11 @@ import {
85
137
  parseWindCommand,
86
138
  windListLines,
87
139
  } from "./commands/wind.ts";
140
+ import {
141
+ applySingCommand,
142
+ parseSingCommand,
143
+ singListLines,
144
+ } from "./commands/sing.ts";
88
145
  import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
89
146
  import { applyStringCommand, parseStringCommand } from "./commands/string.ts";
90
147
  import { instrumentPatchForWord, isModalWord } from "../core/resonators.ts";
@@ -98,6 +155,7 @@ import {
98
155
  applyKeysCommand,
99
156
  newPianoTrack,
100
157
  parseKeysCommand,
158
+ parseRecordCommand,
101
159
  } from "./commands/keys.ts";
102
160
  import {
103
161
  applyTuningCommand,
@@ -105,26 +163,45 @@ import {
105
163
  parseTuningCommand,
106
164
  type TuningCommand,
107
165
  } from "./commands/tuning.ts";
166
+ import {
167
+ applyCalibrationCommand,
168
+ parseCalibrationCommand,
169
+ } from "./commands/calibration.ts";
108
170
  import { TuningError, displayTag, resolveTuning } from "../core/tuning.ts";
109
171
  import {
110
172
  applyMasterCommand,
111
173
  measurementLine,
112
174
  parseMasterCommand,
113
175
  } from "./commands/master.ts";
176
+ import { applyStyleCommand, parseStyleCommand } from "./commands/style.ts";
114
177
  import { exportSampleRate, measureScoreOffThread } from "./audio/measure.ts";
115
178
  import {
116
179
  applySectionCommand,
180
+ loopSpan,
117
181
  parseSectionCommand,
118
182
  } from "./commands/arrange.ts";
119
183
  import {
120
- HELP_TOPICS,
184
+ applyRangeCommand,
185
+ parseRangeCommand,
186
+ type RangeClipboard,
187
+ } from "./commands/range.ts";
188
+ import { rangeLabel } from "../core/range.ts";
189
+ import {
190
+ helpMiss,
121
191
  helpText,
192
+ helpTitle,
122
193
  helpTopicLines,
123
194
  nearestCommand,
124
195
  typoFix,
125
196
  usageHint,
126
197
  } from "./commands/help.ts";
127
- import { historyTarget, REDO_KIND, UNDO_KIND } from "./commands/history.ts";
198
+ import {
199
+ historyReceipt,
200
+ historyTarget,
201
+ paneHistoryStep,
202
+ REDO_KIND,
203
+ UNDO_KIND,
204
+ } from "./commands/history.ts";
128
205
 
129
206
  import {
130
207
  SamplePlacementError,
@@ -139,6 +216,30 @@ import {
139
216
  } from "./commands/sample.ts";
140
217
  import { applyFitCommand, fitVoice, parseFitCommand } from "./commands/fit.ts";
141
218
  import { applyShiftCommand, parseShiftCommand } from "./commands/shift.ts";
219
+ import { parseVocalCommand, runVocalCommand } from "./commands/vocal.ts";
220
+ import {
221
+ applyFormantCommand,
222
+ parseFormantCommand,
223
+ parseVowelCommand,
224
+ } from "./commands/formant.ts";
225
+ import {
226
+ applyLyrics,
227
+ parseClipCommand,
228
+ parseLyricsCommand,
229
+ runClipCommand,
230
+ setClipImportDeps,
231
+ } from "./commands/clips.ts";
232
+ import { pitchTraceFor } from "./commands/vocal-pitch.ts";
233
+ import {
234
+ applyVocoderCommand,
235
+ parseVocoderCommand,
236
+ vocoderListLines,
237
+ } from "./commands/vocoder.ts";
238
+ import {
239
+ applyAutotuneCommand,
240
+ autotuneListLines,
241
+ parseAutotuneCommand,
242
+ } from "./commands/autotune.ts";
142
243
  import { parseResampleCommand, runResample } from "./commands/resample.ts";
143
244
  import { suggestFitMode } from "./audio/dsp/onset.ts";
144
245
  import {
@@ -148,7 +249,14 @@ import {
148
249
  type SampleBank,
149
250
  type SampleProblem,
150
251
  } from "./audio/samples.ts";
252
+ import {
253
+ ANALYSIS_CACHE_BYTES,
254
+ analysisCacheStatus,
255
+ analysisDir,
256
+ pruneAnalysisCache,
257
+ } from "./audio/analysis.ts";
151
258
  import { drumSnapshotFields, samplerSnapshotFields } from "../tui/drums.ts";
259
+ import { clipSnapshots, loadClipPeaks } from "../tui/clip-row.ts";
152
260
  import { highwayLayers } from "../tui/layers.ts";
153
261
  import { drumVoicePitch, isDrumInstrument } from "../core/drums.ts";
154
262
  import {
@@ -157,6 +265,7 @@ import {
157
265
  type AgentEvent,
158
266
  type AgentHost,
159
267
  } from "./agent/agent.ts";
268
+ import { routeDuringTurn } from "./agent/steer.ts";
160
269
  import {
161
270
  isApiSelection,
162
271
  providerFingerprint,
@@ -175,8 +284,24 @@ import {
175
284
  spendLine as formatSpendLine,
176
285
  webHostFor,
177
286
  } from "./agent/usage.ts";
178
- import { configDir } from "./auth/credentials.ts";
179
- import { runAuthCommand, runFirstRunLogin, runTuiLogin } from "./auth/cli.ts";
287
+ import { configDir, readConfig, writeConfig } from "./auth/credentials.ts";
288
+ import type { CommandOutcome } from "./agent/command-agent.ts";
289
+ import {
290
+ GLIDE_STEP_MS,
291
+ NoteScheduler,
292
+ finishHint,
293
+ gestureFor,
294
+ glideValues,
295
+ agentPathsAllowed,
296
+ isAgentCommand,
297
+ isBrokenCommand,
298
+ brokenCommandReceipt,
299
+ parseShowMe,
300
+ toolCaption,
301
+ type ShowMeLevel,
302
+ } from "./agent/show-me.ts";
303
+ import { firstRunCard, runAuthCommand, runTuiLogin } from "./auth/cli.ts";
304
+ import { musicalReceipt } from "./session/receipt.ts";
180
305
  import {
181
306
  modelPickerItems,
182
307
  tuiAuthCommand,
@@ -184,7 +309,12 @@ import {
184
309
  tuiSetModel,
185
310
  } from "./auth/tui.ts";
186
311
  import { TransportClock, transportMapFor } from "./audio/clock.ts";
187
- import { AudioEngine } from "./audio/engine.ts";
312
+ import {
313
+ AudioEngine,
314
+ detectAudioBackend,
315
+ type AudioBackendInfo,
316
+ } from "./audio/engine.ts";
317
+ import { audioMenuState, runAudioCommand } from "./audio/audio-command.ts";
188
318
  import {
189
319
  DEFAULT_GRID,
190
320
  GRIDS,
@@ -192,10 +322,39 @@ import {
192
322
  PlaySession,
193
323
  type LiveEngine,
194
324
  } from "./tui/play-session.ts";
195
- import { EditMenu, MENU_SECTIONS, type MenuContext } from "./tui/menu.ts";
196
325
  import {
326
+ EditMenu,
327
+ menuUsage,
328
+ menuSectionPath,
329
+ type MenuAudioDevices,
330
+ type MenuContext,
331
+ } from "./tui/menu.ts";
332
+ import { KNOB_PAGE_WORDS, knobPageId } from "./tui/knob-map.ts";
333
+ import {
334
+ beatOfBar,
335
+ cutArmed,
336
+ focusCommand,
337
+ foldBase,
338
+ knobNoun,
339
+ tapeKey,
340
+ tapeKnobs,
341
+ type TapeContext,
342
+ } from "./tui/tape-mode.ts";
343
+ import { tapeView } from "./tui/tape-view.ts";
344
+ import { TAPE_ZOOMS, type TapeView, type TapeZoom } from "../tui/tape.ts";
345
+ import type { HitTarget } from "../tui/hits.ts";
346
+ import { knobKey, type KnobState } from "../tui/knobs.ts";
347
+ import {
348
+ clipboardPath,
349
+ loadClipboard,
350
+ saveClipboard,
351
+ } from "./session/clipboard.ts";
352
+ import { extractRange } from "../core/range.ts";
353
+ import {
354
+ drawerAside,
197
355
  drawerView,
198
356
  faderChoose,
357
+ faderCommand,
199
358
  faderKeyPress,
200
359
  faderSetPosition,
201
360
  faderStep,
@@ -217,9 +376,23 @@ import {
217
376
  isStageable,
218
377
  } from "./tui/audition.ts";
219
378
  import { EuclidEditor, type EuclidContext } from "./tui/euclid.ts";
220
- import { HINTS, KEYS, keyLines, type KeySection } from "../tui/grammar.ts";
379
+ import {
380
+ ARROW_DOWN,
381
+ ARROW_UP,
382
+ closesKeys,
383
+ HINTS,
384
+ KEYS,
385
+ keyLines,
386
+ type KeySection,
387
+ } from "../tui/grammar.ts";
221
388
  import { renderAudition } from "./audio/audition.ts";
222
- import { exportScore, playbackTime, scoreBeatAt } from "./audio/arrange.ts";
389
+ import {
390
+ exportScore,
391
+ loopedSection,
392
+ playbackTime,
393
+ scoreBeatAt,
394
+ } from "./audio/arrange.ts";
395
+ import { loopFlash, reelGlyph } from "../tui/delight.ts";
223
396
  import { formatForm } from "../core/sections.ts";
224
397
  import type { ArrangeStripView } from "../tui/arrange-strip.ts";
225
398
  import { renderScorePcm } from "./audio/wav.ts";
@@ -260,11 +433,13 @@ import {
260
433
  packCredits,
261
434
  writeCredits,
262
435
  } from "./audio/packs.ts";
436
+ import { SharedLiveEngine, type LiveLink } from "./session/shared-live.ts";
263
437
  import {
264
438
  addNote,
265
439
  applyScoreOperation,
266
440
  createScore,
267
441
  isSamplerInstrument,
442
+ isTrackAudible,
268
443
  PACK_PREFIX,
269
444
  scoreFromJSON,
270
445
  SCORE_LIMITS,
@@ -277,22 +452,44 @@ import { decodeLoop, encodeLoop } from "../core/loop.ts";
277
452
  import { scoreToMidi } from "../core/midi.ts";
278
453
  import type { TrackScoreSnapshot } from "../tui/render.ts";
279
454
  import { PromptModel } from "../tui/prompt.ts";
280
- import { TerminalInputDecoder } from "../tui/input.ts";
281
455
  import {
456
+ ESCAPE_FLUSH_MS,
457
+ INPUT_FLUSH,
458
+ PASTE_FLUSH_MS,
459
+ TerminalInputDecoder,
460
+ } from "../tui/input.ts";
461
+ import {
462
+ FrameGate,
463
+ IDLE_HEARTBEAT_MS,
464
+ MIN_FRAME_GAP_MS,
465
+ } from "../tui/frame-gate.ts";
466
+ import {
467
+ CARD_GLOW_MS,
282
468
  composeFrame,
469
+ isTooSmall,
470
+ tooSmallKey,
283
471
  TuiApp,
284
472
  type AppView,
285
473
  type LoudnessView,
286
474
  type SyncState,
287
475
  type TypesIndicator,
288
476
  } from "../tui/app.ts";
289
- import { fail, ok, toneOf, warn, type Receipt } from "../tui/activity.ts";
477
+ import type { DrawerView } from "../tui/drawer.ts";
478
+ import { fail, note, ok, toneOf, warn, type Receipt } from "../tui/activity.ts";
290
479
  import { systemRunner } from "./auth/runner.ts";
291
480
  import type { MediaServices } from "./media/types.ts";
292
481
  import { encodeBuffer } from "../tui/screen.ts";
293
482
  import { parseThemeName } from "../tui/theme.ts";
294
483
  import { formatDiagnostic } from "../core/sdk/eval.ts";
295
484
  import { isProject } from "./project/init.ts";
485
+ import {
486
+ parseLaunchArgs,
487
+ parsePaneArgs,
488
+ type PaneArgs,
489
+ parseSimpleArgv,
490
+ resolveTrackArg,
491
+ } from "./launch-args.ts";
492
+ import { RENDER_USAGE, runRenderCommand } from "./render.ts";
296
493
  import { typecheckProject } from "./project/typecheck.ts";
297
494
  import {
298
495
  startProjectSync,
@@ -300,112 +497,100 @@ import {
300
497
  type SyncHost,
301
498
  } from "./project/sync.ts";
302
499
 
500
+ /** Set while `submit` runs a grammar reading, so it reads only once. */
501
+ let recovering = false;
502
+
503
+ /** Slash words `submit` handles itself (no parser in commandParses). */
504
+ const SLASH_HANDLED: ReadonlySet<string> = new Set([
505
+ ...WINDOW_VERBS,
506
+ "chords",
507
+ "click",
508
+ "help",
509
+ ]);
510
+
303
511
  /** Whether a bare command parses (no side effects): for typo suggestions. */
304
512
  function parsesLocally(text: string): boolean {
305
- return [
306
- parsePrompt,
307
- parseMusicCommand,
308
- parseEditCommand,
309
- parseRhythmCommand,
310
- parseFxCommand,
311
- parseSynthCommand,
312
- parseStringCommand,
313
- parseGranularCommand,
314
- parseKeysCommand,
315
- parseExpressionCommand,
316
- parseMasterCommand,
317
- (value: string) => parseSectionCommand(value, score),
318
- parsePatternCommand,
319
- parseKitCommand,
320
- parsePackCommand,
321
- parseSampleCommand,
322
- parseFitCommand,
323
- parseShiftCommand,
324
- parseResampleCommand,
325
- parseWavetableCommand,
326
- parseTimeCommand,
327
- parseTuningCommand,
328
- parseRigCommand,
329
- parseModalCommand,
330
- parseGuitarCommand,
331
- parseStrumCommand,
332
- parseWindCommand,
333
- ].some((parse) => parse(text) !== undefined);
513
+ return commandParses(text, score);
334
514
  }
335
515
 
336
516
  const ESC = "\u001b[";
337
517
  /** `/sessions` rows shown in the overlay. */
338
518
  const MAX_LISTED_SESSIONS = 64;
339
519
 
340
- const SUBCOMMANDS = [
341
- "login",
342
- "logout",
343
- "auth",
344
- "sessions",
345
- "render",
346
- "init",
347
- "check",
348
- ];
349
- const VALUE_FLAGS = ["--session", "--track", "--import", "--export", "--theme"];
350
- const FLAGS = [
351
- ...VALUE_FLAGS,
352
- "--new",
353
- "--demo",
354
- "--help",
355
- "-h",
356
- "--version",
357
- "-v",
358
- "--reduce-motion",
359
- "--no-mouse",
360
- ];
520
+ /** Wraps a usage line at spaces before `width`, indenting continuations. */
521
+ function wrapUsage(text: string, width: number, indent: string): string {
522
+ const lines: string[] = [];
523
+ let line = "";
524
+ for (const word of text.split(" ")) {
525
+ const prefix = lines.length === 0 ? " " : indent;
526
+ if (line && prefix.length + line.length + 1 + word.length > width) {
527
+ lines.push(line);
528
+ line = word;
529
+ } else line = line ? `${line} ${word}` : word;
530
+ }
531
+ lines.push(line);
532
+ return lines.join(`\n${indent}`);
533
+ }
361
534
  const HELP_TEXT = `dawg · local-first terminal music workstation
362
535
 
363
536
  Usage:
364
537
  dawg [--new] [--session <name|id>] [--track <name>]
365
- dawg --import <file> --export <file>
538
+ dawg --import <file> --export <file> convert a loop file (no session)
366
539
  dawg sessions
367
- dawg render <out.wav|out.mid> [--session <name|id>] [--import <file>]
368
- [--normalize <lufs|streaming|club|loud|…>] [--measure]
369
- dawg init [dir] project files: song.ts, tracks/<slug>/track.ts, .dawg/sdk
540
+ ${wrapUsage(RENDER_USAGE.replace(/^usage: /, ""), 76, " ")}
541
+ dawg init [dir] song.ts, tracks/<slug>/track.ts and .dawg/sdk
370
542
  dawg check typecheck + evaluate the project; exit 1 on problems
371
- dawg media doctor|download|stems|analyze|notes|sample|lyrics … (dawg media --help)
543
+ dawg <command> --help usage: sessions render init check media model
544
+ dawg doctor audio backend, native sink and devices
545
+ dawg media doctor|download|stems|analyze|notes|sample|lyrics (--help)
372
546
  dawg --version
373
547
 
374
- Usage flags:
548
+ Display options:
375
549
  --reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
376
550
  --theme <name> default | high-contrast | mono (NO_COLOR forces mono)
377
551
  --no-mouse keys only; no click/wheel reporting (also DAWG_MOUSE=0)
378
552
 
379
553
  Prompt:
380
- Enter submit · Shift-Enter newline · Alt-Enter queue · Ctrl-Q toggle queue
381
- Ctrl-Z undo · Ctrl-Y redo · Ctrl-O transcript · Esc cancel/close · Ctrl-C exit
554
+ Enter submit · Shift-Enter newline · Alt-Enter next · Ctrl-Q now / next
555
+ Ctrl-Z undo · Ctrl-Y redo · Ctrl-O transcript · Esc close · Ctrl-C exit
382
556
  Space on an empty prompt toggles playback
383
557
 
384
558
  Commands (bare music words; app commands take a slash):
385
559
  ${helpText()}
386
560
 
387
- Sign in (the choice is saved and reused until you log out):
388
- dawg login find existing setups and pick a provider
389
- dawg login gateway|openrouter|codex|claude
390
- dawg model [alias] pick a model, with the estimated cost per prompt
561
+ Agent (optional; the choice is saved and reused until you log out):
562
+ dawg model key find existing setups and pick a provider
563
+ dawg model key gateway|openrouter|codex|claude
564
+ dawg model [alias] pick a model, with the cost per prompt
391
565
  dawg logout [provider] · dawg auth status [--check]
392
566
  Unrecognized requests go to the agent once a provider is configured
393
567
  (DAWG_PROVIDER=gateway|openrouter|codex|claude|auto, DAWG_MODEL=<alias>;
394
568
  DAWG_AI=0 disables the agent).`;
395
569
  const args = new Set(process.argv.slice(2));
396
- const requestedSession = optionValue("--session");
397
- const explicitTrack = optionValue("--track");
570
+ /** One parse for every launch flag; problems are reported below. */
571
+ const launch = parseLaunchArgs(process.argv.slice(2));
572
+ const launchArgs = launch.ok ? launch.args : undefined;
573
+ const requestedSession = launchArgs?.session;
574
+ /** `--track` normalized as `/track` does; matched to an existing track below. */
575
+ let explicitTrack = launchArgs?.track?.id;
398
576
  /** The focused track; claimed at startup unless `--track` is given. */
399
577
  let requestedTrack = explicitTrack ?? "main";
400
578
  const initialInstrument = isDrumInstrument(requestedTrack) ? "kit" : "sine";
401
- const importPath = optionValue("--import");
402
- const exportPath = optionValue("--export");
579
+ const importPath = launchArgs?.importPath;
580
+ const exportPath = launchArgs?.exportPath;
581
+ // `dawg model key [provider]` is the canonical spelling of `dawg login`.
582
+ if (process.argv[2] === "model" && process.argv[3] === "key")
583
+ process.exit(await runAuthCommand(["login", ...process.argv.slice(4)]));
403
584
  if (["login", "logout", "auth", "model"].includes(process.argv[2] ?? ""))
404
585
  process.exit(await runAuthCommand(process.argv.slice(2)));
405
586
  {
406
587
  // An unknown DAWG_MODEL is an error, never a silent fallback.
407
588
  const fromEnv = process.env.DAWG_MODEL?.trim();
408
- if (fromEnv && !resolveModelChoice("gateway", fromEnv)) {
589
+ if (
590
+ fromEnv &&
591
+ !resolveModelChoice("gateway", fromEnv) &&
592
+ !resolveModelChoice("openrouter", fromEnv)
593
+ ) {
409
594
  process.stderr.write(
410
595
  `dawg: unknown DAWG_MODEL "${fromEnv.slice(0, 40)}"; use one of ${MODEL_CATALOG.map((row) => row.alias).join(", ")} or a vendor/model ID\n`,
411
596
  );
@@ -413,10 +598,14 @@ if (["login", "logout", "auth", "model"].includes(process.argv[2] ?? ""))
413
598
  }
414
599
  }
415
600
  if (process.argv[2] === "sessions") {
416
- if (args.has("--help") || args.has("-h"))
417
- stdout.write(
418
- "usage: dawg sessions · lists this workspace's sessions, newest first (* marks the current one)\n",
419
- );
601
+ const usage =
602
+ "usage: dawg sessions · lists this workspace's sessions, newest first (* marks the current one)";
603
+ const parsed = parseSimpleArgv(process.argv.slice(3), 0);
604
+ if (parsed.kind === "error") {
605
+ process.stderr.write(`${parsed.problem} · ${usage}\n`);
606
+ process.exit(2);
607
+ }
608
+ if (parsed.kind === "help") stdout.write(`${usage}\n`);
420
609
  else await printSessions(process.cwd(), stdout);
421
610
  process.exit(0);
422
611
  }
@@ -429,6 +618,10 @@ if (process.argv[2] === "init" || process.argv[2] === "check") {
429
618
  await command(process.argv.slice(2), process.cwd(), stdout, process.stderr),
430
619
  );
431
620
  }
621
+ if (process.argv[2] === "doctor") {
622
+ const { runDoctorCommand } = await import("./audio/doctor.ts");
623
+ process.exit(await runDoctorCommand(process.argv.slice(3), stdout));
624
+ }
432
625
  if (process.argv[2] === "media") {
433
626
  const { runMediaCommand } = await import("./media/cli.ts");
434
627
  process.exit(
@@ -461,18 +654,37 @@ if (args.has("--help") || args.has("-h")) {
461
654
  }
462
655
  // Argument mistakes are rejected here, before `.dawg/` could be created.
463
656
  {
464
- const problem = argumentProblem(process.argv.slice(2));
465
- if (problem) {
466
- process.stderr.write(`${problem} · dawg --help\n`);
657
+ if (!launch.ok) {
658
+ process.stderr.write(`${launch.problem} · dawg --help\n`);
467
659
  process.exit(2);
468
660
  }
469
661
  }
662
+ // `--import X --export Y` converts one loop file to another: no session.
663
+ if (importPath && exportPath) {
664
+ try {
665
+ const converted = decodeLoop(await readLoopFile(importPath));
666
+ await writeFile(resolve(exportPath), encodeLoop(converted), "utf8");
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
+ stdout.write(`converted ${importPath} → ${exportPath}\n`);
678
+ process.exit(0);
679
+ }
470
680
  const demo =
471
681
  args.has("--demo") || process.env.DAWG_DEMO === "1" || !stdin.isTTY;
472
- // First run with no provider (or a saved one that stopped working): the
473
- // sign-in picker, in the shell, before the TUI takes the screen.
474
- if (!demo && process.env.DAWG_AI !== "0" && stdout.isTTY)
475
- await runFirstRunLogin();
682
+ // Music first: the TUI opens directly. The first session with no provider
683
+ // (or a saved one that stopped working) gets one card instead of a picker.
684
+ const launchCard =
685
+ !demo && process.env.DAWG_AI !== "0" && stdout.isTTY
686
+ ? firstRunCard().catch(() => undefined)
687
+ : undefined;
476
688
 
477
689
  const initial = createScore({
478
690
  tracks: [
@@ -521,9 +733,21 @@ if (importPath) {
521
733
  process.exit(1);
522
734
  }
523
735
  }
524
- const session = await ensureSession(initial.toJSON(), sessionOptions);
736
+ // A demo frame only renders: with no workspace yet (and nothing imported),
737
+ // it runs on a throwaway session so the cwd gets no `.dawg/`.
738
+ const ephemeralWorkspace =
739
+ demo && freshWorkspace && !importPath && selectedSession === undefined
740
+ ? await mkdtemp(join(tmpdir(), "dawg-demo-"))
741
+ : undefined;
742
+ const session = await ensureSession(initial.toJSON(), {
743
+ ...sessionOptions,
744
+ ...(ephemeralWorkspace ? { workspace: ephemeralWorkspace } : {}),
745
+ });
746
+ // Who is at this window; also salts collision-free ids (core/ids.ts).
747
+ const actor = await currentActor();
525
748
  // dawgd when connected, the file-lock path otherwise (see src/session/port.ts).
526
749
  let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
750
+ actor,
527
751
  paths: session.paths,
528
752
  sessionId: session.record.sessionId,
529
753
  label: explicitTrack ?? "window",
@@ -533,6 +757,12 @@ let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
533
757
  let record: SessionRecord<ReturnType<TrackScore["toJSON"]>> =
534
758
  port.mode === "daemon" ? await port.load() : session.record;
535
759
  let score = scoreFromJSON(record.composition);
760
+ if (importPath && importedScore) score = importedScore;
761
+ // `--track Bass` focuses an existing `bass` (or a track named "Bass").
762
+ if (launchArgs?.track) {
763
+ explicitTrack = resolveTrackArg(score.tracks, launchArgs.track);
764
+ requestedTrack = explicitTrack;
765
+ }
536
766
  if (importPath && importedScore) {
537
767
  score = importedScore;
538
768
  record = await port.append(
@@ -562,6 +792,8 @@ if (!draftTrack) await ensureFocusedTrack();
562
792
 
563
793
  const clock = new TransportClock(score.tempoBpm);
564
794
  let audio = port.player;
795
+ let audioInfoCache: AudioBackendInfo | undefined;
796
+ let audioMenuCache: { at: number; state: MenuAudioDevices } | undefined;
565
797
  /** Session and today's spend, from the usage each response reports. */
566
798
  const meter = new SpendMeter(configDir());
567
799
  void meter.load().catch(() => undefined);
@@ -573,12 +805,22 @@ const tui = new TuiApp({
573
805
  rows: () => stdout.rows ?? 24,
574
806
  },
575
807
  prompt,
576
- theme: parseThemeName(optionValue("--theme") ?? process.env.DAWG_THEME),
808
+ theme: launchArgs?.theme ?? parseThemeName(process.env.DAWG_THEME),
577
809
  reducedMotion:
578
810
  args.has("--reduce-motion") || process.env.DAWG_REDUCE_MOTION === "1",
579
811
  });
580
812
  let syncState: SyncState = port.sync;
581
813
  let windowCount = 1;
814
+ /** Pin and follow (§12.5): set by `dawg pane …`, `pin` and `follow`. */
815
+ const paneOptions: { pinned: boolean; follow: string | undefined } = {
816
+ pinned: false,
817
+ follow: undefined,
818
+ };
819
+ /** Every pane on this session, as the last presence push listed them. */
820
+ let panePresence: readonly PresenceEntry[] = [];
821
+ /** The last view this pane reported (JSON), so unchanged views stay quiet. */
822
+ let reportedView = "";
823
+ let sharedLive: { link: LiveLink; engine: SharedLiveEngine } | undefined;
582
824
  /** Project file sync when `dawg.json` is in the working directory. */
583
825
  let projectSync: ProjectSync | undefined;
584
826
  let packStore: PackStore | undefined;
@@ -623,15 +865,65 @@ const AGENT_PREVIEW_VOICE = 0x7fff_0002;
623
865
  const CHORDS_COMMAND = /^\/chords\s+(.+)$/i;
624
866
  /** Whether the chord settings screen was open at the last menu redraw. */
625
867
  let chordScreenWas = false;
868
+ /** The range clipboard (`copy bass 5-6`, then `paste at 9`); per window. */
869
+ let rangeClipboard: RangeClipboard | undefined;
870
+ /**
871
+ * TAPE (op1-ux §6): on, its zoom, its knob strip, the last view painted
872
+ * (mouse hits map cells to bars through it), a ruler drag in progress, and
873
+ * the pending cut (the score before and after its clear) so the first
874
+ * paste after it runs `move` against the score the cut came from.
875
+ */
876
+ const tape: {
877
+ on: boolean;
878
+ zoom: TapeZoom;
879
+ knobs: KnobState;
880
+ view?: TapeView | undefined;
881
+ drag?: { from: number; to: number; left: number; first: number } | undefined;
882
+ cut?: { before: TrackScore; after?: TrackScore | undefined } | undefined;
883
+ /** Set by `x`: the score before it, until its `clear` lands. */
884
+ cutArmed?: TrackScore | undefined;
885
+ /** Set by the first `v` after a cut: its `move` reads `cut.before`. */
886
+ foldArmed?: boolean | undefined;
887
+ /**
888
+ * Where a paste's queued `jump` will put the playhead (a score beat),
889
+ * so a quick `v v v` tiles before that jump has run.
890
+ */
891
+ pending?: { beat: number; until: number } | undefined;
892
+ } = { on: false, zoom: "beat", knobs: { selected: 0 } };
626
893
  let stageCapture: { next?: TrackScore; committed?: TrackScore } | undefined;
627
894
  /** Redraw soon (the audition reports renders between frames). */
628
895
  let requestFrame: () => void = () => undefined;
629
896
  /** Queue a prompt to run as if typed (set by the interactive loop). */
630
897
  let runPromptLater: (command: string) => void = () => undefined;
898
+ /** Queue several prompts, in order, ahead of anything already queued. */
899
+ let runPromptsLater: (commands: readonly string[]) => void = () => undefined;
631
900
  /** The fader drawer's focus and typing, while one is open over the menu. */
632
901
  let fader: FaderState | undefined;
633
- const KEY_UP = "\u001b[A";
634
- const KEY_DOWN = "\u001b[B";
902
+ /** Show-me state (see the show-me section below). */
903
+ /** A TAPE gesture's typed command, echoed dimly in the prompt row. */
904
+ const gestureEcho: {
905
+ text?: string | undefined;
906
+ clear?: ReturnType<typeof setTimeout> | undefined;
907
+ } = {};
908
+
909
+ const showMe: {
910
+ level: ShowMeLevel;
911
+ ghost?: string | undefined;
912
+ caption?: string | undefined;
913
+ commands: string[];
914
+ notes: NoteScheduler;
915
+ clear?: ReturnType<typeof setTimeout> | undefined;
916
+ } = {
917
+ level: parseShowMe(process.env.DAWG_SHOWME ?? "") ?? "on",
918
+ commands: [],
919
+ notes: new NoteScheduler(() => score.tempoBpm),
920
+ };
921
+ if (!process.env.DAWG_SHOWME)
922
+ void readConfig({ dir: configDir() })
923
+ .then((config) => {
924
+ if (config.showMe) showMe.level = config.showMe;
925
+ })
926
+ .catch(() => undefined);
635
927
  /** The fader bar a left-button drag started on. */
636
928
  let dragging: { field: number; left: number; width: number } | undefined;
637
929
  /** The open drawer came from a command, not the menu. */
@@ -664,7 +956,8 @@ let handoff: <T>(flow: () => Promise<T>) => Promise<T> = (flow) => flow();
664
956
  function spendLine(): string {
665
957
  const width = stdout.columns ?? 80;
666
958
  if (!providerName || providerName === "offline")
667
- return width >= 40 ? "no model · dawg login" : "";
959
+ // A state, not a nag: the first session's card already named /login.
960
+ return width >= 40 ? formatSpendLine({ kind: "offline" }) : "";
668
961
  const [model, provider] = providerName.split(" · ");
669
962
  return formatSpendLine(
670
963
  {
@@ -689,6 +982,10 @@ if (demo) {
689
982
  "add C4 at 0 for 1",
690
983
  0,
691
984
  );
985
+ if (ephemeralWorkspace) {
986
+ await port.close().catch(() => undefined);
987
+ await rm(ephemeralWorkspace, { recursive: true, force: true });
988
+ }
692
989
  process.exit(0);
693
990
  }
694
991
 
@@ -699,36 +996,8 @@ if (exportPath) {
699
996
 
700
997
  await runInteractive();
701
998
 
702
- /** Describes an unknown subcommand or option in `argv`, or undefined. */
703
- function argumentProblem(argv: readonly string[]): string | undefined {
704
- if (argv[0] && !argv[0].startsWith("-") && !SUBCOMMANDS.includes(argv[0]))
705
- return `unknown command · ${argv[0]}`;
706
- if (argv[0] && SUBCOMMANDS.includes(argv[0])) return undefined;
707
- for (let index = 0; index < argv.length; index += 1) {
708
- const arg = argv[index]!;
709
- if (VALUE_FLAGS.includes(arg)) {
710
- index += 1;
711
- continue;
712
- }
713
- if (arg.startsWith("-") && !FLAGS.includes(arg))
714
- return `unknown option · ${arg}`;
715
- if (!arg.startsWith("-")) return `unknown command · ${arg}`;
716
- }
717
- return undefined;
718
- }
719
-
720
999
  async function packageVersion(): Promise<string> {
721
- const raw = await readFile(
722
- new URL("../package.json", import.meta.url),
723
- "utf8",
724
- );
725
- return (JSON.parse(raw) as { version?: string }).version ?? "0.0.0";
726
- }
727
-
728
- function optionValue(name: string): string | undefined {
729
- const index = process.argv.indexOf(name);
730
- const value = index >= 0 ? process.argv[index + 1] : undefined;
731
- return value && !value.startsWith("--") ? value : undefined;
1000
+ return (await import("./version.ts")).VERSION;
732
1001
  }
733
1002
 
734
1003
  function seedDemo(value: TrackScore, trackId: string): TrackScore {
@@ -818,21 +1087,30 @@ function snapshot(
818
1087
  muted: focused?.muted,
819
1088
  ...(tag?.cents !== undefined ? { cents: tag.cents } : {}),
820
1089
  ...(tag?.name !== undefined ? { centsFrom: tag.name } : {}),
1090
+ ...(note.lyric !== undefined ? { lyric: note.lyric } : {}),
821
1091
  };
822
1092
  });
1093
+ // 0.7 clips: the clip row; peaks load off the frame path, then redraw.
1094
+ const clips = clipSnapshots(value, requestedTrack);
1095
+ if (clips)
1096
+ void loadClipPeaks(process.cwd(), focused).then((changed) => {
1097
+ if (changed) requestFrame();
1098
+ });
823
1099
  return {
824
1100
  notes,
1101
+ ...(clips ? { clips } : {}),
825
1102
  trackName:
826
1103
  value.tracks.find((track) => track.id === requestedTrack)?.name ??
827
1104
  requestedTrack,
828
1105
  trackId: requestedTrack,
829
1106
  sessionId: record.sessionId,
830
- revision: record.revision,
1107
+ revision: shownRevision(record),
831
1108
  bpm: value.time?.tempo
832
1109
  ? bpmAtTick(value, beat * value.ticksPerBeat)
833
1110
  : value.tempoBpm,
834
1111
  key: value.key ?? undefined,
835
1112
  loopBeats: loopTicksOf(value) / value.ticksPerBeat,
1113
+ ...(value.loop ? { loopRange: value.loop } : {}),
836
1114
  beatsPerBar: value.beatsPerBar,
837
1115
  ...(hasMeterChanges(value)
838
1116
  ? {
@@ -846,6 +1124,7 @@ function snapshot(
846
1124
  currentBeat: scoreBeatAt(value, beat),
847
1125
  playing: clock.playing,
848
1126
  activity,
1127
+ ...(focused?.instrument === "vocal" ? { vocal: true } : {}),
849
1128
  ...drumSnapshotFields(
850
1129
  value.tracks.find((track) => track.id === requestedTrack)?.instrument,
851
1130
  notes,
@@ -857,6 +1136,7 @@ function snapshot(
857
1136
  ...(table && table.linear && table.size !== 12
858
1137
  ? { tuningPeriod: { size: table.size, root: table.root } }
859
1138
  : {}),
1139
+ pitchTrace: pitchTraceFor(requestedTrack, value),
860
1140
  layers:
861
1141
  tui.highwayView === "all"
862
1142
  ? highwayLayers(
@@ -898,11 +1178,19 @@ function appView(value: TrackScore, beat: number): AppView {
898
1178
  providerName && providerName !== "offline" ? providerName : undefined,
899
1179
  spend: spendLine(),
900
1180
  agentOffline: providerName === "offline" || process.env.DAWG_AI === "0",
1181
+ showMe:
1182
+ showMe.ghost || showMe.caption
1183
+ ? { ghost: showMe.ghost, caption: showMe.caption }
1184
+ : gestureEcho.text
1185
+ ? { caption: gestureEcho.text }
1186
+ : undefined,
901
1187
  sync: syncState,
902
1188
  sessionName: record.meta.name,
903
1189
  windows: windowCount,
1190
+ pane: port.pane,
904
1191
  types: typesIndicator,
905
1192
  play: play?.on ? play.header() : undefined,
1193
+ tape: tape.on && !play?.on ? currentTapeView(value, beat) : undefined,
906
1194
  loudness: masterLoudness(value),
907
1195
  };
908
1196
  }
@@ -1001,17 +1289,20 @@ function receipt(result: string | Receipt, base?: Baseline): void {
1001
1289
  tui.activity.pushError(message);
1002
1290
  return;
1003
1291
  }
1292
+ // A receipt that names its own undo key needs no second hint.
1004
1293
  const hint =
1005
- scoreChanged && undoHintsShown < MAX_UNDO_HINTS
1294
+ scoreChanged &&
1295
+ undoHintsShown < MAX_UNDO_HINTS &&
1296
+ !message.endsWith("ctrl-z undo")
1006
1297
  ? message.startsWith("undid")
1007
- ? "^y redo"
1008
- : "^z undo"
1298
+ ? "ctrl-y redo"
1299
+ : "ctrl-z undo"
1009
1300
  : undefined;
1010
1301
  if (hint) undoHintsShown += 1;
1011
1302
  tui.activity.pushCard(message, {
1012
1303
  tone,
1013
- baseRevision: changed ? base.revision : undefined,
1014
- resultRevision: changed ? record.revision : undefined,
1304
+ baseRevision: changed ? shownRevision(record, base.revision) : undefined,
1305
+ resultRevision: changed ? shownRevision(record) : undefined,
1015
1306
  hint,
1016
1307
  trackId: requestedTrack,
1017
1308
  });
@@ -1023,6 +1314,10 @@ function describeError(command: string, error: unknown): string {
1023
1314
  const message = error instanceof Error ? error.message : String(error);
1024
1315
  if (detail?.code === "ENOENT" && typeof detail.path === "string")
1025
1316
  return `no such file · ${relative(process.cwd(), detail.path)}`;
1317
+ // `tempo 900` → `tempo 900 · tempo takes 20…300 BPM · tempo 128`: no raw
1318
+ // core keys (`tempoBpm`) on a card.
1319
+ const friendly = friendlyCoreError(truncateForCard(command), message);
1320
+ if (friendly) return friendly;
1026
1321
  const verb = command.trim().split(/\s+/)[0]?.replace(/^\//, "") || "command";
1027
1322
  return `${verb} failed · ${message}`;
1028
1323
  }
@@ -1049,25 +1344,108 @@ function mouseOn(): string {
1049
1344
  return mouseEnabled() ? MOUSE_ON : "";
1050
1345
  }
1051
1346
 
1347
+ /**
1348
+ * Mouse off, plain colors, plain paste, cursor shown, main screen. A
1349
+ * function, not a const: `await runInteractive()` runs above this line.
1350
+ */
1351
+ function terminalRestore(): string {
1352
+ return `${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`;
1353
+ }
1354
+
1355
+ /** `dawg: <stack>` for a crash printed after leaving the alternate screen. */
1356
+ function crashText(error: unknown): string {
1357
+ const text =
1358
+ error instanceof Error ? (error.stack ?? error.message) : String(error);
1359
+ return `dawg: ${text}\n`;
1360
+ }
1361
+
1052
1362
  async function runInteractive(): Promise<void> {
1053
- // A crash or a stray process.exit must not leave the terminal reporting
1054
- // mouse events into the shell.
1055
- process.once("exit", () => {
1056
- if (mouseEnabled()) writeSync(1, MOUSE_OFF);
1057
- });
1058
- // Nor may a kill or a closed terminal: restore it, then exit as killed.
1363
+ // Every way out (quit, a failing teardown step, a crash, a kill, a stray
1364
+ // process.exit) restores the whole terminal, once, synchronously: the
1365
+ // shell must not stay on the alternate screen with paste brackets on.
1366
+ let terminalRestored = false;
1367
+ const restoreTerminal = () => {
1368
+ if (terminalRestored) return;
1369
+ terminalRestored = true;
1370
+ try {
1371
+ stdin.setRawMode?.(false);
1372
+ } catch {
1373
+ // stdin may already be closed.
1374
+ }
1375
+ try {
1376
+ writeSync(1, terminalRestore());
1377
+ } catch {
1378
+ // The terminal may already be gone (SIGHUP).
1379
+ }
1380
+ };
1381
+ process.once("exit", restoreTerminal);
1382
+ // A kill or a closed terminal: restore it, then exit as killed.
1059
1383
  for (const [signal, code] of [
1060
1384
  ["SIGTERM", 143],
1061
1385
  ["SIGHUP", 129],
1062
1386
  ] as const)
1063
1387
  process.once(signal, () => {
1388
+ restoreTerminal();
1389
+ process.exit(code);
1390
+ });
1391
+ // An exception nothing caught leaves the editor in an unknown state: leave
1392
+ // the alternate screen first, so the stack lands in the shell, then exit.
1393
+ const onUncaught = (error: unknown) => {
1394
+ restoreTerminal();
1395
+ try {
1396
+ writeSync(2, crashText(error));
1397
+ } catch {
1398
+ // Nowhere left to report it.
1399
+ }
1400
+ process.exit(1);
1401
+ };
1402
+ // A rejected fire-and-forget promise is one failed action, not a broken
1403
+ // editor: report it on the activity strip and repaint over anything the
1404
+ // runtime wrote.
1405
+ const onRejection = (error: unknown) => {
1406
+ if (terminalRestored || screenSuspended) {
1064
1407
  try {
1065
- writeSync(1, `${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1408
+ writeSync(2, crashText(error));
1066
1409
  } catch {
1067
- // The terminal may already be gone (SIGHUP).
1410
+ // Nowhere left to report it.
1068
1411
  }
1069
- process.exit(code);
1070
- });
1412
+ return;
1413
+ }
1414
+ tui.activity.pushError(
1415
+ `failed · ${error instanceof Error ? error.message : String(error)}`,
1416
+ );
1417
+ tui.invalidate();
1418
+ requestFrame();
1419
+ };
1420
+ // Runtime warnings and console output would land on top of the frame; the
1421
+ // writer never repaints rows it did not change. Route them to the
1422
+ // transcript instead, and repaint.
1423
+ const onWarning = (warning: Error) => {
1424
+ if (screenSuspended || terminalRestored) return;
1425
+ tui.activity.pushNote(`warning · ${warning.message}`, "error");
1426
+ tui.invalidate();
1427
+ requestFrame();
1428
+ };
1429
+ const consoleError = console.error;
1430
+ const consoleWarn = console.warn;
1431
+ const consoleToTranscript =
1432
+ (original: (...data: unknown[]) => void) =>
1433
+ (...data: unknown[]) => {
1434
+ if (screenSuspended || terminalRestored) return original(...data);
1435
+ tui.activity.pushNote(
1436
+ data
1437
+ .map((item) => (item instanceof Error ? item.message : String(item)))
1438
+ .join(" "),
1439
+ "error",
1440
+ );
1441
+ tui.invalidate();
1442
+ requestFrame();
1443
+ };
1444
+ process.on("uncaughtException", onUncaught);
1445
+ process.on("unhandledRejection", onRejection);
1446
+ process.on("warning", onWarning);
1447
+ console.error = consoleToTranscript(consoleError);
1448
+ console.warn = consoleToTranscript(consoleWarn);
1071
1449
  stdin.setRawMode?.(true);
1072
1450
  stdin.resume();
1073
1451
  // Alternate screen, hidden cursor, bracketed paste.
@@ -1078,7 +1456,8 @@ async function runInteractive(): Promise<void> {
1078
1456
  const runPrompt = async (text: string): Promise<void> => {
1079
1457
  const ui = tui.command(text);
1080
1458
  if (ui !== undefined) {
1081
- tui.activity.pushCard(ui, { tone: "info" });
1459
+ if (typeof ui === "string") tui.activity.pushCard(ui, { tone: "info" });
1460
+ else tui.activity.pushCard(ui.text, { tone: toneOf(ui) });
1082
1461
  return;
1083
1462
  }
1084
1463
  tui.activity.pushRequest(text);
@@ -1102,6 +1481,16 @@ async function runInteractive(): Promise<void> {
1102
1481
  }
1103
1482
  };
1104
1483
  let agentReported = false;
1484
+ /** A typed command during an agent turn; leaves the turn's receipt alone. */
1485
+ const runLocalBesideAgent = async (text: string): Promise<void> => {
1486
+ tui.activity.pushRequest(text);
1487
+ const base = baseline();
1488
+ try {
1489
+ receipt(await submit(text), base);
1490
+ } catch (error) {
1491
+ tui.activity.pushError(describeError(text, error));
1492
+ }
1493
+ };
1105
1494
  const drainQueue = async (): Promise<void> => {
1106
1495
  if (processingQueue) return;
1107
1496
  processingQueue = true;
@@ -1122,26 +1511,97 @@ async function runInteractive(): Promise<void> {
1122
1511
  tui.activity.setQueueDepth(queuedPrompts.length);
1123
1512
  }
1124
1513
  };
1514
+ // Builds a frame only when something can have changed (tui/frame-gate.ts):
1515
+ // an idle editor no longer rebuilds the whole view 30 times a second.
1516
+ const frameGate = new FrameGate(IDLE_HEARTBEAT_MS, MIN_FRAME_GAP_MS);
1517
+ // `DAWG_FRAME_LOG=<file>`: one `WxH ms bytes` line per frame (the size
1518
+ // matrix in test/sizes-lib.ts reads frame times from it).
1519
+ const frameLog = process.env.DAWG_FRAME_LOG;
1520
+ const unwatchActivity = tui.activity.subscribe(() => frameGate.markDirty());
1521
+ const animating = (): boolean => {
1522
+ if (clock.playing || play?.on || auditionLoop?.looping) return true;
1523
+ const activity = tui.activity;
1524
+ if (activity.spinner || activity.streaming) return true;
1525
+ if (tui.delight.animating(Date.now())) return true;
1526
+ const latest = activity.latest;
1527
+ return (
1528
+ !tui.ui.reducedMotion &&
1529
+ latest !== undefined &&
1530
+ Date.now() - latest.atMs < CARD_GLOW_MS
1531
+ );
1532
+ };
1125
1533
  const tick = (force = false) => {
1126
1534
  if (screenSuspended) return;
1535
+ reportView();
1536
+ if (
1537
+ !frameGate.shouldBuild({
1538
+ nowMs: Date.now(),
1539
+ force,
1540
+ animating: animating(),
1541
+ keys: [
1542
+ score,
1543
+ record.revision,
1544
+ record.sessionId,
1545
+ record.meta.name,
1546
+ syncState,
1547
+ windowCount,
1548
+ panePresence,
1549
+ typesIndicator,
1550
+ menu.open,
1551
+ euclid.open,
1552
+ tui.ui.overlay,
1553
+ ],
1554
+ })
1555
+ )
1556
+ return;
1127
1557
  followCommitted();
1558
+ tui.delight.song(record.sessionId, record.meta.heardLoop === true);
1128
1559
  play?.tick();
1129
1560
  // Values in the menu follow the score as edits land.
1130
1561
  if (menu.open) refreshMenu();
1131
1562
  if (euclid.open) refreshEuclid();
1132
1563
  refreshAuditionPicker();
1133
- tui.render(appView(score, clock.beatAt()), { force });
1564
+ // The gate already paces frames; the app's own throttle would drop an
1565
+ // approved frame that lands just after a forced one, and the gate would
1566
+ // not ask again until the heartbeat.
1567
+ const out = tui.render(appView(score, clock.beatAt()), { force: true });
1568
+ if (frameLog)
1569
+ appendFileSync(
1570
+ frameLog,
1571
+ `${stdout.columns ?? 0}x${stdout.rows ?? 0} ${tui.lastRenderMs.toFixed(3)} ${out.length}\n`,
1572
+ );
1134
1573
  };
1135
1574
  requestFrame = () => tick(true);
1575
+ // The first wrap is remembered per song in .dawg metadata, never the score.
1576
+ tui.onFirstLoop = () => {
1577
+ void port
1578
+ .updateMeta({ heardLoop: true })
1579
+ .then((result) => adoptMeta(result.meta))
1580
+ .catch(() => undefined);
1581
+ };
1136
1582
  runPromptLater = (command) => {
1137
1583
  queuedPrompts.unshift(command);
1138
1584
  void drainQueue();
1139
1585
  };
1586
+ runPromptsLater = (commands) => {
1587
+ queuedPrompts.unshift(...commands);
1588
+ void drainQueue();
1589
+ };
1140
1590
  reportAgentActivity = () => {
1141
1591
  tui.activity.applyAgentEvent({ type: "start", model: providerName });
1142
1592
  };
1143
1593
  agentEventSink = (event) => {
1144
1594
  if (event.type === "usage") return;
1595
+ if (event.type === "command-typing") {
1596
+ showMeTyping(event.text);
1597
+ return;
1598
+ }
1599
+ if (event.type === "command") return;
1600
+ if (event.type === "tool-start") {
1601
+ const caption = toolCaption(event.name);
1602
+ if (caption) showMeCaption(caption);
1603
+ }
1604
+ if (event.type === "done" || event.type === "error") showMeFinish();
1145
1605
  tui.activity.applyAgentEvent(event);
1146
1606
  if (event.type === "done" || event.type === "error") agentReported = true;
1147
1607
  };
@@ -1152,13 +1612,18 @@ async function runInteractive(): Promise<void> {
1152
1612
  tick(true);
1153
1613
  };
1154
1614
  stdout.on("resize", onResize);
1615
+ const ownWrites = new OwnWrites();
1616
+ let presenceClients: readonly PresenceEntry[] = [];
1155
1617
  let applying: Promise<void> = Promise.resolve();
1156
1618
  const applyLatest = (latest: typeof record): Promise<void> =>
1157
1619
  (applying = applying.then(() => applyRecord(latest)));
1158
1620
  const applyRecord = async (latest: typeof record): Promise<void> => {
1159
1621
  try {
1622
+ // Queued before a /fork or /resume swapped the session: stale.
1623
+ if (latest.sessionId !== record.sessionId) return;
1160
1624
  if (latest.revision > record.revision) {
1161
1625
  const previousRevision = record.revision;
1626
+ const before = score;
1162
1627
  record = latest;
1163
1628
  if (stageCapture)
1164
1629
  stageCapture.committed = scoreFromJSON(record.composition);
@@ -1195,11 +1660,50 @@ async function runInteractive(): Promise<void> {
1195
1660
  else if (payload.action === "toggle") await setTransport("toggle");
1196
1661
  }
1197
1662
  projectSync?.scoreChanged(score);
1198
- tui.activity.pushCard("synced from another window", {
1199
- tone: "info",
1200
- baseRevision: previousRevision,
1201
- resultRevision: record.revision,
1202
- });
1663
+ // Another window's play/pause is followed, not announced, and this
1664
+ // window's own writes (which can echo back before the append
1665
+ // resolves) are never "synced".
1666
+ const from = shownRevision(record, previousRevision);
1667
+ const to = shownRevision(record);
1668
+ await ownWrites.settled();
1669
+ const foreignList = foreignEvents(
1670
+ latest,
1671
+ previousRevision,
1672
+ ownWrites,
1673
+ ).filter((event) => event.kind !== "transport");
1674
+ const foreign = foreignList.length > 0;
1675
+ // dawgd stamps each event's author: another pane's edit is its
1676
+ // letter and what it touched, only when it touches this pane's
1677
+ // track (§12.7); no "synced from another window" card.
1678
+ const letters = paneLetters(
1679
+ foreignList,
1680
+ presenceClients,
1681
+ port.clientId,
1682
+ );
1683
+ if (to !== from && foreign && letters.length > 0) {
1684
+ const edited = editedTrack(before, scoreFromJSON(latest.composition));
1685
+ if (edited === undefined || edited === requestedTrack)
1686
+ tui.activity.pushCard(
1687
+ `${letters.join(" ")} ✓ ${
1688
+ edited
1689
+ ? (score.tracks.find((track) => track.id === edited)?.name ??
1690
+ edited)
1691
+ : "song"
1692
+ }`,
1693
+ { tone: "info", baseRevision: from, resultRevision: to },
1694
+ );
1695
+ } else if (to !== from && foreign)
1696
+ tui.activity.pushCard(
1697
+ `synced · ${otherWindowName(presenceClients, port.clientId, {
1698
+ editedTrackId: editedTrack(
1699
+ before,
1700
+ scoreFromJSON(latest.composition),
1701
+ ),
1702
+ trackName: (id) =>
1703
+ score.tracks.find((track) => track.id === id)?.name ?? id,
1704
+ })}`,
1705
+ { tone: "info", baseRevision: from, resultRevision: to },
1706
+ );
1203
1707
  }
1204
1708
  } catch {
1205
1709
  // An invalid composition is skipped; the next update retries.
@@ -1210,8 +1714,12 @@ async function runInteractive(): Promise<void> {
1210
1714
  void applyLatest(update.record);
1211
1715
  adoptMeta(update.record.meta);
1212
1716
  } else if (update.type === "meta") adoptMeta(update.meta);
1213
- else if (update.type === "presence") windowCount = update.clients.length;
1214
- else if (update.type === "sync") syncState = update.sync;
1717
+ else if (update.type === "presence") {
1718
+ presenceClients = update.clients;
1719
+ followPresence(panePresence, update.clients);
1720
+ panePresence = update.clients;
1721
+ windowCount = update.clients.length;
1722
+ } else if (update.type === "sync") syncState = update.sync;
1215
1723
  else if (update.type === "transport") {
1216
1724
  // Every window renders the same hit line from dawgd's timestamp.
1217
1725
  const { playing, beat, bpm, atMs } = update.transport;
@@ -1225,11 +1733,24 @@ async function runInteractive(): Promise<void> {
1225
1733
  });
1226
1734
  }
1227
1735
  };
1228
- let unsubscribe = port.subscribe(onUpdate);
1736
+ // Each subscription answers only while its port is the live one: /fork and
1737
+ // /resume swap the port, and a load the old port started before the swap
1738
+ // could otherwise land afterwards and pull the old session's name and
1739
+ // score into the new one.
1740
+ const subscribeLive = (): (() => void) => {
1741
+ const bound = port;
1742
+ ownWrites.attach(bound);
1743
+ return bound.subscribe((update) => {
1744
+ if (bound === port) onUpdate(update);
1745
+ });
1746
+ };
1747
+ let unsubscribe = subscribeLive();
1229
1748
  const refreshPresence = () =>
1230
1749
  void port
1231
1750
  .presence()
1232
1751
  .then((clients) => {
1752
+ presenceClients = clients;
1753
+ panePresence = clients;
1233
1754
  windowCount = Math.max(1, clients.length);
1234
1755
  })
1235
1756
  .catch(() => undefined);
@@ -1241,12 +1762,14 @@ async function runInteractive(): Promise<void> {
1241
1762
  rebindPort = () => {
1242
1763
  unsubscribe();
1243
1764
  syncState = port.sync;
1244
- unsubscribe = port.subscribe(onUpdate);
1765
+ unsubscribe = subscribeLive();
1245
1766
  refreshPresence();
1767
+ reportedView = "";
1246
1768
  };
1247
1769
  if (freshWorkspace)
1248
1770
  tui.activity.pushCard("created .dawg/ · add it to .gitignore", {
1249
1771
  tone: "info",
1772
+ once: true,
1250
1773
  });
1251
1774
  if (attachNotice)
1252
1775
  tui.activity.pushCard(attachNotice, {
@@ -1256,11 +1779,15 @@ async function runInteractive(): Promise<void> {
1256
1779
  });
1257
1780
  if (port.status !== "file session")
1258
1781
  tui.activity.pushCard(port.status, { tone: "info" });
1782
+ void launchCard?.then((card) => {
1783
+ if (card) tui.activity.pushCard(card.text, { tone: card.tone, once: true });
1784
+ });
1259
1785
  if (await isProject(process.cwd()))
1260
1786
  projectSync = startProjectSync(syncHost());
1261
1787
  void currentProvider().then(() => tick(true));
1262
1788
  tickUi = () => tick(true);
1263
1789
  reportSampleProblems(score);
1790
+ if (launchArgs?.pane) void openPane(launchArgs.pane).then(() => tick(true));
1264
1791
  // Input arrives through a detachable listener (not `for await`), so /login
1265
1792
  // can hand the terminal to an interactive shell flow and take it back.
1266
1793
  const inbox: string[] = [];
@@ -1277,8 +1804,28 @@ async function runInteractive(): Promise<void> {
1277
1804
  stdin.on("end", onEnd);
1278
1805
  async function* chunks(): AsyncGenerator<string> {
1279
1806
  for (;;) {
1280
- while (inbox.length === 0)
1281
- await new Promise<void>((resolve) => (wake = resolve));
1807
+ while (inbox.length === 0) {
1808
+ // A partial escape (Alt+[, a cut-off paste) is flushed after a short
1809
+ // idle so it never holds back the keys behind it.
1810
+ const held = inputDecoder.pending();
1811
+ const idle =
1812
+ held === "paste"
1813
+ ? PASTE_FLUSH_MS
1814
+ : held === "escape"
1815
+ ? ESCAPE_FLUSH_MS
1816
+ : undefined;
1817
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1818
+ const timedOut = await new Promise<boolean>((resolve) => {
1819
+ wake = () => resolve(false);
1820
+ if (idle !== undefined)
1821
+ timeout = setTimeout(() => resolve(true), idle);
1822
+ });
1823
+ clearTimeout(timeout);
1824
+ if (timedOut && inbox.length === 0) {
1825
+ wake = undefined;
1826
+ yield INPUT_FLUSH;
1827
+ }
1828
+ }
1282
1829
  wake = undefined;
1283
1830
  const next = inbox.shift()!;
1284
1831
  if (next === "\u0000eof") return;
@@ -1293,7 +1840,7 @@ async function runInteractive(): Promise<void> {
1293
1840
  stdin.pause();
1294
1841
  stdin.setRawMode?.(false);
1295
1842
  // Leave the alternate screen; restore the cursor and plain paste.
1296
- stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1843
+ stdout.write(terminalRestore());
1297
1844
  try {
1298
1845
  return await flow();
1299
1846
  } finally {
@@ -1312,12 +1859,31 @@ async function runInteractive(): Promise<void> {
1312
1859
  let exiting = false;
1313
1860
  for await (const text of chunks()) {
1314
1861
  // A read that is exactly ESC is the Esc key, not the start of a sequence.
1315
- const values = [
1316
- ...inputDecoder.push(text),
1317
- ...(text === "\u001b" ? inputDecoder.flush() : []),
1318
- ];
1862
+ const values =
1863
+ text === INPUT_FLUSH
1864
+ ? inputDecoder.flush()
1865
+ : [
1866
+ ...inputDecoder.push(text),
1867
+ ...(text === "\u001b" ? inputDecoder.flush() : []),
1868
+ ];
1319
1869
  while (values.length) {
1320
1870
  const value = values.shift()!;
1871
+ // Below the minimum size only quit and play work; the hidden UI keeps
1872
+ // its state for when the terminal grows back (tui/app.ts).
1873
+ if (
1874
+ isTooSmall({ width: stdout.columns ?? 80, height: stdout.rows ?? 24 })
1875
+ ) {
1876
+ const key = tooSmallKey(value);
1877
+ if (key === "quit") {
1878
+ exiting = true;
1879
+ break;
1880
+ }
1881
+ if (key === "play")
1882
+ void toggleTransport()
1883
+ .catch((error: unknown) => transportFailed(error))
1884
+ .finally(() => tick(true));
1885
+ continue;
1886
+ }
1321
1887
  // A mouse report acts on what the last frame painted under it; some
1322
1888
  // become keys (a list row, a wheel notch) and run through below.
1323
1889
  if (typeof value === "string" && isMouseSequence(value)) {
@@ -1328,6 +1894,14 @@ async function runInteractive(): Promise<void> {
1328
1894
  }
1329
1895
  // The fader drawer owns every key while it is up.
1330
1896
  if (typeof value === "string" && fader && menu.open) {
1897
+ // Tab flips the knob front page and every param (design §8.6).
1898
+ if (value === "\t" && menu.knobPage && fader.typing === undefined) {
1899
+ const label = menu.pageKnobs(menuContext(), fader.label);
1900
+ if (label) fader.label = label;
1901
+ refreshMenu();
1902
+ tick(true);
1903
+ continue;
1904
+ }
1331
1905
  const result = faderKeyPress(
1332
1906
  fader,
1333
1907
  menu.faderFields(menuContext()),
@@ -1341,9 +1915,9 @@ async function runInteractive(): Promise<void> {
1341
1915
  continue;
1342
1916
  }
1343
1917
  }
1344
- // The `?` panel closes on any key (Ctrl-C still quits).
1918
+ // The `?` panel closes on esc or ?; other keys wait (Ctrl-C quits).
1345
1919
  if (tui.ui.keys && typeof value === "string" && value !== "\u0003") {
1346
- tui.closeKeys();
1920
+ if (closesKeys(value)) tui.closeKeys();
1347
1921
  tick(true);
1348
1922
  continue;
1349
1923
  }
@@ -1403,7 +1977,10 @@ async function runInteractive(): Promise<void> {
1403
1977
  }
1404
1978
  } else if (result.type === "audition")
1405
1979
  auditionKeyPressed(result.key);
1406
- else if (result.type === "fader") openFader(result.label);
1980
+ else if (result.type === "fader")
1981
+ openFader(
1982
+ menu.openKnobs(menuContext(), result.label) ?? result.label,
1983
+ );
1407
1984
  else if (result.type === "revert") revertStaged();
1408
1985
  else if (result.type === "hover")
1409
1986
  hoverItem(result.command, result.key);
@@ -1452,6 +2029,20 @@ async function runInteractive(): Promise<void> {
1452
2029
  tick(true);
1453
2030
  continue;
1454
2031
  }
2032
+ if (typeof value === "string" && tape.on && tapeInput(value)) {
2033
+ tick(true);
2034
+ continue;
2035
+ }
2036
+ // Ctrl-T opens TAPE (op1-ux §6); again (or esc) goes home.
2037
+ if (value === "\u0014" && tui.ui.overlay === undefined) {
2038
+ if (tape.on) receipt(exitTape());
2039
+ else
2040
+ void enterTape()
2041
+ .then((outcome) => receipt(outcome))
2042
+ .finally(() => tick(true));
2043
+ tick(true);
2044
+ continue;
2045
+ }
1455
2046
  // Ctrl-P enters play mode. A bare `p` would steal the first letter of
1456
2047
  // `pan`, `pattern`, `play` and every prose request starting with p.
1457
2048
  if (value === "\u0010" && tui.ui.overlay === undefined) {
@@ -1474,12 +2065,7 @@ async function runInteractive(): Promise<void> {
1474
2065
  // Never await a daemon round trip here: the key loop must stay
1475
2066
  // live for Esc, quit and redraws while the toggle is in flight.
1476
2067
  void toggleTransport()
1477
- .catch((error: unknown) => {
1478
- tui.activity.pushCard(
1479
- `transport failed · ${error instanceof Error ? error.message : String(error)}`,
1480
- { tone: "error" },
1481
- );
1482
- })
2068
+ .catch((error: unknown) => transportFailed(error))
1483
2069
  .finally(() => tick(true));
1484
2070
  } else {
1485
2071
  const input = tui.input(value);
@@ -1508,7 +2094,9 @@ async function runInteractive(): Promise<void> {
1508
2094
  if (auditionLoop?.looping && isStageable(input.value))
1509
2095
  hoverItem(input.value, `picker:${input.picker}`);
1510
2096
  else if (input.picker === "pattern")
1511
- previewPattern(input.value.replace(/^\/pattern\s+/, ""));
2097
+ previewPattern(
2098
+ input.value.replace(/^\/(?:pattern|groove)\s+/, ""),
2099
+ );
1512
2100
  } else if (input.type === "pick-audition") {
1513
2101
  pickerAuditionKey(input.key);
1514
2102
  } else if (input.type === "pick-cancel") {
@@ -1560,6 +2148,16 @@ async function runInteractive(): Promise<void> {
1560
2148
  else if (action?.kind === "cancel" && agentTurn) {
1561
2149
  agentTurn.controller.abort();
1562
2150
  tui.activity.setSpinner("cancelling");
2151
+ } else if (
2152
+ action?.kind === "submit" &&
2153
+ action.value &&
2154
+ agentTurn &&
2155
+ routeDuringTurn(action.value, parsesLocally) === "local"
2156
+ ) {
2157
+ // A typed command never waits on the agent: it commits its own
2158
+ // revision now, and the agent's next call re-reads the score.
2159
+ const value = action.value;
2160
+ void runLocalBesideAgent(value).finally(() => tick(true));
1563
2161
  } else if (action?.kind === "submit" && action.value && agentTurn) {
1564
2162
  // Enter during a turn steers it; Alt-Enter still queues a follow-up.
1565
2163
  agentTurn.steering.push(action.value);
@@ -1590,17 +2188,36 @@ async function runInteractive(): Promise<void> {
1590
2188
  stdin.off("end", onEnd);
1591
2189
  clearInterval(timer);
1592
2190
  clearInterval(presenceTimer);
1593
- await projectSync?.stop();
1594
- namer.dispose();
1595
2191
  stdout.off("resize", onResize);
1596
- unsubscribe();
1597
- await play?.exit().catch(() => undefined);
1598
- await monitorEngine?.dispose().catch(() => undefined);
1599
- audio.stop();
1600
- await port.close();
1601
- stdin.setRawMode?.(false);
2192
+ // The terminal first: a teardown step that throws (a read-only .dawg,
2193
+ // a dead engine) must not leave the shell on the alternate screen.
2194
+ restoreTerminal();
1602
2195
  stdin.pause();
1603
- stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
2196
+ console.error = consoleError;
2197
+ console.warn = consoleWarn;
2198
+ process.off("warning", onWarning);
2199
+ unwatchActivity();
2200
+ // Each step on its own: one failure does not skip the rest.
2201
+ const failures: unknown[] = [];
2202
+ const steps: Array<() => unknown> = [
2203
+ () => projectSync?.stop(),
2204
+ () => namer.dispose(),
2205
+ () => unsubscribe(),
2206
+ () => play?.exit(),
2207
+ () => monitorEngine?.dispose(),
2208
+ () => audio.stop(),
2209
+ () => port.close(),
2210
+ ];
2211
+ for (const step of steps) {
2212
+ try {
2213
+ await step();
2214
+ } catch (error) {
2215
+ failures.push(error);
2216
+ }
2217
+ }
2218
+ process.off("unhandledRejection", onRejection);
2219
+ process.off("uncaughtException", onUncaught);
2220
+ if (failures.length > 0) throw failures[0];
1604
2221
  }
1605
2222
  }
1606
2223
 
@@ -1615,23 +2232,23 @@ function unknownCommand(command: string): string {
1615
2232
 
1616
2233
  async function submit(prompt: string): Promise<string | Receipt> {
1617
2234
  const command = prompt.trim();
2235
+ // Canonical names for window commands: `model key` (login), `models`,
2236
+ // `voice` (the Voice topic).
2237
+ const canonical = canonicalWindowForm(command);
2238
+ if (canonical) return submit(canonical);
1618
2239
  const helpCommand = command.match(/^\/?(?:help|\?)(?:\s+(\S+))?$/i);
1619
2240
  if (helpCommand) {
1620
2241
  const topic = helpCommand[1];
2242
+ // The panel's inner width (tui/app.ts paintText): rows clip there with
2243
+ // an ellipsis, the same as every other panel.
2244
+ const columns = stdout.columns ?? 80;
1621
2245
  const lines = helpTopicLines(
1622
2246
  topic,
1623
- Math.max(40, (stdout.columns ?? 80) - 10),
1624
- );
1625
- if (!lines)
1626
- return fail(
1627
- `no help topic ${topic} · /help all · ${HELP_TOPICS.join(" ")}`,
1628
- );
1629
- tui.openText(topic ? `help · ${topic.toLowerCase()}` : "help", lines);
1630
- return ok(
1631
- topic
1632
- ? `help · ${topic.toLowerCase()}`
1633
- : "help · /help all for every command",
2247
+ Math.max(10, columns - (columns >= 60 ? 8 : 4)),
1634
2248
  );
2249
+ if (!lines) return fail(helpMiss(topic!));
2250
+ tui.openText(helpTitle(topic), lines);
2251
+ return ok(topic ? helpTitle(topic) : "help · help all for every command");
1635
2252
  }
1636
2253
  if (/^\/?tracks$/i.test(command)) {
1637
2254
  const problems = await sampleProblems(score);
@@ -1648,7 +2265,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1648
2265
  return {
1649
2266
  label: track.id,
1650
2267
  value: `/track ${track.id}`,
1651
- detail: `${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}`,
2268
+ detail: `${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}${paneMarks(track.id)}`,
1652
2269
  current: track.id === requestedTrack,
1653
2270
  };
1654
2271
  });
@@ -1663,7 +2280,39 @@ async function submit(prompt: string): Promise<string | Receipt> {
1663
2280
  items.findIndex((item) => item.current),
1664
2281
  ),
1665
2282
  });
1666
- return ok(`${items.length} track${items.length === 1 ? "" : "s"}`);
2283
+ return note(`${items.length} track${items.length === 1 ? "" : "s"}`);
2284
+ }
2285
+ if (/^\/?(?:instruments|instrument\s+(?:list|ls|presets))$/i.test(command)) {
2286
+ // `instrument list`: every word `instrument <name>` takes, by family.
2287
+ const columns = stdout.columns ?? 80;
2288
+ const lines = instrumentListLines(
2289
+ Math.max(10, columns - (columns >= 60 ? 8 : 4)),
2290
+ );
2291
+ tui.openText("instruments", lines);
2292
+ return note("instruments · instrument <name> on the focused track");
2293
+ }
2294
+ if (/^\/?notes(?:\s+(?:list|ls))?$/i.test(command)) {
2295
+ // `notes`: the focused track's note ids, the names `remove <id>` takes.
2296
+ const notes = score.notes
2297
+ .filter((entry) => entry.trackId === requestedTrack)
2298
+ .slice()
2299
+ .sort((a, b) => a.startTick - b.startTick);
2300
+ const lines = notes.map(
2301
+ (entry) =>
2302
+ `${entry.id} · ${midiNoteName(entry.pitch)} · beat ${+(entry.startTick / score.ticksPerBeat).toFixed(2)}`,
2303
+ );
2304
+ if (lines.length === 0) return note(`notes · ${requestedTrack} has none`);
2305
+ tui.openText(`notes · ${requestedTrack}`, lines);
2306
+ return note(
2307
+ `${lines.length} note${lines.length === 1 ? "" : "s"} · ${requestedTrack} · remove <id>`,
2308
+ );
2309
+ }
2310
+ const tapeCommand = command.match(/^\/tape(?:\s+(on|off))?$/i);
2311
+ if (tapeCommand) {
2312
+ const wanted = tapeCommand[1]?.toLowerCase();
2313
+ if (wanted === "off" || (wanted === undefined && tape.on))
2314
+ return exitTape();
2315
+ return enterTape();
1667
2316
  }
1668
2317
  const playCommand = command.match(
1669
2318
  /^\/play(?:\s+(on|off|degrees|in-key|chromatic))?$/i,
@@ -1696,6 +2345,41 @@ async function submit(prompt: string): Promise<string | Receipt> {
1696
2345
  );
1697
2346
  return result.ok ? ok(result.message) : fail(result.message);
1698
2347
  }
2348
+ const audioCommand = command.match(/^\/audio(?:\s+(.*))?$/i);
2349
+ if (audioCommand) {
2350
+ const result = await runAudioCommand(audioCommand[1] ?? "", {
2351
+ info: audioInfo(),
2352
+ setOutput: (name) => {
2353
+ if (audio instanceof AudioEngine) audio.setDevice(name);
2354
+ monitorEngine?.setDevice(name);
2355
+ },
2356
+ background: (task) => void task,
2357
+ });
2358
+ audioMenuCache = undefined;
2359
+ if (result.ok && result.lines && /^\/audio(\s+test)?\s*$/i.test(command))
2360
+ tui.openText(/test/i.test(command) ? "audio test" : "audio", [
2361
+ ...result.lines,
2362
+ ]);
2363
+ return result.ok ? ok(result.message) : fail(result.message);
2364
+ }
2365
+ const showMeCommand = command.match(/^\/show-?me(?:\s+(\S+))?$/i);
2366
+ if (showMeCommand) {
2367
+ if (!showMeCommand[1])
2368
+ return ok(`show me · ${showMe.level} · /showme on|quiet|off`);
2369
+ const level = parseShowMe(showMeCommand[1]);
2370
+ if (!level) return fail("usage · /showme on|quiet|off");
2371
+ showMe.level = level;
2372
+ void writeConfig({ dir: configDir() }, { showMe: level }).catch(
2373
+ () => undefined,
2374
+ );
2375
+ return ok(
2376
+ level === "off"
2377
+ ? "show me off · the agent edits with tools"
2378
+ : level === "quiet"
2379
+ ? "show me quiet · the agent types commands, no captions"
2380
+ : "show me on · the agent types commands, slides faders, plays keys",
2381
+ );
2382
+ }
1699
2383
  const countIn = command.match(/^\/count-?in\s+([0-2])$/i);
1700
2384
  if (countIn) return ok(playSession().setCountIn(Number(countIn[1])));
1701
2385
  const euclidCommand = command.match(/^\/euclid(?:\s+(\S+))?$/i);
@@ -1710,21 +2394,75 @@ async function submit(prompt: string): Promise<string | Receipt> {
1710
2394
  }
1711
2395
  const tryCommand = command.match(/^\/try(?:\s+(.+))?$/i);
1712
2396
  if (tryCommand) return tryPrompt(tryCommand[1]?.trim() ?? "");
1713
- // A bare parameter (`volume`, `pan`, `fx filter`, `fx reverb mix`)
1714
- // opens the fader drawer on it, with its related params stacked below.
1715
- if (/^\/?(?:volume|pan|fx\s+\S+(?:\s+\S+)?)$/i.test(command)) {
1716
- const label = menu.showFader(menuContext(), command);
2397
+ // A bare scalar (`volume`, `pan`, `fx reverb mix`, and the song's
2398
+ // `tempo`, `bars` and `meter`) opens the fader drawer on it, with its
2399
+ // related params stacked below.
2400
+ if (
2401
+ /^\/?(?:volume|pan|tempo|bpm|bars|meter|fx\s+\S+(?:\s+\S+)?)$/i.test(
2402
+ command,
2403
+ )
2404
+ ) {
2405
+ const context = menuContext();
2406
+ const opened =
2407
+ menu.showFader(context, command) ?? songFader(context, command);
2408
+ // Bare `volume` (and every scalar) opens on its knob page, when its
2409
+ // level has one and the param is a knob: the four knobs up front.
2410
+ const label = opened && (menu.openKnobs(context, opened) ?? opened);
1717
2411
  if (label) {
2412
+ const field = menu
2413
+ .faderFields(context)
2414
+ .find((candidate) => candidate.label === label);
2415
+ // The receipt names the value it opened on: `tempo · 120 BPM · …`.
2416
+ const value =
2417
+ field?.kind === "number"
2418
+ ? field.value !== undefined
2419
+ ? field.format(field.value)
2420
+ : field.off
2421
+ : undefined;
1718
2422
  openFader(label, true);
1719
2423
  refreshMenu();
1720
- return ok(`${label} · ←→ adjust · enter keep · esc revert`);
2424
+ return ok(
2425
+ `${label}${value ? ` · ${value}` : ""} · ${
2426
+ menu.knobPage === "knobs"
2427
+ ? "↑↓ knob · ←→ turn · tab all"
2428
+ : "←→ adjust · enter keep · esc revert"
2429
+ }`,
2430
+ );
1721
2431
  }
1722
2432
  }
2433
+ // `mix`: the drawer's mixer page, every track's level as a fader.
2434
+ // `knobs [sound|mix|master|tempo|fx <effect>]`: a page's four knobs.
2435
+ const knobsCommand = command.match(/^\/?(?:(mix)|knobs(?:\s+(.+))?)$/i);
2436
+ if (knobsCommand) {
2437
+ const context = menuContext();
2438
+ const track = context.score.tracks.find((t) => t.id === context.trackId);
2439
+ const label = knobsCommand[1]
2440
+ ? openMixer(context)
2441
+ : openKnobPage(context, knobsCommand[2], track);
2442
+ if (!label)
2443
+ return fail(
2444
+ knobsCommand[1]
2445
+ ? "mix · no tracks yet · track <name>"
2446
+ : `knobs ${knobsCommand[2] ?? ""} · no such page · knobs ${KNOB_PAGE_WORDS.join("|")}`,
2447
+ );
2448
+ openFader(label, true);
2449
+ refreshMenu();
2450
+ return ok(
2451
+ knobsCommand[1]
2452
+ ? "mix · ↑↓ track · ←→ level · tab this track · enter keep · esc revert"
2453
+ : `knobs · ${menu.knobPageId} · ↑↓ knob · ←→ turn · tab all params`,
2454
+ );
2455
+ }
1723
2456
  const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
1724
2457
  if (menuCommand) {
1725
2458
  const section = menuCommand[1]?.toLowerCase();
1726
- if (section && !(MENU_SECTIONS as readonly string[]).includes(section))
1727
- return fail(`/menu [${MENU_SECTIONS.join("|")}]`);
2459
+ // §4: the keys topic has no menu page; it opens the ? panel.
2460
+ if (section && (section === "keys" || TOPIC_ALIASES[section] === "keys")) {
2461
+ tui.showKeys("keys", keyLines(KEYS.prompt));
2462
+ return ok("keys");
2463
+ }
2464
+ if (section && !menuSectionPath(menuContext(), section))
2465
+ return fail(menuUsage(menuContext(), section));
1728
2466
  openMenu(section);
1729
2467
  return ok("menu");
1730
2468
  }
@@ -1735,12 +2473,18 @@ async function submit(prompt: string): Promise<string | Receipt> {
1735
2473
  ? ok(message)
1736
2474
  : fail("usage: /grid 1/4|1/8|1/8T|1/16|1/16T|1/32");
1737
2475
  }
2476
+ const paneReceipt = paneCommand(command);
2477
+ if (paneReceipt) return paneReceipt;
1738
2478
  if (/^\/status$/i.test(command))
1739
2479
  return ok(
1740
2480
  `status · ${record.meta.name} · rev ${record.revision} · ${compositionDigest(record.composition)} · ${port.mode === "daemon" ? "shared via dawgd" : "saved locally · no daemon"}`,
1741
2481
  );
1742
- if (/^\/?undo$/i.test(command)) return stepHistory("undo");
1743
- if (/^\/?redo$/i.test(command)) return stepHistory("redo");
2482
+ const history = /^\/?(undo|redo)(?:\s+(all))?$/i.exec(command);
2483
+ if (history)
2484
+ return stepHistory(
2485
+ history[1]!.toLowerCase() as "undo" | "redo",
2486
+ history[2] ? "all" : "pane",
2487
+ );
1744
2488
  const trackCommand = command.match(
1745
2489
  /^\/?(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/i,
1746
2490
  );
@@ -1754,6 +2498,73 @@ async function submit(prompt: string): Promise<string | Receipt> {
1754
2498
  if (requestedTrack !== grainTrack[1]!.toLowerCase()) return focused;
1755
2499
  return submit(grainTrack[2]!);
1756
2500
  }
2501
+ // `/track rm <name>` (aliases remove, delete) and `/track move <name>
2502
+ // <position>`: the human surface for the removeTrack and moveTrack
2503
+ // operations. Removing a track also drops any vocoder src or autotune from
2504
+ // that named it; ^z brings everything back.
2505
+ const trackEdit = command.match(
2506
+ /^\/?track\s+(rm|remove|delete|move)\s+(.{1,64}?)\s*$/i,
2507
+ );
2508
+ if (trackEdit) {
2509
+ const verb = trackEdit[1]!.toLowerCase() === "move" ? "move" : "rm";
2510
+ let rest = trackEdit[2]!.trim();
2511
+ let position: number | undefined;
2512
+ if (verb === "move") {
2513
+ const tail = rest.match(/^(.+?)\s+(\d{1,3})$/);
2514
+ if (!tail)
2515
+ return fail(`usage · /track move <name> <1..${score.tracks.length}>`);
2516
+ rest = tail[1]!;
2517
+ position = Number(tail[2]);
2518
+ }
2519
+ const wanted = rest
2520
+ .replace(/^["']|["']$/g, "")
2521
+ .trim()
2522
+ .replace(/\s+/g, " ")
2523
+ .toLowerCase();
2524
+ const found = score.tracks.find(
2525
+ (track) =>
2526
+ track.id.toLowerCase() === wanted ||
2527
+ track.id.toLowerCase() === wanted.replace(/ /g, "-") ||
2528
+ (track.name ?? "").toLowerCase() === wanted,
2529
+ );
2530
+ if (!found) return fail(noTrack(rest));
2531
+ if (verb === "move") {
2532
+ if (position! < 1 || position! > score.tracks.length)
2533
+ return fail(`usage · /track move <name> <1..${score.tracks.length}>`);
2534
+ const next = applyScoreOperation(score, {
2535
+ type: "moveTrack",
2536
+ trackId: found.id,
2537
+ index: position! - 1,
2538
+ });
2539
+ await commitScore(next, "track.move", { trackId: found.id });
2540
+ await projectSync?.flushScore();
2541
+ return ok(`moved ${found.id} to position ${position}`);
2542
+ }
2543
+ if (score.tracks.length <= 1)
2544
+ return fail("the last track stays · /clear empties it");
2545
+ const next = applyScoreOperation(score, {
2546
+ type: "removeTrack",
2547
+ trackId: found.id,
2548
+ });
2549
+ const dropped = score.tracks
2550
+ .filter((track) => track.id !== found.id)
2551
+ .filter((track) => {
2552
+ const after = next.tracks.find((t) => t.id === track.id);
2553
+ return (
2554
+ (track.vocoder?.src !== undefined &&
2555
+ after?.vocoder?.src === undefined) ||
2556
+ (track.autotune?.from !== undefined &&
2557
+ after?.autotune?.from === undefined)
2558
+ );
2559
+ })
2560
+ .map((track) => track.id);
2561
+ await commitScore(next, "track.remove", { trackId: found.id });
2562
+ await projectSync?.flushScore();
2563
+ if (found.id === requestedTrack) await focusTrack(next.tracks[0]!.id);
2564
+ return ok(
2565
+ `removed ${found.id}${dropped.length ? ` · dropped references on ${dropped.join(", ")}` : ""} · ctrl-z undoes`,
2566
+ );
2567
+ }
1757
2568
  // `/track piano b`: a name with spaces focuses the track of that name, or
1758
2569
  // creates `piano-b` named "piano b".
1759
2570
  const namedTrack = command.match(/^\/track\s+([a-z0-9._ -]{1,64})$/i);
@@ -1778,12 +2589,90 @@ async function submit(prompt: string): Promise<string | Receipt> {
1778
2589
  }
1779
2590
  const resample = parseResampleCommand(command);
1780
2591
  if (resample) return resampleCommand(resample);
2592
+ // 0.7 clips: `/clip` edits and `/lyrics` on the focused track.
2593
+ const clipCommand = parseClipCommand(command);
2594
+ if (clipCommand) {
2595
+ if ("error" in clipCommand) return fail(clipCommand.error);
2596
+ const result = await runClipCommand(clipCommand, {
2597
+ score,
2598
+ trackId: requestedTrack,
2599
+ cwd: process.cwd(),
2600
+ });
2601
+ if (!result.ok) return fail(result.message);
2602
+ if (result.next && result.kind) {
2603
+ await commitScore(result.next, result.kind, {
2604
+ trackId: requestedTrack,
2605
+ ...result.payload,
2606
+ });
2607
+ await projectSync?.flushScore();
2608
+ }
2609
+ return ok(result.message);
2610
+ }
2611
+ const lyricsCommand = parseLyricsCommand(command);
2612
+ if (lyricsCommand) {
2613
+ const result = applyLyrics(score, requestedTrack, lyricsCommand);
2614
+ if (!result.ok) return fail(result.message);
2615
+ if (result.next && result.kind) {
2616
+ await commitScore(result.next, result.kind, {
2617
+ trackId: requestedTrack,
2618
+ ...result.payload,
2619
+ });
2620
+ await projectSync?.flushScore();
2621
+ }
2622
+ return ok(result.message);
2623
+ }
2624
+ // 0.7 autotune: `/autotune …` (and `/tune <preset>` pointing here).
2625
+ // `/vocal autotune …` takes the same path (presets panel, draft, commit).
2626
+ const autotune = parseAutotuneCommand(
2627
+ command.replace(/^\/?vocal\s+(?=autotune\b)/i, ""),
2628
+ );
2629
+ if (autotune) {
2630
+ if (autotune.type === "autotune-list")
2631
+ tui.openText("autotune presets", autotuneListLines());
2632
+ if (autotune.type !== "autotune-usage" && autotune.type !== "autotune-list")
2633
+ await materializeDraft();
2634
+ const result = applyAutotuneCommand(score, requestedTrack, autotune);
2635
+ if (result.next && result.kind)
2636
+ await commitScore(result.next, result.kind, result.payload);
2637
+ return result.ok ? ok(result.message) : fail(result.message);
2638
+ }
2639
+ // 0.7 Voice: `/vocal <verb>`; lanes register verbs in VOCAL_VERBS.
2640
+ setClipImportDeps({
2641
+ media: {
2642
+ ...mediaServices(),
2643
+ signal: new AbortController().signal,
2644
+ progress: () => undefined,
2645
+ },
2646
+ });
2647
+ const vocal = parseVocalCommand(command);
2648
+ if (vocal) {
2649
+ if (vocal.kind === "verb") await materializeDraft();
2650
+ const result = await runVocalCommand(vocal, {
2651
+ score,
2652
+ trackId: requestedTrack,
2653
+ cwd: process.cwd(),
2654
+ });
2655
+ if (!result.ok) return fail(result.message);
2656
+ if (result.next && result.kind) {
2657
+ await commitScore(result.next, result.kind, {
2658
+ trackId: requestedTrack,
2659
+ ...result.payload,
2660
+ });
2661
+ await projectSync?.flushScore();
2662
+ }
2663
+ // A verb that made or chose another track (a vocoder carrier): focus it.
2664
+ if (result.trackId && result.trackId !== requestedTrack) {
2665
+ await port.focus(result.trackId);
2666
+ requestedTrack = result.trackId;
2667
+ }
2668
+ return ok(result.message);
2669
+ }
1781
2670
  const pack = parsePackCommand(command);
1782
2671
  if (pack) return packCommand(pack);
1783
2672
  const pattern = parsePatternCommand(command);
1784
2673
  if (pattern) return patternCommand(pattern);
1785
2674
  const kit = parseKitCommand(command);
1786
- if (kit) return kitCommand(kit, /^\/kit\s*$/i.test(command.trim()));
2675
+ if (kit) return kitCommand(kit, /^\/?kits?\s*$/i.test(command.trim()));
1787
2676
  const wavetable = parseWavetableCommand(command);
1788
2677
  if (wavetable) return wavetableCommand(wavetable);
1789
2678
  const sessionReply = await sessionCommand(command);
@@ -1821,11 +2710,24 @@ async function submit(prompt: string): Promise<string | Receipt> {
1821
2710
  await commitScore(result.next, result.kind, result.payload);
1822
2711
  return result.ok ? ok(result.message) : fail(result.message);
1823
2712
  }
2713
+ const progression = parseProgressionCommand(command);
2714
+ if (progression) {
2715
+ if (progression.type === "progression") await materializeDraft();
2716
+ const result = applyProgressionCommand(
2717
+ score,
2718
+ requestedTrack,
2719
+ progression,
2720
+ () => newId("n"),
2721
+ );
2722
+ if (result.next && result.kind)
2723
+ await commitScore(result.next, result.kind, result.payload);
2724
+ return result.ok ? ok(result.message) : fail(result.message);
2725
+ }
1824
2726
  const strum = parseStrumCommand(command);
1825
2727
  if (strum) {
1826
2728
  if (strum.type === "strum") await materializeDraft();
1827
2729
  const result = applyStrumCommand(score, requestedTrack, strum, () =>
1828
- randomUUID().slice(0, 12),
2730
+ newId("n"),
1829
2731
  );
1830
2732
  if (result.next && result.kind)
1831
2733
  await commitScore(result.next, result.kind, result.payload);
@@ -1836,6 +2738,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
1836
2738
  if (rig.type !== "rig-show" && rig.type !== "rig-hint")
1837
2739
  await materializeDraft();
1838
2740
  const result = applyRigCommand(score, requestedTrack, rig);
2741
+ if (result.next && result.kind)
2742
+ await commitScore(result.next, result.kind, result.payload);
2743
+ return readOrDone(result);
2744
+ }
2745
+ // 0.7 `/formant` and `/vowel`: short forms of `fx formant|vowel`.
2746
+ const formant = parseFormantCommand(command) ?? parseVowelCommand(command);
2747
+ if (formant) {
2748
+ if (formant.type === "fx") await materializeDraft();
2749
+ const result = applyFormantCommand(score, requestedTrack, formant);
1839
2750
  if (result.next && result.kind)
1840
2751
  await commitScore(result.next, result.kind, result.payload);
1841
2752
  return result.ok ? ok(result.message) : fail(result.message);
@@ -1857,6 +2768,8 @@ async function submit(prompt: string): Promise<string | Receipt> {
1857
2768
  const result = applyFxCommand(score, requestedTrack, fx, pinnedIr);
1858
2769
  if (result.next && result.kind)
1859
2770
  await commitScore(result.next, result.kind, result.payload);
2771
+ // Bare `fx` reads the chain (`•`); it changes nothing.
2772
+ if (result.ok && fx.type === "fx-list") return note(result.message);
1860
2773
  return result.ok ? ok(result.message) : fail(result.message);
1861
2774
  }
1862
2775
  const unknownFx = unknownFxMessage(command);
@@ -1872,6 +2785,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
1872
2785
  }
1873
2786
  const tuning = parseTuningCommand(command);
1874
2787
  if (tuning) return tuningCommand(tuning);
2788
+ const calibration = parseCalibrationCommand(command);
2789
+ if (calibration) {
2790
+ const result = applyCalibrationCommand(score, calibration);
2791
+ if (result.next && result.kind)
2792
+ await commitScore(result.next, result.kind, result.payload);
2793
+ return result.ok ? ok(result.message) : fail(result.message);
2794
+ }
2795
+ const recordCommand = parseRecordCommand(command);
2796
+ if (recordCommand) return keysRecord(recordCommand);
1875
2797
  const keysCommand = parseKeysCommand(command);
1876
2798
  if (keysCommand) {
1877
2799
  const reads =
@@ -1893,7 +2815,26 @@ async function submit(prompt: string): Promise<string | Receipt> {
1893
2815
  await commitScore(result.next, result.kind, result.payload);
1894
2816
  return result.ok ? ok(result.message) : fail(result.message);
1895
2817
  }
1896
- const windCommand = parseWindCommand(command);
2818
+ const vocoderCommand = parseVocoderCommand(command);
2819
+ if (vocoderCommand) {
2820
+ if (vocoderCommand.type === "vocoder-list")
2821
+ tui.openText("vocoder presets", vocoderListLines());
2822
+ if (
2823
+ vocoderCommand.type !== "vocoder-list" &&
2824
+ vocoderCommand.type !== "vocoder-usage"
2825
+ )
2826
+ await materializeDraft();
2827
+ const result = applyVocoderCommand(score, requestedTrack, vocoderCommand);
2828
+ if (result.next && result.kind)
2829
+ await commitScore(result.next, result.kind, result.payload);
2830
+ // `/vocoder` on a vocal makes a carrier track: focus it.
2831
+ if (result.trackId && result.trackId !== requestedTrack) {
2832
+ await port.focus(result.trackId);
2833
+ requestedTrack = result.trackId;
2834
+ }
2835
+ return result.ok ? ok(result.message) : fail(result.message);
2836
+ }
2837
+ const windCommand = parseWindCommand(command);
1897
2838
  if (windCommand) {
1898
2839
  if (windCommand.type === "wind-list")
1899
2840
  tui.openText("wind presets", windListLines());
@@ -1904,13 +2845,34 @@ async function submit(prompt: string): Promise<string | Receipt> {
1904
2845
  await commitScore(result.next, result.kind, result.payload);
1905
2846
  return result.ok ? ok(result.message) : fail(result.message);
1906
2847
  }
2848
+ const singCommand = parseSingCommand(command);
2849
+ if (singCommand) {
2850
+ if (singCommand.type === "sing-list")
2851
+ tui.openText("sing presets", singListLines());
2852
+ if (singCommand.type !== "sing-show" && singCommand.type !== "sing-list")
2853
+ await materializeDraft();
2854
+ const result = applySingCommand(score, requestedTrack, singCommand);
2855
+ if (result.next && result.kind)
2856
+ await commitScore(result.next, result.kind, result.payload);
2857
+ return result.ok ? ok(result.message) : fail(result.message);
2858
+ }
1907
2859
  const synth = parseSynthCommand(command);
1908
2860
  if (synth) {
1909
2861
  if (synth.type !== "synth-list") await materializeDraft();
1910
2862
  const result = applySynthCommand(score, requestedTrack, synth);
1911
2863
  if (result.next && result.kind)
1912
2864
  await commitScore(result.next, result.kind, result.payload);
1913
- return result.ok ? ok(result.message) : fail(result.message);
2865
+ // Bare `synth` (design §8.6) also opens the sound page's four knobs.
2866
+ if (synth.type === "synth-list" && result.ok && !stageCapture) {
2867
+ const context = menuContext();
2868
+ const track = context.score.tracks.find((t) => t.id === context.trackId);
2869
+ const label = openKnobPage(context, "sound", track);
2870
+ if (label) {
2871
+ openFader(label, true);
2872
+ refreshMenu();
2873
+ }
2874
+ }
2875
+ return readOrDone(result);
1914
2876
  }
1915
2877
  const stringCommand = parseStringCommand(command);
1916
2878
  if (stringCommand) {
@@ -1922,7 +2884,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1922
2884
  const result = applyStringCommand(score, requestedTrack, stringCommand);
1923
2885
  if (result.next && result.kind)
1924
2886
  await commitScore(result.next, result.kind, result.payload);
1925
- return result.ok ? ok(result.message) : fail(result.message);
2887
+ return readOrDone(result);
1926
2888
  }
1927
2889
  const grainHint = grainSrcHint(command);
1928
2890
  if (grainHint) return fail(grainHint);
@@ -1935,6 +2897,50 @@ async function submit(prompt: string): Promise<string | Receipt> {
1935
2897
  await commitScore(result.next, result.kind, result.payload);
1936
2898
  return result.ok ? ok(result.message) : fail(result.message);
1937
2899
  }
2900
+ const styleCommand = parseStyleCommand(
2901
+ /^\/?styles?\s+presets$/i.test(command) ? "style list" : command,
2902
+ );
2903
+ if (styleCommand) {
2904
+ const writes =
2905
+ styleCommand.type === "style-apply" ||
2906
+ styleCommand.type === "style-again";
2907
+ // Bare `style` opens Arrange › style; `style list|ls|presets` lists.
2908
+ if (
2909
+ styleCommand.type === "style-families" &&
2910
+ /^\/?styles?$/i.test(command)
2911
+ ) {
2912
+ openMenu("style");
2913
+ return ok(
2914
+ "style · ↑/↓ browse · enter makes the song · style list prints it",
2915
+ );
2916
+ }
2917
+ if (writes) await materializeDraft();
2918
+ const result = applyStyleCommand(score, styleCommand);
2919
+ if (result.next && result.kind)
2920
+ await commitScore(result.next, result.kind, result.payload);
2921
+ if (result.log) tui.activity.pushNote(result.log);
2922
+ if (!result.ok) return fail(result.message);
2923
+ // A multi-line listing opens as text; the card keeps its first line
2924
+ // so lines never run together on the one-line strip.
2925
+ if (!writes && result.message.includes("\n")) {
2926
+ const [head = "styles", ...rest] = result.message.split("\n");
2927
+ tui.openText(head, rest);
2928
+ return note(head);
2929
+ }
2930
+ // A new song: focus its first melodic track, so play and the menu
2931
+ // land on something tonal instead of a track the style replaced.
2932
+ const melodic = writes
2933
+ ? result.next?.tracks.find((track) => !isDrumInstrument(track.instrument))
2934
+ : undefined;
2935
+ if (melodic && melodic.id !== requestedTrack) {
2936
+ await port.focus(melodic.id);
2937
+ requestedTrack = melodic.id;
2938
+ draftTrack = false;
2939
+ }
2940
+ return ok(
2941
+ melodic ? `${result.message} · focused ${melodic.id}` : result.message,
2942
+ );
2943
+ }
1938
2944
  const masterCommand = parseMasterCommand(command);
1939
2945
  if (masterCommand) {
1940
2946
  if (masterCommand.type === "master-measure")
@@ -1947,7 +2953,82 @@ async function submit(prompt: string): Promise<string | Receipt> {
1947
2953
  const result = applyMasterCommand(score, masterCommand);
1948
2954
  if (result.next && result.kind)
1949
2955
  await commitScore(result.next, result.kind, result.payload);
1950
- return result.ok ? ok(result.message) : fail(result.message);
2956
+ return readOrDone(result);
2957
+ }
2958
+ // Range commands (op1-ux §6.4): copy/move/clear/paste/reverse bars,
2959
+ // bars insert|remove, loop next|prev, jump.
2960
+ const rangeCommand = parseRangeCommand(command, score);
2961
+ if (rangeCommand) {
2962
+ const reads =
2963
+ rangeCommand.type === "range-usage" ||
2964
+ rangeCommand.type === "jump-bar" ||
2965
+ rangeCommand.type === "jump-section" ||
2966
+ (rangeCommand.type === "range-copy" && rangeCommand.to === undefined);
2967
+ if (!reads) await materializeDraft();
2968
+ // TAPE's first paste after a cut (§6.2): the `move` reads the bars from
2969
+ // the score before the cut's clear, so cut and paste land as one move.
2970
+ const folded =
2971
+ rangeCommand.type === "range-move" &&
2972
+ tape.foldArmed &&
2973
+ tape.cut &&
2974
+ cutArmed(tape.cut.after, score)
2975
+ ? foldBase(tape.cut.before, score)
2976
+ : undefined;
2977
+ tape.foldArmed = undefined;
2978
+ const cutBase =
2979
+ rangeCommand.type === "range-clear" ? tape.cutArmed : undefined;
2980
+ if (rangeCommand.type === "range-clear") tape.cutArmed = undefined;
2981
+ const result = applyRangeCommand(
2982
+ folded ?? score,
2983
+ {
2984
+ trackId: requestedTrack,
2985
+ playheadBar: playheadBar(),
2986
+ ...(rangeClipboard ? { clipboard: rangeClipboard } : {}),
2987
+ },
2988
+ rangeCommand,
2989
+ );
2990
+ if (result.delegate) return submit(result.delegate);
2991
+ if (result.clipboard) {
2992
+ rangeClipboard = result.clipboard;
2993
+ tape.cut = undefined;
2994
+ void saveClipboard(paneClipboardPath(), rangeClipboard).catch(
2995
+ () => undefined,
2996
+ );
2997
+ }
2998
+ if (result.next && result.kind)
2999
+ await commitScore(result.next, result.kind, result.payload);
3000
+ if (cutBase && result.next) tape.cut = { before: cutBase, after: score };
3001
+ else if (result.next && result.kind) tape.cut = undefined;
3002
+ if (result.seekBeat !== undefined) await seekTransport(result.seekBeat);
3003
+ return readOrDone(result);
3004
+ }
3005
+ // `loop <a>-<b> | <section> | off`: what playback loops. Bars set the
3006
+ // loop range (score.loop); a name loops that section.
3007
+ const loop = parseLoopCommand(command);
3008
+ if (loop) {
3009
+ if (loop.type === "loop-show")
3010
+ return note(
3011
+ score.loop
3012
+ ? `loop · bars ${rangeLabel(score.loop)} · loop off plays the song`
3013
+ : score.loopSection
3014
+ ? `loop · ${score.loopSection} · loop off plays the song`
3015
+ : `loop · the song · ${LOOP_USAGE}`,
3016
+ );
3017
+ if (loop.type === "loop-off") {
3018
+ if (score.loop) {
3019
+ await materializeDraft();
3020
+ await commitScore(score.withLoop(null), "score.loop", { loop: null });
3021
+ return ok("loop · off · playing the song");
3022
+ }
3023
+ return submit("section loop off");
3024
+ }
3025
+ if (loop.type === "loop-section")
3026
+ return submit(`section loop ${loop.name}`);
3027
+ const result = loopSpan(score, loop.from, loop.to);
3028
+ if (!result.ok) return fail(result.message);
3029
+ await materializeDraft();
3030
+ await commitScore(result.next, "score.loop", { loop: result.next.loop });
3031
+ return ok(result.message);
1951
3032
  }
1952
3033
  const arrange = parseSectionCommand(command, score);
1953
3034
  if (arrange) {
@@ -1961,32 +3042,33 @@ async function submit(prompt: string): Promise<string | Receipt> {
1961
3042
  if (result.next && result.kind)
1962
3043
  await commitScore(result.next, result.kind, result.payload);
1963
3044
  if (result.seekBeat !== undefined) await seekTransport(result.seekBeat);
1964
- return result.ok ? ok(result.message) : fail(result.message);
3045
+ return readOrDone(result);
1965
3046
  }
1966
3047
  const music = parseMusicCommand(command);
1967
3048
  if (music) {
1968
3049
  await materializeDraft();
1969
3050
  const result = applyMusicCommand(score, requestedTrack, music, () =>
1970
- randomUUID().slice(0, 12),
3051
+ newId("n"),
1971
3052
  );
1972
3053
  if (result.next && result.kind)
1973
3054
  await commitScore(result.next, result.kind, result.payload);
1974
3055
  return result.ok ? ok(result.message) : fail(result.message);
1975
3056
  }
1976
- const exportCommand = command.match(/^\/?export\s+([^\s]+)$/i);
3057
+ const exportCommand = parseExportCommand(command);
1977
3058
  if (exportCommand) {
1978
- const path = resolve(exportCommand[1]!);
1979
- if (/\.midi?$/i.test(path)) {
3059
+ const path = resolve(exportCommand.path);
3060
+ if (exportCommand.format === "mid") {
1980
3061
  await writeFile(path, scoreToMidi(exportScore(score)));
1981
- return `exported midi · ${exportCommand[1]}`;
3062
+ return `exported midi · ${exportCommand.path}`;
1982
3063
  }
1983
- // Audio comes from the offline renderer, never JSON under an audio name.
1984
- if (/\.(wav|aiff?|flac|mp3|ogg|m4a)$/i.test(path))
1985
- return fail(
1986
- `/export writes .track.json or .mid · render audio with: dawg render ${exportCommand[1]}`,
1987
- );
3064
+ // Audio goes through the `dawg render` path, the same bytes as the CLI.
3065
+ if (exportCommand.format === "wav")
3066
+ return exportWav(exportCommand.path, exportCommand.stems);
3067
+ // Other audio names are not formats dawg writes.
3068
+ if (/\.(aiff?|flac|mp3|ogg|m4a)$/i.test(path))
3069
+ return fail(`export · ${exportCommand.path} · ${EXPORT_USAGE}`);
1988
3070
  await writeFile(path, encodeLoop(score), "utf8");
1989
- return `exported · ${exportCommand[1]}`;
3071
+ return `exported · ${exportCommand.path}`;
1990
3072
  }
1991
3073
  const importCommand = command.match(/^\/?import\s+([^\s]+)$/i);
1992
3074
  if (importCommand) {
@@ -2021,14 +3103,14 @@ async function submit(prompt: string): Promise<string | Receipt> {
2021
3103
  tui.activity.setSpinner(undefined);
2022
3104
  }
2023
3105
  }
2024
- const receiptText = await tuiSetModel(modelMatch[1]);
3106
+ const result = await tuiSetModel(modelMatch[1]);
2025
3107
  provider = undefined;
2026
3108
  await currentProvider();
2027
- return receiptText;
3109
+ return result.ok ? result.text : fail(result.text);
2028
3110
  }
2029
3111
  if (/^\/login\b/i.test(prompt.trim())) {
2030
3112
  const parsed = tuiLoginArgs(prompt.trim());
2031
- if (typeof parsed === "string") return `login · ${parsed}`;
3113
+ if (typeof parsed === "string") return `model key · ${parsed}`;
2032
3114
  await handoff(() =>
2033
3115
  runTuiLogin(
2034
3116
  parsed.target === "auto" ? "pick" : parsed.target,
@@ -2038,8 +3120,8 @@ async function submit(prompt: string): Promise<string | Receipt> {
2038
3120
  provider = undefined;
2039
3121
  await currentProvider();
2040
3122
  return providerName === "offline"
2041
- ? "login · not signed in · direct commands still work"
2042
- : `signed in · ${providerName}`;
3123
+ ? "no agent key · model key adds one · direct commands still work"
3124
+ : `agent key · ${providerName}`;
2043
3125
  }
2044
3126
  if (/^\/(logout|auth)\b/i.test(prompt.trim())) {
2045
3127
  tui.activity.setSpinner(prompt.trim().split(/\s+/)[0]!.slice(1));
@@ -2054,8 +3136,46 @@ async function submit(prompt: string): Promise<string | Receipt> {
2054
3136
  tui.activity.setSpinner(undefined);
2055
3137
  }
2056
3138
  }
3139
+ // The grammar's second reading (design §3): a line no handler took runs
3140
+ // in its other spellings — `/x` ≡ `x`, aliases, `rm|remove|delete`,
3141
+ // `list|presets|ls` — so only lines that failed before get one.
3142
+ // A line the core prompt parser takes is already understood.
3143
+ if (!recovering && !parsePrompt(prompt)) {
3144
+ const retry = recover(
3145
+ command,
3146
+ (candidate) =>
3147
+ parseExact(candidate, score) !== undefined ||
3148
+ // Window verbs get only the slash toggle, never a listing word.
3149
+ (candidate.startsWith("/") &&
3150
+ SLASH_HANDLED.has(verbOf(candidate)) &&
3151
+ candidate.split(/\s+/).length === command.trim().split(/\s+/).length),
3152
+ );
3153
+ if (retry !== undefined) {
3154
+ recovering = true;
3155
+ try {
3156
+ return await submit(retry);
3157
+ } finally {
3158
+ recovering = false;
3159
+ }
3160
+ }
3161
+ }
3162
+ // Bare `rename`, `fork`, `resume`, `login` take free text: a hint only.
3163
+ const freeText = FREE_TEXT_HINTS[command.toLowerCase()];
3164
+ if (freeText !== undefined) return note(freeText);
3165
+ const elsewhere = elsewhereHint(command);
3166
+ if (elsewhere !== undefined) return note(elsewhere);
2057
3167
  // `/model` belongs to its own handler above; every other slash word that
2058
3168
  // reached here is unknown or misused, and never a question for the agent.
3169
+ // `/instrument aah` is `instrument aah`; `instrument sing choir` is
3170
+ // `instrument choir`.
3171
+ const singWord = command.match(/^\/?instrument\s+sing\s+([a-z]+)$/i);
3172
+ if (singWord && isSingWord(singWord[1]!.toLowerCase()))
3173
+ return submit(`instrument ${singWord[1]!.toLowerCase()}`);
3174
+ if (/^\/instrument\s/i.test(command) && parsePrompt(command.slice(1)))
3175
+ return submit(command.slice(1));
3176
+ // A synth or effect number out of range names its range.
3177
+ const range = paramRangeError(command);
3178
+ if (range) return fail(range);
2059
3179
  if (command.startsWith("/") && !/^\/model\b/i.test(command)) {
2060
3180
  const hint = usageHint(command);
2061
3181
  return fail(
@@ -2070,6 +3190,10 @@ async function submit(prompt: string): Promise<string | Receipt> {
2070
3190
  // A one-letter slip on a command whose arguments parse stays local.
2071
3191
  const fix = typoFix(command, parsesLocally);
2072
3192
  if (fix) return fail(`${truncateForCard(command)} · did you mean ${fix}?`);
3193
+ // A grammar verb (not everyday English) never reaches the agent.
3194
+ const verb = verbOf(command);
3195
+ if (knownVerbs().has(verb) && !EVERYDAY_VERBS.has(verb))
3196
+ return fail(usageCard(truncateForCard(command), `/help ${verb}`));
2073
3197
  if (process.env.DAWG_AI === "0")
2074
3198
  return fail(`unrecognized · ${truncateForCard(command)} · /help`);
2075
3199
  await materializeDraft();
@@ -2099,6 +3223,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
2099
3223
  return parsed.action;
2100
3224
  }
2101
3225
  if (parsed.type === "set-tempo") {
3226
+ const range = RANGES.tempo!;
3227
+ if (parsed.tempoBpm < range.min || parsed.tempoBpm > range.max)
3228
+ return fail(usageError(truncateForCard(command), range));
2102
3229
  const next = score.withTempo(parsed.tempoBpm);
2103
3230
  await commitScore(next, "score.tempo", { tempoBpm: parsed.tempoBpm });
2104
3231
  clock.follow(next);
@@ -2126,15 +3253,10 @@ async function submit(prompt: string): Promise<string | Receipt> {
2126
3253
  if (parsed.type === "track") {
2127
3254
  // An unknown word would store and play a plain sine: say so instead.
2128
3255
  const word = parsed.patch.instrument;
2129
- if (word !== undefined && isUnknownInstrument(word)) {
2130
- const advice = plainSineAdvice(word) ?? "";
2131
- const near = advice.match(/did you mean (\S+)\?/)?.[1];
2132
- return fail(
2133
- `instrument ${truncateForCard(word)} · not a dawg instrument${near ? ` · did you mean ${near}?` : ""} · /menu sounds lists them`,
2134
- );
2135
- }
3256
+ if (word !== undefined && isUnknownInstrument(word))
3257
+ return fail(unknownInstrumentMessage(word));
2136
3258
  // `instrument jangle`: a guitar alias also loads its rig.
2137
- const patch = parsed.word
3259
+ const rigged = parsed.word
2138
3260
  ? {
2139
3261
  ...parsed.patch,
2140
3262
  ...rigWordPatch(
@@ -2143,6 +3265,27 @@ async function submit(prompt: string): Promise<string | Receipt> {
2143
3265
  ),
2144
3266
  }
2145
3267
  : parsed.patch;
3268
+ // `instrument vocal` (0.7): the vocal chain fills unset effects.
3269
+ const chain = isGuideInstrument(parsed.patch.instrument)
3270
+ ? vocalChainPatch(score.tracks.find((t) => t.id === requestedTrack))
3271
+ : {};
3272
+ const patch = { ...chain, ...rigged };
3273
+ // `volume drums 0.5` names its track; it must exist.
3274
+ if (parsed.trackId !== undefined) {
3275
+ const target = score.tracks.find(
3276
+ (candidate) =>
3277
+ candidate.id === parsed.trackId ||
3278
+ candidate.name.toLowerCase() === parsed.trackId,
3279
+ );
3280
+ if (!target) return fail(`no track ${parsed.trackId}`);
3281
+ const next = applyScoreOperation(score, {
3282
+ type: "updateTrack",
3283
+ trackId: target.id,
3284
+ patch,
3285
+ });
3286
+ await commitScore(next, "score.track", { trackId: target.id, patch });
3287
+ return `track · ${target.id}`;
3288
+ }
2146
3289
  const next = applyScoreOperation(score, {
2147
3290
  type: "updateTrack",
2148
3291
  trackId: requestedTrack,
@@ -2163,6 +3306,13 @@ async function submit(prompt: string): Promise<string | Receipt> {
2163
3306
  ? plainSineAdvice(word)
2164
3307
  : undefined;
2165
3308
  if (sine) return `track · ${requestedTrack} · ${sine}`;
3309
+ const added = [
3310
+ chain.filter ? "hpf 90 Hz" : "",
3311
+ chain.fx?.compressor ? "compressor 3:1" : "",
3312
+ chain.reverb ? "plate 0.14" : "",
3313
+ ].filter(Boolean);
3314
+ if (added.length)
3315
+ return `track · ${requestedTrack} · vocal · added ${added.join(", ")} · fx … off to remove`;
2166
3316
  return `track · ${requestedTrack}`;
2167
3317
  }
2168
3318
  if (parsed.type === "automation") {
@@ -2207,6 +3357,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
2207
3357
  return `cleared · ${requestedTrack}`;
2208
3358
  }
2209
3359
  if (parsed.type === "remove-note") {
3360
+ // A receipt never claims a removal that did not happen.
3361
+ if (!score.notes.some((note) => note.id === parsed.noteId))
3362
+ return fail(noNote(parsed.noteId));
2210
3363
  const next = applyScoreOperation(score, {
2211
3364
  type: "removeNote",
2212
3365
  noteId: parsed.noteId,
@@ -2254,7 +3407,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
2254
3407
  const latest = attempt === 0 ? record : await port.load();
2255
3408
  const latestScore = scoreFromJSON(latest.composition);
2256
3409
  const note = {
2257
- id: `${requestedTrack}-${latest.revision + 1}-${randomUUID().slice(0, 6)}`,
3410
+ id: newId(requestedTrack),
2258
3411
  trackId: requestedTrack,
2259
3412
  startTick: Math.round(parsed.start * latestScore.ticksPerBeat),
2260
3413
  durationTicks: Math.max(
@@ -2291,17 +3444,26 @@ async function submit(prompt: string): Promise<string | Receipt> {
2291
3444
  * not in the score yet. A track another live window has focused stays
2292
3445
  * theirs; focus goes through the port so presence is right everywhere.
2293
3446
  */
2294
- async function focusTrack(trackId: string): Promise<Receipt> {
3447
+ async function focusTrack(
3448
+ trackId: string,
3449
+ options: { follow?: boolean } = {},
3450
+ ): Promise<Receipt> {
2295
3451
  if (trackId === requestedTrack && !draftTrack)
2296
3452
  return warn(`already on ${trackId}`);
3453
+ if (paneOptions.pinned)
3454
+ return warn(`pinned to ${requestedTrack} · unpin to change`);
3455
+ // Typing a track yourself stops following (§12.5).
3456
+ if (!options.follow && paneOptions.follow) paneOptions.follow = undefined;
3457
+ // Focus is not exclusive (§12.5): a track another pane shows is a soft
3458
+ // note on the receipt, never a refusal.
2297
3459
  const clients = await port.presence().catch(() => []);
2298
- if (
2299
- clients.some(
2300
- (client) =>
2301
- client.clientId !== port.clientId && client.focusedTrackId === trackId,
2302
- )
2303
- )
2304
- return warn(`${trackId} is open in another window`);
3460
+ const sharing = clients.find(
3461
+ (client) =>
3462
+ client.clientId !== port.clientId && client.focusedTrackId === trackId,
3463
+ );
3464
+ const shared = sharing
3465
+ ? ` · ${sharing.pane ? `pane ${sharing.pane}` : "another pane"} is on ${trackId} too`
3466
+ : "";
2305
3467
  const existing = score.tracks.find((track) => track.id === trackId);
2306
3468
  const exists = existing !== undefined;
2307
3469
  // `track cloud`, `track hold-2`: a new granular track with that preset.
@@ -2324,6 +3486,10 @@ async function focusTrack(trackId: string): Promise<Receipt> {
2324
3486
  ...newPianoTrack(trackId),
2325
3487
  // `track vibes`: a 0.6 modal word names the track and its preset.
2326
3488
  ...(isModalWord(trackId) ? instrumentPatchForWord(trackId) : {}),
3489
+ // `track vocal` (0.7): a clip track with the vocal chain.
3490
+ ...(isGuideInstrument(trackId)
3491
+ ? { instrument: "vocal", ...vocalChainPatch(undefined) }
3492
+ : {}),
2327
3493
  },
2328
3494
  });
2329
3495
  await commitScore(next, "track.create", { trackId });
@@ -2336,11 +3502,11 @@ async function focusTrack(trackId: string): Promise<Receipt> {
2336
3502
  `track · ${trackId} is a ${existing.instrument} track · grain ${grainPreset} makes it granular`,
2337
3503
  );
2338
3504
  return ok(
2339
- exists
3505
+ (exists
2340
3506
  ? `track · ${trackId}`
2341
3507
  : grainPreset
2342
3508
  ? `track created · ${trackId} · granular ${grainPreset} · nothing to download`
2343
- : `track created · ${trackId}`,
3509
+ : `track created · ${trackId}`) + shared,
2344
3510
  );
2345
3511
  }
2346
3512
 
@@ -2624,11 +3790,19 @@ async function packCommand(command: PackCommand): Promise<Receipt> {
2624
3790
  const store = packs();
2625
3791
  const a = await store.pruneCache(to(store.maxFileCacheBytes));
2626
3792
  const b = await library.pruneCache(to(library.maxCacheBytes));
2627
- freed = { files: a.removed + b.removed, bytes: a.freed + b.freed };
3793
+ const c = await pruneAnalysisCache(
3794
+ analysisDir(process.cwd()),
3795
+ to(ANALYSIS_CACHE_BYTES),
3796
+ );
3797
+ freed = {
3798
+ files: a.removed + b.removed + c.removed,
3799
+ bytes: a.freed + b.freed + c.freed,
3800
+ };
2628
3801
  }
2629
3802
  const lines = cacheLines({
2630
3803
  packs: await packs().cacheStatus(),
2631
3804
  assets: await library.cacheStatus(),
3805
+ analysis: await analysisCacheStatus(process.cwd()),
2632
3806
  ...(freed ? { freed } : {}),
2633
3807
  });
2634
3808
  tui.openText("cache", lines);
@@ -2703,27 +3877,38 @@ async function packCommand(command: PackCommand): Promise<Receipt> {
2703
3877
  }
2704
3878
  }
2705
3879
 
3880
+ /**
3881
+ * A command result as a receipt: a refusal is ✗, a change is ✓, and a read
3882
+ * that changed nothing (a listing or a show) is •.
3883
+ */
3884
+ function readOrDone(
3885
+ result: Readonly<{ ok: boolean; message: string; next?: unknown }>,
3886
+ ): Receipt {
3887
+ if (!result.ok) return fail(result.message);
3888
+ return result.next ? ok(result.message) : note(result.message);
3889
+ }
3890
+
2706
3891
  async function patternCommand(command: PatternCommand): Promise<Receipt> {
2707
3892
  if (command.kind === "list") {
2708
- tui.openText("patterns", DRUM_PATTERNS.map(patternLine));
2709
- return ok(`patterns · ${DRUM_PATTERNS.length} · /pattern <name>`);
3893
+ tui.openText("grooves", DRUM_PATTERNS.map(patternLine));
3894
+ return note(`grooves · ${DRUM_PATTERNS.length} · groove <name>`);
2710
3895
  }
2711
3896
  if (command.kind === "browse") {
2712
3897
  tui.openPicker({
2713
3898
  id: "pattern",
2714
- title: "drum patterns",
3899
+ title: "grooves",
2715
3900
  hint: HINTS.audition,
2716
3901
  audition: true,
2717
3902
  items: DRUM_PATTERNS.map((entry) => ({
2718
3903
  label: `${entry.label.padEnd(22)} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
2719
- value: `/pattern ${entry.name}`,
3904
+ value: `/groove ${entry.name}`,
2720
3905
  })),
2721
3906
  filterable: true,
2722
3907
  index: 0,
2723
3908
  });
2724
3909
  patternPreview = undefined;
2725
3910
  previewPattern(DRUM_PATTERNS[0]?.name);
2726
- return ok("patterns · ↑/↓ preview · Enter applies on the focused track");
3911
+ return note("grooves · ↑/↓ preview · Enter applies on the focused track");
2727
3912
  }
2728
3913
  await materializeDraft();
2729
3914
  const result = applyDrumPattern(
@@ -2784,7 +3969,7 @@ function openKitPicker(): Receipt {
2784
3969
  }));
2785
3970
  tui.openPicker({
2786
3971
  id: "kit",
2787
- title: "drum kits",
3972
+ title: "kits",
2788
3973
  hint: HINTS.audition,
2789
3974
  audition: true,
2790
3975
  items,
@@ -2816,7 +4001,7 @@ async function kitCommand(command: KitCommand, bare = false): Promise<Receipt> {
2816
4001
  .map((entry) => `${entry.name} · synth · ${entry.detail}`);
2817
4002
  lines.push(...kitListLines(await packs().bankAliases(ALIASED_PACK)));
2818
4003
  tui.openText("kits", lines);
2819
- return ok("kits · /kit <name or nickname> on the focused drum track");
4004
+ return note("kits · /kit <name or nickname> on the focused drum track");
2820
4005
  }
2821
4006
  await materializeDraft();
2822
4007
  const trackId = kitTarget(score, requestedTrack);
@@ -2927,12 +4112,10 @@ function adoptMeta(meta: typeof record.meta): void {
2927
4112
  record = { ...record, meta };
2928
4113
  if (meta.name !== announcedName) {
2929
4114
  announcedName = meta.name;
2930
- tui.activity.pushCard(
2931
- meta.nameSource === "auto"
2932
- ? `${meta.name} (auto-named) · rename with /rename <name>`
2933
- : `session · ${meta.name}`,
2934
- { tone: "info" },
2935
- );
4115
+ // The auto-name joins the receipt that earned it, as a quiet suffix.
4116
+ if (meta.nameSource === "auto")
4117
+ tui.activity.attachNote(`named “${meta.name}” · /rename`);
4118
+ else tui.activity.pushCard(`session · ${meta.name}`, { tone: "info" });
2936
4119
  }
2937
4120
  }
2938
4121
 
@@ -3046,6 +4229,7 @@ async function switchSession(sessionId: string): Promise<void> {
3046
4229
  setCurrent: true,
3047
4230
  });
3048
4231
  port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
4232
+ actor,
3049
4233
  paths: next.paths,
3050
4234
  sessionId,
3051
4235
  label: "window",
@@ -3072,13 +4256,269 @@ function liveEngine(): LiveEngine | undefined {
3072
4256
  return previewEngine();
3073
4257
  }
3074
4258
 
4259
+ /**
4260
+ * The engine play mode sounds through: dawgd's shared engine when the
4261
+ * daemon offers one (every pane's notes and one click mix there, §12.3),
4262
+ * else this window's own monitor. Never both.
4263
+ */
4264
+ function playEngine(): LiveEngine | undefined {
4265
+ const link = port.liveLink();
4266
+ if (!link) return previewEngine();
4267
+ if (sharedLive?.link !== link)
4268
+ sharedLive = { link, engine: new SharedLiveEngine(link) };
4269
+ return sharedLive.engine;
4270
+ }
4271
+
4272
+ /**
4273
+ * What this pane shows, for the other panes' presence markers (§12.7):
4274
+ * sent only when it changes, and only to a daemon that has panes.
4275
+ */
4276
+ function paneView(): PaneView {
4277
+ const session = play;
4278
+ const view: PaneView = {
4279
+ screen: session?.on
4280
+ ? "play"
4281
+ : tape.on
4282
+ ? "tape"
4283
+ : fader
4284
+ ? "sound"
4285
+ : menu.open
4286
+ ? "menu"
4287
+ : "home",
4288
+ };
4289
+ if (fader) view.param = fader.label.slice(0, 64);
4290
+ if (session?.on) view.playing = true;
4291
+ if (session?.recording)
4292
+ view.recording = session.replace ? "replace" : "overdub";
4293
+ if (paneOptions.pinned) view.pinned = true;
4294
+ if (paneOptions.follow) view.follow = paneOptions.follow;
4295
+ return view;
4296
+ }
4297
+ function reportView(): void {
4298
+ const view = paneView();
4299
+ const key = JSON.stringify(view);
4300
+ if (key === reportedView) return;
4301
+ reportedView = key;
4302
+ port.setView(view);
4303
+ }
4304
+
4305
+ /**
4306
+ * `dawg pane <screen> …` at launch: focus its track, then open the screen
4307
+ * through the same commands a person would type, so the pane starts where
4308
+ * the shell line said.
4309
+ */
4310
+ async function openPane(pane: PaneArgs): Promise<void> {
4311
+ paneOptions.pinned = pane.pin;
4312
+ paneOptions.follow = pane.follow;
4313
+ let result: string | Receipt | undefined;
4314
+ if (pane.screen === "play") result = await enterPlay();
4315
+ else if (pane.screen === "tape") result = await enterTape();
4316
+ else if (pane.screen === "sound")
4317
+ result = await submit(pane.param ?? "volume");
4318
+ else if (pane.screen === "menu")
4319
+ result = await submit(`/menu${pane.param ? ` ${pane.param}` : ""}`);
4320
+ if (result !== undefined) receipt(result);
4321
+ }
4322
+
4323
+ /**
4324
+ * Other panes on `trackId`, for a track row's gutter (§12.7): their letters,
4325
+ * a red-dot `●` after a pane that is recording. Empty for a solo window.
4326
+ */
4327
+ function paneMarks(trackId: string): string {
4328
+ const marks = panePresence
4329
+ .filter(
4330
+ (entry) =>
4331
+ entry.clientId !== port.clientId &&
4332
+ entry.pane &&
4333
+ entry.focusedTrackId === trackId,
4334
+ )
4335
+ .map((entry) => `${entry.pane}${entry.recording ? "●" : ""}`);
4336
+ return marks.length ? ` ${marks.join(" ")}` : "";
4337
+ }
4338
+
4339
+ /**
4340
+ * Another pane recording on `trackId` (§12.4): `pane B`, else undefined.
4341
+ * Play refuses to replace a pass while one is, naming it.
4342
+ */
4343
+ function recordingPane(trackId: string): string | undefined {
4344
+ const other = panePresence.find(
4345
+ (entry) =>
4346
+ entry.clientId !== port.clientId &&
4347
+ entry.recording !== undefined &&
4348
+ entry.focusedTrackId === trackId,
4349
+ );
4350
+ if (!other) return undefined;
4351
+ return other.pane ? `pane ${other.pane}` : "another pane";
4352
+ }
4353
+
4354
+ /** A drawer row another pane has open on this track shows its letter. */
4355
+ function withPaneMarks(view: DrawerView): DrawerView {
4356
+ const others = panePresence.filter(
4357
+ (entry) =>
4358
+ entry.clientId !== port.clientId &&
4359
+ entry.pane &&
4360
+ entry.param &&
4361
+ entry.screen === "sound" &&
4362
+ entry.focusedTrackId === requestedTrack,
4363
+ );
4364
+ if (others.length === 0) return view;
4365
+ return {
4366
+ ...view,
4367
+ fields: view.fields.map((field) => {
4368
+ const peers = others
4369
+ .filter((entry) => entry.param === field.label)
4370
+ .map((entry) => entry.pane)
4371
+ .join(" ");
4372
+ return peers ? { ...field, peers } : field;
4373
+ }),
4374
+ };
4375
+ }
4376
+
4377
+ /** One pane's line for `pane`: letter, screen, track, parameter, state. */
4378
+ function paneLine(entry: PresenceEntry): string {
4379
+ const bits = [
4380
+ `${entry.pane ?? "·"} ${entry.screen ?? "home"}`,
4381
+ entry.focusedTrackId ?? "no track",
4382
+ ];
4383
+ if (entry.param) bits.push(entry.param);
4384
+ if (entry.recording) bits.push(`● ${entry.recording}`);
4385
+ if (entry.pinned) bits.push("pinned");
4386
+ if (entry.follow) bits.push(`follows ${entry.follow}`);
4387
+ if (entry.clientId === port.clientId) bits.push("this pane");
4388
+ return bits.join(" · ");
4389
+ }
4390
+
4391
+ /**
4392
+ * `pane` lists panes; `pane <screen> [track]…` prints the shell line that
4393
+ * opens one (dawg can't open terminal windows portably), with the tmux
4394
+ * split line inside tmux, and copies it via OSC 52. `pin`, `unpin`,
4395
+ * `follow [letter]`, `unfollow`. Undefined: not a pane command.
4396
+ */
4397
+ function paneCommand(command: string): Receipt | undefined {
4398
+ const text = command.trim();
4399
+ if (/^\/?panes?$/i.test(text)) {
4400
+ if (port.mode !== "daemon")
4401
+ return ok("solo pane · no daemon · DAWG_DAEMON=0 is set");
4402
+ const entries = [...panePresence].sort((a, b) =>
4403
+ (a.pane ?? "~").localeCompare(b.pane ?? "~"),
4404
+ );
4405
+ if (entries.length === 0) return ok("1 pane · this one");
4406
+ return ok(`${entries.length} panes │ ${entries.map(paneLine).join(" │ ")}`);
4407
+ }
4408
+ const open = text.match(/^\/?pane\s+(.+)$/i);
4409
+ if (open) {
4410
+ const parsed = parsePaneArgs(open[1]!.split(/\s+/));
4411
+ if (!parsed.ok) return fail(parsed.problem);
4412
+ const line = `dawg pane ${open[1]!.trim().toLowerCase()}`;
4413
+ copyToClipboard(line);
4414
+ return ok(
4415
+ process.env.TMUX
4416
+ ? `run · tmux split-window ${line} · copied`
4417
+ : `run in another terminal · ${line} · copied`,
4418
+ );
4419
+ }
4420
+ if (/^\/?pin$/i.test(text)) {
4421
+ paneOptions.pinned = true;
4422
+ paneOptions.follow = undefined;
4423
+ return ok(`pinned to ${requestedTrack} · unpin releases`);
4424
+ }
4425
+ if (/^\/?unpin$/i.test(text)) {
4426
+ if (!paneOptions.pinned) return warn("not pinned");
4427
+ paneOptions.pinned = false;
4428
+ return ok("unpinned");
4429
+ }
4430
+ const follow = text.match(/^\/?follow(?:\s+([a-z]))?$/i);
4431
+ if (follow) {
4432
+ if (port.mode !== "daemon") return warn("follow needs dawgd · solo pane");
4433
+ paneOptions.pinned = false;
4434
+ paneOptions.follow = follow[1]?.toUpperCase() ?? "*";
4435
+ followPresence([], panePresence);
4436
+ return ok(
4437
+ paneOptions.follow === "*"
4438
+ ? "following the latest pane's track"
4439
+ : `following pane ${paneOptions.follow}`,
4440
+ );
4441
+ }
4442
+ if (/^\/?unfollow$/i.test(text)) {
4443
+ if (!paneOptions.follow) return warn("not following");
4444
+ paneOptions.follow = undefined;
4445
+ return ok("unfollowed");
4446
+ }
4447
+ return undefined;
4448
+ }
4449
+
4450
+ /** OSC 52: the terminal copies `text` where it supports it; else a no-op. */
4451
+ function copyToClipboard(text: string): void {
4452
+ if (!stdout.isTTY) return;
4453
+ stdout.write(`\u001b]52;c;${Buffer.from(text).toString("base64")}\u0007`);
4454
+ }
4455
+
4456
+ /**
4457
+ * A following pane takes the track its target focuses: one letter, or for
4458
+ * `*` the other pane whose focus changed most recently. Pinned panes never
4459
+ * move on someone else's focus.
4460
+ */
4461
+ function followPresence(
4462
+ before: readonly PresenceEntry[],
4463
+ after: readonly PresenceEntry[],
4464
+ ): void {
4465
+ const target = paneOptions.follow;
4466
+ if (!target || paneOptions.pinned) return;
4467
+ const others = after.filter((entry) => entry.clientId !== port.clientId);
4468
+ let next: string | null | undefined;
4469
+ if (target !== "*")
4470
+ next = others.find((entry) => entry.pane === target)?.focusedTrackId;
4471
+ else {
4472
+ const moved = others.filter(
4473
+ (entry) =>
4474
+ before.find((old) => old.clientId === entry.clientId)
4475
+ ?.focusedTrackId !== entry.focusedTrackId,
4476
+ );
4477
+ next = (moved.at(-1) ?? (before.length === 0 ? others.at(-1) : undefined))
4478
+ ?.focusedTrackId;
4479
+ }
4480
+ if (!next || next === requestedTrack) return;
4481
+ void focusTrack(next, { follow: true }).then((result) => {
4482
+ if (result.ok) {
4483
+ receipt(
4484
+ ok(
4485
+ `follows ${target === "*" ? "latest pane" : `pane ${target}`} · ${next}`,
4486
+ ),
4487
+ );
4488
+ tickUi();
4489
+ }
4490
+ });
4491
+ }
4492
+
3075
4493
  /** The engine this window hears itself through (its own in daemon mode). */
3076
4494
  function previewEngine(): AudioEngine {
3077
4495
  if (audio instanceof AudioEngine) return audio;
3078
- monitorEngine ??= new AudioEngine({ projectRoot: process.cwd() });
4496
+ monitorEngine ??= new AudioEngine({
4497
+ projectRoot: process.cwd(),
4498
+ onStatus: (status) => {
4499
+ if (status.state === "device")
4500
+ tui.activity.pushCard(`audio · ${status.message}`, { tone: "warning" });
4501
+ },
4502
+ });
3079
4503
  return monitorEngine;
3080
4504
  }
3081
4505
 
4506
+ /** This machine's player, without starting an engine just to ask. */
4507
+ function audioInfo(): AudioBackendInfo {
4508
+ if (audio instanceof AudioEngine) return audio.info;
4509
+ if (monitorEngine) return monitorEngine.info;
4510
+ audioInfoCache ??= detectAudioBackend();
4511
+ return audioInfoCache;
4512
+ }
4513
+
4514
+ /** Project › audio's rows; device lists are re-read at most every 3 s. */
4515
+ function audioMenu(): MenuAudioDevices {
4516
+ const now = performance.now();
4517
+ if (!audioMenuCache || now - audioMenuCache.at > 3000)
4518
+ audioMenuCache = { at: now, state: audioMenuState(audioInfo()) };
4519
+ return audioMenuCache.state;
4520
+ }
4521
+
3082
4522
  /** The play session for the focused track (re-made when focus moves). */
3083
4523
  function menuContext(): MenuContext {
3084
4524
  const session = play;
@@ -3096,11 +4536,14 @@ function menuContext(): MenuContext {
3096
4536
  committedChords: chordSettings,
3097
4537
  },
3098
4538
  trackId: requestedTrack,
4539
+ sessionName: record.meta.name,
3099
4540
  playing: clock.playing,
3100
4541
  grid: session?.grid ?? DEFAULT_GRID,
3101
4542
  grids: GRIDS.map((grid) => grid.label),
3102
4543
  clickOn: session?.clickOn ?? false,
3103
4544
  countInBars: session?.countInBars ?? 1,
4545
+ showMe: showMe.level,
4546
+ audio: audioMenu(),
3104
4547
  chords: stagedChordSettings() ?? session?.chords.settings ?? chordSettings,
3105
4548
  projectRoot: process.cwd(),
3106
4549
  };
@@ -3125,6 +4568,7 @@ function keysScreen(): readonly KeySection[] | undefined {
3125
4568
  if (prompt.value.length > 0) return undefined;
3126
4569
  if (play?.on)
3127
4570
  return play.chords.on ? [...KEYS.play, ...KEYS.chords] : KEYS.play;
4571
+ if (tape.on) return KEYS.tape;
3128
4572
  return KEYS.prompt;
3129
4573
  }
3130
4574
 
@@ -3390,7 +4834,7 @@ async function startAuditionLoop(): Promise<void> {
3390
4834
  loop.focus(requestedTrack);
3391
4835
  // One sound at a time: the song stops while the loop plays.
3392
4836
  if (clock.playing) await setTransport("pause");
3393
- previewEngine().setLeadMs(PLAY_LEAD_MS);
4837
+ previewEngine().setLeadMs(previewEngine().playLeadMs);
3394
4838
  loop.start();
3395
4839
  }
3396
4840
 
@@ -3606,12 +5050,23 @@ function refreshMenu(): void {
3606
5050
  if (fader) {
3607
5051
  const fields = menu.faderFields(context);
3608
5052
  if (fields.length === 0) closeFader();
3609
- else
3610
- tui.drawer = drawerView(fader, fields, menu.faderCommitted(context), {
5053
+ else {
5054
+ const focusedField = fields[focusIndex(fader, fields)];
5055
+ const drawer = drawerView(fader, fields, menu.faderCommitted(context), {
3611
5056
  title: menu.crumbs,
3612
5057
  dirty: context.audition?.dirty ?? false,
3613
5058
  status: context.audition?.status,
5059
+ atOnce:
5060
+ focusedField !== undefined &&
5061
+ !stageableNow(faderCommand(focusedField)),
5062
+ knobs: menu.knobPage,
3614
5063
  });
5064
+ tui.drawer = withPaneMarks(
5065
+ menu.knobPage === "knobs"
5066
+ ? { ...drawer, aside: drawerAside(menu.pageFields(context), fields) }
5067
+ : drawer,
5068
+ );
5069
+ }
3615
5070
  }
3616
5071
  const view = menu.view(context);
3617
5072
  tui.openPicker({
@@ -3629,6 +5084,117 @@ function refreshMenu(): void {
3629
5084
  // ── the fader drawer ─────────────────────────────────────────────────
3630
5085
 
3631
5086
  /** Enter on a number row (or a bare `volume`, `fx filter`): the drawer. */
5087
+ /**
5088
+ * `export song.wav [stems]`: render the song as `dawg render song.wav` would
5089
+ * (the offline renderer, deterministic), then with `stems` one WAV per
5090
+ * audible track beside it (`song-bass.wav`), each that track soloed.
5091
+ */
5092
+ async function exportWav(path: string, stems: boolean): Promise<Receipt> {
5093
+ const directory = await mkdtemp(join(tmpdir(), "dawg-export-"));
5094
+ const errors: string[] = [];
5095
+ const sink = { write: (text: string) => errors.push(text.trim()) };
5096
+ const quiet = { write: () => undefined };
5097
+ const render = async (song: TrackScore, target: string): Promise<boolean> => {
5098
+ const file = join(directory, "song.track.json");
5099
+ await writeFile(file, encodeLoop(song), "utf8");
5100
+ const code = await runRenderCommand(
5101
+ ["render", target, "--import", file],
5102
+ process.cwd(),
5103
+ quiet,
5104
+ sink,
5105
+ );
5106
+ return code === 0;
5107
+ };
5108
+ tui.activity.setSpinner(`export ${path}`);
5109
+ try {
5110
+ if (!(await render(score, path)))
5111
+ return fail(errors.at(-1) ?? `export failed · ${path}`);
5112
+ if (!stems) return ok(`exported · ${path}`);
5113
+ const written: string[] = [];
5114
+ const base = path.replace(/\.wav$/i, "");
5115
+ for (const track of score.tracks) {
5116
+ if (!isTrackAudible(score, track.id)) continue;
5117
+ let solo = score;
5118
+ for (const other of score.tracks)
5119
+ solo = applyScoreOperation(solo, {
5120
+ type: "updateTrack",
5121
+ trackId: other.id,
5122
+ patch: { solo: other.id === track.id },
5123
+ });
5124
+ const target = `${base}-${track.id}.wav`;
5125
+ if (!(await render(solo, target)))
5126
+ return fail(errors.at(-1) ?? `export failed · ${target}`);
5127
+ written.push(target);
5128
+ }
5129
+ return ok(stemsReceipt(path, written));
5130
+ } finally {
5131
+ tui.activity.setSpinner(undefined);
5132
+ await rm(directory, { recursive: true, force: true });
5133
+ }
5134
+ }
5135
+
5136
+ /** `mix`: Mix › mixer in the drawer, on the focused track's level. */
5137
+ function openMixer(context: MenuContext): string | undefined {
5138
+ if (context.score.tracks.length === 0) return undefined;
5139
+ menu.show(context, "mix");
5140
+ if (!menu.enter(context, "mixer")) {
5141
+ menu.close();
5142
+ return undefined;
5143
+ }
5144
+ const first = menu.openKnobs(context);
5145
+ const fields = menu.faderFields(context);
5146
+ const here = fields.find((field) => field.label.includes("›"));
5147
+ if (!first || fields.length === 0) {
5148
+ menu.close();
5149
+ return undefined;
5150
+ }
5151
+ return here?.label ?? first;
5152
+ }
5153
+
5154
+ /** `knobs <page>`: open that page's level and its four knobs. */
5155
+ function openKnobPage(
5156
+ context: MenuContext,
5157
+ word: string | undefined,
5158
+ track: TrackScore["tracks"][number] | undefined,
5159
+ ): string | undefined {
5160
+ const page = knobPageId(word, track);
5161
+ if (!page) return undefined;
5162
+ if (page.startsWith("fx:")) {
5163
+ if (!menu.showFader(context, `fx ${page.slice(3)}`)) return undefined;
5164
+ } else {
5165
+ const section = page.startsWith("sound:")
5166
+ ? "sound"
5167
+ : page === "tempo"
5168
+ ? "project"
5169
+ : page;
5170
+ menu.show(context, section);
5171
+ }
5172
+ const label = menu.openKnobs(context);
5173
+ if (!label || menu.knobPageId !== page) {
5174
+ menu.close();
5175
+ return undefined;
5176
+ }
5177
+ return label;
5178
+ }
5179
+
5180
+ /** `tempo`, `bars`, `meter`: open Project and name its fader, if it has one. */
5181
+ function songFader(context: MenuContext, command: string): string | undefined {
5182
+ // Bare song scalars and the Project fader each opens.
5183
+ const faders: Readonly<Record<string, string>> = {
5184
+ tempo: "tempo",
5185
+ bpm: "tempo",
5186
+ bars: "loop length",
5187
+ meter: "beats per bar",
5188
+ };
5189
+ const label = faders[command.replace(/^\//, "").toLowerCase()];
5190
+ if (!label) return undefined;
5191
+ menu.show(context, "project");
5192
+ if (menu.faderFields(context).some((field) => field.label === label))
5193
+ return label;
5194
+ menu.close();
5195
+ return undefined;
5196
+ }
5197
+
3632
5198
  /**
3633
5199
  * Open the drawer on `label`. `standalone` drawers came from a command or a
3634
5200
  * click, not from inside the menu, so closing one closes the menu too.
@@ -3708,6 +5274,10 @@ function mouseInput(event: MouseEvent): string[] {
3708
5274
  const frame = tui.frame;
3709
5275
  if (!frame) return [];
3710
5276
  const target = frame.hits.at(event.x, event.y)?.target;
5277
+ if (tape.on && !play?.on && !tui.ui.overlay && !menu.open) {
5278
+ const handled = tapeMouse(event, target);
5279
+ if (handled) return handled;
5280
+ }
3711
5281
  if (event.kind === "wheel") {
3712
5282
  const fields = fader && menu.open ? menu.faderFields(menuContext()) : [];
3713
5283
  if (
@@ -3731,7 +5301,7 @@ function mouseInput(event: MouseEvent): string[] {
3731
5301
  return [];
3732
5302
  }
3733
5303
  if (fader) return [];
3734
- if (tui.ui.overlay) return [event.delta < 0 ? KEY_UP : KEY_DOWN];
5304
+ if (tui.ui.overlay) return [event.delta < 0 ? ARROW_UP : ARROW_DOWN];
3735
5305
  return [];
3736
5306
  }
3737
5307
  // A drag keeps moving the fader it started on, even past the bar's ends.
@@ -3821,7 +5391,9 @@ function mouseInput(event: MouseEvent): string[] {
3821
5391
  }
3822
5392
  case "transport":
3823
5393
  if (tui.ui.overlay) return [];
3824
- void toggleTransport().finally(() => requestFrame());
5394
+ void toggleTransport()
5395
+ .catch((error: unknown) => transportFailed(error))
5396
+ .finally(() => requestFrame());
3825
5397
  return [];
3826
5398
  case "tracks":
3827
5399
  if (tui.ui.overlay) return [];
@@ -3852,22 +5424,335 @@ function playSession(): PlaySession {
3852
5424
  }
3853
5425
 
3854
5426
  async function enterPlay(): Promise<Receipt> {
3855
- if (play?.on) return ok(`play · ${play.track} · esc leaves`);
5427
+ if (play?.on) return ok(`play mode · ${play.track} · esc leaves`);
3856
5428
  await materializeDraft();
3857
5429
  const session = playSession();
3858
5430
  await session.enter();
3859
5431
  if (hasSamplerTracks(score)) void sampleProblems(score);
3860
5432
  return ok(
3861
- `play · ${session.track} · ${session.keyboard.range} · ? keys · esc leaves`,
5433
+ `play mode · ${session.track} · ${session.layout.drums ? "kit" : session.keyboard.range} · ? keys · esc leaves`,
3862
5434
  );
3863
5435
  }
3864
5436
 
5437
+ /**
5438
+ * `keys record [replace|off]` (op1-ux §4.7, r / R on TAPE): opens PLAY on
5439
+ * the focused track and arms recording. With a loop set, every pass over
5440
+ * it commits once, so one undo step; esc goes back to TAPE.
5441
+ */
5442
+ async function keysRecord(
5443
+ mode: "overdub" | "replace" | "off",
5444
+ ): Promise<Receipt> {
5445
+ if (mode === "off" && !play?.on) return ok("record off");
5446
+ if (!play?.on) {
5447
+ const entered = await enterPlay();
5448
+ if (!play?.on) return entered;
5449
+ }
5450
+ const result = playSession().recordCommand(mode);
5451
+ reportView();
5452
+ return result.ok ? ok(result.message) : fail(result.message);
5453
+ }
5454
+
3865
5455
  async function exitPlay(): Promise<Receipt> {
3866
5456
  if (!play?.on) return ok("play mode is off");
3867
5457
  await play.exit();
3868
5458
  return ok("play off");
3869
5459
  }
3870
5460
 
5461
+ // ── TAPE (op1-ux §6) ─────────────────────────────────────────────────
5462
+
5463
+ /** This pane's clipboard file in `.dawg` (§6.3); undefined in a demo. */
5464
+ function paneClipboardPath(): string | undefined {
5465
+ if (demo || !record.sessionId) return undefined;
5466
+ return clipboardPath(process.cwd(), record.sessionId, port.pane);
5467
+ }
5468
+
5469
+ /** The paste's target beat until its `jump` lands (or a moment passes). */
5470
+ function pendingTapeBeat(value: TrackScore): number | undefined {
5471
+ const pending = tape.pending;
5472
+ if (!pending) return undefined;
5473
+ const here = scoreBeatAt(value, clock.beatAt());
5474
+ if (Date.now() > pending.until || clock.playing || here >= pending.beat) {
5475
+ tape.pending = undefined;
5476
+ return undefined;
5477
+ }
5478
+ return pending.beat;
5479
+ }
5480
+
5481
+ /** What TAPE's reducer and view read: the score, focus, playhead, clipboard. */
5482
+ function tapeContext(
5483
+ value: TrackScore = score,
5484
+ beat = clock.beatAt(),
5485
+ ): TapeContext {
5486
+ const board = rangeClipboard;
5487
+ return {
5488
+ score: value,
5489
+ trackId: requestedTrack,
5490
+ beat: pendingTapeBeat(value) ?? scoreBeatAt(value, beat),
5491
+ clipboard: board ? { source: board.source, range: board.range } : undefined,
5492
+ cut: cutArmed(tape.cut?.after, value),
5493
+ };
5494
+ }
5495
+
5496
+ function currentTapeView(value: TrackScore, beat: number): TapeView {
5497
+ const view = tapeView({
5498
+ ...tapeContext(value, beat),
5499
+ // A pending paste's jump has not landed: its bar's first pass shows.
5500
+ transportBeat: pendingTapeBeat(value) === undefined ? beat : undefined,
5501
+ zoom: tape.zoom,
5502
+ selected: tape.knobs.selected,
5503
+ marks: paneMarks,
5504
+ });
5505
+ // §9.1 and §9.3: reels turn on the transport beat; the loop brackets
5506
+ // flash as each pass closes. Both are still with `/motion off`.
5507
+ const reducedMotion = tui.ui.reducedMotion;
5508
+ const withDelight: TapeView = {
5509
+ ...view,
5510
+ reel: reelGlyph({
5511
+ playing: clock.playing,
5512
+ beat,
5513
+ reducedMotion,
5514
+ unicode: tui.capabilities.unicode,
5515
+ }),
5516
+ loopFlash: loopFlash({
5517
+ playing: clock.playing,
5518
+ beat,
5519
+ loopBeats: loopedSection(value)
5520
+ ? loopTicksOf(value) / value.ticksPerBeat
5521
+ : undefined,
5522
+ bpm: bpmAtTick(value, scoreBeatAt(value, beat) * value.ticksPerBeat),
5523
+ reducedMotion,
5524
+ }),
5525
+ };
5526
+ tape.view = withDelight;
5527
+ return withDelight;
5528
+ }
5529
+
5530
+ /** Whether the clipboard's source bars differ from what the score holds now. */
5531
+ function clipboardStale(): boolean {
5532
+ const board = rangeClipboard;
5533
+ if (!board) return false;
5534
+ try {
5535
+ const ids = board.clip.all ? undefined : [board.source];
5536
+ const now = extractRange(score, ids, board.range);
5537
+ return JSON.stringify(now) !== JSON.stringify(board.clip);
5538
+ } catch {
5539
+ return true;
5540
+ }
5541
+ }
5542
+
5543
+ /** Typed commands from a TAPE gesture: each echoes and runs as if typed. */
5544
+ function runTyped(commands: readonly string[]): void {
5545
+ echoGesture(commands);
5546
+ runPromptsLater(commands);
5547
+ }
5548
+
5549
+ /**
5550
+ * The prompt row echoes a gesture's typed command dimly for one beat
5551
+ * (op1-ux §10.1: type what you see), at least long enough to read.
5552
+ */
5553
+ function echoGesture(commands: readonly string[]): void {
5554
+ if (commands.length === 0) return;
5555
+ gestureEcho.text = commands.join(" · ");
5556
+ if (gestureEcho.clear) clearTimeout(gestureEcho.clear);
5557
+ const beatMs = 60_000 / Math.max(1, score.tempoBpm);
5558
+ gestureEcho.clear = setTimeout(
5559
+ () => {
5560
+ gestureEcho.text = undefined;
5561
+ gestureEcho.clear = undefined;
5562
+ requestFrame();
5563
+ },
5564
+ Math.max(800, beatMs),
5565
+ );
5566
+ gestureEcho.clear.unref?.();
5567
+ requestFrame();
5568
+ }
5569
+
5570
+ async function enterTape(): Promise<Receipt> {
5571
+ if (play?.on) await play.exit();
5572
+ if (tape.on) return ok("tape · esc goes home");
5573
+ closeFader();
5574
+ if (menu.open) menu.close();
5575
+ tape.on = true;
5576
+ tape.knobs.selected = 0;
5577
+ if (!rangeClipboard) {
5578
+ rangeClipboard = await loadClipboard(paneClipboardPath());
5579
+ }
5580
+ return ok(
5581
+ `tape · ${score.tracks.length} track${score.tracks.length === 1 ? "" : "s"} · ${score.bars} bars · ? keys · esc home`,
5582
+ );
5583
+ }
5584
+
5585
+ function exitTape(): Receipt {
5586
+ if (!tape.on) return ok("tape is off");
5587
+ tape.on = false;
5588
+ tape.view = undefined;
5589
+ tape.drag = undefined;
5590
+ return ok("home");
5591
+ }
5592
+
5593
+ /**
5594
+ * One key on TAPE. True when TAPE consumed it; false sends it on (the
5595
+ * prompt once a command is typed, overlays, ctrl keys).
5596
+ */
5597
+ function tapeInput(value: string): boolean {
5598
+ if (!tape.on || play?.on) return false;
5599
+ if (tui.ui.overlay !== undefined || menu.open) return false;
5600
+ if (prompt.value.length > 0) return false;
5601
+ if (value === "/" || value === "\u0003") return false;
5602
+ const base = tapeContext();
5603
+ // After a cut the source bars are empty on purpose: not stale.
5604
+ const context = { ...base, stale: !base.cut && clipboardStale() };
5605
+ const action = tapeKey(context, value);
5606
+ switch (action.type) {
5607
+ case "run":
5608
+ if (action.cut) tape.cutArmed = score;
5609
+ runTyped(action.commands);
5610
+ return true;
5611
+ case "paste": {
5612
+ if (action.fold) tape.foldArmed = true;
5613
+ const commands = [action.command];
5614
+ // The playhead moves past the paste so `v v v` tiles.
5615
+ if (action.end < score.bars) {
5616
+ commands.push(`jump ${action.end + 1}`);
5617
+ tape.pending = {
5618
+ beat: beatOfBar(score, action.end),
5619
+ until: Date.now() + 2000,
5620
+ };
5621
+ }
5622
+ runTyped(commands);
5623
+ return true;
5624
+ }
5625
+ case "note":
5626
+ tui.activity.pushCard(action.message, { tone: "info" });
5627
+ return true;
5628
+ case "zoom": {
5629
+ const index = TAPE_ZOOMS.indexOf(tape.zoom) + action.direction;
5630
+ tape.zoom =
5631
+ TAPE_ZOOMS[Math.max(0, Math.min(TAPE_ZOOMS.length - 1, index))]!;
5632
+ return true;
5633
+ }
5634
+ case "focus": {
5635
+ const command = focusCommand(score, action.row);
5636
+ if (command) runTyped([command]);
5637
+ return true;
5638
+ }
5639
+ case "transport":
5640
+ void toggleTransport().catch((error: unknown) => transportFailed(error));
5641
+ return true;
5642
+ case "keys":
5643
+ showKeys();
5644
+ return true;
5645
+ case "exit":
5646
+ receipt(exitTape());
5647
+ return true;
5648
+ case "pass":
5649
+ break;
5650
+ }
5651
+ const knob = knobKey(tape.knobs, tapeKnobs(context), value);
5652
+ if (knob.type === "run") {
5653
+ runTyped([knob.command]);
5654
+ return true;
5655
+ }
5656
+ if (knob.type === "open") {
5657
+ runTyped([knobNoun(context, knob.knob)]);
5658
+ return true;
5659
+ }
5660
+ if (knob.type === "handled") return true;
5661
+ // Unmapped printable keys are swallowed (as in play mode); control keys
5662
+ // (enter, ctrl-z, ctrl-k) keep their bindings.
5663
+ return value.length === 1 && value >= " " && value !== "\u007f";
5664
+ }
5665
+
5666
+ /**
5667
+ * The mouse on TAPE: a ruler click jumps there and a ruler drag sets the
5668
+ * loop, a row click focuses it, the clipboard chip pastes, and the wheel
5669
+ * turns the selected knob. Each runs its typed command. Undefined: not a
5670
+ * TAPE gesture, so the ordinary mouse path takes it.
5671
+ */
5672
+ function tapeMouse(
5673
+ event: MouseEvent,
5674
+ target: HitTarget | undefined,
5675
+ ): string[] | undefined {
5676
+ if (event.kind === "wheel") {
5677
+ if (!target || (target.kind !== "tape-ruler" && target.kind !== "tape-row"))
5678
+ return undefined;
5679
+ // The wheel walks the playhead a bar at a time (never a tempo knob).
5680
+ const bar = playheadBar();
5681
+ const next = Math.max(
5682
+ 0,
5683
+ Math.min(score.bars - 1, bar + (event.delta < 0 ? -1 : 1)),
5684
+ );
5685
+ if (next !== bar) runTyped([`jump ${next + 1}`]);
5686
+ return [];
5687
+ }
5688
+ const drag = tape.drag;
5689
+ if (drag && (event.kind === "drag" || event.kind === "up")) {
5690
+ const view = tape.view;
5691
+ const first = drag.first;
5692
+ const bar =
5693
+ view && first !== undefined
5694
+ ? tapeBarAt(event.x, drag.left, first)
5695
+ : undefined;
5696
+ if (bar !== undefined) drag.to = bar;
5697
+ if (event.kind === "up") {
5698
+ tape.drag = undefined;
5699
+ if (drag.to !== drag.from) {
5700
+ const start = Math.min(drag.from, drag.to);
5701
+ const end = Math.max(drag.from, drag.to);
5702
+ runTyped([`loop ${start + 1}-${end + 1}`]);
5703
+ }
5704
+ }
5705
+ return [];
5706
+ }
5707
+ if (event.kind !== "down" || event.button !== "left" || !target)
5708
+ return undefined;
5709
+ if (target.kind === "tape-ruler") {
5710
+ const bar = tapeBarAt(event.x, target.left, target.firstCell);
5711
+ if (bar === undefined) return [];
5712
+ tape.drag = {
5713
+ from: bar,
5714
+ to: bar,
5715
+ left: target.left,
5716
+ first: target.firstCell,
5717
+ };
5718
+ runTyped([`jump ${bar + 1}`]);
5719
+ return [];
5720
+ }
5721
+ if (target.kind === "tape-row") {
5722
+ // A drag along a row sets the loop over those bars, like the ruler.
5723
+ const bar = tapeBarAt(event.x, target.left, target.firstCell);
5724
+ if (bar !== undefined)
5725
+ tape.drag = {
5726
+ from: bar,
5727
+ to: bar,
5728
+ left: target.left,
5729
+ first: target.firstCell,
5730
+ };
5731
+ const command = focusCommand(score, target.row);
5732
+ if (command && score.tracks[target.row]?.id !== requestedTrack)
5733
+ runTyped([command]);
5734
+ return [];
5735
+ }
5736
+ if (target.kind === "tape-clipboard") {
5737
+ tapeInput("v");
5738
+ return [];
5739
+ }
5740
+ return undefined;
5741
+ }
5742
+
5743
+ /** The bar under a TAPE column, from the last painted view. */
5744
+ function tapeBarAt(
5745
+ x: number,
5746
+ left: number,
5747
+ firstCell: number,
5748
+ ): number | undefined {
5749
+ const view = tape.view;
5750
+ if (!view) return undefined;
5751
+ const cell = x - left + firstCell;
5752
+ if (cell < 0 || cell >= view.cellBars.length) return undefined;
5753
+ return view.cellBars[cell];
5754
+ }
5755
+
3871
5756
  /**
3872
5757
  * One key in play mode. True when play mode consumed it; false sends it on
3873
5758
  * to the normal bindings (overlays, a command being typed, Ctrl-C, arrows).
@@ -3888,9 +5773,7 @@ function playKey(value: string): boolean {
3888
5773
  else if (result.command === "transport") {
3889
5774
  if (!session.startWithCountIn())
3890
5775
  void toggleTransport().catch((error: unknown) =>
3891
- tui.activity.pushError(
3892
- `transport failed · ${error instanceof Error ? error.message : String(error)}`,
3893
- ),
5776
+ transportFailed(error),
3894
5777
  );
3895
5778
  } else if (result.command === "menu") return false;
3896
5779
  return true;
@@ -3908,8 +5791,11 @@ function playHost() {
3908
5791
  playing: () => clock.playing,
3909
5792
  beatAt: (ms: number) => clock.beatAt(ms),
3910
5793
  scoreBeat: (beat: number) => scoreBeatAt(score, beat),
3911
- engine: liveEngine,
5794
+ engine: playEngine,
3912
5795
  samples: () => liveSampleBank,
5796
+ // A take is a plain commitScore (it diffs into operations, so dawgd
5797
+ // rebases a pass over other panes' edits); a stale record reloads and
5798
+ // replays the take's operations on what landed meanwhile.
3913
5799
  async commit(
3914
5800
  next: TrackScore,
3915
5801
  kind: string,
@@ -3919,15 +5805,7 @@ function playHost() {
3919
5805
  let target = next;
3920
5806
  for (let attempt = 0; attempt < 3; attempt += 1) {
3921
5807
  try {
3922
- record = await port.appendOperations(
3923
- record,
3924
- { kind, payload },
3925
- operations,
3926
- target.toJSON(),
3927
- );
3928
- score = scoreFromJSON(record.composition);
3929
- if (clock.playing) void audio.play(score);
3930
- projectSync?.scoreChanged(score);
5808
+ await commitScore(target, kind, payload);
3931
5809
  return;
3932
5810
  } catch (error) {
3933
5811
  if (!(error instanceof SessionConflictError)) throw error;
@@ -3941,6 +5819,7 @@ function playHost() {
3941
5819
  }
3942
5820
  throw new Error("session busy");
3943
5821
  },
5822
+ recordingElsewhere: (trackId: string) => recordingPane(trackId),
3944
5823
  async startTransport(beat: number): Promise<void> {
3945
5824
  if (port.mode === "daemon") {
3946
5825
  await port.transport("play", { beat });
@@ -3952,16 +5831,21 @@ function playHost() {
3952
5831
  async stopTransport(): Promise<void> {
3953
5832
  await setTransport("pause");
3954
5833
  },
5834
+ ghost(pitch: number) {
5835
+ tui.delight.ghost(pitch, Date.now());
5836
+ requestFrame();
5837
+ },
3955
5838
  card(text: string, tone: "info" | "success" | "warning" | "error") {
3956
5839
  if (tone === "error") tui.activity.pushError(text);
3957
5840
  else
3958
5841
  tui.activity.pushCard(text, {
3959
5842
  tone,
3960
5843
  trackId: requestedTrack,
3961
- resultRevision: tone === "success" ? record.revision : undefined,
5844
+ resultRevision:
5845
+ tone === "success" ? shownRevision(record) : undefined,
3962
5846
  });
3963
5847
  },
3964
- newNoteId: () => randomUUID().slice(0, 12),
5848
+ newNoteId: () => newId("n"),
3965
5849
  };
3966
5850
  }
3967
5851
 
@@ -3982,15 +5866,48 @@ async function commitScore(
3982
5866
  if (retimed) clock.follow(score);
3983
5867
  return;
3984
5868
  }
3985
- record = await port.append(record, { kind, payload }, next.toJSON());
3986
- score = next;
3987
- if (retimed) clock.follow(score);
5869
+ // Every edit travels as score operations (snapshot when the diff cannot
5870
+ // express it), so dawgd rebases it over other panes' concurrent edits per
5871
+ // (track, property) and the log replays as operations (design §12.6).
5872
+ const operations = diffOps(score, next);
5873
+ if (operations) {
5874
+ record = await port.appendOperations(
5875
+ record,
5876
+ { kind, payload },
5877
+ operations,
5878
+ next.toJSON(),
5879
+ );
5880
+ // A rebased edit lands on top of what other panes wrote meanwhile.
5881
+ score = deepEqual(record.composition, next.toJSON())
5882
+ ? next
5883
+ : scoreFromJSON(record.composition);
5884
+ } else {
5885
+ record = await port.append(record, { kind, payload }, next.toJSON());
5886
+ score = next;
5887
+ }
5888
+ if (retimed || score !== next) clock.follow(score);
3988
5889
  if (clock.playing) void audio.play(score);
3989
5890
  projectSync?.scoreChanged(score);
3990
5891
  reportSampleProblems(score);
3991
5892
  void updateCredits(score);
3992
5893
  }
3993
5894
 
5895
+ /** The ops from `previous` to `next`, or undefined when they do not diff. */
5896
+ function diffOps(
5897
+ previous: TrackScore,
5898
+ next: TrackScore,
5899
+ ): readonly ScoreOperation[] | undefined {
5900
+ try {
5901
+ const operations = diffScores(previous, next);
5902
+ return operations.length > 0 && operations.length <= 256
5903
+ ? operations
5904
+ : undefined;
5905
+ } catch (error) {
5906
+ if (error instanceof DiffError) return undefined;
5907
+ throw error;
5908
+ }
5909
+ }
5910
+
3994
5911
  /** True when the transport clock must follow `next` (tempo, meter, loop). */
3995
5912
  function timingChanged(previous: TrackScore, next: TrackScore): boolean {
3996
5913
  return (
@@ -4005,6 +5922,9 @@ function timingChanged(previous: TrackScore, next: TrackScore): boolean {
4005
5922
  function syncHost(): SyncHost {
4006
5923
  return {
4007
5924
  project: process.cwd(),
5925
+ // The files belong to one session at a time; another session's window
5926
+ // stays detached instead of reprinting over them.
5927
+ sessionId: () => record.sessionId,
4008
5928
  current: () => score,
4009
5929
  async commit(plan, summary) {
4010
5930
  const baseRevision = record.revision;
@@ -4030,7 +5950,8 @@ function syncHost(): SyncHost {
4030
5950
  tui.activity.pushCard(text, {
4031
5951
  tone,
4032
5952
  hint,
4033
- resultRevision: tone === "success" ? record.revision : undefined,
5953
+ resultRevision:
5954
+ tone === "success" ? shownRevision(record) : undefined,
4034
5955
  });
4035
5956
  },
4036
5957
  types(state) {
@@ -4039,32 +5960,91 @@ function syncHost(): SyncHost {
4039
5960
  };
4040
5961
  }
4041
5962
 
4042
- async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
5963
+ /**
5964
+ * Undo and redo (design §12.6). By default a pane steps its own edits: the
5965
+ * newest edit this client made, inverted and rebased over whatever other
5966
+ * panes did since. `undo all` / `redo all` step the shared history, whoever
5967
+ * made the edit, and the receipt names the other pane. A lone pane (or one
5968
+ * that has made no edit since it opened) falls back to the shared history,
5969
+ * so single-pane sessions behave exactly as before.
5970
+ */
5971
+ async function stepHistory(
5972
+ direction: "undo" | "redo",
5973
+ scope: "pane" | "all" = "pane",
5974
+ ): Promise<Receipt> {
4043
5975
  const latest = await port.load();
4044
5976
  // A fork's undo continues into its parent's history past the fork point.
4045
- const target = historyTarget(
4046
- latest.composition,
4047
- await historyEvents(process.cwd(), latest),
4048
- direction,
5977
+ const events = await historyEvents(process.cwd(), latest);
5978
+ const before = scoreFromJSON(latest.composition);
5979
+ const kind = direction === "undo" ? UNDO_KIND : REDO_KIND;
5980
+ const key = direction === "undo" ? "undoneRevision" : "redoneRevision";
5981
+ const otherPanes = panePresence.filter(
5982
+ (entry) => entry.clientId !== port.clientId,
4049
5983
  );
4050
- if (!target) return warn(`nothing to ${direction}`);
5984
+ let next: TrackScore;
5985
+ let revision: number;
5986
+ let author = "";
5987
+ if (scope === "pane") {
5988
+ const step = paneHistoryStep(
5989
+ latest.composition,
5990
+ events,
5991
+ direction,
5992
+ port.clientId,
5993
+ );
5994
+ if (!step || (!step.ok && step.others.length === 0)) {
5995
+ if (otherPanes.length === 0) return stepHistory(direction, "all");
5996
+ if (!step)
5997
+ return warn(
5998
+ `nothing of this pane's to ${direction} · ${direction} all steps everyone's`,
5999
+ );
6000
+ }
6001
+ if (!step.ok) {
6002
+ if (step.reason === "session-wide")
6003
+ return warn(`that edit is session-wide · ${direction} all`);
6004
+ const who = step.others.map(paneName).join(", ");
6005
+ return warn(
6006
+ `${who} changed ${step.reason.replace(/ changed$/, "")} since · ${direction} all ${direction === "undo" ? "undoes" : "redoes"} theirs too`,
6007
+ );
6008
+ }
6009
+ next = step.next;
6010
+ revision = step.revision;
6011
+ } else {
6012
+ const target = historyTarget(latest.composition, events, direction);
6013
+ if (!target) return warn(`nothing to ${direction}`);
6014
+ next = scoreFromJSON(target.composition);
6015
+ revision = target.revision;
6016
+ const writer = events.find((event) => event.revision === revision)?.actor
6017
+ ?.clientId;
6018
+ if (writer && writer !== port.clientId && otherPanes.length > 0)
6019
+ author = ` (${paneName(writer)})`;
6020
+ }
4051
6021
  try {
4052
- const restored = scoreFromJSON(target.composition);
4053
- record = await port.append(
6022
+ const event = {
6023
+ kind,
6024
+ payload: { [key]: revision, ...(scope === "pane" ? {} : { scope }) },
6025
+ };
6026
+ let operations: readonly ScoreOperation[] = [];
6027
+ try {
6028
+ operations = diffScores(before, next);
6029
+ } catch (error) {
6030
+ if (!(error instanceof DiffError)) throw error;
6031
+ }
6032
+ record = await port.appendOperations(
4054
6033
  latest,
4055
- {
4056
- kind: direction === "undo" ? UNDO_KIND : REDO_KIND,
4057
- payload: {
4058
- [direction === "undo" ? "undoneRevision" : "redoneRevision"]:
4059
- target.revision,
4060
- },
4061
- },
4062
- restored.toJSON(),
6034
+ event,
6035
+ operations,
6036
+ next.toJSON(),
4063
6037
  );
4064
- score = restored;
6038
+ // Compare with what was undone, which another window may have written.
6039
+ score = scoreFromJSON(record.composition);
4065
6040
  if (clock.playing) void audio.play(score);
4066
6041
  return ok(
4067
- `${direction === "undo" ? "undid" : "redid"} · rev ${target.revision}`,
6042
+ historyReceipt(
6043
+ direction,
6044
+ before,
6045
+ score,
6046
+ shownRevision(record, revision),
6047
+ ) + author,
4068
6048
  );
4069
6049
  } catch (error) {
4070
6050
  if (error instanceof SessionConflictError)
@@ -4075,7 +6055,42 @@ async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
4075
6055
  }
4076
6056
  }
4077
6057
 
4078
- /** The space-bar toggle: flip the transport, then record it for other windows. */
6058
+ /** `pane B` for a client in presence, `another pane` once it has left. */
6059
+ function paneName(clientId: string): string {
6060
+ const pane = panePresence.find((entry) => entry.clientId === clientId)?.pane;
6061
+ return pane ? `pane ${pane}` : "another pane";
6062
+ }
6063
+
6064
+ /** Space, the menu and a header click all report a failed toggle the same way. */
6065
+ function transportFailed(error: unknown): void {
6066
+ tui.activity.pushCard(
6067
+ `transport failed · ${error instanceof Error ? error.message : String(error)}`,
6068
+ { tone: "error" },
6069
+ );
6070
+ }
6071
+
6072
+ /**
6073
+ * The revision dawg shows (header, receipts, undo): edits to the song.
6074
+ * Play and pause are logged so other windows follow them, but they are not
6075
+ * edits, so they never move the number: a revision shows as itself minus
6076
+ * the transport events at or before it.
6077
+ */
6078
+ function shownRevision(
6079
+ value: typeof record,
6080
+ revision = value.revision,
6081
+ ): number {
6082
+ let transports = 0;
6083
+ for (const event of value.events)
6084
+ if (event.revision <= revision && event.kind === "transport")
6085
+ transports += 1;
6086
+ return revision - transports;
6087
+ }
6088
+
6089
+ /**
6090
+ * The space-bar toggle: flip the transport, then record it for other
6091
+ * windows. The log entry keeps windows in step; the header's revision
6092
+ * (`shownRevision`) skips it, since play/pause is not an edit.
6093
+ */
4079
6094
  async function toggleTransport(): Promise<void> {
4080
6095
  await setTransport("toggle");
4081
6096
  try {
@@ -4126,6 +6141,12 @@ async function setTransport(
4126
6141
  }
4127
6142
 
4128
6143
  /** Move the playhead to transport `beat`, playing or not (section jump). */
6144
+ /** The bar under the playhead, 0-based on the score. */
6145
+ function playheadBar(): number {
6146
+ const beat = scoreBeatAt(score, clock.beatAt());
6147
+ return barAt(score, Math.max(0, Math.round(beat * score.ticksPerBeat))).bar;
6148
+ }
6149
+
4129
6150
  async function seekTransport(beat: number): Promise<void> {
4130
6151
  if (port.mode === "daemon") {
4131
6152
  await port.transport("seek", { beat });
@@ -4145,11 +6166,11 @@ async function runAgent(text: string): Promise<string | Receipt> {
4145
6166
  if (agentTurn) return warn("agent busy · Esc cancels");
4146
6167
  const selection = await currentProvider();
4147
6168
  if (selection.kind === "offline")
4148
- return fail(
4149
- `unrecognized · ${truncateForCard(text)} · ${selection.reason}`,
4150
- );
6169
+ return fail(`${truncateForCard(text)} · ${NO_AGENT}`);
4151
6170
  const turn = { controller: new AbortController(), steering: [] as string[] };
4152
6171
  agentTurn = turn;
6172
+ // The turn ends on one musical receipt of what it changed.
6173
+ const before = score;
4153
6174
  reportAgentActivity(`${providerName} · thinking…`);
4154
6175
  // xcb admits a pending account on its first call, which takes longer.
4155
6176
  let admitting = selection.kind === "xcb" && selection.admissionPending;
@@ -4182,6 +6203,8 @@ async function runAgent(text: string): Promise<string | Receipt> {
4182
6203
  }
4183
6204
  return;
4184
6205
  }
6206
+ if (event.type === "done")
6207
+ tui.activity.setTurnReceipt(musicalReceipt(before, score));
4185
6208
  agentEventSink(event);
4186
6209
  },
4187
6210
  });
@@ -4213,7 +6236,7 @@ function currentProvider(): Promise<ProviderSelection> {
4213
6236
  provider = selectProvider().catch((): ProviderSelection => ({
4214
6237
  kind: "offline",
4215
6238
  choice: "auto",
4216
- reason: "provider unavailable; run `dawg login`",
6239
+ reason: "provider unavailable; run `dawg model key`",
4217
6240
  }));
4218
6241
  }
4219
6242
  return provider.then((selection) => {
@@ -4225,9 +6248,9 @@ function currentProvider(): Promise<ProviderSelection> {
4225
6248
  !invalidNoticeShown
4226
6249
  ) {
4227
6250
  invalidNoticeShown = true;
4228
- tui.activity.pushCard(`sign-in stopped working · ${selection.reason}`, {
6251
+ tui.activity.pushCard(`agent key stopped working · ${selection.reason}`, {
4229
6252
  tone: "warning",
4230
- hint: "/login",
6253
+ hint: "/model key",
4231
6254
  });
4232
6255
  }
4233
6256
  providerStamp.offline = selection.kind === "offline";
@@ -4302,6 +6325,180 @@ function agentPreviewHost(): PreviewHost {
4302
6325
  };
4303
6326
  }
4304
6327
 
6328
+ // ── show-me ──────────────────────────────────────────────────────────
6329
+
6330
+ /**
6331
+ * Show-me (docs/show-me.md): with an API provider the agent writes prompt
6332
+ * commands, streamed. The line being written is ghost text in the empty
6333
+ * prompt bar at the model's own speed; each complete line runs through
6334
+ * `submit()`, the path a typed Enter takes, at once. A parameter value
6335
+ * glides there over ~150 ms while the loop plays (you hear it), notes and
6336
+ * hits sound as they arrive (in time when the stream is ahead of the tempo,
6337
+ * as step entry when it is behind), and the caption names the key or fader
6338
+ * a person would use. Nothing waits for the turn to end.
6339
+ */
6340
+
6341
+ function showMeTyping(text: string): void {
6342
+ showMe.ghost = text || undefined;
6343
+ if (text && showMe.level === "on") showMe.caption = undefined;
6344
+ requestFrame();
6345
+ }
6346
+
6347
+ function showMeCaption(text: string | undefined): void {
6348
+ if (showMe.level !== "on") return;
6349
+ showMe.caption = text;
6350
+ if (showMe.clear) clearTimeout(showMe.clear);
6351
+ showMe.clear = undefined;
6352
+ requestFrame();
6353
+ }
6354
+
6355
+ /** End of a turn: the ghost goes, the caption becomes the do-it-yourself hint. */
6356
+ function showMeFinish(): void {
6357
+ showMe.ghost = undefined;
6358
+ showMe.notes.reset();
6359
+ const hint = finishHint(showMe.commands);
6360
+ showMe.commands = [];
6361
+ if (hint && showMe.level === "on") {
6362
+ showMeCaption(hint);
6363
+ showMe.clear = setTimeout(() => {
6364
+ showMe.caption = undefined;
6365
+ showMe.clear = undefined;
6366
+ requestFrame();
6367
+ }, 8_000);
6368
+ } else {
6369
+ showMe.caption = undefined;
6370
+ requestFrame();
6371
+ }
6372
+ }
6373
+
6374
+ /**
6375
+ * Slide a fader to its new value the way the drawer does: each eased step is
6376
+ * the field's own command, staged on a scratch score and played, so the loop
6377
+ * moves through the values. Only while the loop plays (otherwise nothing is
6378
+ * heard and the glide would only cost time); never longer than ~150 ms.
6379
+ */
6380
+ async function glideFader(param: string, target: number): Promise<void> {
6381
+ if (!clock.playing || !stdout.isTTY) return;
6382
+ const scratch = new EditMenu();
6383
+ const context = { ...menuContext(), score };
6384
+ const label = scratch.showFader(context, param);
6385
+ if (!label) return;
6386
+ const field = scratch
6387
+ .faderFields(context)
6388
+ .find((candidate) => candidate.label === label);
6389
+ scratch.close();
6390
+ if (field?.kind !== "number") return;
6391
+ const from = field.value ?? field.start ?? target;
6392
+ const steps = glideValues(from, target).slice(0, -1);
6393
+ for (const value of steps) {
6394
+ const staged = await applyStaged(score, field.command(value));
6395
+ if (staged.next && clock.playing) void audio.play(staged.next);
6396
+ await Bun.sleep(GLIDE_STEP_MS);
6397
+ }
6398
+ }
6399
+
6400
+ /** Sound one streamed note on the audition voice (the loop is stopped). */
6401
+ function soundStreamedNote(
6402
+ value: TrackScore,
6403
+ trackId: string,
6404
+ pitch: number,
6405
+ startBeat: number,
6406
+ ): void {
6407
+ if (clock.playing) return; // The loop itself plays it.
6408
+ const engine = liveEngine();
6409
+ if (!engine?.canMonitor) return;
6410
+ const tick = Math.round(startBeat * value.ticksPerBeat);
6411
+ const note = value.notes.find(
6412
+ (candidate) =>
6413
+ candidate.trackId === trackId &&
6414
+ candidate.pitch === pitch &&
6415
+ candidate.startTick === tick,
6416
+ );
6417
+ if (!note) return;
6418
+ try {
6419
+ const json = value.toJSON() as unknown as Record<string, unknown>;
6420
+ const single = scoreFromJSON({
6421
+ ...json,
6422
+ notes: [{ ...note, startTick: 0 }],
6423
+ });
6424
+ const pcm = renderAudition({
6425
+ score: single,
6426
+ trackId,
6427
+ sampleRate: engine.sampleRate,
6428
+ ...(liveSampleBank ? { samples: liveSampleBank } : {}),
6429
+ });
6430
+ if (!pcm) return;
6431
+ void engine
6432
+ .monitor(true)
6433
+ .then(() => engine.noteOn(AUDITION_VOICE, pcm))
6434
+ .catch(() => undefined);
6435
+ } catch {
6436
+ // A note that cannot render is still in the score.
6437
+ }
6438
+ }
6439
+
6440
+ /** The command host the show-me agent loop runs lines through. */
6441
+ function showMeCommandHost(): AgentHost["commands"] {
6442
+ return {
6443
+ isCommand: (line) =>
6444
+ isAgentCommand(line, score) || isBrokenCommand(line, score),
6445
+ async run(line): Promise<CommandOutcome> {
6446
+ if (!agentPathsAllowed(line)) {
6447
+ const revision = shownRevision(record);
6448
+ return {
6449
+ ok: false,
6450
+ message: `${line} · the agent works only inside this folder`,
6451
+ baseRevision: revision,
6452
+ resultRevision: revision,
6453
+ };
6454
+ }
6455
+ if (isBrokenCommand(line, score)) {
6456
+ // A command that does not parse is a red receipt, never prose.
6457
+ const base = baseline();
6458
+ receipt(fail(brokenCommandReceipt(line)), base);
6459
+ return {
6460
+ ok: false,
6461
+ message: brokenCommandReceipt(line),
6462
+ baseRevision: base.revision,
6463
+ resultRevision: base.revision,
6464
+ };
6465
+ }
6466
+ const gesture = gestureFor(line, { score, trackId: requestedTrack });
6467
+ showMeCaption(gesture.caption);
6468
+ showMe.commands.push(line);
6469
+ if (gesture.kind === "fader" && showMe.level === "on")
6470
+ await glideFader(gesture.param, gesture.value);
6471
+ const base = baseline();
6472
+ tui.activity.pushNote(`agent › ${line}`, "request");
6473
+ let result: string | Receipt;
6474
+ try {
6475
+ result = await submit(line);
6476
+ } catch (error) {
6477
+ result = fail(describeError(line, error));
6478
+ }
6479
+ receipt(result, base);
6480
+ if (gesture.kind === "keys" && showMe.level === "on") {
6481
+ const trackId = requestedTrack;
6482
+ const after = score;
6483
+ for (const note of gesture.notes) {
6484
+ const { atMs } = showMe.notes.schedule(note.start, performance.now());
6485
+ const delay = Math.max(0, atMs - performance.now());
6486
+ setTimeout(
6487
+ () => soundStreamedNote(after, trackId, note.pitch, note.start),
6488
+ delay,
6489
+ );
6490
+ }
6491
+ }
6492
+ return {
6493
+ ok: toneOf(result) !== "error",
6494
+ message: typeof result === "string" ? result : result.text,
6495
+ baseRevision: shownRevision(record, base.revision),
6496
+ resultRevision: shownRevision(record),
6497
+ };
6498
+ },
6499
+ };
6500
+ }
6501
+
4305
6502
  function agentHost(
4306
6503
  turn: { steering: string[] },
4307
6504
  selection: ProviderSelection,
@@ -4376,6 +6573,9 @@ function agentHost(
4376
6573
  }
4377
6574
  },
4378
6575
  takeSteering: () => turn.steering.splice(0),
6576
+ ...(showMe.level !== "off" && stdout.isTTY
6577
+ ? { commands: showMeCommandHost() }
6578
+ : {}),
4379
6579
  workspace: { root: process.cwd() },
4380
6580
  // A written project source is applied before the tool result returns,
4381
6581
  // so the model reads the outcome and any type errors in the same step.