@hraness/dawg 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/DAWG.md +605 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +99 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +301 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +337 -0
  42. package/src/audio/engine.ts +27 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +470 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +280 -278
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1119 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +501 -0
  72. package/src/tui/euclid.ts +472 -0
  73. package/src/tui/menu.ts +1286 -244
  74. package/src/tui/play-chords.ts +538 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +281 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
package/src/main.ts CHANGED
@@ -30,15 +30,37 @@ import { AutoNamer, providerNameGenerator } from "./session/naming.ts";
30
30
  import { normalizeSessionName } from "./session/meta.ts";
31
31
  import { parsePrompt } from "./agent/ops.ts";
32
32
  import { applyMusicCommand, parseMusicCommand } from "./commands/music.ts";
33
+ import { applyRhythmCommand, parseRhythmCommand } from "./commands/rhythm.ts";
34
+ import {
35
+ applyDrumPattern,
36
+ applySynthKit,
37
+ DRUM_PATTERNS,
38
+ isSynthKitName,
39
+ parsePatternCommand,
40
+ patternLine,
41
+ type PatternCommand,
42
+ } from "./commands/drums.ts";
43
+ import { kitCatalog } from "./audio/kits.ts";
33
44
  import { applyEditCommand, parseEditCommand } from "./commands/edit.ts";
34
- import { helpLines, helpText, usageHint } from "./commands/help.ts";
45
+ import { applyFxCommand, parseFxCommand } from "./commands/fx.ts";
46
+ import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
47
+ import {
48
+ HELP_TOPICS,
49
+ helpText,
50
+ helpTopicLines,
51
+ nearestCommand,
52
+ typoFix,
53
+ usageHint,
54
+ } from "./commands/help.ts";
35
55
  import { historyTarget, REDO_KIND, UNDO_KIND } from "./commands/history.ts";
56
+
36
57
  import {
37
58
  SamplePlacementError,
38
59
  addSampleVoice,
39
60
  freeVoiceName,
40
61
  listSampleVoices,
41
62
  parseSampleCommand,
63
+ setSampleControls,
42
64
  placeSampleFile,
43
65
  samplerTarget,
44
66
  voiceNameFrom,
@@ -89,19 +111,60 @@ import { AudioEngine } from "./audio/engine.ts";
89
111
  import {
90
112
  DEFAULT_GRID,
91
113
  GRIDS,
114
+ PLAY_LEAD_MS,
92
115
  PlaySession,
93
116
  type LiveEngine,
94
117
  } from "./tui/play-session.ts";
95
118
  import { EditMenu, MENU_SECTIONS, type MenuContext } from "./tui/menu.ts";
119
+ import { Audition, SUPERSEDED, isStageable } from "./tui/audition.ts";
120
+ import { EuclidEditor } from "./tui/euclid.ts";
121
+ import { HINTS, KEYS, keyLines, type KeySection } from "../tui/grammar.ts";
122
+ import { renderAudition } from "./audio/audition.ts";
123
+ import { renderScorePcm } from "./audio/wav.ts";
124
+ import type { PreviewHost } from "./agent/preview-tool.ts";
125
+ import { rhythmVoicePitch } from "../core/rhythm.ts";
126
+ import { applyChordsCommand, defaultChordSettings } from "./tui/play-chords.ts";
127
+ import {
128
+ cacheLines,
129
+ kitListLines,
130
+ kitTarget,
131
+ packListLines,
132
+ parseKitCommand,
133
+ parsePackCommand,
134
+ useKit,
135
+ useSound,
136
+ type KitCommand,
137
+ type PackCommand,
138
+ } from "./commands/pack.ts";
139
+ import {
140
+ describeWavetable,
141
+ parseWavetableCommand,
142
+ pickWavetable,
143
+ wavetableListLines,
144
+ wavetableParamEdit,
145
+ type WavetableCommand,
146
+ } from "./commands/wavetable.ts";
147
+ import {
148
+ ALIASED_PACK,
149
+ DEFAULT_KITS,
150
+ PackError,
151
+ PackStore,
152
+ banksOf,
153
+ packCredits,
154
+ writeCredits,
155
+ } from "./audio/packs.ts";
96
156
  import {
97
157
  addNote,
98
158
  applyScoreOperation,
99
159
  createScore,
160
+ PACK_PREFIX,
100
161
  scoreFromJSON,
101
162
  SCORE_LIMITS,
163
+ type SampleRef,
102
164
  type TrackScore,
103
165
  type ScoreOperation,
104
166
  } from "../core/score.ts";
167
+ import { reconcileRhythm } from "../core/rhythm.ts";
105
168
  import { decodeLoop, encodeLoop } from "../core/loop.ts";
106
169
  import type { TrackScoreSnapshot } from "../tui/render.ts";
107
170
  import { PromptModel } from "../tui/prompt.ts";
@@ -127,6 +190,23 @@ import {
127
190
  type SyncHost,
128
191
  } from "./project/sync.ts";
129
192
 
193
+ /** Whether a bare command parses (no side effects): for typo suggestions. */
194
+ function parsesLocally(text: string): boolean {
195
+ return [
196
+ parsePrompt,
197
+ parseMusicCommand,
198
+ parseEditCommand,
199
+ parseRhythmCommand,
200
+ parseFxCommand,
201
+ parseSynthCommand,
202
+ parsePatternCommand,
203
+ parseKitCommand,
204
+ parsePackCommand,
205
+ parseSampleCommand,
206
+ parseWavetableCommand,
207
+ ].some((parse) => parse(text) !== undefined);
208
+ }
209
+
130
210
  const ESC = "\u001b[";
131
211
  /** `/sessions` rows shown in the overlay. */
132
212
  const MAX_LISTED_SESSIONS = 64;
@@ -351,6 +431,9 @@ let syncState: SyncState = port.sync;
351
431
  let windowCount = 1;
352
432
  /** Project file sync when `dawg.json` is in the working directory. */
353
433
  let projectSync: ProjectSync | undefined;
434
+ let packStore: PackStore | undefined;
435
+ let reportedSampleProblems = "";
436
+ let sampleLibrary: SampleLibrary | undefined;
354
437
  let typesIndicator: TypesIndicator | undefined;
355
438
  let announcedName = record.meta.name;
356
439
  let namer = makeNamer();
@@ -358,10 +441,37 @@ let namer = makeNamer();
358
441
  let rebindPort: () => void = () => undefined;
359
442
  /** Play mode's controller; kept across entries so settings persist. */
360
443
  let play: PlaySession | undefined;
444
+ /** Chord-mode settings, shared by every track's play session. */
445
+ const chordSettings = defaultChordSettings();
361
446
  /** The hand-editing menu (`/menu`, Ctrl-K), drawn as the picker overlay. */
362
447
  const menu = new EditMenu();
448
+ /** The Euclidean rhythm editor (`/euclid`, Rhythm in `/menu`). */
449
+ const euclid = new EuclidEditor();
450
+ /** Live voice id for auditions (play-mode voices use small positive ids). */
451
+ const AUDITION_VOICE = 0x7fff_0001;
452
+ /** A voice to audition once the editor's queued command lands. */
453
+ let pendingAudition: string | undefined;
454
+ /** The pattern the /pattern picker last previewed. */
455
+ let patternPreview: string | undefined;
363
456
  /** Daemon windows play no loop; play mode monitors through its own engine. */
364
457
  let monitorEngine: AudioEngine | undefined;
458
+ /** The audition loop and staged edits (src/tui/audition.ts), made lazily. */
459
+ let auditionLoop: Audition | undefined;
460
+ /** Pickers that host the audition loop (Space, `a`, `c`, hover). */
461
+ const AUDITION_PICKERS = new Set(["kit", "pattern", "try"]);
462
+ const TRY_USAGE =
463
+ "/try <sound command> · /try fx reverb mix 0.6 · /try agent on|off";
464
+ /** Whether the agent's preview_sound plays its snippet in this window. */
465
+ let agentPreviewPlays = process.env.DAWG_AGENT_PREVIEW !== "off";
466
+ const AGENT_PREVIEW_VOICE = 0x7fff_0002;
467
+ /**
468
+ * Set while a staged command runs: `commitScore` hands its result here
469
+ * instead of appending, and a remote revision that lands meanwhile waits in
470
+ * `committed` (the window's `score` is the staged base until it finishes).
471
+ */
472
+ let stageCapture: { next?: TrackScore; committed?: TrackScore } | undefined;
473
+ /** Redraw soon (the audition reports renders between frames). */
474
+ let requestFrame: () => void = () => undefined;
365
475
  /** The decoded sampler voices, for play mode's live voices. */
366
476
  let liveSampleBank: SampleBank | undefined;
367
477
  /** The in-flight agent turn: Esc aborts it, Enter steers it. */
@@ -698,6 +808,11 @@ async function runInteractive(): Promise<void> {
698
808
  const nextPrompt = queuedPrompts.shift()!;
699
809
  tui.activity.setQueueDepth(queuedPrompts.length);
700
810
  await runPrompt(nextPrompt);
811
+ if (pendingAudition && queuedPrompts.length === 0) {
812
+ const voice = pendingAudition;
813
+ pendingAudition = undefined;
814
+ audition(voice);
815
+ }
701
816
  tick(true);
702
817
  }
703
818
  } finally {
@@ -707,11 +822,15 @@ async function runInteractive(): Promise<void> {
707
822
  };
708
823
  const tick = (force = false) => {
709
824
  if (screenSuspended) return;
825
+ followCommitted();
710
826
  play?.tick();
711
827
  // Values in the menu follow the score as edits land.
712
828
  if (menu.open) refreshMenu();
829
+ if (euclid.open) refreshEuclid();
830
+ refreshAuditionPicker();
713
831
  tui.render(appView(score, clock.beatAt()), { force });
714
832
  };
833
+ requestFrame = () => tick(true);
715
834
  reportAgentActivity = () => {
716
835
  tui.activity.applyAgentEvent({ type: "start", model: providerName });
717
836
  };
@@ -735,7 +854,9 @@ async function runInteractive(): Promise<void> {
735
854
  if (latest.revision > record.revision) {
736
855
  const previousRevision = record.revision;
737
856
  record = latest;
738
- score = scoreFromJSON(record.composition);
857
+ if (stageCapture)
858
+ stageCapture.committed = scoreFromJSON(record.composition);
859
+ else score = scoreFromJSON(record.composition);
739
860
  clock.setTempo(score.tempoBpm);
740
861
  if (clock.playing) void audio.play(score);
741
862
  // Connected windows follow dawgd's transport frames instead.
@@ -889,17 +1010,72 @@ async function runInteractive(): Promise<void> {
889
1010
  ...(text === "\u001b" ? inputDecoder.flush() : []),
890
1011
  ];
891
1012
  for (const value of values) {
1013
+ // The `?` panel closes on any key (Ctrl-C still quits).
1014
+ if (tui.ui.keys && typeof value === "string" && value !== "\u0003") {
1015
+ tui.closeKeys();
1016
+ tick(true);
1017
+ continue;
1018
+ }
1019
+ if (value === "?" && keysScreen()) {
1020
+ showKeys();
1021
+ tick(true);
1022
+ continue;
1023
+ }
1024
+ // The rhythm editor owns every key while it is up (Esc backs out).
1025
+ if (typeof value === "string" && euclid.open) {
1026
+ if (tui.ui.overlay !== "picker" || tui.ui.picker?.id !== "euclid")
1027
+ euclid.close();
1028
+ else {
1029
+ const result = euclid.key(value, euclidContext());
1030
+ if (result.type === "close") {
1031
+ const back = euclid.returnTo;
1032
+ euclid.close();
1033
+ tui.closePicker();
1034
+ if (back === "menu") openMenu();
1035
+ } else if (result.type === "run") {
1036
+ queuedPrompts.unshift(result.command);
1037
+ if (result.audition) pendingAudition = result.audition;
1038
+ void drainQueue();
1039
+ } else if (result.type === "audition") audition(result.voice);
1040
+ if (result.type !== "pass") {
1041
+ refreshEuclid();
1042
+ tick(true);
1043
+ continue;
1044
+ }
1045
+ }
1046
+ }
892
1047
  // The edit menu owns every key while it is up (Esc backs out).
893
1048
  if (typeof value === "string" && menu.open) {
894
1049
  if (tui.ui.overlay !== "picker" || tui.ui.picker?.id !== "menu")
895
1050
  menu.close();
896
1051
  else {
897
1052
  const result = menu.key(value, menuContext());
898
- if (result.type === "close") tui.closePicker();
899
- else if (result.type === "run") {
900
- queuedPrompts.unshift(result.command);
901
- void drainQueue();
902
- }
1053
+ if (result.type === "close") {
1054
+ tui.closePicker();
1055
+ leaveAuditionScreen();
1056
+ } else if (result.type === "run") {
1057
+ if (!stageIfAuditioning(result.command)) {
1058
+ queuedPrompts.unshift(result.command);
1059
+ void drainQueue();
1060
+ }
1061
+ } else if (result.type === "audition")
1062
+ auditionKeyPressed(result.key);
1063
+ else if (result.type === "revert") revertStaged();
1064
+ else if (result.type === "hover")
1065
+ hoverItem(result.command, result.key);
1066
+ else if (result.type === "unhover")
1067
+ void auditionLoop
1068
+ ?.unhover(result.key)
1069
+ .finally(() => requestFrame());
1070
+ else if (result.type === "choose")
1071
+ chooseItem(result.command, result.key);
1072
+ else if (result.type === "keep")
1073
+ void keepStaged()
1074
+ .then((outcome) => receipt(outcome))
1075
+ .catch((error) =>
1076
+ tui.activity.pushError(describeError("keep", error)),
1077
+ )
1078
+ .finally(() => tick(true));
903
1079
  if (result.type !== "pass") {
904
1080
  refreshMenu();
905
1081
  tick(true);
@@ -935,7 +1111,11 @@ async function runInteractive(): Promise<void> {
935
1111
  // shell exit key; Ctrl-Q is reserved for prompt mode switching.
936
1112
  // Keep the empty-prompt space shortcut for transport, while allowing
937
1113
  // ordinary spaces once a request is being composed.
938
- if (value === " " && prompt.value.length === 0) {
1114
+ if (
1115
+ value === " " &&
1116
+ prompt.value.length === 0 &&
1117
+ !(tui.ui.overlay === "picker" && tui.ui.picker?.audition)
1118
+ ) {
939
1119
  // Never await a daemon round trip here: the key loop must stay
940
1120
  // live for Esc, quit and redraws while the toggle is in flight.
941
1121
  void toggleTransport()
@@ -967,6 +1147,52 @@ async function runInteractive(): Promise<void> {
967
1147
  })
968
1148
  .finally(() => tick(true));
969
1149
  }
1150
+ } else if (input.type === "pick-move") {
1151
+ // Moving through a list auditions the row under the cursor on
1152
+ // the loop; with the loop off, /pattern plays one bar of it.
1153
+ if (auditionLoop?.looping && isStageable(input.value))
1154
+ hoverItem(input.value, `picker:${input.picker}`);
1155
+ else if (input.picker === "pattern")
1156
+ previewPattern(input.value.replace(/^\/pattern\s+/, ""));
1157
+ } else if (input.type === "pick-audition") {
1158
+ pickerAuditionKey(input.key);
1159
+ } else if (input.type === "pick-cancel") {
1160
+ if (AUDITION_PICKERS.has(input.picker)) leaveAuditionScreen();
1161
+ } else if (input.type === "pick" && input.picker === "try") {
1162
+ if (input.value === "keep")
1163
+ void keepStaged()
1164
+ .then((outcome) => receipt(outcome))
1165
+ .catch((error) =>
1166
+ tui.activity.pushError(describeError("keep", error)),
1167
+ )
1168
+ .finally(() => {
1169
+ stopAuditionLoop();
1170
+ tick(true);
1171
+ });
1172
+ else leaveAuditionScreen();
1173
+ } else if (
1174
+ input.type === "pick" &&
1175
+ AUDITION_PICKERS.has(input.picker) &&
1176
+ auditionLoop?.staging
1177
+ ) {
1178
+ // Enter in an auditioning list keeps what you hear.
1179
+ const loop = auditionLoop;
1180
+ const key = `picker:${input.picker}`;
1181
+ void (
1182
+ isStageable(input.value)
1183
+ ? loop.hover(input.value, key)
1184
+ : Promise.resolve()
1185
+ )
1186
+ .then(() => loop.settle(key))
1187
+ .then(() => keepStaged())
1188
+ .then((outcome) => receipt(outcome))
1189
+ .catch((error) =>
1190
+ tui.activity.pushError(describeError("keep", error)),
1191
+ )
1192
+ .finally(() => {
1193
+ stopAuditionLoop();
1194
+ tick(true);
1195
+ });
970
1196
  } else if (input.type === "pick") {
971
1197
  // Picker choices run as the command they stand for.
972
1198
  queuedPrompts.unshift(
@@ -1023,11 +1249,34 @@ async function runInteractive(): Promise<void> {
1023
1249
  }
1024
1250
  }
1025
1251
 
1252
+ /** `unknown command /clik · did you mean /click? · /help`. */
1253
+ function unknownCommand(command: string): string {
1254
+ const word = command.split(/\s+/)[0] ?? command;
1255
+ const near = nearestCommand(command);
1256
+ return near
1257
+ ? `unknown command ${word} · did you mean ${near}? · /help`
1258
+ : `unknown command ${word} · /help`;
1259
+ }
1260
+
1026
1261
  async function submit(prompt: string): Promise<string | Receipt> {
1027
1262
  const command = prompt.trim();
1028
- if (/^\/?help$|^\/?\?$/.test(command.toLowerCase())) {
1029
- tui.openText("help", helpLines(Math.max(40, (stdout.columns ?? 80) - 8)));
1030
- return ok("help · esc closes");
1263
+ const helpCommand = command.match(/^\/?(?:help|\?)(?:\s+(\S+))?$/i);
1264
+ if (helpCommand) {
1265
+ const topic = helpCommand[1];
1266
+ const lines = helpTopicLines(
1267
+ topic,
1268
+ Math.max(40, (stdout.columns ?? 80) - 10),
1269
+ );
1270
+ if (!lines)
1271
+ return fail(
1272
+ `no help topic ${topic} · /help all · ${HELP_TOPICS.join(" ")}`,
1273
+ );
1274
+ tui.openText(topic ? `help · ${topic.toLowerCase()}` : "help", lines);
1275
+ return ok(
1276
+ topic
1277
+ ? `help · ${topic.toLowerCase()}`
1278
+ : "help · /help all for every command",
1279
+ );
1031
1280
  }
1032
1281
  if (/^\/?tracks$/i.test(command)) {
1033
1282
  const problems = await sampleProblems(score);
@@ -1044,9 +1293,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1044
1293
  return `${track.id === requestedTrack ? "*" : " "} ${track.id} · ${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}`;
1045
1294
  });
1046
1295
  tui.openText("tracks · * focused", lines);
1047
- return ok(
1048
- `${lines.length} track${lines.length === 1 ? "" : "s"} · esc closes`,
1049
- );
1296
+ return ok(`${lines.length} track${lines.length === 1 ? "" : "s"}`);
1050
1297
  }
1051
1298
  const playCommand = command.match(/^\/play(?:\s+(on|off))?$/i);
1052
1299
  if (playCommand) {
@@ -1062,15 +1309,32 @@ async function submit(prompt: string): Promise<string | Receipt> {
1062
1309
  ? fail(message)
1063
1310
  : ok(message);
1064
1311
  }
1312
+ const chordsCommand = command.match(/^\/chords(?:\s+(.+))?$/i);
1313
+ if (chordsCommand) {
1314
+ const result = applyChordsCommand(chordSettings, chordsCommand[1] ?? "");
1315
+ return result.ok ? ok(result.message) : fail(result.message);
1316
+ }
1065
1317
  const countIn = command.match(/^\/count-?in\s+([0-2])$/i);
1066
1318
  if (countIn) return ok(playSession().setCountIn(Number(countIn[1])));
1319
+ const euclidCommand = command.match(/^\/euclid(?:\s+(\S+))?$/i);
1320
+ if (euclidCommand) {
1321
+ const from = menu.open ? "menu" : undefined;
1322
+ if (menu.open) {
1323
+ menu.close();
1324
+ tui.closePicker();
1325
+ }
1326
+ openEuclid(euclidCommand[1]?.toLowerCase(), from);
1327
+ return ok("rhythm editor");
1328
+ }
1329
+ const tryCommand = command.match(/^\/try(?:\s+(.+))?$/i);
1330
+ if (tryCommand) return tryPrompt(tryCommand[1]?.trim() ?? "");
1067
1331
  const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
1068
1332
  if (menuCommand) {
1069
1333
  const section = menuCommand[1]?.toLowerCase();
1070
1334
  if (section && !(MENU_SECTIONS as readonly string[]).includes(section))
1071
1335
  return fail(`/menu [${MENU_SECTIONS.join("|")}]`);
1072
1336
  openMenu(section);
1073
- return ok("menu · esc closes");
1337
+ return ok("menu");
1074
1338
  }
1075
1339
  const gridCommand = command.match(/^\/grid\s+(\S+)$/i);
1076
1340
  if (gridCommand) {
@@ -1091,6 +1355,14 @@ async function submit(prompt: string): Promise<string | Receipt> {
1091
1355
  if (trackCommand) return focusTrack(trackCommand[1]!.toLowerCase());
1092
1356
  const sample = parseSampleCommand(command);
1093
1357
  if (sample) return sampleCommand(sample);
1358
+ const pack = parsePackCommand(command);
1359
+ if (pack) return packCommand(pack);
1360
+ const pattern = parsePatternCommand(command);
1361
+ if (pattern) return patternCommand(pattern);
1362
+ const kit = parseKitCommand(command);
1363
+ if (kit) return kitCommand(kit, /^\/kit\s*$/i.test(command.trim()));
1364
+ const wavetable = parseWavetableCommand(command);
1365
+ if (wavetable) return wavetableCommand(wavetable);
1094
1366
  const sessionReply = await sessionCommand(command);
1095
1367
  if (sessionReply !== undefined) return sessionReply;
1096
1368
  const edit = parseEditCommand(command);
@@ -1101,6 +1373,41 @@ async function submit(prompt: string): Promise<string | Receipt> {
1101
1373
  await commitScore(result.next, result.kind, result.payload);
1102
1374
  return result.ok ? ok(result.message) : fail(result.message);
1103
1375
  }
1376
+ const rhythm = parseRhythmCommand(command);
1377
+ if (rhythm) {
1378
+ await materializeDraft();
1379
+ const result = applyRhythmCommand(score, requestedTrack, rhythm);
1380
+ if (result.next && result.kind)
1381
+ await commitScore(result.next, result.kind, result.payload);
1382
+ return result.ok ? ok(result.message) : fail(result.message);
1383
+ }
1384
+ const fx = parseFxCommand(command);
1385
+ if (fx) {
1386
+ if (fx.type !== "fx-list") await materializeDraft();
1387
+ // A pack impulse is pinned (sha256 + url) once, as wavetable tables are.
1388
+ let pinnedIr: SampleRef | undefined;
1389
+ if (fx.type === "fx-ir" && fx.ir?.startsWith(PACK_PREFIX)) {
1390
+ try {
1391
+ pinnedIr = await packs().pin(fx.ir);
1392
+ } catch (error) {
1393
+ if (error instanceof PackError)
1394
+ return fail(`reverb ir · ${error.message}`);
1395
+ throw error;
1396
+ }
1397
+ }
1398
+ const result = applyFxCommand(score, requestedTrack, fx, pinnedIr);
1399
+ if (result.next && result.kind)
1400
+ await commitScore(result.next, result.kind, result.payload);
1401
+ return result.ok ? ok(result.message) : fail(result.message);
1402
+ }
1403
+ const synth = parseSynthCommand(command);
1404
+ if (synth) {
1405
+ if (synth.type !== "synth-list") await materializeDraft();
1406
+ const result = applySynthCommand(score, requestedTrack, synth);
1407
+ if (result.next && result.kind)
1408
+ await commitScore(result.next, result.kind, result.payload);
1409
+ return result.ok ? ok(result.message) : fail(result.message);
1410
+ }
1104
1411
  const music = parseMusicCommand(command);
1105
1412
  if (music) {
1106
1413
  await materializeDraft();
@@ -1188,9 +1495,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1188
1495
  if (command.startsWith("/") && !/^\/model\b/i.test(command)) {
1189
1496
  const hint = usageHint(command);
1190
1497
  return fail(
1191
- hint
1192
- ? `${truncateForCard(command)} · ${hint}`
1193
- : `unknown command ${command.split(/\s+/)[0]} · /help`,
1498
+ hint ? `${truncateForCard(command)} · ${hint}` : unknownCommand(command),
1194
1499
  );
1195
1500
  }
1196
1501
  const parsed = parsePrompt(prompt);
@@ -1198,6 +1503,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
1198
1503
  // A known verb with bad arguments gets usage, not a model call.
1199
1504
  const hint = usageHint(command);
1200
1505
  if (hint) return fail(`${truncateForCard(command)} · ${hint}`);
1506
+ // A one-letter slip on a command whose arguments parse stays local.
1507
+ const fix = typoFix(command, parsesLocally);
1508
+ if (fix) return fail(`${truncateForCard(command)} · did you mean ${fix}?`);
1201
1509
  if (process.env.DAWG_AI === "0")
1202
1510
  return fail(`unrecognized · ${truncateForCard(command)} · /help`);
1203
1511
  await materializeDraft();
@@ -1405,15 +1713,15 @@ async function focusTrack(trackId: string): Promise<Receipt> {
1405
1713
  return ok(exists ? `track · ${trackId}` : `track created · ${trackId}`);
1406
1714
  }
1407
1715
 
1408
- let sampleLibrary: SampleLibrary | undefined;
1409
- let reportedSampleProblems = "";
1410
-
1411
1716
  /** Decode every sampler voice (cached) and return what failed to load. */
1412
1717
  async function sampleProblems(
1413
1718
  value: TrackScore,
1414
1719
  ): Promise<readonly SampleProblem[]> {
1415
1720
  if (!hasSamplerTracks(value)) return [];
1416
- sampleLibrary ??= new SampleLibrary({ projectRoot: process.cwd() });
1721
+ sampleLibrary ??= new SampleLibrary({
1722
+ projectRoot: process.cwd(),
1723
+ packs: packs(),
1724
+ });
1417
1725
  try {
1418
1726
  const bank = await sampleLibrary.load(value);
1419
1727
  liveSampleBank = bank;
@@ -1465,9 +1773,22 @@ async function sampleCommand(
1465
1773
  `sample · ${requestedTrack} has no samples · /sample <path> [as <voice>]`,
1466
1774
  );
1467
1775
  tui.openText(`samples · ${requestedTrack}`, lines);
1468
- return ok(
1469
- `${lines.length} sample${lines.length === 1 ? "" : "s"} · esc closes`,
1776
+ return ok(`${lines.length} sample${lines.length === 1 ? "" : "s"}`);
1777
+ }
1778
+ if (command.kind === "set") {
1779
+ const result = setSampleControls(
1780
+ score,
1781
+ requestedTrack,
1782
+ command.voice,
1783
+ command.values,
1470
1784
  );
1785
+ if (!result.ok) return fail(result.message);
1786
+ await commitScore(result.next, "sample.set", {
1787
+ trackId: requestedTrack,
1788
+ voice: command.voice,
1789
+ });
1790
+ await projectSync?.flushScore();
1791
+ return ok(result.message);
1471
1792
  }
1472
1793
  await materializeDraft();
1473
1794
  const trackId = samplerTarget(score, requestedTrack);
@@ -1515,6 +1836,326 @@ async function sampleCommand(
1515
1836
  );
1516
1837
  }
1517
1838
 
1839
+ function packs(): PackStore {
1840
+ packStore ??= new PackStore();
1841
+ return packStore;
1842
+ }
1843
+
1844
+ /** Keeps CREDITS.md in step with the CC-BY packs the score uses (quietly). */
1845
+ async function updateCredits(value: TrackScore): Promise<void> {
1846
+ const refs = value.tracks.flatMap((track) =>
1847
+ Object.values(track.sampler?.voices ?? {}),
1848
+ );
1849
+ if (!refs.some((ref) => ref.src.startsWith("pack:"))) return;
1850
+ // Only a dawg project gets CREDITS.md; a bare session directory does not.
1851
+ if (!projectSync) return;
1852
+ try {
1853
+ await writeCredits(process.cwd(), packCredits(refs, await packs().list()));
1854
+ } catch {
1855
+ /* credits are best effort; render metadata carries them too */
1856
+ }
1857
+ }
1858
+
1859
+ /** Applies a pack edit as one undo step and focuses its track. */
1860
+ async function commitPackEdit(
1861
+ operations: readonly ScoreOperation[],
1862
+ trackId: string,
1863
+ kind: string,
1864
+ payload: Record<string, unknown>,
1865
+ ): Promise<Receipt | undefined> {
1866
+ let next = score;
1867
+ for (const operation of operations)
1868
+ next = applyScoreOperation(next, operation);
1869
+ const problems = (await sampleProblems(next)).filter(
1870
+ (problem) => problem.trackId === trackId && problem.level === "error",
1871
+ );
1872
+ if (problems.length > 0) return fail(`sound · ${problems[0]!.message}`);
1873
+ await commitScore(next, kind, payload);
1874
+ if (stageCapture) return undefined;
1875
+ if (trackId !== requestedTrack) {
1876
+ await port.focus(trackId);
1877
+ requestedTrack = trackId;
1878
+ draftTrack = false;
1879
+ }
1880
+ await projectSync?.flushScore();
1881
+ return undefined;
1882
+ }
1883
+
1884
+ async function packCommand(command: PackCommand): Promise<Receipt> {
1885
+ try {
1886
+ if (command.kind === "usage") return warn(command.message);
1887
+ if (command.kind === "list") {
1888
+ const lines = packListLines(await packs().list());
1889
+ tui.openText("packs", lines);
1890
+ return ok(`${lines.length} packs · /pack info <name>`);
1891
+ }
1892
+ if (command.kind === "cache") {
1893
+ const library = (sampleLibrary ??= new SampleLibrary({
1894
+ projectRoot: process.cwd(),
1895
+ packs: packs(),
1896
+ }));
1897
+ let freed: { files: number; bytes: number } | undefined;
1898
+ if (command.pruneTo !== undefined) {
1899
+ const to = (cap: number) =>
1900
+ command.pruneTo === "cap" ? cap : (command.pruneTo as number);
1901
+ const store = packs();
1902
+ const a = await store.pruneCache(to(store.maxFileCacheBytes));
1903
+ const b = await library.pruneCache(to(library.maxCacheBytes));
1904
+ freed = { files: a.removed + b.removed, bytes: a.freed + b.freed };
1905
+ }
1906
+ const lines = cacheLines({
1907
+ packs: await packs().cacheStatus(),
1908
+ assets: await library.cacheStatus(),
1909
+ ...(freed ? { freed } : {}),
1910
+ });
1911
+ tui.openText("cache", lines);
1912
+ return ok(lines[0]!);
1913
+ }
1914
+ if (command.kind === "add") {
1915
+ const added = await packs().add(
1916
+ command.source,
1917
+ command.name ? { name: command.name } : {},
1918
+ );
1919
+ return ok(
1920
+ `pack added · ${added.pack.name} · ${added.sounds} sounds · /pack info ${added.pack.name}`,
1921
+ );
1922
+ }
1923
+ if (command.kind === "info") {
1924
+ const info = await packs().info(command.name);
1925
+ if (!info)
1926
+ return fail(
1927
+ `pack · no pack named ${command.name.slice(0, 40)} · /pack list`,
1928
+ );
1929
+ const manifest = await packs().manifest(info.name);
1930
+ const banks = banksOf(manifest);
1931
+ const names = [...manifest.sounds.keys()];
1932
+ const lines = [
1933
+ `${info.name} · ${info.title}`,
1934
+ `license · ${info.license}${info.attribution ? ` · ${info.attribution}` : ""}`,
1935
+ `manifest · ${info.manifestUrl}`,
1936
+ ...(info.homepage ? [`home · ${info.homepage}`] : []),
1937
+ `${names.length} sounds${banks.length ? ` · ${banks.length} kits` : ""}`,
1938
+ ...(banks.length ? [`kits · ${banks.join(" ")}`] : []),
1939
+ ...names.slice(0, 400).map((name) => {
1940
+ const sound = manifest.sounds.get(name)!;
1941
+ return `${name} · ${sound.kind === "zones" ? `${sound.zones.length} notes (keyed)` : `${sound.files.length} file${sound.files.length === 1 ? "" : "s"}`}`;
1942
+ }),
1943
+ ];
1944
+ tui.openText(`pack · ${info.name}`, lines);
1945
+ return ok(
1946
+ `${info.name} · ${names.length} sounds · /pack use ${info.name}/<sound>`,
1947
+ );
1948
+ }
1949
+ if (command.kind === "remove") {
1950
+ const known = await packs().remove(command.name);
1951
+ return known
1952
+ ? ok(`pack removed · ${command.name}`)
1953
+ : fail(
1954
+ `pack · no pack named ${command.name.slice(0, 40)} · /pack list`,
1955
+ );
1956
+ }
1957
+ await materializeDraft();
1958
+ const result = await useSound(
1959
+ packs(),
1960
+ score,
1961
+ requestedTrack,
1962
+ command.sound,
1963
+ {
1964
+ ...(command.voice ? { voice: command.voice } : {}),
1965
+ },
1966
+ );
1967
+ const failed = await commitPackEdit(
1968
+ result.operations,
1969
+ result.trackId,
1970
+ "pack.use",
1971
+ {
1972
+ trackId: result.trackId,
1973
+ sound: command.sound,
1974
+ },
1975
+ );
1976
+ return failed ?? ok(`sound · ${result.summary}`);
1977
+ } catch (error) {
1978
+ if (error instanceof PackError) return fail(`pack · ${error.message}`);
1979
+ throw error;
1980
+ }
1981
+ }
1982
+
1983
+ async function patternCommand(command: PatternCommand): Promise<Receipt> {
1984
+ if (command.kind === "list") {
1985
+ tui.openText("patterns", DRUM_PATTERNS.map(patternLine));
1986
+ return ok(`patterns · ${DRUM_PATTERNS.length} · /pattern <name>`);
1987
+ }
1988
+ if (command.kind === "browse") {
1989
+ tui.openPicker({
1990
+ id: "pattern",
1991
+ title: "drum patterns",
1992
+ hint: HINTS.audition,
1993
+ audition: true,
1994
+ items: DRUM_PATTERNS.map((entry) => ({
1995
+ label: `${entry.label.padEnd(22)} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
1996
+ value: `/pattern ${entry.name}`,
1997
+ })),
1998
+ filterable: true,
1999
+ index: 0,
2000
+ });
2001
+ patternPreview = undefined;
2002
+ previewPattern(DRUM_PATTERNS[0]?.name);
2003
+ return ok("patterns · ↑/↓ preview · Enter applies on the focused track");
2004
+ }
2005
+ await materializeDraft();
2006
+ const result = applyDrumPattern(
2007
+ score,
2008
+ requestedTrack,
2009
+ command.name,
2010
+ command.tempo,
2011
+ );
2012
+ if (result.next && result.kind)
2013
+ await commitScore(result.next, result.kind, result.payload);
2014
+ return result.ok ? ok(result.message) : fail(result.message);
2015
+ }
2016
+
2017
+ /**
2018
+ * Play one bar of the pattern under the picker cursor, on a scratch copy
2019
+ * of the focused track (or a fresh kit track when the focus is melodic).
2020
+ * Silent while the loop plays, like the Euclidean editor's audition.
2021
+ */
2022
+ function previewPattern(name: string | undefined): void {
2023
+ if (!name || name === patternPreview) return;
2024
+ patternPreview = name;
2025
+ if (clock.playing) return;
2026
+ const entry = DRUM_PATTERNS.find((candidate) => candidate.name === name);
2027
+ if (!entry) return;
2028
+ let result = applyDrumPattern(score, requestedTrack, entry.name, "set");
2029
+ let trackId = requestedTrack;
2030
+ if (!result.next) {
2031
+ trackId = "pattern-preview";
2032
+ result = applyDrumPattern(score, trackId, entry.name, "set");
2033
+ }
2034
+ if (!result.next) return;
2035
+ const engine = liveEngine();
2036
+ if (!engine?.canMonitor) return;
2037
+ const pcm = renderAudition({
2038
+ score: result.next,
2039
+ trackId,
2040
+ bars: 1,
2041
+ sampleRate: engine.sampleRate,
2042
+ ...(liveSampleBank ? { samples: liveSampleBank } : {}),
2043
+ });
2044
+ if (!pcm) return;
2045
+ void engine
2046
+ .monitor(true)
2047
+ .then(() => engine.noteOn(AUDITION_VOICE, pcm))
2048
+ .catch(() => undefined);
2049
+ }
2050
+
2051
+ /** `/kit` with no name: one picker with the synth kits and the sample kits. */
2052
+ function openKitPicker(): Receipt {
2053
+ const current = score.tracks.find((track) => track.id === requestedTrack);
2054
+ const items = kitCatalog().map((entry) => ({
2055
+ label: `${entry.kind === "synth" ? "synth " : "sample"} ${entry.label.padEnd(18)} ${entry.detail}`,
2056
+ value: entry.command,
2057
+ current:
2058
+ entry.kind === "synth" &&
2059
+ isDrumInstrumentTrack(current) &&
2060
+ (current?.kit ?? "default") === entry.name,
2061
+ }));
2062
+ tui.openPicker({
2063
+ id: "kit",
2064
+ title: "drum kits",
2065
+ hint: HINTS.audition,
2066
+ audition: true,
2067
+ items,
2068
+ filterable: true,
2069
+ index: Math.max(
2070
+ 0,
2071
+ items.findIndex((item) => item.current),
2072
+ ),
2073
+ });
2074
+ return ok("kits · synth kits work offline · sample kits are fetched once");
2075
+ }
2076
+
2077
+ function isDrumInstrumentTrack(track: { instrument: string } | undefined) {
2078
+ return track !== undefined && isDrumInstrument(track.instrument);
2079
+ }
2080
+
2081
+ async function kitCommand(command: KitCommand, bare = false): Promise<Receipt> {
2082
+ if (bare) return openKitPicker();
2083
+ if (command.kind === "set" && isSynthKitName(command.bank)) {
2084
+ await materializeDraft();
2085
+ const result = applySynthKit(score, requestedTrack, command.bank);
2086
+ if (result.next && result.kind)
2087
+ await commitScore(result.next, result.kind, result.payload);
2088
+ return result.ok ? ok(result.message) : fail(result.message);
2089
+ }
2090
+ if (command.kind === "list") {
2091
+ const lines = kitCatalog()
2092
+ .filter((entry) => entry.kind === "synth")
2093
+ .map((entry) => `${entry.name} · synth · ${entry.detail}`);
2094
+ lines.push(...kitListLines(await packs().bankAliases(ALIASED_PACK)));
2095
+ tui.openText("kits", lines);
2096
+ return ok("kits · /kit <name or nickname> on the focused drum track");
2097
+ }
2098
+ await materializeDraft();
2099
+ const trackId = kitTarget(score, requestedTrack);
2100
+ if (!trackId)
2101
+ return fail(
2102
+ `kit · ${requestedTrack} is a melodic track · focus a drum track (/track drums) and retry`,
2103
+ );
2104
+ try {
2105
+ const result = await useKit(packs(), score, trackId, command.bank);
2106
+ const failed = await commitPackEdit(
2107
+ result.operations,
2108
+ trackId,
2109
+ "pack.kit",
2110
+ {
2111
+ trackId,
2112
+ bank: command.bank,
2113
+ },
2114
+ );
2115
+ return failed ?? ok(result.summary);
2116
+ } catch (error) {
2117
+ if (error instanceof PackError) return fail(`kit · ${error.message}`);
2118
+ throw error;
2119
+ }
2120
+ }
2121
+
2122
+ async function wavetableCommand(command: WavetableCommand): Promise<Receipt> {
2123
+ if (command.kind === "usage") return warn(command.message);
2124
+ if (command.kind === "show")
2125
+ return ok(describeWavetable(score, requestedTrack));
2126
+ if (command.kind === "list") {
2127
+ tui.openText(
2128
+ "wavetables",
2129
+ await wavetableListLines(packs(), process.cwd()),
2130
+ );
2131
+ return ok("wavetables · wt <table> sets the focused track");
2132
+ }
2133
+ await materializeDraft();
2134
+ const trackId = requestedTrack;
2135
+ try {
2136
+ const edit =
2137
+ command.kind === "table"
2138
+ ? await pickWavetable(
2139
+ packs(),
2140
+ score,
2141
+ trackId,
2142
+ command.table,
2143
+ process.cwd(),
2144
+ )
2145
+ : wavetableParamEdit(score, trackId, command);
2146
+ const failed = await commitPackEdit(
2147
+ [edit.operation],
2148
+ trackId,
2149
+ "track.wavetable",
2150
+ { trackId, ...command },
2151
+ );
2152
+ return failed ?? ok(edit.summary);
2153
+ } catch (error) {
2154
+ if (error instanceof PackError) return fail(`wavetable · ${error.message}`);
2155
+ throw error;
2156
+ }
2157
+ }
2158
+
1518
2159
  async function readLoopFile(path: string): Promise<string> {
1519
2160
  const contents = await readFile(resolve(path));
1520
2161
  if (contents.byteLength > 512 * 1024)
@@ -1627,7 +2268,7 @@ async function sessionCommand(
1627
2268
  );
1628
2269
  tui.openPicker({
1629
2270
  id: "resume",
1630
- title: "resume session · ↑/↓ Enter · Esc",
2271
+ title: "resume a session",
1631
2272
  items: readable.map((session) => ({
1632
2273
  label: `${session.sessionId === record.sessionId ? "* " : " "}${formatSessionLine(session, sessions)}`,
1633
2274
  value: session.sessionId,
@@ -1644,9 +2285,7 @@ async function sessionCommand(
1644
2285
  MAX_LISTED_SESSIONS,
1645
2286
  );
1646
2287
  tui.openText("sessions · * current · /resume <n>", lines);
1647
- return ok(
1648
- `${lines.length} session${lines.length === 1 ? "" : "s"} · esc closes`,
1649
- );
2288
+ return ok(`${lines.length} session${lines.length === 1 ? "" : "s"}`);
1650
2289
  }
1651
2290
  if (agentTurn) return warn("agent busy · finish or Esc first");
1652
2291
  if (verb === "fork") {
@@ -1707,6 +2346,11 @@ async function switchSession(sessionId: string): Promise<void> {
1707
2346
  }
1708
2347
 
1709
2348
  function liveEngine(): LiveEngine | undefined {
2349
+ return previewEngine();
2350
+ }
2351
+
2352
+ /** The engine this window hears itself through (its own in daemon mode). */
2353
+ function previewEngine(): AudioEngine {
1710
2354
  if (audio instanceof AudioEngine) return audio;
1711
2355
  monitorEngine ??= new AudioEngine({ projectRoot: process.cwd() });
1712
2356
  return monitorEngine;
@@ -1715,27 +2359,424 @@ function liveEngine(): LiveEngine | undefined {
1715
2359
  /** The play session for the focused track (re-made when focus moves). */
1716
2360
  function menuContext(): MenuContext {
1717
2361
  const session = play;
2362
+ const loop = auditionController();
2363
+ loop.focus(requestedTrack);
1718
2364
  return {
1719
- score,
2365
+ score: loop.staging ? loop.score : score,
2366
+ audition: {
2367
+ looping: loop.looping,
2368
+ dirty: loop.dirtyEdits,
2369
+ committed: score,
2370
+ hint: loop.hint(),
2371
+ status: loop.status(),
2372
+ },
1720
2373
  trackId: requestedTrack,
1721
2374
  playing: clock.playing,
1722
2375
  grid: session?.grid ?? DEFAULT_GRID,
1723
2376
  grids: GRIDS.map((grid) => grid.label),
1724
2377
  clickOn: session?.clickOn ?? false,
1725
2378
  countInBars: session?.countInBars ?? 1,
2379
+ chords: session?.chords.settings ?? chordSettings,
2380
+ projectRoot: process.cwd(),
1726
2381
  };
1727
2382
  }
1728
2383
 
2384
+ /** The screen `?` describes, or undefined while `?` is typed text. */
2385
+ function keysScreen(): readonly KeySection[] | undefined {
2386
+ if (euclid.open) return euclid.typing ? undefined : KEYS.euclid;
2387
+ if (menu.open) return menu.typing ? undefined : KEYS.menu;
2388
+ const overlay = tui.ui.overlay;
2389
+ if (overlay === "picker")
2390
+ return tui.pickerTyping
2391
+ ? undefined
2392
+ : AUDITION_PICKERS.has(tui.ui.picker?.id ?? "")
2393
+ ? KEYS.audition
2394
+ : KEYS.list;
2395
+ if (overlay === "text") return KEYS.text;
2396
+ if (overlay === "log") return KEYS.log;
2397
+ if (prompt.value.length > 0) return undefined;
2398
+ if (play?.on)
2399
+ return play.chords.on ? [...KEYS.play, ...KEYS.chords] : KEYS.play;
2400
+ return KEYS.prompt;
2401
+ }
2402
+
2403
+ function showKeys(): void {
2404
+ const sections = keysScreen();
2405
+ if (!sections) return;
2406
+ const lines = keyLines(sections);
2407
+ // Play mode's secondary state lives here instead of the header.
2408
+ if (play?.on && !tui.ui.overlay && !menu.open)
2409
+ lines.unshift(...play.details(), "");
2410
+ tui.showKeys("keys", lines);
2411
+ }
2412
+
1729
2413
  function openMenu(section?: string): void {
1730
2414
  menu.show(menuContext(), section);
1731
2415
  refreshMenu();
1732
2416
  }
1733
2417
 
2418
+ function euclidContext() {
2419
+ return { score, trackId: requestedTrack };
2420
+ }
2421
+
2422
+ function openEuclid(voice?: string, origin?: string): void {
2423
+ euclid.show(euclidContext(), voice, origin);
2424
+ refreshEuclid();
2425
+ }
2426
+
2427
+ function refreshEuclid(): void {
2428
+ if (!euclid.open) return;
2429
+ if (tui.ui.overlay !== undefined && tui.ui.picker?.id !== "euclid") {
2430
+ euclid.close();
2431
+ return;
2432
+ }
2433
+ const view = euclid.view(euclidContext());
2434
+ tui.openPicker({
2435
+ id: "euclid",
2436
+ title: view.title,
2437
+ items: view.items,
2438
+ index: view.index,
2439
+ hint: view.hint,
2440
+ });
2441
+ }
2442
+
2443
+ /**
2444
+ * Play one bar of `voice` on the focused track over silence. While the loop
2445
+ * is playing the edit is already audible, so nothing extra sounds.
2446
+ */
2447
+ function audition(voice: string): void {
2448
+ if (clock.playing) return;
2449
+ const track = score.tracks.find(
2450
+ (candidate) => candidate.id === requestedTrack,
2451
+ );
2452
+ if (!track) return;
2453
+ const pitch = rhythmVoicePitch(track, voice);
2454
+ if (pitch === undefined) return;
2455
+ const engine = liveEngine();
2456
+ if (!engine?.canMonitor) return;
2457
+ const pcm = renderAudition({
2458
+ score,
2459
+ trackId: track.id,
2460
+ pitches: new Set([pitch]),
2461
+ bars: 1,
2462
+ sampleRate: engine.sampleRate,
2463
+ ...(liveSampleBank ? { samples: liveSampleBank } : {}),
2464
+ });
2465
+ if (!pcm) return;
2466
+ void engine
2467
+ .monitor(true)
2468
+ .then(() => engine.noteOn(AUDITION_VOICE, pcm))
2469
+ .catch(() => undefined);
2470
+ }
2471
+
2472
+ /**
2473
+ * The window's audition controller. Its loop plays through the ordinary
2474
+ * engine (the render worker and stem cache), so a preview sounds exactly
2475
+ * like the same bars of the song; staged commands run through `submit`
2476
+ * with `commitScore` captured, so a staged edit is made by the same code
2477
+ * as a committed one.
2478
+ */
2479
+ function auditionController(): Audition {
2480
+ auditionLoop ??= new Audition(
2481
+ {
2482
+ apply: applyStaged,
2483
+ play: (preview) => previewEngine().play(preview),
2484
+ stop() {
2485
+ const engine = previewEngine();
2486
+ engine.stop();
2487
+ if (!play?.on) engine.setLeadMs(undefined);
2488
+ },
2489
+ leadMs: () => previewEngine().leadMs,
2490
+ now: () => performance.now(),
2491
+ beat: () => clock.beatAt(),
2492
+ setTimer: (callback, ms) => setTimeout(callback, ms),
2493
+ clearTimer: (handle) => clearTimeout(handle as Timer),
2494
+ changed: () => requestFrame(),
2495
+ level: () => previewEngine().level,
2496
+ },
2497
+ score,
2498
+ requestedTrack,
2499
+ );
2500
+ return auditionLoop;
2501
+ }
2502
+
2503
+ /**
2504
+ * Warm the pack cache for a staged command before it runs, so a lazy fetch
2505
+ * (a sample kit, a pack sound or table) happens outside the capture and the
2506
+ * window's score is swapped only for the instant the command takes.
2507
+ */
2508
+ async function prefetchStaged(
2509
+ base: TrackScore,
2510
+ command: string,
2511
+ ): Promise<void> {
2512
+ try {
2513
+ const kit = parseKitCommand(command);
2514
+ if (kit?.kind === "set" && !isSynthKitName(kit.bank)) {
2515
+ const trackId = kitTarget(base, requestedTrack);
2516
+ if (trackId) await useKit(packs(), base, trackId, kit.bank);
2517
+ return;
2518
+ }
2519
+ const pack = parsePackCommand(command);
2520
+ if (pack?.kind === "use") {
2521
+ await useSound(packs(), base, requestedTrack, pack.sound, {
2522
+ ...(pack.voice ? { voice: pack.voice } : {}),
2523
+ });
2524
+ return;
2525
+ }
2526
+ const wavetable = parseWavetableCommand(command);
2527
+ if (wavetable?.kind === "table")
2528
+ await pickWavetable(
2529
+ packs(),
2530
+ base,
2531
+ requestedTrack,
2532
+ wavetable.table,
2533
+ process.cwd(),
2534
+ );
2535
+ } catch {
2536
+ // The command itself reports the failure.
2537
+ }
2538
+ }
2539
+
2540
+ /** Apply one prompt command to `base` without committing (see stageCapture). */
2541
+ async function applyStaged(
2542
+ base: TrackScore,
2543
+ command: string,
2544
+ ): Promise<{ next?: TrackScore; ok: boolean; message: string }> {
2545
+ await prefetchStaged(base, command);
2546
+ // Staged commands run one at a time, between queued prompts.
2547
+ while (stageCapture) await Bun.sleep(1);
2548
+ const committed = score;
2549
+ stageCapture = {};
2550
+ score = base;
2551
+ try {
2552
+ const result = await submit(command);
2553
+ const text = typeof result === "string" ? result : result.text;
2554
+ const good = toneOf(result) !== "error";
2555
+ return { next: stageCapture.next, ok: good, message: text };
2556
+ } catch (error) {
2557
+ return { ok: false, message: describeError(command, error) };
2558
+ } finally {
2559
+ score = stageCapture?.committed ?? committed;
2560
+ stageCapture = undefined;
2561
+ }
2562
+ }
2563
+
2564
+ /** Keep the controller on the committed score (remote edits, undo, agent). */
2565
+ function followCommitted(): void {
2566
+ const loop = auditionLoop;
2567
+ if (!loop || stageCapture || loop.committed === score) return;
2568
+ if (!loop.dirtyEdits) {
2569
+ loop.committedNow(score);
2570
+ return;
2571
+ }
2572
+ void loop.rebase(score).then(({ dropped }) => {
2573
+ tui.activity.pushCard(
2574
+ dropped.length
2575
+ ? `score changed · dropped staged ${dropped.join(", ")}`
2576
+ : "score changed · staged edits re-applied on top",
2577
+ { tone: "warning" },
2578
+ );
2579
+ requestFrame();
2580
+ });
2581
+ }
2582
+
2583
+ async function startAuditionLoop(): Promise<void> {
2584
+ const loop = auditionController();
2585
+ await materializeDraft();
2586
+ loop.committedNow(score);
2587
+ loop.focus(requestedTrack);
2588
+ // One sound at a time: the song stops while the loop plays.
2589
+ if (clock.playing) await setTransport("pause");
2590
+ previewEngine().setLeadMs(PLAY_LEAD_MS);
2591
+ loop.start();
2592
+ }
2593
+
2594
+ function stopAuditionLoop(): void {
2595
+ auditionLoop?.stop();
2596
+ }
2597
+
2598
+ /** A menu or picker audition key: Space, `a` (A/B) or `c` (context). */
2599
+ function auditionKeyPressed(
2600
+ key: "loop" | "ab" | "context",
2601
+ ): Promise<void> | undefined {
2602
+ const loop = auditionController();
2603
+ if (key === "loop") {
2604
+ if (loop.looping) stopAuditionLoop();
2605
+ else return startAuditionLoop().finally(() => requestFrame());
2606
+ } else if (key === "ab") {
2607
+ if (!loop.dirtyEdits) {
2608
+ tui.activity.pushCard("A/B · nothing staged yet · ←→ to change a value", {
2609
+ tone: "info",
2610
+ });
2611
+ return;
2612
+ }
2613
+ loop.toggleAB();
2614
+ } else loop.toggleContext();
2615
+ return undefined;
2616
+ }
2617
+
2618
+ /** An auditioning picker's title carries the loop status (`♪ solo · B`). */
2619
+ function refreshAuditionPicker(): void {
2620
+ const picker = tui.ui.picker as
2621
+ (NonNullable<typeof tui.ui.picker> & { baseTitle?: string }) | undefined;
2622
+ if (tui.ui.overlay !== "picker" || !picker?.audition) return;
2623
+ // Kept on the picker so a filter's copy of it keeps the plain title too.
2624
+ const base = (picker.baseTitle ??= picker.title);
2625
+ const status = auditionLoop?.status();
2626
+ const dirty = auditionLoop?.dirtyEdits ? "● " : "";
2627
+ picker.title = status ? `${dirty}${base} · ${status}` : base;
2628
+ }
2629
+
2630
+ /**
2631
+ * `/try <command>`: hear a sound command on the audition loop before it
2632
+ * lands. A small picker offers keep (one undo step) or revert; Space, `a`
2633
+ * and `c` work as in the menu.
2634
+ */
2635
+ async function tryPrompt(command: string): Promise<Receipt> {
2636
+ if (!command) return fail(TRY_USAGE);
2637
+ const agentToggle = command.match(/^agent\s+(on|off)$/i);
2638
+ if (agentToggle) {
2639
+ agentPreviewPlays = agentToggle[1]!.toLowerCase() === "on";
2640
+ return ok(
2641
+ `try · agent previews ${agentPreviewPlays ? "play once here" : "stay silent (numbers only)"}`,
2642
+ );
2643
+ }
2644
+ if (!isStageable(command))
2645
+ return fail(`try · only sound changes can be tried · ${TRY_USAGE}`);
2646
+ if (menu.open || euclid.open)
2647
+ return fail("try · close the open screen first");
2648
+ const loop = auditionController();
2649
+ if (!loop.looping) await startAuditionLoop();
2650
+ const result = await loop.stage(command);
2651
+ if (!result.ok) {
2652
+ if (!loop.dirtyEdits) stopAuditionLoop();
2653
+ return fail(`try · ${result.message}`);
2654
+ }
2655
+ tui.openPicker({
2656
+ id: "try",
2657
+ title: `try · ${command.slice(0, 48)}`,
2658
+ hint: HINTS.audition,
2659
+ audition: true,
2660
+ items: [
2661
+ { label: "keep", value: "keep", detail: "commit it · one undo step" },
2662
+ { label: "revert", value: "revert", detail: "drop it" },
2663
+ ],
2664
+ });
2665
+ return ok(`trying · ${command} · a A/B · enter keep · esc revert`);
2666
+ }
2667
+
2668
+ /** Audition a list's highlighted item in place of its previous hover. */
2669
+ function hoverItem(command: string, key: string): void {
2670
+ const loop = auditionLoop;
2671
+ if (!loop?.looping) return;
2672
+ requestFrame();
2673
+ void loop.hover(command, key).then((result) => {
2674
+ if (!result.ok && result.message !== SUPERSEDED)
2675
+ tui.activity.pushCard(result.message, { tone: "warning" });
2676
+ requestFrame();
2677
+ });
2678
+ }
2679
+
2680
+ /** Enter on a list item while auditioning: it stays staged for good. */
2681
+ function chooseItem(command: string, key: string): void {
2682
+ const loop = auditionLoop;
2683
+ if (!loop) return;
2684
+ void loop
2685
+ .hover(command, key)
2686
+ .then((result) => {
2687
+ if (!result.ok && result.message !== SUPERSEDED)
2688
+ tui.activity.pushCard(result.message, { tone: "warning" });
2689
+ loop.settle(key);
2690
+ })
2691
+ .finally(() => requestFrame());
2692
+ }
2693
+
2694
+ /** Space, `a` or `c` in a picker: starting the loop hovers the cursor row. */
2695
+ function pickerAuditionKey(key: "loop" | "ab" | "context"): void {
2696
+ const started = auditionKeyPressed(key);
2697
+ if (!started) return;
2698
+ const picker = tui.ui.picker;
2699
+ const item = picker?.items[picker.index];
2700
+ if (!picker || !item || !isStageable(item.value)) return;
2701
+ void started.then(() => {
2702
+ const now = tui.ui.picker;
2703
+ const current = now?.id === picker.id ? now.items[now.index] : undefined;
2704
+ if (current && isStageable(current.value))
2705
+ hoverItem(current.value, `picker:${picker.id}`);
2706
+ });
2707
+ }
2708
+
2709
+ /** Stage a menu command while auditioning; undefined when it should run. */
2710
+ function stageIfAuditioning(command: string): boolean {
2711
+ const loop = auditionLoop;
2712
+ if (!loop?.staging || !isStageable(command)) return false;
2713
+ void loop.stage(command).then((result) => {
2714
+ if (!result.ok)
2715
+ tui.activity.pushCard(result.message, {
2716
+ tone: result.message.includes("failed") ? "error" : "warning",
2717
+ });
2718
+ requestFrame();
2719
+ });
2720
+ return true;
2721
+ }
2722
+
2723
+ /** Enter: every staged edit becomes ONE revision (one undo step). */
2724
+ async function keepStaged(): Promise<Receipt> {
2725
+ const loop = auditionLoop;
2726
+ await loop?.settled();
2727
+ const taken = loop?.take();
2728
+ if (!loop || !taken) return warn("nothing staged");
2729
+ await materializeDraft();
2730
+ let next = taken.score;
2731
+ for (let attempt = 0; ; attempt += 1) {
2732
+ try {
2733
+ await commitScore(next, "preview.commit", {
2734
+ trackId: loop.trackId,
2735
+ commands: taken.commands,
2736
+ });
2737
+ break;
2738
+ } catch (error) {
2739
+ if (!(error instanceof SessionConflictError) || attempt > 0) throw error;
2740
+ // Another window committed first: replay the staged commands on it.
2741
+ record = await port.load();
2742
+ score = scoreFromJSON(record.composition);
2743
+ next = score;
2744
+ for (const command of taken.commands) {
2745
+ const result = await applyStaged(next, command);
2746
+ if (result.next) next = result.next;
2747
+ }
2748
+ }
2749
+ }
2750
+ loop.committedNow(score);
2751
+ await projectSync?.flushScore();
2752
+ return ok(
2753
+ `kept ${taken.commands.length} change${taken.commands.length === 1 ? "" : "s"} · one undo step`,
2754
+ );
2755
+ }
2756
+
2757
+ function revertStaged(): void {
2758
+ const loop = auditionLoop;
2759
+ if (!loop?.dirtyEdits) return;
2760
+ const count = loop.commands.length;
2761
+ loop.revert();
2762
+ tui.activity.pushCard(
2763
+ `reverted ${count} staged change${count === 1 ? "" : "s"}`,
2764
+ { tone: "info" },
2765
+ );
2766
+ }
2767
+
2768
+ /** Leaving an auditioning screen stops the loop and drops staged edits. */
2769
+ function leaveAuditionScreen(): void {
2770
+ if (auditionLoop?.dirtyEdits) revertStaged();
2771
+ stopAuditionLoop();
2772
+ }
2773
+
1734
2774
  function refreshMenu(): void {
1735
2775
  if (!menu.open) return;
1736
2776
  if (tui.ui.overlay !== undefined && tui.ui.picker?.id !== "menu") {
1737
2777
  // Another overlay (help, a picker) replaced the menu.
1738
2778
  menu.close();
2779
+ leaveAuditionScreen();
1739
2780
  return;
1740
2781
  }
1741
2782
  const view = menu.view(menuContext());
@@ -1747,6 +2788,7 @@ function refreshMenu(): void {
1747
2788
  : [{ label: "no matches", value: "none" }],
1748
2789
  index: view.index,
1749
2790
  hint: view.hint,
2791
+ note: view.note,
1750
2792
  });
1751
2793
  }
1752
2794
 
@@ -1756,6 +2798,7 @@ function playSession(): PlaySession {
1756
2798
  play = new PlaySession(playHost(), {
1757
2799
  clickOn: previous?.clickOn,
1758
2800
  clickVolume: previous?.clickVolume,
2801
+ chords: chordSettings,
1759
2802
  });
1760
2803
  if (previous) {
1761
2804
  play.countInBars = previous.countInBars;
@@ -1771,7 +2814,7 @@ async function enterPlay(): Promise<Receipt> {
1771
2814
  await session.enter();
1772
2815
  if (hasSamplerTracks(score)) void sampleProblems(score);
1773
2816
  return ok(
1774
- `play · ${session.track} · ${session.keyboard.range} · z/x octave · r record · m click · esc leaves`,
2817
+ `play · ${session.track} · ${session.keyboard.range} · ? keys · esc leaves`,
1775
2818
  );
1776
2819
  }
1777
2820
 
@@ -1883,11 +2926,21 @@ async function commitScore(
1883
2926
  payload: Record<string, unknown> = {},
1884
2927
  ): Promise<void> {
1885
2928
  if (next === score) return;
2929
+ // Rhythm rows regenerate after a loop resize and freeze when their lane
2930
+ // is hand-edited, so rows and notes never disagree.
2931
+ next = reconcileRhythm(score, next);
2932
+ if (stageCapture) {
2933
+ // A staged edit: the audition plays it; nothing is written yet.
2934
+ stageCapture.next = next;
2935
+ score = next;
2936
+ return;
2937
+ }
1886
2938
  record = await port.append(record, { kind, payload }, next.toJSON());
1887
2939
  score = next;
1888
2940
  if (clock.playing) void audio.play(score);
1889
2941
  projectSync?.scoreChanged(score);
1890
2942
  reportSampleProblems(score);
2943
+ void updateCredits(score);
1891
2944
  }
1892
2945
 
1893
2946
  /** The window's side of the project file sync (see src/project/sync.ts). */
@@ -1992,6 +3045,8 @@ async function toggleTransport(): Promise<void> {
1992
3045
  async function setTransport(
1993
3046
  action: "play" | "pause" | "toggle",
1994
3047
  ): Promise<void> {
3048
+ // One sound at a time: starting the song stops an audition loop.
3049
+ if (action !== "pause" && auditionLoop?.looping) stopAuditionLoop();
1995
3050
  if (port.mode === "daemon") {
1996
3051
  // dawgd owns the only transport; its broadcast updates `clock`.
1997
3052
  await port.transport(action);
@@ -2116,6 +3171,39 @@ function mediaServices(): MediaServices {
2116
3171
  return { runner: systemRunner, env: process.env };
2117
3172
  }
2118
3173
 
3174
+ /**
3175
+ * How the agent's preview_sound renders (with this window's decoded
3176
+ * samples, at the engine's rate) and plays: once, over silence, unless the
3177
+ * song or the audition loop is already sounding or `/try agent off`.
3178
+ */
3179
+ function agentPreviewHost(): PreviewHost {
3180
+ return {
3181
+ render: async (value) => {
3182
+ await sampleProblems(value);
3183
+ return renderScorePcm(value, {
3184
+ sampleRate: previewEngine().sampleRate,
3185
+ ...(liveSampleBank ? { samples: liveSampleBank } : {}),
3186
+ });
3187
+ },
3188
+ play: async (rendered) => {
3189
+ if (!agentPreviewPlays || clock.playing || auditionLoop?.looping)
3190
+ return false;
3191
+ const engine = liveEngine();
3192
+ if (!engine?.canMonitor || rendered.frames === 0) return false;
3193
+ try {
3194
+ await engine.monitor(true);
3195
+ engine.noteOn(AGENT_PREVIEW_VOICE, {
3196
+ pcm: rendered.pcm,
3197
+ frames: rendered.frames,
3198
+ });
3199
+ return true;
3200
+ } catch {
3201
+ return false;
3202
+ }
3203
+ },
3204
+ };
3205
+ }
3206
+
2119
3207
  function agentHost(
2120
3208
  turn: { steering: string[] },
2121
3209
  selection: ProviderSelection,
@@ -2133,6 +3221,7 @@ function agentHost(
2133
3221
  }),
2134
3222
  }),
2135
3223
  media: mediaServices(),
3224
+ preview: agentPreviewHost(),
2136
3225
  async commit(change) {
2137
3226
  // dawgd rebases operation intents onto newer revisions when nothing
2138
3227
  // they touch changed; the file port keeps the strict base check.