@hraness/dawg 0.4.1 → 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 (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/DAWG.md +293 -31
  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/common.ts +30 -1
  64. package/src/audio/effects/dynamics.ts +2 -1
  65. package/src/audio/effects/filter.ts +5 -3
  66. package/src/audio/effects/modulation.ts +5 -1
  67. package/src/audio/effects/space.ts +92 -2
  68. package/src/audio/engine.ts +90 -20
  69. package/src/audio/live.ts +33 -6
  70. package/src/audio/loudness.ts +551 -0
  71. package/src/audio/master.ts +660 -0
  72. package/src/audio/measure-worker.ts +45 -0
  73. package/src/audio/measure.ts +110 -0
  74. package/src/audio/player.ts +11 -6
  75. package/src/audio/preview.ts +74 -14
  76. package/src/audio/render-worker.ts +6 -1
  77. package/src/audio/renderer.ts +7 -1
  78. package/src/audio/sampler.ts +108 -19
  79. package/src/audio/synth/voice.ts +60 -13
  80. package/src/audio/synth/zzfx.ts +10 -4
  81. package/src/audio/warp.ts +86 -0
  82. package/src/audio/wav.ts +301 -26
  83. package/src/commands/arrange.ts +949 -0
  84. package/src/commands/expression.ts +934 -0
  85. package/src/commands/help.ts +281 -24
  86. package/src/commands/master.ts +361 -0
  87. package/src/commands/music.ts +6 -1
  88. package/src/commands/synth.ts +11 -1
  89. package/src/commands/time.ts +964 -0
  90. package/src/commands/tuning.ts +490 -0
  91. package/src/main.ts +709 -33
  92. package/src/render.ts +109 -7
  93. package/src/session/daemon.ts +18 -8
  94. package/src/session/naming.ts +8 -1
  95. package/src/session/rebase.ts +21 -0
  96. package/src/tui/arrange-menu.ts +390 -0
  97. package/src/tui/audition.ts +38 -5
  98. package/src/tui/fader.ts +409 -0
  99. package/src/tui/menu-time.ts +401 -0
  100. package/src/tui/menu.ts +635 -14
  101. package/src/tui/performance-menu.ts +235 -0
  102. package/src/tui/play-chords.ts +3 -1
  103. package/src/tui/play-mode.ts +150 -6
  104. package/src/tui/play-session.ts +414 -41
  105. package/tui/app.ts +262 -13
  106. package/tui/arrange-strip.ts +174 -0
  107. package/tui/drawer.ts +478 -0
  108. package/tui/grammar.ts +63 -1
  109. package/tui/guide.ts +351 -0
  110. package/tui/highway.ts +87 -4
  111. package/tui/hits.ts +68 -0
  112. package/tui/input.ts +7 -0
  113. 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,6 +145,23 @@ import {
116
145
  type LiveEngine,
117
146
  } from "./tui/play-session.ts";
118
147
  import { EditMenu, MENU_SECTIONS, type MenuContext } from "./tui/menu.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";
119
165
  import {
120
166
  Audition,
121
167
  SUPERSEDED,
@@ -125,6 +171,9 @@ import {
125
171
  import { EuclidEditor, type EuclidContext } from "./tui/euclid.ts";
126
172
  import { HINTS, KEYS, keyLines, type KeySection } from "../tui/grammar.ts";
127
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";
128
177
  import { renderScorePcm } from "./audio/wav.ts";
129
178
  import type { PreviewHost } from "./agent/preview-tool.ts";
130
179
  import { rhythmVoicePitch } from "../core/rhythm.ts";
@@ -176,6 +225,7 @@ import {
176
225
  } from "../core/score.ts";
177
226
  import { reconcileRhythm } from "../core/rhythm.ts";
178
227
  import { decodeLoop, encodeLoop } from "../core/loop.ts";
228
+ import { scoreToMidi } from "../core/midi.ts";
179
229
  import type { TrackScoreSnapshot } from "../tui/render.ts";
180
230
  import { PromptModel } from "../tui/prompt.ts";
181
231
  import { TerminalInputDecoder } from "../tui/input.ts";
@@ -183,6 +233,7 @@ import {
183
233
  composeFrame,
184
234
  TuiApp,
185
235
  type AppView,
236
+ type LoudnessView,
186
237
  type SyncState,
187
238
  type TypesIndicator,
188
239
  } from "../tui/app.ts";
@@ -209,11 +260,16 @@ function parsesLocally(text: string): boolean {
209
260
  parseRhythmCommand,
210
261
  parseFxCommand,
211
262
  parseSynthCommand,
263
+ parseExpressionCommand,
264
+ parseMasterCommand,
265
+ (value: string) => parseSectionCommand(value, score),
212
266
  parsePatternCommand,
213
267
  parseKitCommand,
214
268
  parsePackCommand,
215
269
  parseSampleCommand,
216
270
  parseWavetableCommand,
271
+ parseTimeCommand,
272
+ parseTuningCommand,
217
273
  ].some((parse) => parse(text) !== undefined);
218
274
  }
219
275
 
@@ -240,6 +296,7 @@ const FLAGS = [
240
296
  "--version",
241
297
  "-v",
242
298
  "--reduce-motion",
299
+ "--no-mouse",
243
300
  ];
244
301
  const HELP_TEXT = `dawg · local-first terminal music workstation
245
302
 
@@ -247,7 +304,8 @@ Usage:
247
304
  dawg [--new] [--session <name|id>] [--track <name>]
248
305
  dawg --import <file> --export <file>
249
306
  dawg sessions
250
- 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]
251
309
  dawg init [dir] project files: song.ts, tracks/<slug>/track.ts, .dawg/sdk
252
310
  dawg check typecheck + evaluate the project; exit 1 on problems
253
311
  dawg media doctor|download|stems|analyze|notes|sample|lyrics … (dawg media --help)
@@ -256,6 +314,7 @@ Usage:
256
314
  Usage flags:
257
315
  --reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
258
316
  --theme <name> default | high-contrast | mono (NO_COLOR forces mono)
317
+ --no-mouse keys only; no click/wheel reporting (also DAWG_MOUSE=0)
259
318
 
260
319
  Prompt:
261
320
  Enter submit · Shift-Enter newline · Alt-Enter queue · Ctrl-Q toggle queue
@@ -486,6 +545,18 @@ let chordScreenWas = false;
486
545
  let stageCapture: { next?: TrackScore; committed?: TrackScore } | undefined;
487
546
  /** Redraw soon (the audition reports renders between frames). */
488
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>();
489
560
  /** The decoded sampler voices, for play mode's live voices. */
490
561
  let liveSampleBank: SampleBank | undefined;
491
562
  /** The in-flight agent turn: Esc aborts it, Enter steers it. */
@@ -645,16 +716,29 @@ function snapshot(
645
716
  beat: number,
646
717
  activity?: string,
647
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;
648
724
  const notes = value.notes
649
725
  .filter((note) => note.trackId === requestedTrack)
650
- .map((note) => ({
651
- id: note.id,
652
- startBeat: note.startTick / value.ticksPerBeat,
653
- durationBeats: note.durationTicks / value.ticksPerBeat,
654
- pitch: note.pitch,
655
- velocity: note.velocity,
656
- muted: value.tracks.find((track) => track.id === requestedTrack)?.muted,
657
- }));
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
+ });
658
742
  return {
659
743
  notes,
660
744
  trackName:
@@ -663,12 +747,22 @@ function snapshot(
663
747
  trackId: requestedTrack,
664
748
  sessionId: record.sessionId,
665
749
  revision: record.revision,
666
- bpm: value.tempoBpm,
750
+ bpm: value.time?.tempo
751
+ ? bpmAtTick(value, beat * value.ticksPerBeat)
752
+ : value.tempoBpm,
667
753
  key: value.key ?? undefined,
668
- loopBeats: value.bars * value.beatsPerBar,
754
+ loopBeats: loopTicksOf(value) / value.ticksPerBeat,
669
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
+ : {}),
670
764
  laneCount: 24,
671
- currentBeat: beat,
765
+ currentBeat: scoreBeatAt(value, beat),
672
766
  playing: clock.playing,
673
767
  activity,
674
768
  ...drumSnapshotFields(
@@ -679,6 +773,9 @@ function snapshot(
679
773
  value.tracks.find((track) => track.id === requestedTrack),
680
774
  notes,
681
775
  ),
776
+ ...(table && table.linear && table.size !== 12
777
+ ? { tuningPeriod: { size: table.size, root: table.root } }
778
+ : {}),
682
779
  layers:
683
780
  tui.highwayView === "all"
684
781
  ? highwayLayers(
@@ -712,7 +809,9 @@ function renderOnce(
712
809
  function appView(value: TrackScore, beat: number): AppView {
713
810
  return {
714
811
  score: snapshot(value, beat),
715
- beat,
812
+ // The highway shows score time: inside the looped section or form pass.
813
+ beat: scoreBeatAt(value, beat),
814
+ arrange: arrangeStripView(value, beat),
716
815
  // `opus-5.5 · gateway`, `sonnet · claude`; hidden when offline.
717
816
  model:
718
817
  providerName && providerName !== "offline" ? providerName : undefined,
@@ -723,6 +822,79 @@ function appView(value: TrackScore, beat: number): AppView {
723
822
  windows: windowCount,
724
823
  types: typesIndicator,
725
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,
726
898
  };
727
899
  }
728
900
 
@@ -779,11 +951,46 @@ function truncateForCard(value: string): string {
779
951
  return line.length > 32 ? `${line.slice(0, 31)}…` : line;
780
952
  }
781
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
+
782
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
+ });
783
990
  stdin.setRawMode?.(true);
784
991
  stdin.resume();
785
992
  // Alternate screen, hidden cursor, bracketed paste.
786
- stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
993
+ stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J${mouseOn()}`);
787
994
  const inputDecoder = new TerminalInputDecoder();
788
995
  const queuedPrompts: string[] = [];
789
996
  let processingQueue = false;
@@ -845,6 +1052,10 @@ async function runInteractive(): Promise<void> {
845
1052
  tui.render(appView(score, clock.beatAt()), { force });
846
1053
  };
847
1054
  requestFrame = () => tick(true);
1055
+ runPromptLater = (command) => {
1056
+ queuedPrompts.unshift(command);
1057
+ void drainQueue();
1058
+ };
848
1059
  reportAgentActivity = () => {
849
1060
  tui.activity.applyAgentEvent({ type: "start", model: providerName });
850
1061
  };
@@ -871,7 +1082,7 @@ async function runInteractive(): Promise<void> {
871
1082
  if (stageCapture)
872
1083
  stageCapture.committed = scoreFromJSON(record.composition);
873
1084
  else score = scoreFromJSON(record.composition);
874
- clock.setTempo(score.tempoBpm);
1085
+ clock.follow(score);
875
1086
  if (clock.playing) void audio.play(score);
876
1087
  // Connected windows follow dawgd's transport frames instead.
877
1088
  const replay =
@@ -924,6 +1135,7 @@ async function runInteractive(): Promise<void> {
924
1135
  // Every window renders the same hit line from dawgd's timestamp.
925
1136
  const { playing, beat, bpm, atMs } = update.transport;
926
1137
  clock.setTempo(bpm);
1138
+ clock.setTimeMap(transportMapFor(playbackTime(score)));
927
1139
  clock.sync(beat, playing, atMs, monotonicEpochMs());
928
1140
  } else if (update.type === "status") {
929
1141
  syncState = port.sync;
@@ -1000,11 +1212,11 @@ async function runInteractive(): Promise<void> {
1000
1212
  stdin.pause();
1001
1213
  stdin.setRawMode?.(false);
1002
1214
  // Leave the alternate screen; restore the cursor and plain paste.
1003
- stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1215
+ stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1004
1216
  try {
1005
1217
  return await flow();
1006
1218
  } finally {
1007
- stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
1219
+ stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J${mouseOn()}`);
1008
1220
  stdin.setRawMode?.(true);
1009
1221
  stdin.on("data", onData);
1010
1222
  stdin.resume();
@@ -1023,7 +1235,31 @@ async function runInteractive(): Promise<void> {
1023
1235
  ...inputDecoder.push(text),
1024
1236
  ...(text === "\u001b" ? inputDecoder.flush() : []),
1025
1237
  ];
1026
- 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
+ }
1027
1263
  // The `?` panel closes on any key (Ctrl-C still quits).
1028
1264
  if (tui.ui.keys && typeof value === "string" && value !== "\u0003") {
1029
1265
  tui.closeKeys();
@@ -1086,6 +1322,7 @@ async function runInteractive(): Promise<void> {
1086
1322
  }
1087
1323
  } else if (result.type === "audition")
1088
1324
  auditionKeyPressed(result.key);
1325
+ else if (result.type === "fader") openFader(result.label);
1089
1326
  else if (result.type === "revert") revertStaged();
1090
1327
  else if (result.type === "hover")
1091
1328
  hoverItem(result.command, result.key);
@@ -1109,6 +1346,16 @@ async function runInteractive(): Promise<void> {
1109
1346
  }
1110
1347
  }
1111
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
+ }
1112
1359
  // Ctrl-K opens the menu on an empty prompt (in play mode too); with
1113
1360
  // text it keeps its kill-to-end-of-line meaning.
1114
1361
  if (
@@ -1140,7 +1387,8 @@ async function runInteractive(): Promise<void> {
1140
1387
  if (
1141
1388
  value === " " &&
1142
1389
  prompt.value.length === 0 &&
1143
- !(tui.ui.overlay === "picker" && tui.ui.picker?.audition)
1390
+ !(tui.ui.overlay === "picker" && tui.ui.picker?.audition) &&
1391
+ tui.ui.overlay !== "guide"
1144
1392
  ) {
1145
1393
  // Never await a daemon round trip here: the key loop must stay
1146
1394
  // live for Esc, quit and redraws while the toggle is in flight.
@@ -1271,7 +1519,7 @@ async function runInteractive(): Promise<void> {
1271
1519
  await port.close();
1272
1520
  stdin.setRawMode?.(false);
1273
1521
  stdin.pause();
1274
- stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1522
+ stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1275
1523
  }
1276
1524
  }
1277
1525
 
@@ -1306,7 +1554,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1306
1554
  }
1307
1555
  if (/^\/?tracks$/i.test(command)) {
1308
1556
  const problems = await sampleProblems(score);
1309
- const lines = score.tracks.map((track) => {
1557
+ const items = score.tracks.map((track) => {
1310
1558
  const voices = track.sampler
1311
1559
  ? Object.keys(track.sampler.voices).length
1312
1560
  : 0;
@@ -1316,16 +1564,39 @@ async function submit(prompt: string): Promise<string | Receipt> {
1316
1564
  const samples = track.sampler
1317
1565
  ? ` · ${voices} sample${voices === 1 ? "" : "s"}${missing ? ` · ${missing} missing` : ""}`
1318
1566
  : "";
1319
- 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
+ };
1573
+ });
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
+ ),
1320
1584
  });
1321
- tui.openText("tracks · * focused", lines);
1322
- return ok(`${lines.length} track${lines.length === 1 ? "" : "s"}`);
1585
+ return ok(`${items.length} track${items.length === 1 ? "" : "s"}`);
1323
1586
  }
1324
- 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
+ );
1325
1590
  if (playCommand) {
1326
1591
  const wanted = playCommand[1]?.toLowerCase();
1327
1592
  if (wanted === "off" || (wanted === undefined && play?.on))
1328
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
+ }
1329
1600
  return enterPlay();
1330
1601
  }
1331
1602
  const clickCommand = command.match(/^\/click(?:\s+(.+))?$/i);
@@ -1354,6 +1625,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
1354
1625
  }
1355
1626
  const tryCommand = command.match(/^\/try(?:\s+(.+))?$/i);
1356
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
+ }
1357
1638
  const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
1358
1639
  if (menuCommand) {
1359
1640
  const section = menuCommand[1]?.toLowerCase();
@@ -1379,6 +1660,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
1379
1660
  /^\/?(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/i,
1380
1661
  );
1381
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
+ }
1382
1673
  const sample = parseSampleCommand(command);
1383
1674
  if (sample) return sampleCommand(sample);
1384
1675
  const pack = parsePackCommand(command);
@@ -1391,6 +1682,14 @@ async function submit(prompt: string): Promise<string | Receipt> {
1391
1682
  if (wavetable) return wavetableCommand(wavetable);
1392
1683
  const sessionReply = await sessionCommand(command);
1393
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
+ }
1394
1693
  const edit = parseEditCommand(command);
1395
1694
  if (edit) {
1396
1695
  await materializeDraft();
@@ -1426,6 +1725,17 @@ async function submit(prompt: string): Promise<string | Receipt> {
1426
1725
  await commitScore(result.next, result.kind, result.payload);
1427
1726
  return result.ok ? ok(result.message) : fail(result.message);
1428
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);
1429
1739
  const synth = parseSynthCommand(command);
1430
1740
  if (synth) {
1431
1741
  if (synth.type !== "synth-list") await materializeDraft();
@@ -1434,6 +1744,34 @@ async function submit(prompt: string): Promise<string | Receipt> {
1434
1744
  await commitScore(result.next, result.kind, result.payload);
1435
1745
  return result.ok ? ok(result.message) : fail(result.message);
1436
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
+ }
1437
1775
  const music = parseMusicCommand(command);
1438
1776
  if (music) {
1439
1777
  await materializeDraft();
@@ -1447,6 +1785,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
1447
1785
  const exportCommand = command.match(/^\/?export\s+([^\s]+)$/i);
1448
1786
  if (exportCommand) {
1449
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
+ );
1450
1797
  await writeFile(path, encodeLoop(score), "utf8");
1451
1798
  return `exported · ${exportCommand[1]}`;
1452
1799
  }
@@ -1563,7 +1910,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1563
1910
  if (parsed.type === "set-tempo") {
1564
1911
  const next = score.withTempo(parsed.tempoBpm);
1565
1912
  await commitScore(next, "score.tempo", { tempoBpm: parsed.tempoBpm });
1566
- clock.setTempo?.(next.tempoBpm);
1913
+ clock.follow(next);
1567
1914
  return `tempo · ${next.tempoBpm} BPM`;
1568
1915
  }
1569
1916
  if (parsed.type === "add-track") return focusTrack(parsed.trackId);
@@ -1658,6 +2005,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
1658
2005
  ...(typeof parsed.patch.velocity === "number"
1659
2006
  ? { velocity: parsed.patch.velocity }
1660
2007
  : {}),
2008
+ ...(typeof parsed.patch.cents === "number"
2009
+ ? { cents: parsed.patch.cents }
2010
+ : {}),
1661
2011
  };
1662
2012
  const next = applyScoreOperation(score, {
1663
2013
  type: "updateNote",
@@ -1683,6 +2033,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1683
2033
  ),
1684
2034
  pitch: parsed.pitch,
1685
2035
  velocity: parsed.velocity,
2036
+ ...(parsed.cents ? { cents: parsed.cents } : {}),
1686
2037
  };
1687
2038
  const operation: ScoreOperation = { type: "addNote", note };
1688
2039
  const next = applyScoreOperation(latestScore, operation);
@@ -1788,6 +2139,40 @@ function reportSampleProblems(value: TrackScore): void {
1788
2139
  });
1789
2140
  }
1790
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
+
1791
2176
  async function sampleCommand(
1792
2177
  command: NonNullable<ReturnType<typeof parseSampleCommand>>,
1793
2178
  ): Promise<Receipt> {
@@ -2357,7 +2742,7 @@ async function switchSession(sessionId: string): Promise<void> {
2357
2742
  audio = port.player;
2358
2743
  record = port.mode === "daemon" ? await port.load() : next.record;
2359
2744
  score = scoreFromJSON(record.composition);
2360
- clock.setTempo(score.tempoBpm);
2745
+ clock.follow(score);
2361
2746
  const attached = await attachTrack(port, score, undefined);
2362
2747
  requestedTrack = attached.trackId;
2363
2748
  draftTrack = attached.draft;
@@ -2412,6 +2797,8 @@ function menuContext(): MenuContext {
2412
2797
  /** The screen `?` describes, or undefined while `?` is typed text. */
2413
2798
  function keysScreen(): readonly KeySection[] | undefined {
2414
2799
  if (euclid.open) return euclid.typing ? undefined : KEYS.euclid;
2800
+ if (fader && menu.open)
2801
+ return fader.typing !== undefined ? undefined : KEYS.fader;
2415
2802
  if (menu.open) return menu.typing ? undefined : KEYS.menu;
2416
2803
  const overlay = tui.ui.overlay;
2417
2804
  if (overlay === "picker")
@@ -2422,6 +2809,7 @@ function keysScreen(): readonly KeySection[] | undefined {
2422
2809
  : KEYS.list;
2423
2810
  if (overlay === "text") return KEYS.text;
2424
2811
  if (overlay === "log") return KEYS.log;
2812
+ if (overlay === "guide") return tui.guideTyping ? undefined : KEYS.guide;
2425
2813
  if (prompt.value.length > 0) return undefined;
2426
2814
  if (play?.on)
2427
2815
  return play.chords.on ? [...KEYS.play, ...KEYS.chords] : KEYS.play;
@@ -2533,6 +2921,11 @@ function auditionController(): Audition {
2533
2921
  clearTimer: (handle) => clearTimeout(handle as Timer),
2534
2922
  changed: () => requestFrame(),
2535
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,
2536
2929
  // The chord settings screen loops a progression with its settings.
2537
2930
  phrase: (showing) => {
2538
2931
  if (!chordScreenOpen()) return undefined;
@@ -2875,14 +3268,28 @@ function leaveAuditionScreen(): void {
2875
3268
 
2876
3269
  function refreshMenu(): void {
2877
3270
  noteChordScreen();
2878
- if (!menu.open) return;
3271
+ if (!menu.open) {
3272
+ closeFader();
3273
+ return;
3274
+ }
2879
3275
  if (tui.ui.overlay !== undefined && tui.ui.picker?.id !== "menu") {
2880
3276
  // Another overlay (help, a picker) replaced the menu.
2881
3277
  menu.close();
2882
3278
  leaveAuditionScreen();
2883
3279
  return;
2884
3280
  }
2885
- 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);
2886
3293
  tui.openPicker({
2887
3294
  id: "menu",
2888
3295
  title: view.title,
@@ -2895,6 +3302,216 @@ function refreshMenu(): void {
2895
3302
  });
2896
3303
  }
2897
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
+
2898
3515
  function playSession(): PlaySession {
2899
3516
  if (play && play.track === requestedTrack) return play;
2900
3517
  const previous = play;
@@ -2966,6 +3583,7 @@ function playHost() {
2966
3583
  now: () => performance.now(),
2967
3584
  playing: () => clock.playing,
2968
3585
  beatAt: (ms: number) => clock.beatAt(ms),
3586
+ scoreBeat: (beat: number) => scoreBeatAt(score, beat),
2969
3587
  engine: liveEngine,
2970
3588
  samples: () => liveSampleBank,
2971
3589
  async commit(
@@ -3032,20 +3650,33 @@ async function commitScore(
3032
3650
  // Rhythm rows regenerate after a loop resize and freeze when their lane
3033
3651
  // is hand-edited, so rows and notes never disagree.
3034
3652
  next = reconcileRhythm(score, next);
3653
+ const retimed = timingChanged(score, next);
3035
3654
  if (stageCapture) {
3036
3655
  // A staged edit: the audition plays it; nothing is written yet.
3037
3656
  stageCapture.next = next;
3038
3657
  score = next;
3658
+ if (retimed) clock.follow(score);
3039
3659
  return;
3040
3660
  }
3041
3661
  record = await port.append(record, { kind, payload }, next.toJSON());
3042
3662
  score = next;
3663
+ if (retimed) clock.follow(score);
3043
3664
  if (clock.playing) void audio.play(score);
3044
3665
  projectSync?.scoreChanged(score);
3045
3666
  reportSampleProblems(score);
3046
3667
  void updateCredits(score);
3047
3668
  }
3048
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
+
3049
3680
  /** The window's side of the project file sync (see src/project/sync.ts). */
3050
3681
  function syncHost(): SyncHost {
3051
3682
  return {
@@ -3063,7 +3694,7 @@ function syncHost(): SyncHost {
3063
3694
  plan.next.toJSON(),
3064
3695
  );
3065
3696
  score = scoreFromJSON(record.composition);
3066
- clock.setTempo(score.tempoBpm);
3697
+ clock.follow(score);
3067
3698
  if (clock.playing) void audio.play(score);
3068
3699
  reportSampleProblems(score);
3069
3700
  void baseRevision;
@@ -3170,6 +3801,17 @@ async function setTransport(
3170
3801
  }
3171
3802
  }
3172
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
+
3173
3815
  /**
3174
3816
  * Run one streaming tool-calling turn. Each validated tool call commits its
3175
3817
  * own revision through `agentHost`, so cancelling keeps every accepted change
@@ -3274,6 +3916,25 @@ function mediaServices(): MediaServices {
3274
3916
  return { runner: systemRunner, env: process.env };
3275
3917
  }
3276
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
+
3277
3938
  /**
3278
3939
  * How the agent's preview_sound renders (with this window's decoded
3279
3940
  * samples, at the engine's rate) and plays: once, over silence, unless the
@@ -3288,6 +3949,16 @@ function agentPreviewHost(): PreviewHost {
3288
3949
  ...(liveSampleBank ? { samples: liveSampleBank } : {}),
3289
3950
  });
3290
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
+ },
3291
3962
  play: async (rendered) => {
3292
3963
  if (!agentPreviewPlays || clock.playing || auditionLoop?.looping)
3293
3964
  return false;
@@ -3356,8 +4027,13 @@ function agentHost(
3356
4027
  );
3357
4028
  throw error;
3358
4029
  }
3359
- if (change.operations.some((operation) => operation.type === "setTempo"))
3360
- 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);
3361
4037
  return { revision: record.revision };
3362
4038
  },
3363
4039
  async transport(action) {