@hraness/dawg 0.4.1 → 0.6.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 (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. package/tui/keys.ts +72 -0
package/src/main.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env bun
2
+ import {
3
+ isUnknownInstrument,
4
+ plainSineAdvice,
5
+ } from "./audio/instrument-check.ts";
2
6
  import { randomUUID } from "node:crypto";
7
+ import { readFileSync, writeSync } from "node:fs";
3
8
  import { readFile, stat, writeFile } from "node:fs/promises";
4
9
  import { join, relative, resolve } from "node:path";
5
10
  import { stdin, stdout } from "node:process";
@@ -42,8 +47,64 @@ import {
42
47
  } from "./commands/drums.ts";
43
48
  import { kitCatalog } from "./audio/kits.ts";
44
49
  import { applyEditCommand, parseEditCommand } from "./commands/edit.ts";
45
- import { applyFxCommand, parseFxCommand } from "./commands/fx.ts";
50
+ import { applyTimeCommand, parseTimeCommand } from "./commands/time.ts";
51
+ import {
52
+ barStartTick,
53
+ bpmAtTick,
54
+ hasMeterChanges,
55
+ loopTicksOf,
56
+ } from "../core/tempo.ts";
57
+ import {
58
+ applyExpressionCommand,
59
+ parseExpressionCommand,
60
+ } from "./commands/expression.ts";
61
+ import {
62
+ applyFxCommand,
63
+ parseFxCommand,
64
+ unknownFxMessage,
65
+ } from "./commands/fx.ts";
66
+ import {
67
+ applyRigCommand,
68
+ parseRigCommand,
69
+ rigTrackFields,
70
+ rigWordPatch,
71
+ } from "./commands/rig.ts";
72
+ import {
73
+ applyModalCommand,
74
+ modalListLines,
75
+ parseModalCommand,
76
+ } from "./commands/modal.ts";
46
77
  import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
78
+ import { applyStringCommand, parseStringCommand } from "./commands/string.ts";
79
+ import { instrumentPatchForWord, isModalWord } from "../core/resonators.ts";
80
+ import {
81
+ applyGranularCommand,
82
+ granularTrackPreset,
83
+ grainSrcHint,
84
+ parseGranularCommand,
85
+ } from "./commands/granular.ts";
86
+ import {
87
+ applyKeysCommand,
88
+ newPianoTrack,
89
+ parseKeysCommand,
90
+ } from "./commands/keys.ts";
91
+ import {
92
+ applyTuningCommand,
93
+ importTuningFile,
94
+ parseTuningCommand,
95
+ type TuningCommand,
96
+ } from "./commands/tuning.ts";
97
+ import { TuningError, displayTag, resolveTuning } from "../core/tuning.ts";
98
+ import {
99
+ applyMasterCommand,
100
+ measurementLine,
101
+ parseMasterCommand,
102
+ } from "./commands/master.ts";
103
+ import { exportSampleRate, measureScoreOffThread } from "./audio/measure.ts";
104
+ import {
105
+ applySectionCommand,
106
+ parseSectionCommand,
107
+ } from "./commands/arrange.ts";
47
108
  import {
48
109
  HELP_TOPICS,
49
110
  helpText,
@@ -65,9 +126,12 @@ import {
65
126
  samplerTarget,
66
127
  voiceNameFrom,
67
128
  } from "./commands/sample.ts";
129
+ import { applyFitCommand, fitVoice, parseFitCommand } from "./commands/fit.ts";
130
+ import { suggestFitMode } from "./audio/dsp/onset.ts";
68
131
  import {
69
132
  SampleLibrary,
70
133
  hasSamplerTracks,
134
+ sampleKey,
71
135
  type SampleBank,
72
136
  type SampleProblem,
73
137
  } from "./audio/samples.ts";
@@ -106,7 +170,7 @@ import {
106
170
  tuiLoginArgs,
107
171
  tuiSetModel,
108
172
  } from "./auth/tui.ts";
109
- import { TransportClock } from "./audio/clock.ts";
173
+ import { TransportClock, transportMapFor } from "./audio/clock.ts";
110
174
  import { AudioEngine } from "./audio/engine.ts";
111
175
  import {
112
176
  DEFAULT_GRID,
@@ -116,6 +180,23 @@ import {
116
180
  type LiveEngine,
117
181
  } from "./tui/play-session.ts";
118
182
  import { EditMenu, MENU_SECTIONS, type MenuContext } from "./tui/menu.ts";
183
+ import {
184
+ drawerView,
185
+ faderChoose,
186
+ faderKeyPress,
187
+ faderSetPosition,
188
+ faderStep,
189
+ focusIndex,
190
+ type FaderResult,
191
+ type FaderState,
192
+ } from "./tui/fader.ts";
193
+ import {
194
+ isMouseSequence,
195
+ MOUSE_OFF,
196
+ MOUSE_ON,
197
+ parseMouse,
198
+ type MouseEvent,
199
+ } from "../tui/keys.ts";
119
200
  import {
120
201
  Audition,
121
202
  SUPERSEDED,
@@ -125,6 +206,9 @@ import {
125
206
  import { EuclidEditor, type EuclidContext } from "./tui/euclid.ts";
126
207
  import { HINTS, KEYS, keyLines, type KeySection } from "../tui/grammar.ts";
127
208
  import { renderAudition } from "./audio/audition.ts";
209
+ import { exportScore, playbackTime, scoreBeatAt } from "./audio/arrange.ts";
210
+ import { formatForm } from "../core/sections.ts";
211
+ import type { ArrangeStripView } from "../tui/arrange-strip.ts";
128
212
  import { renderScorePcm } from "./audio/wav.ts";
129
213
  import type { PreviewHost } from "./agent/preview-tool.ts";
130
214
  import { rhythmVoicePitch } from "../core/rhythm.ts";
@@ -167,6 +251,7 @@ import {
167
251
  addNote,
168
252
  applyScoreOperation,
169
253
  createScore,
254
+ isSamplerInstrument,
170
255
  PACK_PREFIX,
171
256
  scoreFromJSON,
172
257
  SCORE_LIMITS,
@@ -176,6 +261,7 @@ import {
176
261
  } from "../core/score.ts";
177
262
  import { reconcileRhythm } from "../core/rhythm.ts";
178
263
  import { decodeLoop, encodeLoop } from "../core/loop.ts";
264
+ import { scoreToMidi } from "../core/midi.ts";
179
265
  import type { TrackScoreSnapshot } from "../tui/render.ts";
180
266
  import { PromptModel } from "../tui/prompt.ts";
181
267
  import { TerminalInputDecoder } from "../tui/input.ts";
@@ -183,6 +269,7 @@ import {
183
269
  composeFrame,
184
270
  TuiApp,
185
271
  type AppView,
272
+ type LoudnessView,
186
273
  type SyncState,
187
274
  type TypesIndicator,
188
275
  } from "../tui/app.ts";
@@ -209,11 +296,22 @@ function parsesLocally(text: string): boolean {
209
296
  parseRhythmCommand,
210
297
  parseFxCommand,
211
298
  parseSynthCommand,
299
+ parseStringCommand,
300
+ parseGranularCommand,
301
+ parseKeysCommand,
302
+ parseExpressionCommand,
303
+ parseMasterCommand,
304
+ (value: string) => parseSectionCommand(value, score),
212
305
  parsePatternCommand,
213
306
  parseKitCommand,
214
307
  parsePackCommand,
215
308
  parseSampleCommand,
309
+ parseFitCommand,
216
310
  parseWavetableCommand,
311
+ parseTimeCommand,
312
+ parseTuningCommand,
313
+ parseRigCommand,
314
+ parseModalCommand,
217
315
  ].some((parse) => parse(text) !== undefined);
218
316
  }
219
317
 
@@ -240,6 +338,7 @@ const FLAGS = [
240
338
  "--version",
241
339
  "-v",
242
340
  "--reduce-motion",
341
+ "--no-mouse",
243
342
  ];
244
343
  const HELP_TEXT = `dawg · local-first terminal music workstation
245
344
 
@@ -247,7 +346,8 @@ Usage:
247
346
  dawg [--new] [--session <name|id>] [--track <name>]
248
347
  dawg --import <file> --export <file>
249
348
  dawg sessions
250
- dawg render <out.wav> [--session <name|id>] [--import <file>]
349
+ dawg render <out.wav|out.mid> [--session <name|id>] [--import <file>]
350
+ [--normalize <lufs|streaming|club|loud|…>] [--measure]
251
351
  dawg init [dir] project files: song.ts, tracks/<slug>/track.ts, .dawg/sdk
252
352
  dawg check typecheck + evaluate the project; exit 1 on problems
253
353
  dawg media doctor|download|stems|analyze|notes|sample|lyrics … (dawg media --help)
@@ -256,6 +356,7 @@ Usage:
256
356
  Usage flags:
257
357
  --reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
258
358
  --theme <name> default | high-contrast | mono (NO_COLOR forces mono)
359
+ --no-mouse keys only; no click/wheel reporting (also DAWG_MOUSE=0)
259
360
 
260
361
  Prompt:
261
362
  Enter submit · Shift-Enter newline · Alt-Enter queue · Ctrl-Q toggle queue
@@ -357,7 +458,13 @@ if (!demo && process.env.DAWG_AI !== "0" && stdout.isTTY)
357
458
 
358
459
  const initial = createScore({
359
460
  tracks: [
360
- { id: requestedTrack, name: requestedTrack, instrument: initialInstrument },
461
+ {
462
+ id: requestedTrack,
463
+ name: requestedTrack,
464
+ instrument: initialInstrument,
465
+ // `dawg jangle`: a guitar alias starts with its voice and rig.
466
+ ...rigTrackFields(requestedTrack),
467
+ },
361
468
  ],
362
469
  });
363
470
  const sessionOptions: { sessionId?: string; setCurrent: boolean } = {
@@ -486,6 +593,18 @@ let chordScreenWas = false;
486
593
  let stageCapture: { next?: TrackScore; committed?: TrackScore } | undefined;
487
594
  /** Redraw soon (the audition reports renders between frames). */
488
595
  let requestFrame: () => void = () => undefined;
596
+ /** Queue a prompt to run as if typed (set by the interactive loop). */
597
+ let runPromptLater: (command: string) => void = () => undefined;
598
+ /** The fader drawer's focus and typing, while one is open over the menu. */
599
+ let fader: FaderState | undefined;
600
+ const KEY_UP = "\u001b[A";
601
+ const KEY_DOWN = "\u001b[B";
602
+ /** The fader bar a left-button drag started on. */
603
+ let dragging: { field: number; left: number; width: number } | undefined;
604
+ /** The open drawer came from a command, not the menu. */
605
+ let faderStandalone = false;
606
+ /** Per-field sequence: a drag drops stale stages that would land late. */
607
+ const faderSeq = new Map<string, number>();
489
608
  /** The decoded sampler voices, for play mode's live voices. */
490
609
  let liveSampleBank: SampleBank | undefined;
491
610
  /** The in-flight agent turn: Esc aborts it, Enter steers it. */
@@ -645,16 +764,29 @@ function snapshot(
645
764
  beat: number,
646
765
  activity?: string,
647
766
  ): TrackScoreSnapshot {
767
+ const focused = value.tracks.find((track) => track.id === requestedTrack);
768
+ const table =
769
+ focused && !isDrumInstrument(focused.instrument)
770
+ ? resolveTuning(value.tuning, focused.tuning, value.key)
771
+ : undefined;
648
772
  const notes = value.notes
649
773
  .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
- }));
774
+ .map((note) => {
775
+ const tag =
776
+ focused && !isDrumInstrument(focused.instrument)
777
+ ? displayTag(table, note.pitch, note.cents)
778
+ : undefined;
779
+ return {
780
+ id: note.id,
781
+ startBeat: note.startTick / value.ticksPerBeat,
782
+ durationBeats: note.durationTicks / value.ticksPerBeat,
783
+ pitch: note.pitch,
784
+ velocity: note.velocity,
785
+ muted: focused?.muted,
786
+ ...(tag?.cents !== undefined ? { cents: tag.cents } : {}),
787
+ ...(tag?.name !== undefined ? { centsFrom: tag.name } : {}),
788
+ };
789
+ });
658
790
  return {
659
791
  notes,
660
792
  trackName:
@@ -663,12 +795,22 @@ function snapshot(
663
795
  trackId: requestedTrack,
664
796
  sessionId: record.sessionId,
665
797
  revision: record.revision,
666
- bpm: value.tempoBpm,
798
+ bpm: value.time?.tempo
799
+ ? bpmAtTick(value, beat * value.ticksPerBeat)
800
+ : value.tempoBpm,
667
801
  key: value.key ?? undefined,
668
- loopBeats: value.bars * value.beatsPerBar,
802
+ loopBeats: loopTicksOf(value) / value.ticksPerBeat,
669
803
  beatsPerBar: value.beatsPerBar,
804
+ ...(hasMeterChanges(value)
805
+ ? {
806
+ barBeats: Array.from(
807
+ { length: value.bars },
808
+ (_, bar) => barStartTick(value, bar) / value.ticksPerBeat,
809
+ ),
810
+ }
811
+ : {}),
670
812
  laneCount: 24,
671
- currentBeat: beat,
813
+ currentBeat: scoreBeatAt(value, beat),
672
814
  playing: clock.playing,
673
815
  activity,
674
816
  ...drumSnapshotFields(
@@ -679,6 +821,9 @@ function snapshot(
679
821
  value.tracks.find((track) => track.id === requestedTrack),
680
822
  notes,
681
823
  ),
824
+ ...(table && table.linear && table.size !== 12
825
+ ? { tuningPeriod: { size: table.size, root: table.root } }
826
+ : {}),
682
827
  layers:
683
828
  tui.highwayView === "all"
684
829
  ? highwayLayers(
@@ -712,7 +857,9 @@ function renderOnce(
712
857
  function appView(value: TrackScore, beat: number): AppView {
713
858
  return {
714
859
  score: snapshot(value, beat),
715
- beat,
860
+ // The highway shows score time: inside the looped section or form pass.
861
+ beat: scoreBeatAt(value, beat),
862
+ arrange: arrangeStripView(value, beat),
716
863
  // `opus-5.5 · gateway`, `sonnet · claude`; hidden when offline.
717
864
  model:
718
865
  providerName && providerName !== "offline" ? providerName : undefined,
@@ -723,6 +870,79 @@ function appView(value: TrackScore, beat: number): AppView {
723
870
  windows: windowCount,
724
871
  types: typesIndicator,
725
872
  play: play?.on ? play.header() : undefined,
873
+ loudness: masterLoudness(value),
874
+ };
875
+ }
876
+
877
+ /**
878
+ * The 48 kHz reading of the mastered song (what `master measure` and the
879
+ * export read), for the score it measured; one measurement at a time, in a
880
+ * worker, started when the loop plays a mastered score it has not read.
881
+ */
882
+ let exportMeter:
883
+ { score: TrackScore; view?: LoudnessView; running: boolean } | undefined;
884
+
885
+ /**
886
+ * The header meter: loudness of the loop the local engine is playing. With
887
+ * a master the engine monitors at its own rate, so the meter shows the
888
+ * export-rate reading once it is in and the monitor's (marked as an
889
+ * estimate) until then.
890
+ */
891
+ function masterLoudness(value: TrackScore): LoudnessView | undefined {
892
+ const engine = audio instanceof AudioEngine ? audio : monitorEngine;
893
+ const report = engine?.loudness;
894
+ if (!report) return undefined;
895
+ const ceiling =
896
+ typeof value.master?.limiter?.ceiling === "number"
897
+ ? value.master.limiter.ceiling
898
+ : value.master?.limiter
899
+ ? -1
900
+ : undefined;
901
+ const target =
902
+ typeof value.master?.target === "number" ? value.master.target : undefined;
903
+ const monitor: LoudnessView = {
904
+ integrated: report.integrated,
905
+ truePeak: report.truePeak,
906
+ target,
907
+ ceiling,
908
+ };
909
+ if (!value.master || exportSampleRate(value) === engine.sampleRate)
910
+ return monitor;
911
+ if (exportMeter?.score === value && exportMeter.view) return exportMeter.view;
912
+ if (!exportMeter?.running) {
913
+ const measuring = { score: value, running: true } as NonNullable<
914
+ typeof exportMeter
915
+ >;
916
+ exportMeter = measuring;
917
+ void measureScoreOffThread(value, { projectRoot: process.cwd() })
918
+ .then((measured) => {
919
+ measuring.view = {
920
+ integrated: measured.mix.loudness.integrated,
921
+ truePeak: measured.mix.loudness.truePeak,
922
+ target,
923
+ ceiling,
924
+ };
925
+ })
926
+ .catch(() => undefined)
927
+ .finally(() => {
928
+ measuring.running = false;
929
+ });
930
+ }
931
+ return { ...monitor, estimate: true };
932
+ }
933
+
934
+ /** The arrangement strip over the timeline; absent without sections. */
935
+ function arrangeStripView(
936
+ value: TrackScore,
937
+ beat: number,
938
+ ): ArrangeStripView | undefined {
939
+ if (value.sections.length === 0) return undefined;
940
+ return {
941
+ bars: value.bars,
942
+ sections: value.sections,
943
+ loop: value.loopSection,
944
+ playheadBar: scoreBeatAt(value, beat) / value.beatsPerBar,
945
+ form: value.form.length ? formatForm(value.form) : undefined,
726
946
  };
727
947
  }
728
948
 
@@ -779,11 +999,46 @@ function truncateForCard(value: string): string {
779
999
  return line.length > 32 ? `${line.slice(0, 31)}…` : line;
780
1000
  }
781
1001
 
1002
+ /**
1003
+ * Mouse reporting is on unless `--no-mouse` or `DAWG_MOUSE=0` (or a dumb
1004
+ * terminal) says otherwise. A terminal without mouse support ignores the
1005
+ * modes and every key still works.
1006
+ */
1007
+ function mouseEnabled(): boolean {
1008
+ return (
1009
+ !process.argv.includes("--no-mouse") &&
1010
+ process.env.DAWG_MOUSE !== "0" &&
1011
+ process.env.TERM !== "dumb"
1012
+ );
1013
+ }
1014
+
1015
+ function mouseOn(): string {
1016
+ return mouseEnabled() ? MOUSE_ON : "";
1017
+ }
1018
+
782
1019
  async function runInteractive(): Promise<void> {
1020
+ // A crash or a stray process.exit must not leave the terminal reporting
1021
+ // mouse events into the shell.
1022
+ process.once("exit", () => {
1023
+ if (mouseEnabled()) writeSync(1, MOUSE_OFF);
1024
+ });
1025
+ // Nor may a kill or a closed terminal: restore it, then exit as killed.
1026
+ for (const [signal, code] of [
1027
+ ["SIGTERM", 143],
1028
+ ["SIGHUP", 129],
1029
+ ] as const)
1030
+ process.once(signal, () => {
1031
+ try {
1032
+ writeSync(1, `${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1033
+ } catch {
1034
+ // The terminal may already be gone (SIGHUP).
1035
+ }
1036
+ process.exit(code);
1037
+ });
783
1038
  stdin.setRawMode?.(true);
784
1039
  stdin.resume();
785
1040
  // Alternate screen, hidden cursor, bracketed paste.
786
- stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
1041
+ stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J${mouseOn()}`);
787
1042
  const inputDecoder = new TerminalInputDecoder();
788
1043
  const queuedPrompts: string[] = [];
789
1044
  let processingQueue = false;
@@ -845,6 +1100,10 @@ async function runInteractive(): Promise<void> {
845
1100
  tui.render(appView(score, clock.beatAt()), { force });
846
1101
  };
847
1102
  requestFrame = () => tick(true);
1103
+ runPromptLater = (command) => {
1104
+ queuedPrompts.unshift(command);
1105
+ void drainQueue();
1106
+ };
848
1107
  reportAgentActivity = () => {
849
1108
  tui.activity.applyAgentEvent({ type: "start", model: providerName });
850
1109
  };
@@ -871,7 +1130,7 @@ async function runInteractive(): Promise<void> {
871
1130
  if (stageCapture)
872
1131
  stageCapture.committed = scoreFromJSON(record.composition);
873
1132
  else score = scoreFromJSON(record.composition);
874
- clock.setTempo(score.tempoBpm);
1133
+ clock.follow(score);
875
1134
  if (clock.playing) void audio.play(score);
876
1135
  // Connected windows follow dawgd's transport frames instead.
877
1136
  const replay =
@@ -924,6 +1183,7 @@ async function runInteractive(): Promise<void> {
924
1183
  // Every window renders the same hit line from dawgd's timestamp.
925
1184
  const { playing, beat, bpm, atMs } = update.transport;
926
1185
  clock.setTempo(bpm);
1186
+ clock.setTimeMap(transportMapFor(playbackTime(score)));
927
1187
  clock.sync(beat, playing, atMs, monotonicEpochMs());
928
1188
  } else if (update.type === "status") {
929
1189
  syncState = port.sync;
@@ -1000,11 +1260,11 @@ async function runInteractive(): Promise<void> {
1000
1260
  stdin.pause();
1001
1261
  stdin.setRawMode?.(false);
1002
1262
  // Leave the alternate screen; restore the cursor and plain paste.
1003
- stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1263
+ stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1004
1264
  try {
1005
1265
  return await flow();
1006
1266
  } finally {
1007
- stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
1267
+ stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J${mouseOn()}`);
1008
1268
  stdin.setRawMode?.(true);
1009
1269
  stdin.on("data", onData);
1010
1270
  stdin.resume();
@@ -1023,7 +1283,31 @@ async function runInteractive(): Promise<void> {
1023
1283
  ...inputDecoder.push(text),
1024
1284
  ...(text === "\u001b" ? inputDecoder.flush() : []),
1025
1285
  ];
1026
- for (const value of values) {
1286
+ while (values.length) {
1287
+ const value = values.shift()!;
1288
+ // A mouse report acts on what the last frame painted under it; some
1289
+ // become keys (a list row, a wheel notch) and run through below.
1290
+ if (typeof value === "string" && isMouseSequence(value)) {
1291
+ const event = parseMouse(value);
1292
+ if (event) values.unshift(...mouseInput(event));
1293
+ tick(true);
1294
+ continue;
1295
+ }
1296
+ // The fader drawer owns every key while it is up.
1297
+ if (typeof value === "string" && fader && menu.open) {
1298
+ const result = faderKeyPress(
1299
+ fader,
1300
+ menu.faderFields(menuContext()),
1301
+ value,
1302
+ faderKeyOptions(),
1303
+ );
1304
+ if (result.type !== "pass") {
1305
+ faderOutcome(result);
1306
+ refreshMenu();
1307
+ tick(true);
1308
+ continue;
1309
+ }
1310
+ }
1027
1311
  // The `?` panel closes on any key (Ctrl-C still quits).
1028
1312
  if (tui.ui.keys && typeof value === "string" && value !== "\u0003") {
1029
1313
  tui.closeKeys();
@@ -1086,6 +1370,7 @@ async function runInteractive(): Promise<void> {
1086
1370
  }
1087
1371
  } else if (result.type === "audition")
1088
1372
  auditionKeyPressed(result.key);
1373
+ else if (result.type === "fader") openFader(result.label);
1089
1374
  else if (result.type === "revert") revertStaged();
1090
1375
  else if (result.type === "hover")
1091
1376
  hoverItem(result.command, result.key);
@@ -1109,6 +1394,16 @@ async function runInteractive(): Promise<void> {
1109
1394
  }
1110
1395
  }
1111
1396
  }
1397
+ // F1 opens the guides from anywhere the prompt has focus.
1398
+ if (
1399
+ (value === "\u001bOP" || value === "\u001b[11~") &&
1400
+ (tui.ui.overlay === undefined || tui.ui.overlay === "guide")
1401
+ ) {
1402
+ if (tui.ui.overlay === "guide") tui.closeGuide();
1403
+ else tui.openGuide();
1404
+ tick(true);
1405
+ continue;
1406
+ }
1112
1407
  // Ctrl-K opens the menu on an empty prompt (in play mode too); with
1113
1408
  // text it keeps its kill-to-end-of-line meaning.
1114
1409
  if (
@@ -1140,7 +1435,8 @@ async function runInteractive(): Promise<void> {
1140
1435
  if (
1141
1436
  value === " " &&
1142
1437
  prompt.value.length === 0 &&
1143
- !(tui.ui.overlay === "picker" && tui.ui.picker?.audition)
1438
+ !(tui.ui.overlay === "picker" && tui.ui.picker?.audition) &&
1439
+ tui.ui.overlay !== "guide"
1144
1440
  ) {
1145
1441
  // Never await a daemon round trip here: the key loop must stay
1146
1442
  // live for Esc, quit and redraws while the toggle is in flight.
@@ -1271,7 +1567,7 @@ async function runInteractive(): Promise<void> {
1271
1567
  await port.close();
1272
1568
  stdin.setRawMode?.(false);
1273
1569
  stdin.pause();
1274
- stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1570
+ stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
1275
1571
  }
1276
1572
  }
1277
1573
 
@@ -1306,7 +1602,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1306
1602
  }
1307
1603
  if (/^\/?tracks$/i.test(command)) {
1308
1604
  const problems = await sampleProblems(score);
1309
- const lines = score.tracks.map((track) => {
1605
+ const items = score.tracks.map((track) => {
1310
1606
  const voices = track.sampler
1311
1607
  ? Object.keys(track.sampler.voices).length
1312
1608
  : 0;
@@ -1316,16 +1612,39 @@ async function submit(prompt: string): Promise<string | Receipt> {
1316
1612
  const samples = track.sampler
1317
1613
  ? ` · ${voices} sample${voices === 1 ? "" : "s"}${missing ? ` · ${missing} missing` : ""}`
1318
1614
  : "";
1319
- return `${track.id === requestedTrack ? "*" : " "} ${track.id} · ${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}`;
1615
+ return {
1616
+ label: track.id,
1617
+ value: `/track ${track.id}`,
1618
+ detail: `${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}`,
1619
+ current: track.id === requestedTrack,
1620
+ };
1320
1621
  });
1321
- tui.openText("tracks · * focused", lines);
1322
- return ok(`${lines.length} track${lines.length === 1 ? "" : "s"}`);
1622
+ // A picker: Enter (or a click) focuses the track; Esc closes.
1623
+ tui.openPicker({
1624
+ id: "tracks",
1625
+ title: "tracks · ● focused",
1626
+ items,
1627
+ filterable: items.length > 8,
1628
+ index: Math.max(
1629
+ 0,
1630
+ items.findIndex((item) => item.current),
1631
+ ),
1632
+ });
1633
+ return ok(`${items.length} track${items.length === 1 ? "" : "s"}`);
1323
1634
  }
1324
- const playCommand = command.match(/^\/play(?:\s+(on|off))?$/i);
1635
+ const playCommand = command.match(
1636
+ /^\/play(?:\s+(on|off|degrees|in-key|chromatic))?$/i,
1637
+ );
1325
1638
  if (playCommand) {
1326
1639
  const wanted = playCommand[1]?.toLowerCase();
1327
1640
  if (wanted === "off" || (wanted === undefined && play?.on))
1328
1641
  return exitPlay();
1642
+ if (wanted && wanted !== "on") {
1643
+ const message = playSession().toggleDegrees(wanted !== "chromatic");
1644
+ if (message.startsWith("scale degrees need")) return fail(message);
1645
+ const entered = await enterPlay();
1646
+ return entered.ok ? ok(message) : entered;
1647
+ }
1329
1648
  return enterPlay();
1330
1649
  }
1331
1650
  const clickCommand = command.match(/^\/click(?:\s+(.+))?$/i);
@@ -1354,6 +1673,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
1354
1673
  }
1355
1674
  const tryCommand = command.match(/^\/try(?:\s+(.+))?$/i);
1356
1675
  if (tryCommand) return tryPrompt(tryCommand[1]?.trim() ?? "");
1676
+ // A bare parameter (`volume`, `pan`, `fx filter`, `fx reverb mix`)
1677
+ // opens the fader drawer on it, with its related params stacked below.
1678
+ if (/^\/?(?:volume|pan|fx\s+\S+(?:\s+\S+)?)$/i.test(command)) {
1679
+ const label = menu.showFader(menuContext(), command);
1680
+ if (label) {
1681
+ openFader(label, true);
1682
+ refreshMenu();
1683
+ return ok(`${label} · ←→ adjust · enter keep · esc revert`);
1684
+ }
1685
+ }
1357
1686
  const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
1358
1687
  if (menuCommand) {
1359
1688
  const section = menuCommand[1]?.toLowerCase();
@@ -1379,8 +1708,29 @@ async function submit(prompt: string): Promise<string | Receipt> {
1379
1708
  /^\/?(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/i,
1380
1709
  );
1381
1710
  if (trackCommand) return focusTrack(trackCommand[1]!.toLowerCase());
1711
+ // `track cloud grain hold`: focus (or create) a track and grain it.
1712
+ const grainTrack = command.match(
1713
+ /^\/?track\s+([a-z0-9._-]{1,64})\s+(grain\s+.+)$/i,
1714
+ );
1715
+ if (grainTrack && parseGranularCommand(grainTrack[2]!)) {
1716
+ const focused = await focusTrack(grainTrack[1]!.toLowerCase());
1717
+ if (requestedTrack !== grainTrack[1]!.toLowerCase()) return focused;
1718
+ return submit(grainTrack[2]!);
1719
+ }
1720
+ // `/track piano b`: a name with spaces focuses the track of that name, or
1721
+ // creates `piano-b` named "piano b".
1722
+ const namedTrack = command.match(/^\/track\s+([a-z0-9._ -]{1,64})$/i);
1723
+ if (namedTrack) {
1724
+ const name = namedTrack[1]!.trim().replace(/\s+/g, " ");
1725
+ const found = score.tracks.find(
1726
+ (track) => (track.name ?? track.id).toLowerCase() === name.toLowerCase(),
1727
+ );
1728
+ return focusTrack(found?.id ?? name.toLowerCase().replace(/ /g, "-"));
1729
+ }
1382
1730
  const sample = parseSampleCommand(command);
1383
1731
  if (sample) return sampleCommand(sample);
1732
+ const fit = parseFitCommand(command);
1733
+ if (fit) return fitCommand(fit);
1384
1734
  const pack = parsePackCommand(command);
1385
1735
  if (pack) return packCommand(pack);
1386
1736
  const pattern = parsePatternCommand(command);
@@ -1391,6 +1741,14 @@ async function submit(prompt: string): Promise<string | Receipt> {
1391
1741
  if (wavetable) return wavetableCommand(wavetable);
1392
1742
  const sessionReply = await sessionCommand(command);
1393
1743
  if (sessionReply !== undefined) return sessionReply;
1744
+ const time = parseTimeCommand(command);
1745
+ if (time) {
1746
+ if (time.type !== "tempo-map") await materializeDraft();
1747
+ const result = applyTimeCommand(score, requestedTrack, time);
1748
+ if (result.next && result.kind)
1749
+ await commitScore(result.next, result.kind, result.payload);
1750
+ return result.ok ? ok(result.message) : fail(result.message);
1751
+ }
1394
1752
  const edit = parseEditCommand(command);
1395
1753
  if (edit) {
1396
1754
  await materializeDraft();
@@ -1407,6 +1765,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
1407
1765
  await commitScore(result.next, result.kind, result.payload);
1408
1766
  return result.ok ? ok(result.message) : fail(result.message);
1409
1767
  }
1768
+ const rig = parseRigCommand(command);
1769
+ if (rig) {
1770
+ if (rig.type !== "rig-show" && rig.type !== "rig-hint")
1771
+ await materializeDraft();
1772
+ const result = applyRigCommand(score, requestedTrack, rig);
1773
+ if (result.next && result.kind)
1774
+ await commitScore(result.next, result.kind, result.payload);
1775
+ return result.ok ? ok(result.message) : fail(result.message);
1776
+ }
1410
1777
  const fx = parseFxCommand(command);
1411
1778
  if (fx) {
1412
1779
  if (fx.type !== "fx-list") await materializeDraft();
@@ -1426,6 +1793,40 @@ async function submit(prompt: string): Promise<string | Receipt> {
1426
1793
  await commitScore(result.next, result.kind, result.payload);
1427
1794
  return result.ok ? ok(result.message) : fail(result.message);
1428
1795
  }
1796
+ const unknownFx = unknownFxMessage(command);
1797
+ if (unknownFx) return fail(unknownFx);
1798
+ const expression = parseExpressionCommand(command);
1799
+ if (expression) {
1800
+ if (expression.type !== "show" && expression.type !== "invalid")
1801
+ await materializeDraft();
1802
+ const result = applyExpressionCommand(score, requestedTrack, expression);
1803
+ if (result.next && result.kind)
1804
+ await commitScore(result.next, result.kind, result.payload);
1805
+ return result.ok ? ok(result.message) : fail(result.message);
1806
+ }
1807
+ const tuning = parseTuningCommand(command);
1808
+ if (tuning) return tuningCommand(tuning);
1809
+ const keysCommand = parseKeysCommand(command);
1810
+ if (keysCommand) {
1811
+ const reads =
1812
+ keysCommand.type === "keys-list" || keysCommand.type === "keys-presets";
1813
+ if (!reads) await materializeDraft();
1814
+ const result = applyKeysCommand(score, requestedTrack, keysCommand);
1815
+ if (result.next && result.kind)
1816
+ await commitScore(result.next, result.kind, result.payload);
1817
+ return result.ok ? ok(result.message) : fail(result.message);
1818
+ }
1819
+ const modal = parseModalCommand(command);
1820
+ if (modal) {
1821
+ if (modal.type === "modal-list")
1822
+ tui.openText("modal presets", modalListLines());
1823
+ if (modal.type !== "modal-show" && modal.type !== "modal-list")
1824
+ await materializeDraft();
1825
+ const result = applyModalCommand(score, requestedTrack, modal);
1826
+ if (result.next && result.kind)
1827
+ await commitScore(result.next, result.kind, result.payload);
1828
+ return result.ok ? ok(result.message) : fail(result.message);
1829
+ }
1429
1830
  const synth = parseSynthCommand(command);
1430
1831
  if (synth) {
1431
1832
  if (synth.type !== "synth-list") await materializeDraft();
@@ -1434,6 +1835,57 @@ async function submit(prompt: string): Promise<string | Receipt> {
1434
1835
  await commitScore(result.next, result.kind, result.payload);
1435
1836
  return result.ok ? ok(result.message) : fail(result.message);
1436
1837
  }
1838
+ const stringCommand = parseStringCommand(command);
1839
+ if (stringCommand) {
1840
+ if (
1841
+ stringCommand.type !== "string-list" &&
1842
+ stringCommand.type !== "string-presets"
1843
+ )
1844
+ await materializeDraft();
1845
+ const result = applyStringCommand(score, requestedTrack, stringCommand);
1846
+ if (result.next && result.kind)
1847
+ await commitScore(result.next, result.kind, result.payload);
1848
+ return result.ok ? ok(result.message) : fail(result.message);
1849
+ }
1850
+ const grainHint = grainSrcHint(command);
1851
+ if (grainHint) return fail(grainHint);
1852
+ const grain = parseGranularCommand(command);
1853
+ if (grain) {
1854
+ if (grain.type !== "grain-list" && grain.type !== "grain-presets")
1855
+ await materializeDraft();
1856
+ const result = applyGranularCommand(score, requestedTrack, grain);
1857
+ if (result.next && result.kind)
1858
+ await commitScore(result.next, result.kind, result.payload);
1859
+ return result.ok ? ok(result.message) : fail(result.message);
1860
+ }
1861
+ const masterCommand = parseMasterCommand(command);
1862
+ if (masterCommand) {
1863
+ if (masterCommand.type === "master-measure")
1864
+ return ok(await measureLine(score));
1865
+ if (
1866
+ masterCommand.type !== "master-list" &&
1867
+ masterCommand.type !== "master-show"
1868
+ )
1869
+ await materializeDraft();
1870
+ const result = applyMasterCommand(score, masterCommand);
1871
+ if (result.next && result.kind)
1872
+ await commitScore(result.next, result.kind, result.payload);
1873
+ return result.ok ? ok(result.message) : fail(result.message);
1874
+ }
1875
+ const arrange = parseSectionCommand(command, score);
1876
+ if (arrange) {
1877
+ const reads =
1878
+ arrange.type === "section-list" ||
1879
+ arrange.type === "section-unknown" ||
1880
+ arrange.type === "form-show" ||
1881
+ arrange.type === "section-jump";
1882
+ if (!reads) await materializeDraft();
1883
+ const result = applySectionCommand(score, requestedTrack, arrange);
1884
+ if (result.next && result.kind)
1885
+ await commitScore(result.next, result.kind, result.payload);
1886
+ if (result.seekBeat !== undefined) await seekTransport(result.seekBeat);
1887
+ return result.ok ? ok(result.message) : fail(result.message);
1888
+ }
1437
1889
  const music = parseMusicCommand(command);
1438
1890
  if (music) {
1439
1891
  await materializeDraft();
@@ -1447,6 +1899,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
1447
1899
  const exportCommand = command.match(/^\/?export\s+([^\s]+)$/i);
1448
1900
  if (exportCommand) {
1449
1901
  const path = resolve(exportCommand[1]!);
1902
+ if (/\.midi?$/i.test(path)) {
1903
+ await writeFile(path, scoreToMidi(exportScore(score)));
1904
+ return `exported midi · ${exportCommand[1]}`;
1905
+ }
1906
+ // Audio comes from the offline renderer, never JSON under an audio name.
1907
+ if (/\.(wav|aiff?|flac|mp3|ogg|m4a)$/i.test(path))
1908
+ return fail(
1909
+ `/export writes .track.json or .mid · render audio with: dawg render ${exportCommand[1]}`,
1910
+ );
1450
1911
  await writeFile(path, encodeLoop(score), "utf8");
1451
1912
  return `exported · ${exportCommand[1]}`;
1452
1913
  }
@@ -1563,8 +2024,17 @@ async function submit(prompt: string): Promise<string | Receipt> {
1563
2024
  if (parsed.type === "set-tempo") {
1564
2025
  const next = score.withTempo(parsed.tempoBpm);
1565
2026
  await commitScore(next, "score.tempo", { tempoBpm: parsed.tempoBpm });
1566
- clock.setTempo?.(next.tempoBpm);
1567
- return `tempo · ${next.tempoBpm} BPM`;
2027
+ clock.follow(next);
2028
+ // A bare `bpm <n>` stays song tempo; on a sampler track say where the
2029
+ // sample's own tempo lives (`/bpm`, 0.6 fit).
2030
+ const focused = next.tracks.find((t) => t.id === requestedTrack);
2031
+ const samplerHint =
2032
+ /^\s*bpm\b/i.test(prompt) &&
2033
+ focused?.sampler &&
2034
+ isSamplerInstrument(focused.instrument)
2035
+ ? ` · song tempo · the sample's own tempo is /bpm ${parsed.tempoBpm}`
2036
+ : "";
2037
+ return `tempo · ${next.tempoBpm} BPM${samplerHint}`;
1568
2038
  }
1569
2039
  if (parsed.type === "add-track") return focusTrack(parsed.trackId);
1570
2040
  if (parsed.type === "set-bars" || parsed.type === "extend-bars") {
@@ -1577,15 +2047,42 @@ async function submit(prompt: string): Promise<string | Receipt> {
1577
2047
  return `bars · ${bars}`;
1578
2048
  }
1579
2049
  if (parsed.type === "track") {
2050
+ // An unknown word would store and play a plain sine: say so instead.
2051
+ const word = parsed.patch.instrument;
2052
+ if (word !== undefined && isUnknownInstrument(word)) {
2053
+ const advice = plainSineAdvice(word) ?? "";
2054
+ const near = advice.match(/did you mean (\S+)\?/)?.[1];
2055
+ return fail(
2056
+ `instrument ${truncateForCard(word)} · not a dawg instrument${near ? ` · did you mean ${near}?` : ""} · /menu sounds lists them`,
2057
+ );
2058
+ }
2059
+ // `instrument jangle`: a guitar alias also loads its rig.
2060
+ const patch = parsed.word
2061
+ ? {
2062
+ ...parsed.patch,
2063
+ ...rigWordPatch(
2064
+ parsed.word,
2065
+ score.tracks.find((t) => t.id === requestedTrack)?.fx,
2066
+ ),
2067
+ }
2068
+ : parsed.patch;
1580
2069
  const next = applyScoreOperation(score, {
1581
2070
  type: "updateTrack",
1582
2071
  trackId: requestedTrack,
1583
- patch: parsed.patch,
2072
+ patch,
1584
2073
  });
1585
2074
  await commitScore(next, "score.track", {
1586
2075
  trackId: requestedTrack,
1587
- patch: parsed.patch,
2076
+ patch,
1588
2077
  });
2078
+ // Plain `marimba` keeps the legacy tone; point at the mallet engine.
2079
+ if (parsed.patch.instrument === "marimba" && !("modal" in parsed.patch))
2080
+ return `track · ${requestedTrack} · marimba (legacy tone) · modal marimba for the mallet engine`;
2081
+ const sine =
2082
+ word !== undefined && !("string" in parsed.patch)
2083
+ ? plainSineAdvice(word)
2084
+ : undefined;
2085
+ if (sine) return `track · ${requestedTrack} · ${sine}`;
1589
2086
  return `track · ${requestedTrack}`;
1590
2087
  }
1591
2088
  if (parsed.type === "automation") {
@@ -1658,6 +2155,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
1658
2155
  ...(typeof parsed.patch.velocity === "number"
1659
2156
  ? { velocity: parsed.patch.velocity }
1660
2157
  : {}),
2158
+ ...(typeof parsed.patch.cents === "number"
2159
+ ? { cents: parsed.patch.cents }
2160
+ : {}),
1661
2161
  };
1662
2162
  const next = applyScoreOperation(score, {
1663
2163
  type: "updateNote",
@@ -1683,6 +2183,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
1683
2183
  ),
1684
2184
  pitch: parsed.pitch,
1685
2185
  velocity: parsed.velocity,
2186
+ ...(parsed.cents ? { cents: parsed.cents } : {}),
1686
2187
  };
1687
2188
  const operation: ScoreOperation = { type: "addNote", note };
1688
2189
  const next = applyScoreOperation(latestScore, operation);
@@ -1721,14 +2222,28 @@ async function focusTrack(trackId: string): Promise<Receipt> {
1721
2222
  )
1722
2223
  )
1723
2224
  return warn(`${trackId} is open in another window`);
1724
- const exists = score.tracks.some((track) => track.id === trackId);
2225
+ const existing = score.tracks.find((track) => track.id === trackId);
2226
+ const exists = existing !== undefined;
2227
+ // `track cloud`, `track hold-2`: a new granular track with that preset.
2228
+ const grainPreset = granularTrackPreset(trackId);
1725
2229
  if (!exists) {
1726
2230
  const next = applyScoreOperation(score, {
1727
2231
  type: "addTrack",
1728
2232
  track: {
1729
2233
  id: trackId,
1730
2234
  name: trackId,
1731
- instrument: isDrumInstrument(trackId) ? "kit" : "sine",
2235
+ instrument: grainPreset
2236
+ ? "granular"
2237
+ : isDrumInstrument(trackId)
2238
+ ? "kit"
2239
+ : "sine",
2240
+ // `track jangle`, `track gtr-metal`…: a guitar voice and its rig.
2241
+ ...rigTrackFields(trackId),
2242
+ ...(grainPreset ? { granular: { preset: grainPreset } } : {}),
2243
+ // `/track piano` (or grand, felt…) starts on the modelled piano.
2244
+ ...newPianoTrack(trackId),
2245
+ // `track vibes`: a 0.6 modal word names the track and its preset.
2246
+ ...(isModalWord(trackId) ? instrumentPatchForWord(trackId) : {}),
1732
2247
  },
1733
2248
  });
1734
2249
  await commitScore(next, "track.create", { trackId });
@@ -1736,7 +2251,17 @@ async function focusTrack(trackId: string): Promise<Receipt> {
1736
2251
  await port.focus(trackId);
1737
2252
  requestedTrack = trackId;
1738
2253
  draftTrack = false;
1739
- return ok(exists ? `track · ${trackId}` : `track created · ${trackId}`);
2254
+ if (exists && grainPreset && existing.instrument !== "granular")
2255
+ return warn(
2256
+ `track · ${trackId} is a ${existing.instrument} track · grain ${grainPreset} makes it granular`,
2257
+ );
2258
+ return ok(
2259
+ exists
2260
+ ? `track · ${trackId}`
2261
+ : grainPreset
2262
+ ? `track created · ${trackId} · granular ${grainPreset} · nothing to download`
2263
+ : `track created · ${trackId}`,
2264
+ );
1740
2265
  }
1741
2266
 
1742
2267
  /** Decode every sampler voice (cached) and return what failed to load. */
@@ -1788,6 +2313,68 @@ function reportSampleProblems(value: TrackScore): void {
1788
2313
  });
1789
2314
  }
1790
2315
 
2316
+ /** `/tuning …` and `/scale …` (src/commands/tuning.ts). */
2317
+ async function tuningCommand(command: TuningCommand): Promise<Receipt> {
2318
+ const projectRoot = process.cwd();
2319
+ if (command.type === "tuning-set") {
2320
+ // Scala files outside the project are copied into tunings/ first.
2321
+ try {
2322
+ const patch = { ...command.patch };
2323
+ if (patch.scl)
2324
+ patch.scl = await importTuningFile(projectRoot, projectRoot, patch.scl);
2325
+ if (patch.kbm)
2326
+ patch.kbm = await importTuningFile(projectRoot, projectRoot, patch.kbm);
2327
+ command = { ...command, patch };
2328
+ } catch (error) {
2329
+ if (error instanceof TuningError)
2330
+ return fail(`tuning · ${error.message}`);
2331
+ throw error;
2332
+ }
2333
+ }
2334
+ if (command.type === "tuning-set" || command.type === "tuning-off")
2335
+ if (command.target === "track") await materializeDraft();
2336
+ const read = (path: string): string | undefined => {
2337
+ try {
2338
+ return readFileSync(join(projectRoot, path), "utf8");
2339
+ } catch {
2340
+ return undefined;
2341
+ }
2342
+ };
2343
+ const result = applyTuningCommand(score, requestedTrack, command, read);
2344
+ if (result.panel) tui.openText(result.panel.title, result.panel.lines);
2345
+ if (result.next && result.kind)
2346
+ await commitScore(result.next, result.kind, result.payload);
2347
+ return result.ok ? ok(result.message) : fail(result.message);
2348
+ }
2349
+
2350
+ /** `/fitmode`, `/bpm`, `/len` on the focused sampler voice (0.6). */
2351
+ async function fitCommand(
2352
+ command: NonNullable<ReturnType<typeof parseFitCommand>>,
2353
+ ): Promise<Receipt> {
2354
+ let suggested: "beats" | "tones" | undefined;
2355
+ if (command.control === "fitmode" && command.value === undefined) {
2356
+ const target = fitVoice(score, requestedTrack, command.voice);
2357
+ if ("error" in target) return fail(target.error);
2358
+ const decoded = liveSampleBank?.voices.get(
2359
+ sampleKey(requestedTrack, target.voice),
2360
+ );
2361
+ if (!decoded)
2362
+ return warn(
2363
+ "fit · the sample is still loading · fitmode repitch|beats|tones",
2364
+ );
2365
+ suggested = suggestFitMode(decoded.mono, decoded.sampleRate);
2366
+ }
2367
+ const result = applyFitCommand(score, requestedTrack, command, suggested);
2368
+ if (!result.ok) return fail(result.message);
2369
+ await commitScore(result.next, "sample.set", { trackId: requestedTrack });
2370
+ await projectSync?.flushScore();
2371
+ return ok(
2372
+ suggested
2373
+ ? `${result.message} · suggested from the sound (${suggested === "beats" ? "hits" : "held tones"})`
2374
+ : result.message,
2375
+ );
2376
+ }
2377
+
1791
2378
  async function sampleCommand(
1792
2379
  command: NonNullable<ReturnType<typeof parseSampleCommand>>,
1793
2380
  ): Promise<Receipt> {
@@ -2357,7 +2944,7 @@ async function switchSession(sessionId: string): Promise<void> {
2357
2944
  audio = port.player;
2358
2945
  record = port.mode === "daemon" ? await port.load() : next.record;
2359
2946
  score = scoreFromJSON(record.composition);
2360
- clock.setTempo(score.tempoBpm);
2947
+ clock.follow(score);
2361
2948
  const attached = await attachTrack(port, score, undefined);
2362
2949
  requestedTrack = attached.trackId;
2363
2950
  draftTrack = attached.draft;
@@ -2412,6 +2999,8 @@ function menuContext(): MenuContext {
2412
2999
  /** The screen `?` describes, or undefined while `?` is typed text. */
2413
3000
  function keysScreen(): readonly KeySection[] | undefined {
2414
3001
  if (euclid.open) return euclid.typing ? undefined : KEYS.euclid;
3002
+ if (fader && menu.open)
3003
+ return fader.typing !== undefined ? undefined : KEYS.fader;
2415
3004
  if (menu.open) return menu.typing ? undefined : KEYS.menu;
2416
3005
  const overlay = tui.ui.overlay;
2417
3006
  if (overlay === "picker")
@@ -2422,6 +3011,7 @@ function keysScreen(): readonly KeySection[] | undefined {
2422
3011
  : KEYS.list;
2423
3012
  if (overlay === "text") return KEYS.text;
2424
3013
  if (overlay === "log") return KEYS.log;
3014
+ if (overlay === "guide") return tui.guideTyping ? undefined : KEYS.guide;
2425
3015
  if (prompt.value.length > 0) return undefined;
2426
3016
  if (play?.on)
2427
3017
  return play.chords.on ? [...KEYS.play, ...KEYS.chords] : KEYS.play;
@@ -2533,6 +3123,11 @@ function auditionController(): Audition {
2533
3123
  clearTimer: (handle) => clearTimeout(handle as Timer),
2534
3124
  changed: () => requestFrame(),
2535
3125
  level: () => previewEngine().level,
3126
+ // The last loop the engine played is the song's when a loop starts.
3127
+ masterGainDb: () =>
3128
+ score.master?.target === undefined
3129
+ ? undefined
3130
+ : previewEngine().loudness?.gainDb,
2536
3131
  // The chord settings screen loops a progression with its settings.
2537
3132
  phrase: (showing) => {
2538
3133
  if (!chordScreenOpen()) return undefined;
@@ -2875,14 +3470,28 @@ function leaveAuditionScreen(): void {
2875
3470
 
2876
3471
  function refreshMenu(): void {
2877
3472
  noteChordScreen();
2878
- if (!menu.open) return;
3473
+ if (!menu.open) {
3474
+ closeFader();
3475
+ return;
3476
+ }
2879
3477
  if (tui.ui.overlay !== undefined && tui.ui.picker?.id !== "menu") {
2880
3478
  // Another overlay (help, a picker) replaced the menu.
2881
3479
  menu.close();
2882
3480
  leaveAuditionScreen();
2883
3481
  return;
2884
3482
  }
2885
- const view = menu.view(menuContext());
3483
+ const context = menuContext();
3484
+ if (fader) {
3485
+ const fields = menu.faderFields(context);
3486
+ if (fields.length === 0) closeFader();
3487
+ else
3488
+ tui.drawer = drawerView(fader, fields, menu.faderCommitted(context), {
3489
+ title: menu.crumbs,
3490
+ dirty: context.audition?.dirty ?? false,
3491
+ status: context.audition?.status,
3492
+ });
3493
+ }
3494
+ const view = menu.view(context);
2886
3495
  tui.openPicker({
2887
3496
  id: "menu",
2888
3497
  title: view.title,
@@ -2895,6 +3504,216 @@ function refreshMenu(): void {
2895
3504
  });
2896
3505
  }
2897
3506
 
3507
+ // ── the fader drawer ─────────────────────────────────────────────────
3508
+
3509
+ /** Enter on a number row (or a bare `volume`, `fx filter`): the drawer. */
3510
+ /**
3511
+ * Open the drawer on `label`. `standalone` drawers came from a command or a
3512
+ * click, not from inside the menu, so closing one closes the menu too.
3513
+ */
3514
+ function openFader(label: string, standalone = false): void {
3515
+ fader = { label };
3516
+ faderStandalone = standalone;
3517
+ const loop = auditionController();
3518
+ if (!loop.dirtyEdits) loop.committedNow(score);
3519
+ }
3520
+
3521
+ function closeFader(): void {
3522
+ fader = undefined;
3523
+ tui.drawer = undefined;
3524
+ if (faderStandalone && menu.open) {
3525
+ faderStandalone = false;
3526
+ menu.close();
3527
+ tui.closePicker();
3528
+ stopAuditionLoop();
3529
+ }
3530
+ faderStandalone = false;
3531
+ }
3532
+
3533
+ function faderKeyOptions(): { dirty: boolean; audition: boolean } {
3534
+ return { dirty: auditionLoop?.dirtyEdits ?? false, audition: true };
3535
+ }
3536
+
3537
+ /**
3538
+ * Act on a drawer result. A set stages on the audition loop, filed under
3539
+ * its field so every nudge replaces the last: the piano roll (and the loop,
3540
+ * when it plays) follows at once, and Enter keeps them all as one revision.
3541
+ * A setting the loop cannot stage (tempo, loop length) applies directly.
3542
+ */
3543
+ function faderOutcome(result: FaderResult): void {
3544
+ if (result.type === "set") {
3545
+ if (!stageableNow(result.command)) {
3546
+ runPromptLater(result.command);
3547
+ return;
3548
+ }
3549
+ const loop = auditionController();
3550
+ if (!loop.dirtyEdits) loop.committedNow(score);
3551
+ const seq = (faderSeq.get(result.key) ?? 0) + 1;
3552
+ faderSeq.set(result.key, seq);
3553
+ void loop
3554
+ .stage(result.command, {
3555
+ replaceKey: result.key,
3556
+ superseded: () => faderSeq.get(result.key) !== seq,
3557
+ })
3558
+ .then((outcome) => {
3559
+ if (!outcome.ok && outcome.message !== SUPERSEDED)
3560
+ tui.activity.pushCard(outcome.message, {
3561
+ tone: outcome.message.includes("failed") ? "error" : "warning",
3562
+ });
3563
+ requestFrame();
3564
+ });
3565
+ } else if (result.type === "keep") {
3566
+ closeFader();
3567
+ void keepStaged()
3568
+ .then((outcome) => receipt(outcome))
3569
+ .catch((error) => tui.activity.pushError(describeError("keep", error)))
3570
+ .finally(() => requestFrame());
3571
+ } else if (result.type === "revert") {
3572
+ revertStaged();
3573
+ closeFader();
3574
+ } else if (result.type === "close") closeFader();
3575
+ else if (result.type === "audition") auditionKeyPressed(result.key);
3576
+ }
3577
+
3578
+ // ── the mouse ────────────────────────────────────────────────────────
3579
+
3580
+ /**
3581
+ * One mouse report, hit-tested against the regions the last frame painted.
3582
+ * Acts directly (a fader, the transport) or returns keys to run through the
3583
+ * ordinary key path (a list row is Enter, a wheel notch is an arrow).
3584
+ */
3585
+ function mouseInput(event: MouseEvent): string[] {
3586
+ const frame = tui.frame;
3587
+ if (!frame) return [];
3588
+ const target = frame.hits.at(event.x, event.y)?.target;
3589
+ if (event.kind === "wheel") {
3590
+ const fields = fader && menu.open ? menu.faderFields(menuContext()) : [];
3591
+ if (
3592
+ fader &&
3593
+ target &&
3594
+ (target.kind === "fader-bar" ||
3595
+ target.kind === "fader-row" ||
3596
+ target.kind === "fader-step" ||
3597
+ target.kind === "fader-option")
3598
+ ) {
3599
+ // Wheel up raises the value under the pointer.
3600
+ faderOutcome(
3601
+ faderStep(
3602
+ fader,
3603
+ fields[target.field],
3604
+ event.delta < 0 ? 1 : -1,
3605
+ event.shift ? "coarse" : "normal",
3606
+ ),
3607
+ );
3608
+ refreshMenu();
3609
+ return [];
3610
+ }
3611
+ if (fader) return [];
3612
+ if (tui.ui.overlay) return [event.delta < 0 ? KEY_UP : KEY_DOWN];
3613
+ return [];
3614
+ }
3615
+ // A drag keeps moving the fader it started on, even past the bar's ends.
3616
+ if (event.kind === "drag" && event.button === "left" && fader && dragging) {
3617
+ const fields = menu.faderFields(menuContext());
3618
+ faderOutcome(
3619
+ faderSetPosition(
3620
+ fader,
3621
+ fields[dragging.field],
3622
+ (event.x - dragging.left) / Math.max(1, dragging.width - 1),
3623
+ ),
3624
+ );
3625
+ refreshMenu();
3626
+ return [];
3627
+ }
3628
+ if (event.kind === "up") {
3629
+ dragging = undefined;
3630
+ return [];
3631
+ }
3632
+ if (event.kind !== "down" || event.button !== "left" || !target) return [];
3633
+ if (fader && menu.open) {
3634
+ const fields = menu.faderFields(menuContext());
3635
+ let result: FaderResult | undefined;
3636
+ switch (target.kind) {
3637
+ case "fader-step":
3638
+ result = faderStep(
3639
+ fader,
3640
+ fields[target.field],
3641
+ target.direction,
3642
+ event.shift ? "coarse" : "normal",
3643
+ );
3644
+ break;
3645
+ case "fader-bar":
3646
+ dragging = target;
3647
+ result = faderSetPosition(
3648
+ fader,
3649
+ fields[target.field],
3650
+ (event.x - target.left) / Math.max(1, target.width - 1),
3651
+ );
3652
+ break;
3653
+ case "fader-option":
3654
+ result = faderChoose(fader, fields[target.field], target.option);
3655
+ break;
3656
+ case "fader-row": {
3657
+ const field = fields[target.field];
3658
+ if (field) {
3659
+ fader.label = field.label;
3660
+ fader.typing = undefined;
3661
+ }
3662
+ result = { type: "handled" };
3663
+ break;
3664
+ }
3665
+ case "fader-keep":
3666
+ result = auditionLoop?.dirtyEdits
3667
+ ? { type: "keep" }
3668
+ : { type: "close" };
3669
+ break;
3670
+ case "fader-revert":
3671
+ result = auditionLoop?.dirtyEdits
3672
+ ? { type: "revert" }
3673
+ : { type: "close" };
3674
+ break;
3675
+ default:
3676
+ break;
3677
+ }
3678
+ if (result) {
3679
+ faderOutcome(result);
3680
+ refreshMenu();
3681
+ return [];
3682
+ }
3683
+ }
3684
+ switch (target.kind) {
3685
+ case "picker-row": {
3686
+ const picker = tui.ui.picker;
3687
+ if (!picker) return [];
3688
+ if (picker.id === "menu" && menu.open) {
3689
+ // The first click selects a row; a click on the selected row opens it.
3690
+ const again = menu.index === target.index;
3691
+ menu.select(menuContext(), target.index);
3692
+ refreshMenu();
3693
+ return again ? ["\r"] : [];
3694
+ }
3695
+ const again = picker.index === target.index;
3696
+ picker.index = target.index;
3697
+ picker.filtering = false;
3698
+ return again ? ["\r"] : [];
3699
+ }
3700
+ case "transport":
3701
+ if (tui.ui.overlay) return [];
3702
+ void toggleTransport().finally(() => requestFrame());
3703
+ return [];
3704
+ case "tracks":
3705
+ if (tui.ui.overlay) return [];
3706
+ runPromptLater("/tracks");
3707
+ return [];
3708
+ case "model":
3709
+ if (tui.ui.overlay) return [];
3710
+ runPromptLater("/model");
3711
+ return [];
3712
+ default:
3713
+ return [];
3714
+ }
3715
+ }
3716
+
2898
3717
  function playSession(): PlaySession {
2899
3718
  if (play && play.track === requestedTrack) return play;
2900
3719
  const previous = play;
@@ -2966,6 +3785,7 @@ function playHost() {
2966
3785
  now: () => performance.now(),
2967
3786
  playing: () => clock.playing,
2968
3787
  beatAt: (ms: number) => clock.beatAt(ms),
3788
+ scoreBeat: (beat: number) => scoreBeatAt(score, beat),
2969
3789
  engine: liveEngine,
2970
3790
  samples: () => liveSampleBank,
2971
3791
  async commit(
@@ -3032,20 +3852,33 @@ async function commitScore(
3032
3852
  // Rhythm rows regenerate after a loop resize and freeze when their lane
3033
3853
  // is hand-edited, so rows and notes never disagree.
3034
3854
  next = reconcileRhythm(score, next);
3855
+ const retimed = timingChanged(score, next);
3035
3856
  if (stageCapture) {
3036
3857
  // A staged edit: the audition plays it; nothing is written yet.
3037
3858
  stageCapture.next = next;
3038
3859
  score = next;
3860
+ if (retimed) clock.follow(score);
3039
3861
  return;
3040
3862
  }
3041
3863
  record = await port.append(record, { kind, payload }, next.toJSON());
3042
3864
  score = next;
3865
+ if (retimed) clock.follow(score);
3043
3866
  if (clock.playing) void audio.play(score);
3044
3867
  projectSync?.scoreChanged(score);
3045
3868
  reportSampleProblems(score);
3046
3869
  void updateCredits(score);
3047
3870
  }
3048
3871
 
3872
+ /** True when the transport clock must follow `next` (tempo, meter, loop). */
3873
+ function timingChanged(previous: TrackScore, next: TrackScore): boolean {
3874
+ return (
3875
+ previous.time !== next.time ||
3876
+ previous.tempoBpm !== next.tempoBpm ||
3877
+ previous.beatsPerBar !== next.beatsPerBar ||
3878
+ previous.bars !== next.bars
3879
+ );
3880
+ }
3881
+
3049
3882
  /** The window's side of the project file sync (see src/project/sync.ts). */
3050
3883
  function syncHost(): SyncHost {
3051
3884
  return {
@@ -3063,7 +3896,7 @@ function syncHost(): SyncHost {
3063
3896
  plan.next.toJSON(),
3064
3897
  );
3065
3898
  score = scoreFromJSON(record.composition);
3066
- clock.setTempo(score.tempoBpm);
3899
+ clock.follow(score);
3067
3900
  if (clock.playing) void audio.play(score);
3068
3901
  reportSampleProblems(score);
3069
3902
  void baseRevision;
@@ -3170,6 +4003,17 @@ async function setTransport(
3170
4003
  }
3171
4004
  }
3172
4005
 
4006
+ /** Move the playhead to transport `beat`, playing or not (section jump). */
4007
+ async function seekTransport(beat: number): Promise<void> {
4008
+ if (port.mode === "daemon") {
4009
+ await port.transport("seek", { beat });
4010
+ return;
4011
+ }
4012
+ const now = Date.now();
4013
+ clock.sync(beat, clock.playing, now, now);
4014
+ if (clock.playing) await audio.play(score, clock.beatAt());
4015
+ }
4016
+
3173
4017
  /**
3174
4018
  * Run one streaming tool-calling turn. Each validated tool call commits its
3175
4019
  * own revision through `agentHost`, so cancelling keeps every accepted change
@@ -3274,6 +4118,25 @@ function mediaServices(): MediaServices {
3274
4118
  return { runner: systemRunner, env: process.env };
3275
4119
  }
3276
4120
 
4121
+ /**
4122
+ * `master measure`: render the song as the loop that plays and measure
4123
+ * loudness, peaks and balance at the export rate. The render, master and
4124
+ * meter run in a worker (`measureScoreOffThread`), so the header, input and
4125
+ * playback keep going on a long loop.
4126
+ */
4127
+ async function measureLine(value: TrackScore): Promise<string> {
4128
+ await sampleProblems(value);
4129
+ tui.activity.setSpinner("measuring");
4130
+ try {
4131
+ const measured = await measureScoreOffThread(value, {
4132
+ projectRoot: process.cwd(),
4133
+ });
4134
+ return `master measure · ${measurementLine(measured.mix, measured.master)} · ${measured.sampleRate / 1000} kHz`;
4135
+ } finally {
4136
+ tui.activity.setSpinner(undefined);
4137
+ }
4138
+ }
4139
+
3277
4140
  /**
3278
4141
  * How the agent's preview_sound renders (with this window's decoded
3279
4142
  * samples, at the engine's rate) and plays: once, over silence, unless the
@@ -3288,6 +4151,16 @@ function agentPreviewHost(): PreviewHost {
3288
4151
  ...(liveSampleBank ? { samples: liveSampleBank } : {}),
3289
4152
  });
3290
4153
  },
4154
+ // Off the UI thread, at the export rate (see measureLine).
4155
+ measure: async (value, options) => {
4156
+ await sampleProblems(value);
4157
+ return measureScoreOffThread(value, {
4158
+ projectRoot: process.cwd(),
4159
+ ...(options?.sampleRate === undefined
4160
+ ? {}
4161
+ : { sampleRate: options.sampleRate }),
4162
+ });
4163
+ },
3291
4164
  play: async (rendered) => {
3292
4165
  if (!agentPreviewPlays || clock.playing || auditionLoop?.looping)
3293
4166
  return false;
@@ -3356,8 +4229,13 @@ function agentHost(
3356
4229
  );
3357
4230
  throw error;
3358
4231
  }
3359
- if (change.operations.some((operation) => operation.type === "setTempo"))
3360
- clock.setTempo(score.tempoBpm);
4232
+ if (
4233
+ change.operations.some(
4234
+ (operation) =>
4235
+ operation.type === "setTempo" || operation.type === "setTime",
4236
+ )
4237
+ )
4238
+ clock.follow(score);
3361
4239
  return { revision: record.revision };
3362
4240
  },
3363
4241
  async transport(action) {