@hraness/dawg 0.4.0 → 0.5.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 (116) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/DAWG.md +300 -35
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/chain.ts +12 -2
  64. package/src/audio/effects/common.ts +30 -1
  65. package/src/audio/effects/convolution.ts +43 -5
  66. package/src/audio/effects/dynamics.ts +2 -1
  67. package/src/audio/effects/filter.ts +5 -3
  68. package/src/audio/effects/modulation.ts +5 -1
  69. package/src/audio/effects/space.ts +197 -50
  70. package/src/audio/engine.ts +175 -20
  71. package/src/audio/live.ts +33 -6
  72. package/src/audio/loudness.ts +551 -0
  73. package/src/audio/master.ts +660 -0
  74. package/src/audio/measure-worker.ts +45 -0
  75. package/src/audio/measure.ts +110 -0
  76. package/src/audio/player.ts +11 -6
  77. package/src/audio/preview.ts +87 -16
  78. package/src/audio/render-worker.ts +6 -1
  79. package/src/audio/renderer.ts +7 -1
  80. package/src/audio/sampler.ts +108 -19
  81. package/src/audio/synth/voice.ts +60 -13
  82. package/src/audio/synth/zzfx.ts +10 -4
  83. package/src/audio/warp.ts +86 -0
  84. package/src/audio/wav.ts +448 -59
  85. package/src/commands/arrange.ts +949 -0
  86. package/src/commands/expression.ts +934 -0
  87. package/src/commands/help.ts +281 -24
  88. package/src/commands/master.ts +361 -0
  89. package/src/commands/music.ts +6 -1
  90. package/src/commands/synth.ts +11 -1
  91. package/src/commands/time.ts +964 -0
  92. package/src/commands/tuning.ts +490 -0
  93. package/src/main.ts +826 -47
  94. package/src/render.ts +109 -7
  95. package/src/session/daemon.ts +18 -8
  96. package/src/session/naming.ts +8 -1
  97. package/src/session/rebase.ts +21 -0
  98. package/src/tui/arrange-menu.ts +390 -0
  99. package/src/tui/audition.ts +58 -5
  100. package/src/tui/euclid.ts +50 -6
  101. package/src/tui/fader.ts +409 -0
  102. package/src/tui/menu-time.ts +401 -0
  103. package/src/tui/menu.ts +656 -16
  104. package/src/tui/performance-menu.ts +235 -0
  105. package/src/tui/play-chords.ts +95 -1
  106. package/src/tui/play-mode.ts +150 -6
  107. package/src/tui/play-session.ts +414 -41
  108. package/tui/app.ts +262 -13
  109. package/tui/arrange-strip.ts +174 -0
  110. package/tui/drawer.ts +478 -0
  111. package/tui/grammar.ts +79 -12
  112. package/tui/guide.ts +351 -0
  113. package/tui/highway.ts +87 -4
  114. package/tui/hits.ts +68 -0
  115. package/tui/input.ts +7 -0
  116. package/tui/keys.ts +72 -0
package/src/main.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { randomUUID } from "node:crypto";
3
+ import { readFileSync, writeSync } from "node:fs";
3
4
  import { readFile, stat, writeFile } from "node:fs/promises";
4
5
  import { join, relative, resolve } from "node:path";
5
6
  import { stdin, stdout } from "node:process";
@@ -42,8 +43,36 @@ import {
42
43
  } from "./commands/drums.ts";
43
44
  import { kitCatalog } from "./audio/kits.ts";
44
45
  import { applyEditCommand, parseEditCommand } from "./commands/edit.ts";
46
+ import { applyTimeCommand, parseTimeCommand } from "./commands/time.ts";
47
+ import {
48
+ barStartTick,
49
+ bpmAtTick,
50
+ hasMeterChanges,
51
+ loopTicksOf,
52
+ } from "../core/tempo.ts";
53
+ import {
54
+ applyExpressionCommand,
55
+ parseExpressionCommand,
56
+ } from "./commands/expression.ts";
45
57
  import { applyFxCommand, parseFxCommand } from "./commands/fx.ts";
46
58
  import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
59
+ import {
60
+ applyTuningCommand,
61
+ importTuningFile,
62
+ parseTuningCommand,
63
+ type TuningCommand,
64
+ } from "./commands/tuning.ts";
65
+ import { TuningError, displayTag, resolveTuning } from "../core/tuning.ts";
66
+ import {
67
+ applyMasterCommand,
68
+ measurementLine,
69
+ parseMasterCommand,
70
+ } from "./commands/master.ts";
71
+ import { exportSampleRate, measureScoreOffThread } from "./audio/measure.ts";
72
+ import {
73
+ applySectionCommand,
74
+ parseSectionCommand,
75
+ } from "./commands/arrange.ts";
47
76
  import {
48
77
  HELP_TOPICS,
49
78
  helpText,
@@ -106,7 +135,7 @@ import {
106
135
  tuiLoginArgs,
107
136
  tuiSetModel,
108
137
  } from "./auth/tui.ts";
109
- import { TransportClock } from "./audio/clock.ts";
138
+ import { TransportClock, transportMapFor } from "./audio/clock.ts";
110
139
  import { AudioEngine } from "./audio/engine.ts";
111
140
  import {
112
141
  DEFAULT_GRID,
@@ -116,14 +145,44 @@ import {
116
145
  type LiveEngine,
117
146
  } from "./tui/play-session.ts";
118
147
  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";
148
+ import {
149
+ drawerView,
150
+ faderChoose,
151
+ faderKeyPress,
152
+ faderSetPosition,
153
+ faderStep,
154
+ focusIndex,
155
+ type FaderResult,
156
+ type FaderState,
157
+ } from "./tui/fader.ts";
158
+ import {
159
+ isMouseSequence,
160
+ MOUSE_OFF,
161
+ MOUSE_ON,
162
+ parseMouse,
163
+ type MouseEvent,
164
+ } from "../tui/keys.ts";
165
+ import {
166
+ Audition,
167
+ SUPERSEDED,
168
+ isChordStageable,
169
+ isStageable,
170
+ } from "./tui/audition.ts";
171
+ import { EuclidEditor, type EuclidContext } from "./tui/euclid.ts";
121
172
  import { HINTS, KEYS, keyLines, type KeySection } from "../tui/grammar.ts";
122
173
  import { renderAudition } from "./audio/audition.ts";
174
+ import { exportScore, playbackTime, scoreBeatAt } from "./audio/arrange.ts";
175
+ import { formatForm } from "../core/sections.ts";
176
+ import type { ArrangeStripView } from "../tui/arrange-strip.ts";
123
177
  import { renderScorePcm } from "./audio/wav.ts";
124
178
  import type { PreviewHost } from "./agent/preview-tool.ts";
125
179
  import { rhythmVoicePitch } from "../core/rhythm.ts";
126
- import { applyChordsCommand, defaultChordSettings } from "./tui/play-chords.ts";
180
+ import {
181
+ applyChordsCommand,
182
+ chordPhrase,
183
+ defaultChordSettings,
184
+ type ChordSettings,
185
+ } from "./tui/play-chords.ts";
127
186
  import {
128
187
  cacheLines,
129
188
  kitListLines,
@@ -166,6 +225,7 @@ import {
166
225
  } from "../core/score.ts";
167
226
  import { reconcileRhythm } from "../core/rhythm.ts";
168
227
  import { decodeLoop, encodeLoop } from "../core/loop.ts";
228
+ import { scoreToMidi } from "../core/midi.ts";
169
229
  import type { TrackScoreSnapshot } from "../tui/render.ts";
170
230
  import { PromptModel } from "../tui/prompt.ts";
171
231
  import { TerminalInputDecoder } from "../tui/input.ts";
@@ -173,6 +233,7 @@ import {
173
233
  composeFrame,
174
234
  TuiApp,
175
235
  type AppView,
236
+ type LoudnessView,
176
237
  type SyncState,
177
238
  type TypesIndicator,
178
239
  } from "../tui/app.ts";
@@ -199,11 +260,16 @@ function parsesLocally(text: string): boolean {
199
260
  parseRhythmCommand,
200
261
  parseFxCommand,
201
262
  parseSynthCommand,
263
+ parseExpressionCommand,
264
+ parseMasterCommand,
265
+ (value: string) => parseSectionCommand(value, score),
202
266
  parsePatternCommand,
203
267
  parseKitCommand,
204
268
  parsePackCommand,
205
269
  parseSampleCommand,
206
270
  parseWavetableCommand,
271
+ parseTimeCommand,
272
+ parseTuningCommand,
207
273
  ].some((parse) => parse(text) !== undefined);
208
274
  }
209
275
 
@@ -230,6 +296,7 @@ const FLAGS = [
230
296
  "--version",
231
297
  "-v",
232
298
  "--reduce-motion",
299
+ "--no-mouse",
233
300
  ];
234
301
  const HELP_TEXT = `dawg · local-first terminal music workstation
235
302
 
@@ -237,7 +304,8 @@ Usage:
237
304
  dawg [--new] [--session <name|id>] [--track <name>]
238
305
  dawg --import <file> --export <file>
239
306
  dawg sessions
240
- dawg render <out.wav> [--session <name|id>] [--import <file>]
307
+ dawg render <out.wav|out.mid> [--session <name|id>] [--import <file>]
308
+ [--normalize <lufs|streaming|club|loud|…>] [--measure]
241
309
  dawg init [dir] project files: song.ts, tracks/<slug>/track.ts, .dawg/sdk
242
310
  dawg check typecheck + evaluate the project; exit 1 on problems
243
311
  dawg media doctor|download|stems|analyze|notes|sample|lyrics … (dawg media --help)
@@ -246,6 +314,7 @@ Usage:
246
314
  Usage flags:
247
315
  --reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
248
316
  --theme <name> default | high-contrast | mono (NO_COLOR forces mono)
317
+ --no-mouse keys only; no click/wheel reporting (also DAWG_MOUSE=0)
249
318
 
250
319
  Prompt:
251
320
  Enter submit · Shift-Enter newline · Alt-Enter queue · Ctrl-Q toggle queue
@@ -469,9 +538,25 @@ const AGENT_PREVIEW_VOICE = 0x7fff_0002;
469
538
  * instead of appending, and a remote revision that lands meanwhile waits in
470
539
  * `committed` (the window's `score` is the staged base until it finishes).
471
540
  */
541
+ /** A `/chords …` settings command (staged as window state, not a score edit). */
542
+ const CHORDS_COMMAND = /^\/chords\s+(.+)$/i;
543
+ /** Whether the chord settings screen was open at the last menu redraw. */
544
+ let chordScreenWas = false;
472
545
  let stageCapture: { next?: TrackScore; committed?: TrackScore } | undefined;
473
546
  /** Redraw soon (the audition reports renders between frames). */
474
547
  let requestFrame: () => void = () => undefined;
548
+ /** Queue a prompt to run as if typed (set by the interactive loop). */
549
+ let runPromptLater: (command: string) => void = () => undefined;
550
+ /** The fader drawer's focus and typing, while one is open over the menu. */
551
+ let fader: FaderState | undefined;
552
+ const KEY_UP = "\u001b[A";
553
+ const KEY_DOWN = "\u001b[B";
554
+ /** The fader bar a left-button drag started on. */
555
+ let dragging: { field: number; left: number; width: number } | undefined;
556
+ /** The open drawer came from a command, not the menu. */
557
+ let faderStandalone = false;
558
+ /** Per-field sequence: a drag drops stale stages that would land late. */
559
+ const faderSeq = new Map<string, number>();
475
560
  /** The decoded sampler voices, for play mode's live voices. */
476
561
  let liveSampleBank: SampleBank | undefined;
477
562
  /** The in-flight agent turn: Esc aborts it, Enter steers it. */
@@ -631,16 +716,29 @@ function snapshot(
631
716
  beat: number,
632
717
  activity?: string,
633
718
  ): TrackScoreSnapshot {
719
+ const focused = value.tracks.find((track) => track.id === requestedTrack);
720
+ const table =
721
+ focused && !isDrumInstrument(focused.instrument)
722
+ ? resolveTuning(value.tuning, focused.tuning, value.key)
723
+ : undefined;
634
724
  const notes = value.notes
635
725
  .filter((note) => note.trackId === requestedTrack)
636
- .map((note) => ({
637
- id: note.id,
638
- startBeat: note.startTick / value.ticksPerBeat,
639
- durationBeats: note.durationTicks / value.ticksPerBeat,
640
- pitch: note.pitch,
641
- velocity: note.velocity,
642
- muted: value.tracks.find((track) => track.id === requestedTrack)?.muted,
643
- }));
726
+ .map((note) => {
727
+ const tag =
728
+ focused && !isDrumInstrument(focused.instrument)
729
+ ? displayTag(table, note.pitch, note.cents)
730
+ : undefined;
731
+ return {
732
+ id: note.id,
733
+ startBeat: note.startTick / value.ticksPerBeat,
734
+ durationBeats: note.durationTicks / value.ticksPerBeat,
735
+ pitch: note.pitch,
736
+ velocity: note.velocity,
737
+ muted: focused?.muted,
738
+ ...(tag?.cents !== undefined ? { cents: tag.cents } : {}),
739
+ ...(tag?.name !== undefined ? { centsFrom: tag.name } : {}),
740
+ };
741
+ });
644
742
  return {
645
743
  notes,
646
744
  trackName:
@@ -649,12 +747,22 @@ function snapshot(
649
747
  trackId: requestedTrack,
650
748
  sessionId: record.sessionId,
651
749
  revision: record.revision,
652
- bpm: value.tempoBpm,
750
+ bpm: value.time?.tempo
751
+ ? bpmAtTick(value, beat * value.ticksPerBeat)
752
+ : value.tempoBpm,
653
753
  key: value.key ?? undefined,
654
- loopBeats: value.bars * value.beatsPerBar,
754
+ loopBeats: loopTicksOf(value) / value.ticksPerBeat,
655
755
  beatsPerBar: value.beatsPerBar,
756
+ ...(hasMeterChanges(value)
757
+ ? {
758
+ barBeats: Array.from(
759
+ { length: value.bars },
760
+ (_, bar) => barStartTick(value, bar) / value.ticksPerBeat,
761
+ ),
762
+ }
763
+ : {}),
656
764
  laneCount: 24,
657
- currentBeat: beat,
765
+ currentBeat: scoreBeatAt(value, beat),
658
766
  playing: clock.playing,
659
767
  activity,
660
768
  ...drumSnapshotFields(
@@ -665,6 +773,9 @@ function snapshot(
665
773
  value.tracks.find((track) => track.id === requestedTrack),
666
774
  notes,
667
775
  ),
776
+ ...(table && table.linear && table.size !== 12
777
+ ? { tuningPeriod: { size: table.size, root: table.root } }
778
+ : {}),
668
779
  layers:
669
780
  tui.highwayView === "all"
670
781
  ? highwayLayers(
@@ -698,7 +809,9 @@ function renderOnce(
698
809
  function appView(value: TrackScore, beat: number): AppView {
699
810
  return {
700
811
  score: snapshot(value, beat),
701
- beat,
812
+ // The highway shows score time: inside the looped section or form pass.
813
+ beat: scoreBeatAt(value, beat),
814
+ arrange: arrangeStripView(value, beat),
702
815
  // `opus-5.5 · gateway`, `sonnet · claude`; hidden when offline.
703
816
  model:
704
817
  providerName && providerName !== "offline" ? providerName : undefined,
@@ -709,6 +822,79 @@ function appView(value: TrackScore, beat: number): AppView {
709
822
  windows: windowCount,
710
823
  types: typesIndicator,
711
824
  play: play?.on ? play.header() : undefined,
825
+ loudness: masterLoudness(value),
826
+ };
827
+ }
828
+
829
+ /**
830
+ * The 48 kHz reading of the mastered song (what `master measure` and the
831
+ * export read), for the score it measured; one measurement at a time, in a
832
+ * worker, started when the loop plays a mastered score it has not read.
833
+ */
834
+ let exportMeter:
835
+ { score: TrackScore; view?: LoudnessView; running: boolean } | undefined;
836
+
837
+ /**
838
+ * The header meter: loudness of the loop the local engine is playing. With
839
+ * a master the engine monitors at its own rate, so the meter shows the
840
+ * export-rate reading once it is in and the monitor's (marked as an
841
+ * estimate) until then.
842
+ */
843
+ function masterLoudness(value: TrackScore): LoudnessView | undefined {
844
+ const engine = audio instanceof AudioEngine ? audio : monitorEngine;
845
+ const report = engine?.loudness;
846
+ if (!report) return undefined;
847
+ const ceiling =
848
+ typeof value.master?.limiter?.ceiling === "number"
849
+ ? value.master.limiter.ceiling
850
+ : value.master?.limiter
851
+ ? -1
852
+ : undefined;
853
+ const target =
854
+ typeof value.master?.target === "number" ? value.master.target : undefined;
855
+ const monitor: LoudnessView = {
856
+ integrated: report.integrated,
857
+ truePeak: report.truePeak,
858
+ target,
859
+ ceiling,
860
+ };
861
+ if (!value.master || exportSampleRate(value) === engine.sampleRate)
862
+ return monitor;
863
+ if (exportMeter?.score === value && exportMeter.view) return exportMeter.view;
864
+ if (!exportMeter?.running) {
865
+ const measuring = { score: value, running: true } as NonNullable<
866
+ typeof exportMeter
867
+ >;
868
+ exportMeter = measuring;
869
+ void measureScoreOffThread(value, { projectRoot: process.cwd() })
870
+ .then((measured) => {
871
+ measuring.view = {
872
+ integrated: measured.mix.loudness.integrated,
873
+ truePeak: measured.mix.loudness.truePeak,
874
+ target,
875
+ ceiling,
876
+ };
877
+ })
878
+ .catch(() => undefined)
879
+ .finally(() => {
880
+ measuring.running = false;
881
+ });
882
+ }
883
+ return { ...monitor, estimate: true };
884
+ }
885
+
886
+ /** The arrangement strip over the timeline; absent without sections. */
887
+ function arrangeStripView(
888
+ value: TrackScore,
889
+ beat: number,
890
+ ): ArrangeStripView | undefined {
891
+ if (value.sections.length === 0) return undefined;
892
+ return {
893
+ bars: value.bars,
894
+ sections: value.sections,
895
+ loop: value.loopSection,
896
+ playheadBar: scoreBeatAt(value, beat) / value.beatsPerBar,
897
+ form: value.form.length ? formatForm(value.form) : undefined,
712
898
  };
713
899
  }
714
900
 
@@ -765,11 +951,46 @@ function truncateForCard(value: string): string {
765
951
  return line.length > 32 ? `${line.slice(0, 31)}…` : line;
766
952
  }
767
953
 
954
+ /**
955
+ * Mouse reporting is on unless `--no-mouse` or `DAWG_MOUSE=0` (or a dumb
956
+ * terminal) says otherwise. A terminal without mouse support ignores the
957
+ * modes and every key still works.
958
+ */
959
+ function mouseEnabled(): boolean {
960
+ return (
961
+ !process.argv.includes("--no-mouse") &&
962
+ process.env.DAWG_MOUSE !== "0" &&
963
+ process.env.TERM !== "dumb"
964
+ );
965
+ }
966
+
967
+ function mouseOn(): string {
968
+ return mouseEnabled() ? MOUSE_ON : "";
969
+ }
970
+
768
971
  async function runInteractive(): Promise<void> {
972
+ // A crash or a stray process.exit must not leave the terminal reporting
973
+ // mouse events into the shell.
974
+ process.once("exit", () => {
975
+ if (mouseEnabled()) writeSync(1, MOUSE_OFF);
976
+ });
977
+ // Nor may a kill or a closed terminal: restore it, then exit as killed.
978
+ for (const [signal, code] of [
979
+ ["SIGTERM", 143],
980
+ ["SIGHUP", 129],
981
+ ] as const)
982
+ process.once(signal, () => {
983
+ try {
984
+ writeSync(1, `${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
985
+ } catch {
986
+ // The terminal may already be gone (SIGHUP).
987
+ }
988
+ process.exit(code);
989
+ });
769
990
  stdin.setRawMode?.(true);
770
991
  stdin.resume();
771
992
  // Alternate screen, hidden cursor, bracketed paste.
772
- stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
993
+ stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J${mouseOn()}`);
773
994
  const inputDecoder = new TerminalInputDecoder();
774
995
  const queuedPrompts: string[] = [];
775
996
  let processingQueue = false;
@@ -831,6 +1052,10 @@ async function runInteractive(): Promise<void> {
831
1052
  tui.render(appView(score, clock.beatAt()), { force });
832
1053
  };
833
1054
  requestFrame = () => tick(true);
1055
+ runPromptLater = (command) => {
1056
+ queuedPrompts.unshift(command);
1057
+ void drainQueue();
1058
+ };
834
1059
  reportAgentActivity = () => {
835
1060
  tui.activity.applyAgentEvent({ type: "start", model: providerName });
836
1061
  };
@@ -857,7 +1082,7 @@ async function runInteractive(): Promise<void> {
857
1082
  if (stageCapture)
858
1083
  stageCapture.committed = scoreFromJSON(record.composition);
859
1084
  else score = scoreFromJSON(record.composition);
860
- clock.setTempo(score.tempoBpm);
1085
+ clock.follow(score);
861
1086
  if (clock.playing) void audio.play(score);
862
1087
  // Connected windows follow dawgd's transport frames instead.
863
1088
  const replay =
@@ -910,6 +1135,7 @@ async function runInteractive(): Promise<void> {
910
1135
  // Every window renders the same hit line from dawgd's timestamp.
911
1136
  const { playing, beat, bpm, atMs } = update.transport;
912
1137
  clock.setTempo(bpm);
1138
+ clock.setTimeMap(transportMapFor(playbackTime(score)));
913
1139
  clock.sync(beat, playing, atMs, monotonicEpochMs());
914
1140
  } else if (update.type === "status") {
915
1141
  syncState = port.sync;
@@ -986,11 +1212,11 @@ async function runInteractive(): Promise<void> {
986
1212
  stdin.pause();
987
1213
  stdin.setRawMode?.(false);
988
1214
  // Leave the alternate screen; restore the cursor and plain paste.
989
- stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1215
+ stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
990
1216
  try {
991
1217
  return await flow();
992
1218
  } finally {
993
- stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
1219
+ stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J${mouseOn()}`);
994
1220
  stdin.setRawMode?.(true);
995
1221
  stdin.on("data", onData);
996
1222
  stdin.resume();
@@ -1009,7 +1235,31 @@ async function runInteractive(): Promise<void> {
1009
1235
  ...inputDecoder.push(text),
1010
1236
  ...(text === "\u001b" ? inputDecoder.flush() : []),
1011
1237
  ];
1012
- for (const value of values) {
1238
+ while (values.length) {
1239
+ const value = values.shift()!;
1240
+ // A mouse report acts on what the last frame painted under it; some
1241
+ // become keys (a list row, a wheel notch) and run through below.
1242
+ if (typeof value === "string" && isMouseSequence(value)) {
1243
+ const event = parseMouse(value);
1244
+ if (event) values.unshift(...mouseInput(event));
1245
+ tick(true);
1246
+ continue;
1247
+ }
1248
+ // The fader drawer owns every key while it is up.
1249
+ if (typeof value === "string" && fader && menu.open) {
1250
+ const result = faderKeyPress(
1251
+ fader,
1252
+ menu.faderFields(menuContext()),
1253
+ value,
1254
+ faderKeyOptions(),
1255
+ );
1256
+ if (result.type !== "pass") {
1257
+ faderOutcome(result);
1258
+ refreshMenu();
1259
+ tick(true);
1260
+ continue;
1261
+ }
1262
+ }
1013
1263
  // The `?` panel closes on any key (Ctrl-C still quits).
1014
1264
  if (tui.ui.keys && typeof value === "string" && value !== "\u0003") {
1015
1265
  tui.closeKeys();
@@ -1032,11 +1282,23 @@ async function runInteractive(): Promise<void> {
1032
1282
  euclid.close();
1033
1283
  tui.closePicker();
1034
1284
  if (back === "menu") openMenu();
1285
+ else leaveAuditionScreen();
1035
1286
  } else if (result.type === "run") {
1036
- queuedPrompts.unshift(result.command);
1037
- if (result.audition) pendingAudition = result.audition;
1038
- void drainQueue();
1287
+ if (!stageIfAuditioning(result.command)) {
1288
+ queuedPrompts.unshift(result.command);
1289
+ if (result.audition) pendingAudition = result.audition;
1290
+ void drainQueue();
1291
+ }
1039
1292
  } else if (result.type === "audition") audition(result.voice);
1293
+ else if (result.type === "loop") auditionKeyPressed(result.key);
1294
+ else if (result.type === "revert") revertStaged();
1295
+ else if (result.type === "keep")
1296
+ void keepStaged()
1297
+ .then((outcome) => receipt(outcome))
1298
+ .catch((error) =>
1299
+ tui.activity.pushError(describeError("keep", error)),
1300
+ )
1301
+ .finally(() => tick(true));
1040
1302
  if (result.type !== "pass") {
1041
1303
  refreshEuclid();
1042
1304
  tick(true);
@@ -1060,6 +1322,7 @@ async function runInteractive(): Promise<void> {
1060
1322
  }
1061
1323
  } else if (result.type === "audition")
1062
1324
  auditionKeyPressed(result.key);
1325
+ else if (result.type === "fader") openFader(result.label);
1063
1326
  else if (result.type === "revert") revertStaged();
1064
1327
  else if (result.type === "hover")
1065
1328
  hoverItem(result.command, result.key);
@@ -1083,6 +1346,16 @@ async function runInteractive(): Promise<void> {
1083
1346
  }
1084
1347
  }
1085
1348
  }
1349
+ // F1 opens the guides from anywhere the prompt has focus.
1350
+ if (
1351
+ (value === "\u001bOP" || value === "\u001b[11~") &&
1352
+ (tui.ui.overlay === undefined || tui.ui.overlay === "guide")
1353
+ ) {
1354
+ if (tui.ui.overlay === "guide") tui.closeGuide();
1355
+ else tui.openGuide();
1356
+ tick(true);
1357
+ continue;
1358
+ }
1086
1359
  // Ctrl-K opens the menu on an empty prompt (in play mode too); with
1087
1360
  // text it keeps its kill-to-end-of-line meaning.
1088
1361
  if (
@@ -1114,7 +1387,8 @@ async function runInteractive(): Promise<void> {
1114
1387
  if (
1115
1388
  value === " " &&
1116
1389
  prompt.value.length === 0 &&
1117
- !(tui.ui.overlay === "picker" && tui.ui.picker?.audition)
1390
+ !(tui.ui.overlay === "picker" && tui.ui.picker?.audition) &&
1391
+ tui.ui.overlay !== "guide"
1118
1392
  ) {
1119
1393
  // Never await a daemon round trip here: the key loop must stay
1120
1394
  // live for Esc, quit and redraws while the toggle is in flight.
@@ -1245,7 +1519,7 @@ async function runInteractive(): Promise<void> {
1245
1519
  await port.close();
1246
1520
  stdin.setRawMode?.(false);
1247
1521
  stdin.pause();
1248
- stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1522
+ stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1249
1523
  }
1250
1524
  }
1251
1525
 
@@ -1280,7 +1554,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1280
1554
  }
1281
1555
  if (/^\/?tracks$/i.test(command)) {
1282
1556
  const problems = await sampleProblems(score);
1283
- const lines = score.tracks.map((track) => {
1557
+ const items = score.tracks.map((track) => {
1284
1558
  const voices = track.sampler
1285
1559
  ? Object.keys(track.sampler.voices).length
1286
1560
  : 0;
@@ -1290,16 +1564,39 @@ async function submit(prompt: string): Promise<string | Receipt> {
1290
1564
  const samples = track.sampler
1291
1565
  ? ` · ${voices} sample${voices === 1 ? "" : "s"}${missing ? ` · ${missing} missing` : ""}`
1292
1566
  : "";
1293
- return `${track.id === requestedTrack ? "*" : " "} ${track.id} · ${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}`;
1567
+ return {
1568
+ label: track.id,
1569
+ value: `/track ${track.id}`,
1570
+ detail: `${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}`,
1571
+ current: track.id === requestedTrack,
1572
+ };
1294
1573
  });
1295
- tui.openText("tracks · * focused", lines);
1296
- return ok(`${lines.length} track${lines.length === 1 ? "" : "s"}`);
1574
+ // A picker: Enter (or a click) focuses the track; Esc closes.
1575
+ tui.openPicker({
1576
+ id: "tracks",
1577
+ title: "tracks · ● focused",
1578
+ items,
1579
+ filterable: items.length > 8,
1580
+ index: Math.max(
1581
+ 0,
1582
+ items.findIndex((item) => item.current),
1583
+ ),
1584
+ });
1585
+ return ok(`${items.length} track${items.length === 1 ? "" : "s"}`);
1297
1586
  }
1298
- const playCommand = command.match(/^\/play(?:\s+(on|off))?$/i);
1587
+ const playCommand = command.match(
1588
+ /^\/play(?:\s+(on|off|degrees|in-key|chromatic))?$/i,
1589
+ );
1299
1590
  if (playCommand) {
1300
1591
  const wanted = playCommand[1]?.toLowerCase();
1301
1592
  if (wanted === "off" || (wanted === undefined && play?.on))
1302
1593
  return exitPlay();
1594
+ if (wanted && wanted !== "on") {
1595
+ const message = playSession().toggleDegrees(wanted !== "chromatic");
1596
+ if (message.startsWith("scale degrees need")) return fail(message);
1597
+ const entered = await enterPlay();
1598
+ return entered.ok ? ok(message) : entered;
1599
+ }
1303
1600
  return enterPlay();
1304
1601
  }
1305
1602
  const clickCommand = command.match(/^\/click(?:\s+(.+))?$/i);
@@ -1328,6 +1625,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
1328
1625
  }
1329
1626
  const tryCommand = command.match(/^\/try(?:\s+(.+))?$/i);
1330
1627
  if (tryCommand) return tryPrompt(tryCommand[1]?.trim() ?? "");
1628
+ // A bare parameter (`volume`, `pan`, `fx filter`, `fx reverb mix`)
1629
+ // opens the fader drawer on it, with its related params stacked below.
1630
+ if (/^\/?(?:volume|pan|fx\s+\S+(?:\s+\S+)?)$/i.test(command)) {
1631
+ const label = menu.showFader(menuContext(), command);
1632
+ if (label) {
1633
+ openFader(label, true);
1634
+ refreshMenu();
1635
+ return ok(`${label} · ←→ adjust · enter keep · esc revert`);
1636
+ }
1637
+ }
1331
1638
  const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
1332
1639
  if (menuCommand) {
1333
1640
  const section = menuCommand[1]?.toLowerCase();
@@ -1353,6 +1660,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
1353
1660
  /^\/?(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/i,
1354
1661
  );
1355
1662
  if (trackCommand) return focusTrack(trackCommand[1]!.toLowerCase());
1663
+ // `/track piano b`: a name with spaces focuses the track of that name, or
1664
+ // creates `piano-b` named "piano b".
1665
+ const namedTrack = command.match(/^\/track\s+([a-z0-9._ -]{1,64})$/i);
1666
+ if (namedTrack) {
1667
+ const name = namedTrack[1]!.trim().replace(/\s+/g, " ");
1668
+ const found = score.tracks.find(
1669
+ (track) => (track.name ?? track.id).toLowerCase() === name.toLowerCase(),
1670
+ );
1671
+ return focusTrack(found?.id ?? name.toLowerCase().replace(/ /g, "-"));
1672
+ }
1356
1673
  const sample = parseSampleCommand(command);
1357
1674
  if (sample) return sampleCommand(sample);
1358
1675
  const pack = parsePackCommand(command);
@@ -1365,6 +1682,14 @@ async function submit(prompt: string): Promise<string | Receipt> {
1365
1682
  if (wavetable) return wavetableCommand(wavetable);
1366
1683
  const sessionReply = await sessionCommand(command);
1367
1684
  if (sessionReply !== undefined) return sessionReply;
1685
+ const time = parseTimeCommand(command);
1686
+ if (time) {
1687
+ if (time.type !== "tempo-map") await materializeDraft();
1688
+ const result = applyTimeCommand(score, requestedTrack, time);
1689
+ if (result.next && result.kind)
1690
+ await commitScore(result.next, result.kind, result.payload);
1691
+ return result.ok ? ok(result.message) : fail(result.message);
1692
+ }
1368
1693
  const edit = parseEditCommand(command);
1369
1694
  if (edit) {
1370
1695
  await materializeDraft();
@@ -1400,6 +1725,17 @@ async function submit(prompt: string): Promise<string | Receipt> {
1400
1725
  await commitScore(result.next, result.kind, result.payload);
1401
1726
  return result.ok ? ok(result.message) : fail(result.message);
1402
1727
  }
1728
+ const expression = parseExpressionCommand(command);
1729
+ if (expression) {
1730
+ if (expression.type !== "show" && expression.type !== "invalid")
1731
+ await materializeDraft();
1732
+ const result = applyExpressionCommand(score, requestedTrack, expression);
1733
+ if (result.next && result.kind)
1734
+ await commitScore(result.next, result.kind, result.payload);
1735
+ return result.ok ? ok(result.message) : fail(result.message);
1736
+ }
1737
+ const tuning = parseTuningCommand(command);
1738
+ if (tuning) return tuningCommand(tuning);
1403
1739
  const synth = parseSynthCommand(command);
1404
1740
  if (synth) {
1405
1741
  if (synth.type !== "synth-list") await materializeDraft();
@@ -1408,6 +1744,34 @@ async function submit(prompt: string): Promise<string | Receipt> {
1408
1744
  await commitScore(result.next, result.kind, result.payload);
1409
1745
  return result.ok ? ok(result.message) : fail(result.message);
1410
1746
  }
1747
+ const masterCommand = parseMasterCommand(command);
1748
+ if (masterCommand) {
1749
+ if (masterCommand.type === "master-measure")
1750
+ return ok(await measureLine(score));
1751
+ if (
1752
+ masterCommand.type !== "master-list" &&
1753
+ masterCommand.type !== "master-show"
1754
+ )
1755
+ await materializeDraft();
1756
+ const result = applyMasterCommand(score, masterCommand);
1757
+ if (result.next && result.kind)
1758
+ await commitScore(result.next, result.kind, result.payload);
1759
+ return result.ok ? ok(result.message) : fail(result.message);
1760
+ }
1761
+ const arrange = parseSectionCommand(command, score);
1762
+ if (arrange) {
1763
+ const reads =
1764
+ arrange.type === "section-list" ||
1765
+ arrange.type === "section-unknown" ||
1766
+ arrange.type === "form-show" ||
1767
+ arrange.type === "section-jump";
1768
+ if (!reads) await materializeDraft();
1769
+ const result = applySectionCommand(score, requestedTrack, arrange);
1770
+ if (result.next && result.kind)
1771
+ await commitScore(result.next, result.kind, result.payload);
1772
+ if (result.seekBeat !== undefined) await seekTransport(result.seekBeat);
1773
+ return result.ok ? ok(result.message) : fail(result.message);
1774
+ }
1411
1775
  const music = parseMusicCommand(command);
1412
1776
  if (music) {
1413
1777
  await materializeDraft();
@@ -1421,6 +1785,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
1421
1785
  const exportCommand = command.match(/^\/?export\s+([^\s]+)$/i);
1422
1786
  if (exportCommand) {
1423
1787
  const path = resolve(exportCommand[1]!);
1788
+ if (/\.midi?$/i.test(path)) {
1789
+ await writeFile(path, scoreToMidi(exportScore(score)));
1790
+ return `exported midi · ${exportCommand[1]}`;
1791
+ }
1792
+ // Audio comes from the offline renderer, never JSON under an audio name.
1793
+ if (/\.(wav|aiff?|flac|mp3|ogg|m4a)$/i.test(path))
1794
+ return fail(
1795
+ `/export writes .track.json or .mid · render audio with: dawg render ${exportCommand[1]}`,
1796
+ );
1424
1797
  await writeFile(path, encodeLoop(score), "utf8");
1425
1798
  return `exported · ${exportCommand[1]}`;
1426
1799
  }
@@ -1537,7 +1910,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1537
1910
  if (parsed.type === "set-tempo") {
1538
1911
  const next = score.withTempo(parsed.tempoBpm);
1539
1912
  await commitScore(next, "score.tempo", { tempoBpm: parsed.tempoBpm });
1540
- clock.setTempo?.(next.tempoBpm);
1913
+ clock.follow(next);
1541
1914
  return `tempo · ${next.tempoBpm} BPM`;
1542
1915
  }
1543
1916
  if (parsed.type === "add-track") return focusTrack(parsed.trackId);
@@ -1632,6 +2005,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
1632
2005
  ...(typeof parsed.patch.velocity === "number"
1633
2006
  ? { velocity: parsed.patch.velocity }
1634
2007
  : {}),
2008
+ ...(typeof parsed.patch.cents === "number"
2009
+ ? { cents: parsed.patch.cents }
2010
+ : {}),
1635
2011
  };
1636
2012
  const next = applyScoreOperation(score, {
1637
2013
  type: "updateNote",
@@ -1657,6 +2033,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1657
2033
  ),
1658
2034
  pitch: parsed.pitch,
1659
2035
  velocity: parsed.velocity,
2036
+ ...(parsed.cents ? { cents: parsed.cents } : {}),
1660
2037
  };
1661
2038
  const operation: ScoreOperation = { type: "addNote", note };
1662
2039
  const next = applyScoreOperation(latestScore, operation);
@@ -1762,6 +2139,40 @@ function reportSampleProblems(value: TrackScore): void {
1762
2139
  });
1763
2140
  }
1764
2141
 
2142
+ /** `/tuning …` and `/scale …` (src/commands/tuning.ts). */
2143
+ async function tuningCommand(command: TuningCommand): Promise<Receipt> {
2144
+ const projectRoot = process.cwd();
2145
+ if (command.type === "tuning-set") {
2146
+ // Scala files outside the project are copied into tunings/ first.
2147
+ try {
2148
+ const patch = { ...command.patch };
2149
+ if (patch.scl)
2150
+ patch.scl = await importTuningFile(projectRoot, projectRoot, patch.scl);
2151
+ if (patch.kbm)
2152
+ patch.kbm = await importTuningFile(projectRoot, projectRoot, patch.kbm);
2153
+ command = { ...command, patch };
2154
+ } catch (error) {
2155
+ if (error instanceof TuningError)
2156
+ return fail(`tuning · ${error.message}`);
2157
+ throw error;
2158
+ }
2159
+ }
2160
+ if (command.type === "tuning-set" || command.type === "tuning-off")
2161
+ if (command.target === "track") await materializeDraft();
2162
+ const read = (path: string): string | undefined => {
2163
+ try {
2164
+ return readFileSync(join(projectRoot, path), "utf8");
2165
+ } catch {
2166
+ return undefined;
2167
+ }
2168
+ };
2169
+ const result = applyTuningCommand(score, requestedTrack, command, read);
2170
+ if (result.panel) tui.openText(result.panel.title, result.panel.lines);
2171
+ if (result.next && result.kind)
2172
+ await commitScore(result.next, result.kind, result.payload);
2173
+ return result.ok ? ok(result.message) : fail(result.message);
2174
+ }
2175
+
1765
2176
  async function sampleCommand(
1766
2177
  command: NonNullable<ReturnType<typeof parseSampleCommand>>,
1767
2178
  ): Promise<Receipt> {
@@ -2331,7 +2742,7 @@ async function switchSession(sessionId: string): Promise<void> {
2331
2742
  audio = port.player;
2332
2743
  record = port.mode === "daemon" ? await port.load() : next.record;
2333
2744
  score = scoreFromJSON(record.composition);
2334
- clock.setTempo(score.tempoBpm);
2745
+ clock.follow(score);
2335
2746
  const attached = await attachTrack(port, score, undefined);
2336
2747
  requestedTrack = attached.trackId;
2337
2748
  draftTrack = attached.draft;
@@ -2369,6 +2780,8 @@ function menuContext(): MenuContext {
2369
2780
  committed: score,
2370
2781
  hint: loop.hint(),
2371
2782
  status: loop.status(),
2783
+ stageable: stageableNow,
2784
+ committedChords: chordSettings,
2372
2785
  },
2373
2786
  trackId: requestedTrack,
2374
2787
  playing: clock.playing,
@@ -2376,7 +2789,7 @@ function menuContext(): MenuContext {
2376
2789
  grids: GRIDS.map((grid) => grid.label),
2377
2790
  clickOn: session?.clickOn ?? false,
2378
2791
  countInBars: session?.countInBars ?? 1,
2379
- chords: session?.chords.settings ?? chordSettings,
2792
+ chords: stagedChordSettings() ?? session?.chords.settings ?? chordSettings,
2380
2793
  projectRoot: process.cwd(),
2381
2794
  };
2382
2795
  }
@@ -2384,6 +2797,8 @@ function menuContext(): MenuContext {
2384
2797
  /** The screen `?` describes, or undefined while `?` is typed text. */
2385
2798
  function keysScreen(): readonly KeySection[] | undefined {
2386
2799
  if (euclid.open) return euclid.typing ? undefined : KEYS.euclid;
2800
+ if (fader && menu.open)
2801
+ return fader.typing !== undefined ? undefined : KEYS.fader;
2387
2802
  if (menu.open) return menu.typing ? undefined : KEYS.menu;
2388
2803
  const overlay = tui.ui.overlay;
2389
2804
  if (overlay === "picker")
@@ -2394,6 +2809,7 @@ function keysScreen(): readonly KeySection[] | undefined {
2394
2809
  : KEYS.list;
2395
2810
  if (overlay === "text") return KEYS.text;
2396
2811
  if (overlay === "log") return KEYS.log;
2812
+ if (overlay === "guide") return tui.guideTyping ? undefined : KEYS.guide;
2397
2813
  if (prompt.value.length > 0) return undefined;
2398
2814
  if (play?.on)
2399
2815
  return play.chords.on ? [...KEYS.play, ...KEYS.chords] : KEYS.play;
@@ -2415,8 +2831,20 @@ function openMenu(section?: string): void {
2415
2831
  refreshMenu();
2416
2832
  }
2417
2833
 
2418
- function euclidContext() {
2419
- return { score, trackId: requestedTrack };
2834
+ function euclidContext(): EuclidContext {
2835
+ const loop = auditionController();
2836
+ loop.focus(requestedTrack);
2837
+ return {
2838
+ score: loop.staging ? loop.score : score,
2839
+ trackId: requestedTrack,
2840
+ audition: {
2841
+ looping: loop.looping,
2842
+ dirty: loop.dirtyEdits,
2843
+ committed: score,
2844
+ hint: loop.hint(),
2845
+ status: loop.status(),
2846
+ },
2847
+ };
2420
2848
  }
2421
2849
 
2422
2850
  function openEuclid(voice?: string, origin?: string): void {
@@ -2493,6 +2921,20 @@ function auditionController(): Audition {
2493
2921
  clearTimer: (handle) => clearTimeout(handle as Timer),
2494
2922
  changed: () => requestFrame(),
2495
2923
  level: () => previewEngine().level,
2924
+ // The last loop the engine played is the song's when a loop starts.
2925
+ masterGainDb: () =>
2926
+ score.master?.target === undefined
2927
+ ? undefined
2928
+ : previewEngine().loudness?.gainDb,
2929
+ // The chord settings screen loops a progression with its settings.
2930
+ phrase: (showing) => {
2931
+ if (!chordScreenOpen()) return undefined;
2932
+ const settings =
2933
+ showing === "A"
2934
+ ? chordSettings
2935
+ : (stagedChordSettings() ?? chordSettings);
2936
+ return (base, track, bars) => chordPhrase(settings, base, track, bars);
2937
+ },
2496
2938
  },
2497
2939
  score,
2498
2940
  requestedTrack,
@@ -2500,6 +2942,41 @@ function auditionController(): Audition {
2500
2942
  return auditionLoop;
2501
2943
  }
2502
2944
 
2945
+ /** True while the menu's Chords section (`/menu chords`) is open. */
2946
+ function chordScreenOpen(): boolean {
2947
+ return menu.open && menu.section === "chords";
2948
+ }
2949
+
2950
+ /** What stages while auditioning on the screen open now. */
2951
+ function stageableNow(command: string): boolean {
2952
+ return (
2953
+ isStageable(command) || (chordScreenOpen() && isChordStageable(command))
2954
+ );
2955
+ }
2956
+
2957
+ /**
2958
+ * The chord settings with the staged `/chords …` commands applied, or
2959
+ * undefined while none are staged. Chord settings are window state (not in
2960
+ * the score), so the staged ones are derived from the staged commands.
2961
+ */
2962
+ function stagedChordSettings(): ChordSettings | undefined {
2963
+ const commands = auditionLoop?.dirtyEdits ? auditionLoop.commands : [];
2964
+ const chords = commands.filter((command) => CHORDS_COMMAND.test(command));
2965
+ if (chords.length === 0) return undefined;
2966
+ const settings = { ...chordSettings };
2967
+ for (const command of chords)
2968
+ applyChordsCommand(settings, command.match(CHORDS_COMMAND)![1]!);
2969
+ return settings;
2970
+ }
2971
+
2972
+ /** Re-render the loop when the chord screen opens or closes. */
2973
+ function noteChordScreen(): void {
2974
+ const now = chordScreenOpen();
2975
+ if (now === chordScreenWas) return;
2976
+ chordScreenWas = now;
2977
+ auditionLoop?.request();
2978
+ }
2979
+
2503
2980
  /**
2504
2981
  * Warm the pack cache for a staged command before it runs, so a lazy fetch
2505
2982
  * (a sample kit, a pack sound or table) happens outside the capture and the
@@ -2542,6 +3019,12 @@ async function applyStaged(
2542
3019
  base: TrackScore,
2543
3020
  command: string,
2544
3021
  ): Promise<{ next?: TrackScore; ok: boolean; message: string }> {
3022
+ const chords = command.trim().match(CHORDS_COMMAND);
3023
+ if (chords) {
3024
+ // Window state: checked on a copy; kept settings apply on Enter.
3025
+ const result = applyChordsCommand({ ...chordSettings }, chords[1]!);
3026
+ return { next: base, ok: result.ok, message: result.message };
3027
+ }
2545
3028
  await prefetchStaged(base, command);
2546
3029
  // Staged commands run one at a time, between queued prompts.
2547
3030
  while (stageCapture) await Bun.sleep(1);
@@ -2709,7 +3192,7 @@ function pickerAuditionKey(key: "loop" | "ab" | "context"): void {
2709
3192
  /** Stage a menu command while auditioning; undefined when it should run. */
2710
3193
  function stageIfAuditioning(command: string): boolean {
2711
3194
  const loop = auditionLoop;
2712
- if (!loop?.staging || !isStageable(command)) return false;
3195
+ if (!loop?.staging || !stageableNow(command)) return false;
2713
3196
  void loop.stage(command).then((result) => {
2714
3197
  if (!result.ok)
2715
3198
  tui.activity.pushCard(result.message, {
@@ -2727,12 +3210,21 @@ async function keepStaged(): Promise<Receipt> {
2727
3210
  const taken = loop?.take();
2728
3211
  if (!loop || !taken) return warn("nothing staged");
2729
3212
  await materializeDraft();
3213
+ // Chord settings are window state: they apply here, outside the revision.
3214
+ const chordCommands = taken.commands.filter((command) =>
3215
+ CHORDS_COMMAND.test(command),
3216
+ );
3217
+ for (const command of chordCommands)
3218
+ applyChordsCommand(chordSettings, command.match(CHORDS_COMMAND)![1]!);
3219
+ const scoreCommands = taken.commands.filter(
3220
+ (command) => !CHORDS_COMMAND.test(command),
3221
+ );
2730
3222
  let next = taken.score;
2731
- for (let attempt = 0; ; attempt += 1) {
3223
+ for (let attempt = 0; scoreCommands.length && next !== score; attempt += 1) {
2732
3224
  try {
2733
3225
  await commitScore(next, "preview.commit", {
2734
3226
  trackId: loop.trackId,
2735
- commands: taken.commands,
3227
+ commands: scoreCommands,
2736
3228
  });
2737
3229
  break;
2738
3230
  } catch (error) {
@@ -2741,7 +3233,7 @@ async function keepStaged(): Promise<Receipt> {
2741
3233
  record = await port.load();
2742
3234
  score = scoreFromJSON(record.composition);
2743
3235
  next = score;
2744
- for (const command of taken.commands) {
3236
+ for (const command of scoreCommands) {
2745
3237
  const result = await applyStaged(next, command);
2746
3238
  if (result.next) next = result.next;
2747
3239
  }
@@ -2749,8 +3241,11 @@ async function keepStaged(): Promise<Receipt> {
2749
3241
  }
2750
3242
  loop.committedNow(score);
2751
3243
  await projectSync?.flushScore();
3244
+ const kept = `kept ${taken.commands.length} change${taken.commands.length === 1 ? "" : "s"}`;
2752
3245
  return ok(
2753
- `kept ${taken.commands.length} change${taken.commands.length === 1 ? "" : "s"} · one undo step`,
3246
+ scoreCommands.length
3247
+ ? `${kept} · one undo step`
3248
+ : `${kept} · chord settings (window state, no revision)`,
2754
3249
  );
2755
3250
  }
2756
3251
 
@@ -2772,14 +3267,29 @@ function leaveAuditionScreen(): void {
2772
3267
  }
2773
3268
 
2774
3269
  function refreshMenu(): void {
2775
- if (!menu.open) return;
3270
+ noteChordScreen();
3271
+ if (!menu.open) {
3272
+ closeFader();
3273
+ return;
3274
+ }
2776
3275
  if (tui.ui.overlay !== undefined && tui.ui.picker?.id !== "menu") {
2777
3276
  // Another overlay (help, a picker) replaced the menu.
2778
3277
  menu.close();
2779
3278
  leaveAuditionScreen();
2780
3279
  return;
2781
3280
  }
2782
- const view = menu.view(menuContext());
3281
+ const context = menuContext();
3282
+ if (fader) {
3283
+ const fields = menu.faderFields(context);
3284
+ if (fields.length === 0) closeFader();
3285
+ else
3286
+ tui.drawer = drawerView(fader, fields, menu.faderCommitted(context), {
3287
+ title: menu.crumbs,
3288
+ dirty: context.audition?.dirty ?? false,
3289
+ status: context.audition?.status,
3290
+ });
3291
+ }
3292
+ const view = menu.view(context);
2783
3293
  tui.openPicker({
2784
3294
  id: "menu",
2785
3295
  title: view.title,
@@ -2792,6 +3302,216 @@ function refreshMenu(): void {
2792
3302
  });
2793
3303
  }
2794
3304
 
3305
+ // ── the fader drawer ─────────────────────────────────────────────────
3306
+
3307
+ /** Enter on a number row (or a bare `volume`, `fx filter`): the drawer. */
3308
+ /**
3309
+ * Open the drawer on `label`. `standalone` drawers came from a command or a
3310
+ * click, not from inside the menu, so closing one closes the menu too.
3311
+ */
3312
+ function openFader(label: string, standalone = false): void {
3313
+ fader = { label };
3314
+ faderStandalone = standalone;
3315
+ const loop = auditionController();
3316
+ if (!loop.dirtyEdits) loop.committedNow(score);
3317
+ }
3318
+
3319
+ function closeFader(): void {
3320
+ fader = undefined;
3321
+ tui.drawer = undefined;
3322
+ if (faderStandalone && menu.open) {
3323
+ faderStandalone = false;
3324
+ menu.close();
3325
+ tui.closePicker();
3326
+ stopAuditionLoop();
3327
+ }
3328
+ faderStandalone = false;
3329
+ }
3330
+
3331
+ function faderKeyOptions(): { dirty: boolean; audition: boolean } {
3332
+ return { dirty: auditionLoop?.dirtyEdits ?? false, audition: true };
3333
+ }
3334
+
3335
+ /**
3336
+ * Act on a drawer result. A set stages on the audition loop, filed under
3337
+ * its field so every nudge replaces the last: the piano roll (and the loop,
3338
+ * when it plays) follows at once, and Enter keeps them all as one revision.
3339
+ * A setting the loop cannot stage (tempo, loop length) applies directly.
3340
+ */
3341
+ function faderOutcome(result: FaderResult): void {
3342
+ if (result.type === "set") {
3343
+ if (!stageableNow(result.command)) {
3344
+ runPromptLater(result.command);
3345
+ return;
3346
+ }
3347
+ const loop = auditionController();
3348
+ if (!loop.dirtyEdits) loop.committedNow(score);
3349
+ const seq = (faderSeq.get(result.key) ?? 0) + 1;
3350
+ faderSeq.set(result.key, seq);
3351
+ void loop
3352
+ .stage(result.command, {
3353
+ replaceKey: result.key,
3354
+ superseded: () => faderSeq.get(result.key) !== seq,
3355
+ })
3356
+ .then((outcome) => {
3357
+ if (!outcome.ok && outcome.message !== SUPERSEDED)
3358
+ tui.activity.pushCard(outcome.message, {
3359
+ tone: outcome.message.includes("failed") ? "error" : "warning",
3360
+ });
3361
+ requestFrame();
3362
+ });
3363
+ } else if (result.type === "keep") {
3364
+ closeFader();
3365
+ void keepStaged()
3366
+ .then((outcome) => receipt(outcome))
3367
+ .catch((error) => tui.activity.pushError(describeError("keep", error)))
3368
+ .finally(() => requestFrame());
3369
+ } else if (result.type === "revert") {
3370
+ revertStaged();
3371
+ closeFader();
3372
+ } else if (result.type === "close") closeFader();
3373
+ else if (result.type === "audition") auditionKeyPressed(result.key);
3374
+ }
3375
+
3376
+ // ── the mouse ────────────────────────────────────────────────────────
3377
+
3378
+ /**
3379
+ * One mouse report, hit-tested against the regions the last frame painted.
3380
+ * Acts directly (a fader, the transport) or returns keys to run through the
3381
+ * ordinary key path (a list row is Enter, a wheel notch is an arrow).
3382
+ */
3383
+ function mouseInput(event: MouseEvent): string[] {
3384
+ const frame = tui.frame;
3385
+ if (!frame) return [];
3386
+ const target = frame.hits.at(event.x, event.y)?.target;
3387
+ if (event.kind === "wheel") {
3388
+ const fields = fader && menu.open ? menu.faderFields(menuContext()) : [];
3389
+ if (
3390
+ fader &&
3391
+ target &&
3392
+ (target.kind === "fader-bar" ||
3393
+ target.kind === "fader-row" ||
3394
+ target.kind === "fader-step" ||
3395
+ target.kind === "fader-option")
3396
+ ) {
3397
+ // Wheel up raises the value under the pointer.
3398
+ faderOutcome(
3399
+ faderStep(
3400
+ fader,
3401
+ fields[target.field],
3402
+ event.delta < 0 ? 1 : -1,
3403
+ event.shift ? "coarse" : "normal",
3404
+ ),
3405
+ );
3406
+ refreshMenu();
3407
+ return [];
3408
+ }
3409
+ if (fader) return [];
3410
+ if (tui.ui.overlay) return [event.delta < 0 ? KEY_UP : KEY_DOWN];
3411
+ return [];
3412
+ }
3413
+ // A drag keeps moving the fader it started on, even past the bar's ends.
3414
+ if (event.kind === "drag" && event.button === "left" && fader && dragging) {
3415
+ const fields = menu.faderFields(menuContext());
3416
+ faderOutcome(
3417
+ faderSetPosition(
3418
+ fader,
3419
+ fields[dragging.field],
3420
+ (event.x - dragging.left) / Math.max(1, dragging.width - 1),
3421
+ ),
3422
+ );
3423
+ refreshMenu();
3424
+ return [];
3425
+ }
3426
+ if (event.kind === "up") {
3427
+ dragging = undefined;
3428
+ return [];
3429
+ }
3430
+ if (event.kind !== "down" || event.button !== "left" || !target) return [];
3431
+ if (fader && menu.open) {
3432
+ const fields = menu.faderFields(menuContext());
3433
+ let result: FaderResult | undefined;
3434
+ switch (target.kind) {
3435
+ case "fader-step":
3436
+ result = faderStep(
3437
+ fader,
3438
+ fields[target.field],
3439
+ target.direction,
3440
+ event.shift ? "coarse" : "normal",
3441
+ );
3442
+ break;
3443
+ case "fader-bar":
3444
+ dragging = target;
3445
+ result = faderSetPosition(
3446
+ fader,
3447
+ fields[target.field],
3448
+ (event.x - target.left) / Math.max(1, target.width - 1),
3449
+ );
3450
+ break;
3451
+ case "fader-option":
3452
+ result = faderChoose(fader, fields[target.field], target.option);
3453
+ break;
3454
+ case "fader-row": {
3455
+ const field = fields[target.field];
3456
+ if (field) {
3457
+ fader.label = field.label;
3458
+ fader.typing = undefined;
3459
+ }
3460
+ result = { type: "handled" };
3461
+ break;
3462
+ }
3463
+ case "fader-keep":
3464
+ result = auditionLoop?.dirtyEdits
3465
+ ? { type: "keep" }
3466
+ : { type: "close" };
3467
+ break;
3468
+ case "fader-revert":
3469
+ result = auditionLoop?.dirtyEdits
3470
+ ? { type: "revert" }
3471
+ : { type: "close" };
3472
+ break;
3473
+ default:
3474
+ break;
3475
+ }
3476
+ if (result) {
3477
+ faderOutcome(result);
3478
+ refreshMenu();
3479
+ return [];
3480
+ }
3481
+ }
3482
+ switch (target.kind) {
3483
+ case "picker-row": {
3484
+ const picker = tui.ui.picker;
3485
+ if (!picker) return [];
3486
+ if (picker.id === "menu" && menu.open) {
3487
+ // The first click selects a row; a click on the selected row opens it.
3488
+ const again = menu.index === target.index;
3489
+ menu.select(menuContext(), target.index);
3490
+ refreshMenu();
3491
+ return again ? ["\r"] : [];
3492
+ }
3493
+ const again = picker.index === target.index;
3494
+ picker.index = target.index;
3495
+ picker.filtering = false;
3496
+ return again ? ["\r"] : [];
3497
+ }
3498
+ case "transport":
3499
+ if (tui.ui.overlay) return [];
3500
+ void toggleTransport().finally(() => requestFrame());
3501
+ return [];
3502
+ case "tracks":
3503
+ if (tui.ui.overlay) return [];
3504
+ runPromptLater("/tracks");
3505
+ return [];
3506
+ case "model":
3507
+ if (tui.ui.overlay) return [];
3508
+ runPromptLater("/model");
3509
+ return [];
3510
+ default:
3511
+ return [];
3512
+ }
3513
+ }
3514
+
2795
3515
  function playSession(): PlaySession {
2796
3516
  if (play && play.track === requestedTrack) return play;
2797
3517
  const previous = play;
@@ -2863,6 +3583,7 @@ function playHost() {
2863
3583
  now: () => performance.now(),
2864
3584
  playing: () => clock.playing,
2865
3585
  beatAt: (ms: number) => clock.beatAt(ms),
3586
+ scoreBeat: (beat: number) => scoreBeatAt(score, beat),
2866
3587
  engine: liveEngine,
2867
3588
  samples: () => liveSampleBank,
2868
3589
  async commit(
@@ -2929,20 +3650,33 @@ async function commitScore(
2929
3650
  // Rhythm rows regenerate after a loop resize and freeze when their lane
2930
3651
  // is hand-edited, so rows and notes never disagree.
2931
3652
  next = reconcileRhythm(score, next);
3653
+ const retimed = timingChanged(score, next);
2932
3654
  if (stageCapture) {
2933
3655
  // A staged edit: the audition plays it; nothing is written yet.
2934
3656
  stageCapture.next = next;
2935
3657
  score = next;
3658
+ if (retimed) clock.follow(score);
2936
3659
  return;
2937
3660
  }
2938
3661
  record = await port.append(record, { kind, payload }, next.toJSON());
2939
3662
  score = next;
3663
+ if (retimed) clock.follow(score);
2940
3664
  if (clock.playing) void audio.play(score);
2941
3665
  projectSync?.scoreChanged(score);
2942
3666
  reportSampleProblems(score);
2943
3667
  void updateCredits(score);
2944
3668
  }
2945
3669
 
3670
+ /** True when the transport clock must follow `next` (tempo, meter, loop). */
3671
+ function timingChanged(previous: TrackScore, next: TrackScore): boolean {
3672
+ return (
3673
+ previous.time !== next.time ||
3674
+ previous.tempoBpm !== next.tempoBpm ||
3675
+ previous.beatsPerBar !== next.beatsPerBar ||
3676
+ previous.bars !== next.bars
3677
+ );
3678
+ }
3679
+
2946
3680
  /** The window's side of the project file sync (see src/project/sync.ts). */
2947
3681
  function syncHost(): SyncHost {
2948
3682
  return {
@@ -2960,7 +3694,7 @@ function syncHost(): SyncHost {
2960
3694
  plan.next.toJSON(),
2961
3695
  );
2962
3696
  score = scoreFromJSON(record.composition);
2963
- clock.setTempo(score.tempoBpm);
3697
+ clock.follow(score);
2964
3698
  if (clock.playing) void audio.play(score);
2965
3699
  reportSampleProblems(score);
2966
3700
  void baseRevision;
@@ -3067,6 +3801,17 @@ async function setTransport(
3067
3801
  }
3068
3802
  }
3069
3803
 
3804
+ /** Move the playhead to transport `beat`, playing or not (section jump). */
3805
+ async function seekTransport(beat: number): Promise<void> {
3806
+ if (port.mode === "daemon") {
3807
+ await port.transport("seek", { beat });
3808
+ return;
3809
+ }
3810
+ const now = Date.now();
3811
+ clock.sync(beat, clock.playing, now, now);
3812
+ if (clock.playing) await audio.play(score, clock.beatAt());
3813
+ }
3814
+
3070
3815
  /**
3071
3816
  * Run one streaming tool-calling turn. Each validated tool call commits its
3072
3817
  * own revision through `agentHost`, so cancelling keeps every accepted change
@@ -3171,6 +3916,25 @@ function mediaServices(): MediaServices {
3171
3916
  return { runner: systemRunner, env: process.env };
3172
3917
  }
3173
3918
 
3919
+ /**
3920
+ * `master measure`: render the song as the loop that plays and measure
3921
+ * loudness, peaks and balance at the export rate. The render, master and
3922
+ * meter run in a worker (`measureScoreOffThread`), so the header, input and
3923
+ * playback keep going on a long loop.
3924
+ */
3925
+ async function measureLine(value: TrackScore): Promise<string> {
3926
+ await sampleProblems(value);
3927
+ tui.activity.setSpinner("measuring");
3928
+ try {
3929
+ const measured = await measureScoreOffThread(value, {
3930
+ projectRoot: process.cwd(),
3931
+ });
3932
+ return `master measure · ${measurementLine(measured.mix, measured.master)} · ${measured.sampleRate / 1000} kHz`;
3933
+ } finally {
3934
+ tui.activity.setSpinner(undefined);
3935
+ }
3936
+ }
3937
+
3174
3938
  /**
3175
3939
  * How the agent's preview_sound renders (with this window's decoded
3176
3940
  * samples, at the engine's rate) and plays: once, over silence, unless the
@@ -3185,6 +3949,16 @@ function agentPreviewHost(): PreviewHost {
3185
3949
  ...(liveSampleBank ? { samples: liveSampleBank } : {}),
3186
3950
  });
3187
3951
  },
3952
+ // Off the UI thread, at the export rate (see measureLine).
3953
+ measure: async (value, options) => {
3954
+ await sampleProblems(value);
3955
+ return measureScoreOffThread(value, {
3956
+ projectRoot: process.cwd(),
3957
+ ...(options?.sampleRate === undefined
3958
+ ? {}
3959
+ : { sampleRate: options.sampleRate }),
3960
+ });
3961
+ },
3188
3962
  play: async (rendered) => {
3189
3963
  if (!agentPreviewPlays || clock.playing || auditionLoop?.looping)
3190
3964
  return false;
@@ -3253,8 +4027,13 @@ function agentHost(
3253
4027
  );
3254
4028
  throw error;
3255
4029
  }
3256
- if (change.operations.some((operation) => operation.type === "setTempo"))
3257
- clock.setTempo(score.tempoBpm);
4030
+ if (
4031
+ change.operations.some(
4032
+ (operation) =>
4033
+ operation.type === "setTempo" || operation.type === "setTime",
4034
+ )
4035
+ )
4036
+ clock.follow(score);
3258
4037
  return { revision: record.revision };
3259
4038
  },
3260
4039
  async transport(action) {