@hraness/dawg 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/DAWG.md +117 -30
  3. package/README.md +7 -5
  4. package/core/diff.ts +2 -0
  5. package/core/ids.ts +52 -0
  6. package/core/loop.ts +3 -0
  7. package/core/range.ts +581 -0
  8. package/core/score.ts +123 -1
  9. package/core/sdk/print.ts +11 -2
  10. package/core/sdk/v1.ts +281 -2
  11. package/core/sections.ts +198 -19
  12. package/guides/arrange.md +9 -8
  13. package/guides/audio.md +31 -0
  14. package/guides/mix.md +3 -1
  15. package/guides/panes.md +32 -0
  16. package/guides/project.md +3 -2
  17. package/guides/rhythm.md +2 -1
  18. package/guides/tape.md +32 -0
  19. package/package.json +2 -1
  20. package/src/agent/agent.ts +4 -4
  21. package/src/agent/command-agent.ts +19 -10
  22. package/src/agent/ops.ts +15 -0
  23. package/src/agent/planner.ts +12 -0
  24. package/src/agent/range-tools.ts +216 -0
  25. package/src/agent/show-me.ts +1 -1
  26. package/src/agent/tools.ts +2 -0
  27. package/src/agent/xcb-agent.ts +2 -3
  28. package/src/audio/arrange.ts +12 -2
  29. package/src/audio/audio-command.ts +218 -0
  30. package/src/audio/devices.ts +264 -0
  31. package/src/audio/doctor.ts +84 -13
  32. package/src/audio/engine.ts +216 -28
  33. package/src/commands/arrange.ts +60 -25
  34. package/src/commands/grammar.ts +3 -1
  35. package/src/commands/help.ts +187 -9
  36. package/src/commands/history.ts +122 -11
  37. package/src/commands/keys.ts +17 -0
  38. package/src/commands/parses.ts +6 -1
  39. package/src/commands/range.ts +763 -0
  40. package/src/commands/resample.ts +13 -10
  41. package/src/commands/sample.ts +11 -1
  42. package/src/commands/sing.ts +2 -6
  43. package/src/commands/vocal-pitch.ts +2 -1
  44. package/src/identity/actor.ts +127 -0
  45. package/src/lang/glossary.ts +18 -0
  46. package/src/launch-args.ts +105 -1
  47. package/src/main.ts +1142 -86
  48. package/src/session/client.ts +107 -3
  49. package/src/session/clipboard.ts +79 -0
  50. package/src/session/daemon.ts +196 -5
  51. package/src/session/live-host.ts +232 -0
  52. package/src/session/origin.ts +20 -0
  53. package/src/session/port.ts +87 -10
  54. package/src/session/protocol.ts +360 -6
  55. package/src/session/rebase.ts +46 -8
  56. package/src/session/receipt.ts +7 -0
  57. package/src/session/shared-live.ts +131 -0
  58. package/src/session/store.ts +61 -2
  59. package/src/tui/arrange-menu.ts +169 -1
  60. package/src/tui/euclid.ts +95 -14
  61. package/src/tui/fader.ts +55 -2
  62. package/src/tui/knob-fields.ts +107 -0
  63. package/src/tui/knob-map.ts +198 -0
  64. package/src/tui/menu.ts +228 -2
  65. package/src/tui/play-session.ts +196 -21
  66. package/src/tui/tape-mode.ts +580 -0
  67. package/src/tui/tape-view.ts +263 -0
  68. package/tui/app.ts +217 -44
  69. package/tui/delight.ts +49 -0
  70. package/tui/drawer.ts +167 -9
  71. package/tui/grammar.ts +34 -5
  72. package/tui/guide.ts +8 -1
  73. package/tui/highway.ts +40 -5
  74. package/tui/hints.ts +5 -0
  75. package/tui/hits.ts +7 -1
  76. package/tui/knobs.ts +268 -0
  77. package/tui/play-strip.ts +12 -0
  78. package/tui/screen.ts +7 -4
  79. package/tui/tape.ts +439 -0
  80. package/tui/text.ts +35 -2
  81. package/tui/theme.ts +19 -1
package/src/main.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  import { TOPIC_ALIASES } from "./lang/glossary.ts";
3
3
  import { isGuideInstrument, vocalChainPatch } from "../core/clips.ts";
4
+ import { newId } from "../core/ids.ts";
5
+ import { deepEqual, DiffError, diffScores } from "../core/diff.ts";
6
+ import { currentActor } from "./identity/actor.ts";
4
7
  import { isSingWord } from "../core/sing.ts";
5
8
  import { commandParses, parseExact } from "./commands/parses.ts";
6
9
  import { paramRangeError } from "./commands/param-range.ts";
@@ -35,7 +38,7 @@ import {
35
38
  unknownInstrumentMessage,
36
39
  } from "./audio/instrument-check.ts";
37
40
  import { randomUUID } from "node:crypto";
38
- import { readFileSync, writeSync } from "node:fs";
41
+ import { appendFileSync, readFileSync, writeSync } from "node:fs";
39
42
  import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
40
43
  import { tmpdir } from "node:os";
41
44
  import { join, relative, resolve } from "node:path";
@@ -51,8 +54,14 @@ import {
51
54
  editedTrack,
52
55
  foreignEvents,
53
56
  otherWindowName,
57
+ paneLetters,
54
58
  } from "./session/origin.ts";
55
- import { compositionDigest, monotonicEpochMs } from "./session/protocol.ts";
59
+ import {
60
+ compositionDigest,
61
+ monotonicEpochMs,
62
+ type PaneView,
63
+ type PresenceEntry,
64
+ } from "./session/protocol.ts";
56
65
  import {
57
66
  formatSessionLine,
58
67
  listSessions,
@@ -87,6 +96,7 @@ import { kitCatalog } from "./audio/kits.ts";
87
96
  import { applyEditCommand, parseEditCommand } from "./commands/edit.ts";
88
97
  import { applyTimeCommand, parseTimeCommand } from "./commands/time.ts";
89
98
  import {
99
+ barAt,
90
100
  barStartTick,
91
101
  bpmAtTick,
92
102
  hasMeterChanges,
@@ -145,6 +155,7 @@ import {
145
155
  applyKeysCommand,
146
156
  newPianoTrack,
147
157
  parseKeysCommand,
158
+ parseRecordCommand,
148
159
  } from "./commands/keys.ts";
149
160
  import {
150
161
  applyTuningCommand,
@@ -169,6 +180,12 @@ import {
169
180
  loopSpan,
170
181
  parseSectionCommand,
171
182
  } from "./commands/arrange.ts";
183
+ import {
184
+ applyRangeCommand,
185
+ parseRangeCommand,
186
+ type RangeClipboard,
187
+ } from "./commands/range.ts";
188
+ import { rangeLabel } from "../core/range.ts";
172
189
  import {
173
190
  helpMiss,
174
191
  helpText,
@@ -181,6 +198,7 @@ import {
181
198
  import {
182
199
  historyReceipt,
183
200
  historyTarget,
201
+ paneHistoryStep,
184
202
  REDO_KIND,
185
203
  UNDO_KIND,
186
204
  } from "./commands/history.ts";
@@ -291,7 +309,12 @@ import {
291
309
  tuiSetModel,
292
310
  } from "./auth/tui.ts";
293
311
  import { TransportClock, transportMapFor } from "./audio/clock.ts";
294
- import { AudioEngine } from "./audio/engine.ts";
312
+ import {
313
+ AudioEngine,
314
+ detectAudioBackend,
315
+ type AudioBackendInfo,
316
+ } from "./audio/engine.ts";
317
+ import { audioMenuState, runAudioCommand } from "./audio/audio-command.ts";
295
318
  import {
296
319
  DEFAULT_GRID,
297
320
  GRIDS,
@@ -303,9 +326,32 @@ import {
303
326
  EditMenu,
304
327
  menuUsage,
305
328
  menuSectionPath,
329
+ type MenuAudioDevices,
306
330
  type MenuContext,
307
331
  } from "./tui/menu.ts";
332
+ import { KNOB_PAGE_WORDS, knobPageId } from "./tui/knob-map.ts";
333
+ import {
334
+ beatOfBar,
335
+ cutArmed,
336
+ focusCommand,
337
+ foldBase,
338
+ knobNoun,
339
+ tapeKey,
340
+ tapeKnobs,
341
+ type TapeContext,
342
+ } from "./tui/tape-mode.ts";
343
+ import { tapeView } from "./tui/tape-view.ts";
344
+ import { TAPE_ZOOMS, type TapeView, type TapeZoom } from "../tui/tape.ts";
345
+ import type { HitTarget } from "../tui/hits.ts";
346
+ import { knobKey, type KnobState } from "../tui/knobs.ts";
347
+ import {
348
+ clipboardPath,
349
+ loadClipboard,
350
+ saveClipboard,
351
+ } from "./session/clipboard.ts";
352
+ import { extractRange } from "../core/range.ts";
308
353
  import {
354
+ drawerAside,
309
355
  drawerView,
310
356
  faderChoose,
311
357
  faderCommand,
@@ -340,7 +386,13 @@ import {
340
386
  type KeySection,
341
387
  } from "../tui/grammar.ts";
342
388
  import { renderAudition } from "./audio/audition.ts";
343
- import { exportScore, playbackTime, scoreBeatAt } from "./audio/arrange.ts";
389
+ import {
390
+ exportScore,
391
+ loopedSection,
392
+ playbackTime,
393
+ scoreBeatAt,
394
+ } from "./audio/arrange.ts";
395
+ import { loopFlash, reelGlyph } from "../tui/delight.ts";
344
396
  import { formatForm } from "../core/sections.ts";
345
397
  import type { ArrangeStripView } from "../tui/arrange-strip.ts";
346
398
  import { renderScorePcm } from "./audio/wav.ts";
@@ -381,6 +433,7 @@ import {
381
433
  packCredits,
382
434
  writeCredits,
383
435
  } from "./audio/packs.ts";
436
+ import { SharedLiveEngine, type LiveLink } from "./session/shared-live.ts";
384
437
  import {
385
438
  addNote,
386
439
  applyScoreOperation,
@@ -413,12 +466,15 @@ import {
413
466
  import {
414
467
  CARD_GLOW_MS,
415
468
  composeFrame,
469
+ isTooSmall,
470
+ tooSmallKey,
416
471
  TuiApp,
417
472
  type AppView,
418
473
  type LoudnessView,
419
474
  type SyncState,
420
475
  type TypesIndicator,
421
476
  } from "../tui/app.ts";
477
+ import type { DrawerView } from "../tui/drawer.ts";
422
478
  import { fail, note, ok, toneOf, warn, type Receipt } from "../tui/activity.ts";
423
479
  import { systemRunner } from "./auth/runner.ts";
424
480
  import type { MediaServices } from "./media/types.ts";
@@ -428,6 +484,8 @@ import { formatDiagnostic } from "../core/sdk/eval.ts";
428
484
  import { isProject } from "./project/init.ts";
429
485
  import {
430
486
  parseLaunchArgs,
487
+ parsePaneArgs,
488
+ type PaneArgs,
431
489
  parseSimpleArgv,
432
490
  resolveTrackArg,
433
491
  } from "./launch-args.ts";
@@ -685,8 +743,11 @@ const session = await ensureSession(initial.toJSON(), {
685
743
  ...sessionOptions,
686
744
  ...(ephemeralWorkspace ? { workspace: ephemeralWorkspace } : {}),
687
745
  });
746
+ // Who is at this window; also salts collision-free ids (core/ids.ts).
747
+ const actor = await currentActor();
688
748
  // dawgd when connected, the file-lock path otherwise (see src/session/port.ts).
689
749
  let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
750
+ actor,
690
751
  paths: session.paths,
691
752
  sessionId: session.record.sessionId,
692
753
  label: explicitTrack ?? "window",
@@ -731,6 +792,8 @@ if (!draftTrack) await ensureFocusedTrack();
731
792
 
732
793
  const clock = new TransportClock(score.tempoBpm);
733
794
  let audio = port.player;
795
+ let audioInfoCache: AudioBackendInfo | undefined;
796
+ let audioMenuCache: { at: number; state: MenuAudioDevices } | undefined;
734
797
  /** Session and today's spend, from the usage each response reports. */
735
798
  const meter = new SpendMeter(configDir());
736
799
  void meter.load().catch(() => undefined);
@@ -748,6 +811,16 @@ const tui = new TuiApp({
748
811
  });
749
812
  let syncState: SyncState = port.sync;
750
813
  let windowCount = 1;
814
+ /** Pin and follow (§12.5): set by `dawg pane …`, `pin` and `follow`. */
815
+ const paneOptions: { pinned: boolean; follow: string | undefined } = {
816
+ pinned: false,
817
+ follow: undefined,
818
+ };
819
+ /** Every pane on this session, as the last presence push listed them. */
820
+ let panePresence: readonly PresenceEntry[] = [];
821
+ /** The last view this pane reported (JSON), so unchanged views stay quiet. */
822
+ let reportedView = "";
823
+ let sharedLive: { link: LiveLink; engine: SharedLiveEngine } | undefined;
751
824
  /** Project file sync when `dawg.json` is in the working directory. */
752
825
  let projectSync: ProjectSync | undefined;
753
826
  let packStore: PackStore | undefined;
@@ -792,14 +865,47 @@ const AGENT_PREVIEW_VOICE = 0x7fff_0002;
792
865
  const CHORDS_COMMAND = /^\/chords\s+(.+)$/i;
793
866
  /** Whether the chord settings screen was open at the last menu redraw. */
794
867
  let chordScreenWas = false;
868
+ /** The range clipboard (`copy bass 5-6`, then `paste at 9`); per window. */
869
+ let rangeClipboard: RangeClipboard | undefined;
870
+ /**
871
+ * TAPE (op1-ux §6): on, its zoom, its knob strip, the last view painted
872
+ * (mouse hits map cells to bars through it), a ruler drag in progress, and
873
+ * the pending cut (the score before and after its clear) so the first
874
+ * paste after it runs `move` against the score the cut came from.
875
+ */
876
+ const tape: {
877
+ on: boolean;
878
+ zoom: TapeZoom;
879
+ knobs: KnobState;
880
+ view?: TapeView | undefined;
881
+ drag?: { from: number; to: number; left: number; first: number } | undefined;
882
+ cut?: { before: TrackScore; after?: TrackScore | undefined } | undefined;
883
+ /** Set by `x`: the score before it, until its `clear` lands. */
884
+ cutArmed?: TrackScore | undefined;
885
+ /** Set by the first `v` after a cut: its `move` reads `cut.before`. */
886
+ foldArmed?: boolean | undefined;
887
+ /**
888
+ * Where a paste's queued `jump` will put the playhead (a score beat),
889
+ * so a quick `v v v` tiles before that jump has run.
890
+ */
891
+ pending?: { beat: number; until: number } | undefined;
892
+ } = { on: false, zoom: "beat", knobs: { selected: 0 } };
795
893
  let stageCapture: { next?: TrackScore; committed?: TrackScore } | undefined;
796
894
  /** Redraw soon (the audition reports renders between frames). */
797
895
  let requestFrame: () => void = () => undefined;
798
896
  /** Queue a prompt to run as if typed (set by the interactive loop). */
799
897
  let runPromptLater: (command: string) => void = () => undefined;
898
+ /** Queue several prompts, in order, ahead of anything already queued. */
899
+ let runPromptsLater: (commands: readonly string[]) => void = () => undefined;
800
900
  /** The fader drawer's focus and typing, while one is open over the menu. */
801
901
  let fader: FaderState | undefined;
802
902
  /** Show-me state (see the show-me section below). */
903
+ /** A TAPE gesture's typed command, echoed dimly in the prompt row. */
904
+ const gestureEcho: {
905
+ text?: string | undefined;
906
+ clear?: ReturnType<typeof setTimeout> | undefined;
907
+ } = {};
908
+
803
909
  const showMe: {
804
910
  level: ShowMeLevel;
805
911
  ghost?: string | undefined;
@@ -1004,6 +1110,7 @@ function snapshot(
1004
1110
  : value.tempoBpm,
1005
1111
  key: value.key ?? undefined,
1006
1112
  loopBeats: loopTicksOf(value) / value.ticksPerBeat,
1113
+ ...(value.loop ? { loopRange: value.loop } : {}),
1007
1114
  beatsPerBar: value.beatsPerBar,
1008
1115
  ...(hasMeterChanges(value)
1009
1116
  ? {
@@ -1074,12 +1181,16 @@ function appView(value: TrackScore, beat: number): AppView {
1074
1181
  showMe:
1075
1182
  showMe.ghost || showMe.caption
1076
1183
  ? { ghost: showMe.ghost, caption: showMe.caption }
1077
- : undefined,
1184
+ : gestureEcho.text
1185
+ ? { caption: gestureEcho.text }
1186
+ : undefined,
1078
1187
  sync: syncState,
1079
1188
  sessionName: record.meta.name,
1080
1189
  windows: windowCount,
1190
+ pane: port.pane,
1081
1191
  types: typesIndicator,
1082
1192
  play: play?.on ? play.header() : undefined,
1193
+ tape: tape.on && !play?.on ? currentTapeView(value, beat) : undefined,
1083
1194
  loudness: masterLoudness(value),
1084
1195
  };
1085
1196
  }
@@ -1403,6 +1514,9 @@ async function runInteractive(): Promise<void> {
1403
1514
  // Builds a frame only when something can have changed (tui/frame-gate.ts):
1404
1515
  // an idle editor no longer rebuilds the whole view 30 times a second.
1405
1516
  const frameGate = new FrameGate(IDLE_HEARTBEAT_MS, MIN_FRAME_GAP_MS);
1517
+ // `DAWG_FRAME_LOG=<file>`: one `WxH ms bytes` line per frame (the size
1518
+ // matrix in test/sizes-lib.ts reads frame times from it).
1519
+ const frameLog = process.env.DAWG_FRAME_LOG;
1406
1520
  const unwatchActivity = tui.activity.subscribe(() => frameGate.markDirty());
1407
1521
  const animating = (): boolean => {
1408
1522
  if (clock.playing || play?.on || auditionLoop?.looping) return true;
@@ -1418,6 +1532,7 @@ async function runInteractive(): Promise<void> {
1418
1532
  };
1419
1533
  const tick = (force = false) => {
1420
1534
  if (screenSuspended) return;
1535
+ reportView();
1421
1536
  if (
1422
1537
  !frameGate.shouldBuild({
1423
1538
  nowMs: Date.now(),
@@ -1430,6 +1545,7 @@ async function runInteractive(): Promise<void> {
1430
1545
  record.meta.name,
1431
1546
  syncState,
1432
1547
  windowCount,
1548
+ panePresence,
1433
1549
  typesIndicator,
1434
1550
  menu.open,
1435
1551
  euclid.open,
@@ -1448,7 +1564,12 @@ async function runInteractive(): Promise<void> {
1448
1564
  // The gate already paces frames; the app's own throttle would drop an
1449
1565
  // approved frame that lands just after a forced one, and the gate would
1450
1566
  // not ask again until the heartbeat.
1451
- tui.render(appView(score, clock.beatAt()), { force: true });
1567
+ const out = tui.render(appView(score, clock.beatAt()), { force: true });
1568
+ if (frameLog)
1569
+ appendFileSync(
1570
+ frameLog,
1571
+ `${stdout.columns ?? 0}x${stdout.rows ?? 0} ${tui.lastRenderMs.toFixed(3)} ${out.length}\n`,
1572
+ );
1452
1573
  };
1453
1574
  requestFrame = () => tick(true);
1454
1575
  // The first wrap is remembered per song in .dawg metadata, never the score.
@@ -1462,6 +1583,10 @@ async function runInteractive(): Promise<void> {
1462
1583
  queuedPrompts.unshift(command);
1463
1584
  void drainQueue();
1464
1585
  };
1586
+ runPromptsLater = (commands) => {
1587
+ queuedPrompts.unshift(...commands);
1588
+ void drainQueue();
1589
+ };
1465
1590
  reportAgentActivity = () => {
1466
1591
  tui.activity.applyAgentEvent({ type: "start", model: providerName });
1467
1592
  };
@@ -1488,10 +1613,7 @@ async function runInteractive(): Promise<void> {
1488
1613
  };
1489
1614
  stdout.on("resize", onResize);
1490
1615
  const ownWrites = new OwnWrites();
1491
- let presenceClients: readonly {
1492
- clientId: string;
1493
- focusedTrackId?: string | null;
1494
- }[] = [];
1616
+ let presenceClients: readonly PresenceEntry[] = [];
1495
1617
  let applying: Promise<void> = Promise.resolve();
1496
1618
  const applyLatest = (latest: typeof record): Promise<void> =>
1497
1619
  (applying = applying.then(() => applyRecord(latest)));
@@ -1544,10 +1666,33 @@ async function runInteractive(): Promise<void> {
1544
1666
  const from = shownRevision(record, previousRevision);
1545
1667
  const to = shownRevision(record);
1546
1668
  await ownWrites.settled();
1547
- const foreign = foreignEvents(latest, previousRevision, ownWrites).some(
1548
- (event) => event.kind !== "transport",
1669
+ const foreignList = foreignEvents(
1670
+ latest,
1671
+ previousRevision,
1672
+ ownWrites,
1673
+ ).filter((event) => event.kind !== "transport");
1674
+ const foreign = foreignList.length > 0;
1675
+ // dawgd stamps each event's author: another pane's edit is its
1676
+ // letter and what it touched, only when it touches this pane's
1677
+ // track (§12.7); no "synced from another window" card.
1678
+ const letters = paneLetters(
1679
+ foreignList,
1680
+ presenceClients,
1681
+ port.clientId,
1549
1682
  );
1550
- if (to !== from && foreign)
1683
+ if (to !== from && foreign && letters.length > 0) {
1684
+ const edited = editedTrack(before, scoreFromJSON(latest.composition));
1685
+ if (edited === undefined || edited === requestedTrack)
1686
+ tui.activity.pushCard(
1687
+ `${letters.join(" ")} ✓ ${
1688
+ edited
1689
+ ? (score.tracks.find((track) => track.id === edited)?.name ??
1690
+ edited)
1691
+ : "song"
1692
+ }`,
1693
+ { tone: "info", baseRevision: from, resultRevision: to },
1694
+ );
1695
+ } else if (to !== from && foreign)
1551
1696
  tui.activity.pushCard(
1552
1697
  `synced · ${otherWindowName(presenceClients, port.clientId, {
1553
1698
  editedTrackId: editedTrack(
@@ -1571,6 +1716,8 @@ async function runInteractive(): Promise<void> {
1571
1716
  } else if (update.type === "meta") adoptMeta(update.meta);
1572
1717
  else if (update.type === "presence") {
1573
1718
  presenceClients = update.clients;
1719
+ followPresence(panePresence, update.clients);
1720
+ panePresence = update.clients;
1574
1721
  windowCount = update.clients.length;
1575
1722
  } else if (update.type === "sync") syncState = update.sync;
1576
1723
  else if (update.type === "transport") {
@@ -1603,6 +1750,7 @@ async function runInteractive(): Promise<void> {
1603
1750
  .presence()
1604
1751
  .then((clients) => {
1605
1752
  presenceClients = clients;
1753
+ panePresence = clients;
1606
1754
  windowCount = Math.max(1, clients.length);
1607
1755
  })
1608
1756
  .catch(() => undefined);
@@ -1616,6 +1764,7 @@ async function runInteractive(): Promise<void> {
1616
1764
  syncState = port.sync;
1617
1765
  unsubscribe = subscribeLive();
1618
1766
  refreshPresence();
1767
+ reportedView = "";
1619
1768
  };
1620
1769
  if (freshWorkspace)
1621
1770
  tui.activity.pushCard("created .dawg/ · add it to .gitignore", {
@@ -1638,6 +1787,7 @@ async function runInteractive(): Promise<void> {
1638
1787
  void currentProvider().then(() => tick(true));
1639
1788
  tickUi = () => tick(true);
1640
1789
  reportSampleProblems(score);
1790
+ if (launchArgs?.pane) void openPane(launchArgs.pane).then(() => tick(true));
1641
1791
  // Input arrives through a detachable listener (not `for await`), so /login
1642
1792
  // can hand the terminal to an interactive shell flow and take it back.
1643
1793
  const inbox: string[] = [];
@@ -1718,6 +1868,22 @@ async function runInteractive(): Promise<void> {
1718
1868
  ];
1719
1869
  while (values.length) {
1720
1870
  const value = values.shift()!;
1871
+ // Below the minimum size only quit and play work; the hidden UI keeps
1872
+ // its state for when the terminal grows back (tui/app.ts).
1873
+ if (
1874
+ isTooSmall({ width: stdout.columns ?? 80, height: stdout.rows ?? 24 })
1875
+ ) {
1876
+ const key = tooSmallKey(value);
1877
+ if (key === "quit") {
1878
+ exiting = true;
1879
+ break;
1880
+ }
1881
+ if (key === "play")
1882
+ void toggleTransport()
1883
+ .catch((error: unknown) => transportFailed(error))
1884
+ .finally(() => tick(true));
1885
+ continue;
1886
+ }
1721
1887
  // A mouse report acts on what the last frame painted under it; some
1722
1888
  // become keys (a list row, a wheel notch) and run through below.
1723
1889
  if (typeof value === "string" && isMouseSequence(value)) {
@@ -1728,6 +1894,14 @@ async function runInteractive(): Promise<void> {
1728
1894
  }
1729
1895
  // The fader drawer owns every key while it is up.
1730
1896
  if (typeof value === "string" && fader && menu.open) {
1897
+ // Tab flips the knob front page and every param (design §8.6).
1898
+ if (value === "\t" && menu.knobPage && fader.typing === undefined) {
1899
+ const label = menu.pageKnobs(menuContext(), fader.label);
1900
+ if (label) fader.label = label;
1901
+ refreshMenu();
1902
+ tick(true);
1903
+ continue;
1904
+ }
1731
1905
  const result = faderKeyPress(
1732
1906
  fader,
1733
1907
  menu.faderFields(menuContext()),
@@ -1803,7 +1977,10 @@ async function runInteractive(): Promise<void> {
1803
1977
  }
1804
1978
  } else if (result.type === "audition")
1805
1979
  auditionKeyPressed(result.key);
1806
- else if (result.type === "fader") openFader(result.label);
1980
+ else if (result.type === "fader")
1981
+ openFader(
1982
+ menu.openKnobs(menuContext(), result.label) ?? result.label,
1983
+ );
1807
1984
  else if (result.type === "revert") revertStaged();
1808
1985
  else if (result.type === "hover")
1809
1986
  hoverItem(result.command, result.key);
@@ -1852,6 +2029,20 @@ async function runInteractive(): Promise<void> {
1852
2029
  tick(true);
1853
2030
  continue;
1854
2031
  }
2032
+ if (typeof value === "string" && tape.on && tapeInput(value)) {
2033
+ tick(true);
2034
+ continue;
2035
+ }
2036
+ // Ctrl-T opens TAPE (op1-ux §6); again (or esc) goes home.
2037
+ if (value === "\u0014" && tui.ui.overlay === undefined) {
2038
+ if (tape.on) receipt(exitTape());
2039
+ else
2040
+ void enterTape()
2041
+ .then((outcome) => receipt(outcome))
2042
+ .finally(() => tick(true));
2043
+ tick(true);
2044
+ continue;
2045
+ }
1855
2046
  // Ctrl-P enters play mode. A bare `p` would steal the first letter of
1856
2047
  // `pan`, `pattern`, `play` and every prose request starting with p.
1857
2048
  if (value === "\u0010" && tui.ui.overlay === undefined) {
@@ -2074,7 +2265,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
2074
2265
  return {
2075
2266
  label: track.id,
2076
2267
  value: `/track ${track.id}`,
2077
- detail: `${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}`,
2268
+ detail: `${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}${paneMarks(track.id)}`,
2078
2269
  current: track.id === requestedTrack,
2079
2270
  };
2080
2271
  });
@@ -2116,6 +2307,13 @@ async function submit(prompt: string): Promise<string | Receipt> {
2116
2307
  `${lines.length} note${lines.length === 1 ? "" : "s"} · ${requestedTrack} · remove <id>`,
2117
2308
  );
2118
2309
  }
2310
+ const tapeCommand = command.match(/^\/tape(?:\s+(on|off))?$/i);
2311
+ if (tapeCommand) {
2312
+ const wanted = tapeCommand[1]?.toLowerCase();
2313
+ if (wanted === "off" || (wanted === undefined && tape.on))
2314
+ return exitTape();
2315
+ return enterTape();
2316
+ }
2119
2317
  const playCommand = command.match(
2120
2318
  /^\/play(?:\s+(on|off|degrees|in-key|chromatic))?$/i,
2121
2319
  );
@@ -2147,6 +2345,23 @@ async function submit(prompt: string): Promise<string | Receipt> {
2147
2345
  );
2148
2346
  return result.ok ? ok(result.message) : fail(result.message);
2149
2347
  }
2348
+ const audioCommand = command.match(/^\/audio(?:\s+(.*))?$/i);
2349
+ if (audioCommand) {
2350
+ const result = await runAudioCommand(audioCommand[1] ?? "", {
2351
+ info: audioInfo(),
2352
+ setOutput: (name) => {
2353
+ if (audio instanceof AudioEngine) audio.setDevice(name);
2354
+ monitorEngine?.setDevice(name);
2355
+ },
2356
+ background: (task) => void task,
2357
+ });
2358
+ audioMenuCache = undefined;
2359
+ if (result.ok && result.lines && /^\/audio(\s+test)?\s*$/i.test(command))
2360
+ tui.openText(/test/i.test(command) ? "audio test" : "audio", [
2361
+ ...result.lines,
2362
+ ]);
2363
+ return result.ok ? ok(result.message) : fail(result.message);
2364
+ }
2150
2365
  const showMeCommand = command.match(/^\/show-?me(?:\s+(\S+))?$/i);
2151
2366
  if (showMeCommand) {
2152
2367
  if (!showMeCommand[1])
@@ -2188,8 +2403,11 @@ async function submit(prompt: string): Promise<string | Receipt> {
2188
2403
  )
2189
2404
  ) {
2190
2405
  const context = menuContext();
2191
- const label =
2406
+ const opened =
2192
2407
  menu.showFader(context, command) ?? songFader(context, command);
2408
+ // Bare `volume` (and every scalar) opens on its knob page, when its
2409
+ // level has one and the param is a knob: the four knobs up front.
2410
+ const label = opened && (menu.openKnobs(context, opened) ?? opened);
2193
2411
  if (label) {
2194
2412
  const field = menu
2195
2413
  .faderFields(context)
@@ -2204,10 +2422,37 @@ async function submit(prompt: string): Promise<string | Receipt> {
2204
2422
  openFader(label, true);
2205
2423
  refreshMenu();
2206
2424
  return ok(
2207
- `${label}${value ? ` · ${value}` : ""} · ←→ adjust · enter keep · esc revert`,
2425
+ `${label}${value ? ` · ${value}` : ""} · ${
2426
+ menu.knobPage === "knobs"
2427
+ ? "↑↓ knob · ←→ turn · tab all"
2428
+ : "←→ adjust · enter keep · esc revert"
2429
+ }`,
2208
2430
  );
2209
2431
  }
2210
2432
  }
2433
+ // `mix`: the drawer's mixer page, every track's level as a fader.
2434
+ // `knobs [sound|mix|master|tempo|fx <effect>]`: a page's four knobs.
2435
+ const knobsCommand = command.match(/^\/?(?:(mix)|knobs(?:\s+(.+))?)$/i);
2436
+ if (knobsCommand) {
2437
+ const context = menuContext();
2438
+ const track = context.score.tracks.find((t) => t.id === context.trackId);
2439
+ const label = knobsCommand[1]
2440
+ ? openMixer(context)
2441
+ : openKnobPage(context, knobsCommand[2], track);
2442
+ if (!label)
2443
+ return fail(
2444
+ knobsCommand[1]
2445
+ ? "mix · no tracks yet · track <name>"
2446
+ : `knobs ${knobsCommand[2] ?? ""} · no such page · knobs ${KNOB_PAGE_WORDS.join("|")}`,
2447
+ );
2448
+ openFader(label, true);
2449
+ refreshMenu();
2450
+ return ok(
2451
+ knobsCommand[1]
2452
+ ? "mix · ↑↓ track · ←→ level · tab this track · enter keep · esc revert"
2453
+ : `knobs · ${menu.knobPageId} · ↑↓ knob · ←→ turn · tab all params`,
2454
+ );
2455
+ }
2211
2456
  const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
2212
2457
  if (menuCommand) {
2213
2458
  const section = menuCommand[1]?.toLowerCase();
@@ -2228,12 +2473,18 @@ async function submit(prompt: string): Promise<string | Receipt> {
2228
2473
  ? ok(message)
2229
2474
  : fail("usage: /grid 1/4|1/8|1/8T|1/16|1/16T|1/32");
2230
2475
  }
2476
+ const paneReceipt = paneCommand(command);
2477
+ if (paneReceipt) return paneReceipt;
2231
2478
  if (/^\/status$/i.test(command))
2232
2479
  return ok(
2233
2480
  `status · ${record.meta.name} · rev ${record.revision} · ${compositionDigest(record.composition)} · ${port.mode === "daemon" ? "shared via dawgd" : "saved locally · no daemon"}`,
2234
2481
  );
2235
- if (/^\/?undo$/i.test(command)) return stepHistory("undo");
2236
- if (/^\/?redo$/i.test(command)) return stepHistory("redo");
2482
+ const history = /^\/?(undo|redo)(?:\s+(all))?$/i.exec(command);
2483
+ if (history)
2484
+ return stepHistory(
2485
+ history[1]!.toLowerCase() as "undo" | "redo",
2486
+ history[2] ? "all" : "pane",
2487
+ );
2237
2488
  const trackCommand = command.match(
2238
2489
  /^\/?(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/i,
2239
2490
  );
@@ -2466,7 +2717,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
2466
2717
  score,
2467
2718
  requestedTrack,
2468
2719
  progression,
2469
- () => randomUUID().slice(0, 12),
2720
+ () => newId("n"),
2470
2721
  );
2471
2722
  if (result.next && result.kind)
2472
2723
  await commitScore(result.next, result.kind, result.payload);
@@ -2476,7 +2727,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
2476
2727
  if (strum) {
2477
2728
  if (strum.type === "strum") await materializeDraft();
2478
2729
  const result = applyStrumCommand(score, requestedTrack, strum, () =>
2479
- randomUUID().slice(0, 12),
2730
+ newId("n"),
2480
2731
  );
2481
2732
  if (result.next && result.kind)
2482
2733
  await commitScore(result.next, result.kind, result.payload);
@@ -2541,6 +2792,8 @@ async function submit(prompt: string): Promise<string | Receipt> {
2541
2792
  await commitScore(result.next, result.kind, result.payload);
2542
2793
  return result.ok ? ok(result.message) : fail(result.message);
2543
2794
  }
2795
+ const recordCommand = parseRecordCommand(command);
2796
+ if (recordCommand) return keysRecord(recordCommand);
2544
2797
  const keysCommand = parseKeysCommand(command);
2545
2798
  if (keysCommand) {
2546
2799
  const reads =
@@ -2609,6 +2862,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
2609
2862
  const result = applySynthCommand(score, requestedTrack, synth);
2610
2863
  if (result.next && result.kind)
2611
2864
  await commitScore(result.next, result.kind, result.payload);
2865
+ // Bare `synth` (design §8.6) also opens the sound page's four knobs.
2866
+ if (synth.type === "synth-list" && result.ok && !stageCapture) {
2867
+ const context = menuContext();
2868
+ const track = context.score.tracks.find((t) => t.id === context.trackId);
2869
+ const label = openKnobPage(context, "sound", track);
2870
+ if (label) {
2871
+ openFader(label, true);
2872
+ refreshMenu();
2873
+ }
2874
+ }
2612
2875
  return readOrDone(result);
2613
2876
  }
2614
2877
  const stringCommand = parseStringCommand(command);
@@ -2692,30 +2955,79 @@ async function submit(prompt: string): Promise<string | Receipt> {
2692
2955
  await commitScore(result.next, result.kind, result.payload);
2693
2956
  return readOrDone(result);
2694
2957
  }
2695
- // `loop <section> | <a>-<b> | off`: what playback loops, a section.
2958
+ // Range commands (op1-ux §6.4): copy/move/clear/paste/reverse bars,
2959
+ // bars insert|remove, loop next|prev, jump.
2960
+ const rangeCommand = parseRangeCommand(command, score);
2961
+ if (rangeCommand) {
2962
+ const reads =
2963
+ rangeCommand.type === "range-usage" ||
2964
+ rangeCommand.type === "jump-bar" ||
2965
+ rangeCommand.type === "jump-section" ||
2966
+ (rangeCommand.type === "range-copy" && rangeCommand.to === undefined);
2967
+ if (!reads) await materializeDraft();
2968
+ // TAPE's first paste after a cut (§6.2): the `move` reads the bars from
2969
+ // the score before the cut's clear, so cut and paste land as one move.
2970
+ const folded =
2971
+ rangeCommand.type === "range-move" &&
2972
+ tape.foldArmed &&
2973
+ tape.cut &&
2974
+ cutArmed(tape.cut.after, score)
2975
+ ? foldBase(tape.cut.before, score)
2976
+ : undefined;
2977
+ tape.foldArmed = undefined;
2978
+ const cutBase =
2979
+ rangeCommand.type === "range-clear" ? tape.cutArmed : undefined;
2980
+ if (rangeCommand.type === "range-clear") tape.cutArmed = undefined;
2981
+ const result = applyRangeCommand(
2982
+ folded ?? score,
2983
+ {
2984
+ trackId: requestedTrack,
2985
+ playheadBar: playheadBar(),
2986
+ ...(rangeClipboard ? { clipboard: rangeClipboard } : {}),
2987
+ },
2988
+ rangeCommand,
2989
+ );
2990
+ if (result.delegate) return submit(result.delegate);
2991
+ if (result.clipboard) {
2992
+ rangeClipboard = result.clipboard;
2993
+ tape.cut = undefined;
2994
+ void saveClipboard(paneClipboardPath(), rangeClipboard).catch(
2995
+ () => undefined,
2996
+ );
2997
+ }
2998
+ if (result.next && result.kind)
2999
+ await commitScore(result.next, result.kind, result.payload);
3000
+ if (cutBase && result.next) tape.cut = { before: cutBase, after: score };
3001
+ else if (result.next && result.kind) tape.cut = undefined;
3002
+ if (result.seekBeat !== undefined) await seekTransport(result.seekBeat);
3003
+ return readOrDone(result);
3004
+ }
3005
+ // `loop <a>-<b> | <section> | off`: what playback loops. Bars set the
3006
+ // loop range (score.loop); a name loops that section.
2696
3007
  const loop = parseLoopCommand(command);
2697
3008
  if (loop) {
2698
3009
  if (loop.type === "loop-show")
2699
3010
  return note(
2700
- score.loopSection
2701
- ? `loop · ${score.loopSection} · loop off plays the song`
2702
- : `loop · the song · ${LOOP_USAGE}`,
3011
+ score.loop
3012
+ ? `loop · bars ${rangeLabel(score.loop)} · loop off plays the song`
3013
+ : score.loopSection
3014
+ ? `loop · ${score.loopSection} · loop off plays the song`
3015
+ : `loop · the song · ${LOOP_USAGE}`,
2703
3016
  );
2704
- if (loop.type === "loop-off") return submit("section loop off");
3017
+ if (loop.type === "loop-off") {
3018
+ if (score.loop) {
3019
+ await materializeDraft();
3020
+ await commitScore(score.withLoop(null), "score.loop", { loop: null });
3021
+ return ok("loop · off · playing the song");
3022
+ }
3023
+ return submit("section loop off");
3024
+ }
2705
3025
  if (loop.type === "loop-section")
2706
3026
  return submit(`section loop ${loop.name}`);
2707
- const span = score.sections.find(
2708
- (section) =>
2709
- section.startBar + 1 === loop.from &&
2710
- section.startBar + section.bars === loop.to,
2711
- );
2712
- if (span) return submit(`section loop ${span.name}`);
2713
- // No section spans the bars: mark (or move) the section named `loop`
2714
- // and loop it, as one revision.
2715
- await materializeDraft();
2716
- const result = loopSpan(score, requestedTrack, loop.from, loop.to);
3027
+ const result = loopSpan(score, loop.from, loop.to);
2717
3028
  if (!result.ok) return fail(result.message);
2718
- await commitScore(result.next, "score.sections", { loop: result.name });
3029
+ await materializeDraft();
3030
+ await commitScore(result.next, "score.loop", { loop: result.next.loop });
2719
3031
  return ok(result.message);
2720
3032
  }
2721
3033
  const arrange = parseSectionCommand(command, score);
@@ -2736,7 +3048,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
2736
3048
  if (music) {
2737
3049
  await materializeDraft();
2738
3050
  const result = applyMusicCommand(score, requestedTrack, music, () =>
2739
- randomUUID().slice(0, 12),
3051
+ newId("n"),
2740
3052
  );
2741
3053
  if (result.next && result.kind)
2742
3054
  await commitScore(result.next, result.kind, result.payload);
@@ -2958,6 +3270,22 @@ async function submit(prompt: string): Promise<string | Receipt> {
2958
3270
  ? vocalChainPatch(score.tracks.find((t) => t.id === requestedTrack))
2959
3271
  : {};
2960
3272
  const patch = { ...chain, ...rigged };
3273
+ // `volume drums 0.5` names its track; it must exist.
3274
+ if (parsed.trackId !== undefined) {
3275
+ const target = score.tracks.find(
3276
+ (candidate) =>
3277
+ candidate.id === parsed.trackId ||
3278
+ candidate.name.toLowerCase() === parsed.trackId,
3279
+ );
3280
+ if (!target) return fail(`no track ${parsed.trackId}`);
3281
+ const next = applyScoreOperation(score, {
3282
+ type: "updateTrack",
3283
+ trackId: target.id,
3284
+ patch,
3285
+ });
3286
+ await commitScore(next, "score.track", { trackId: target.id, patch });
3287
+ return `track · ${target.id}`;
3288
+ }
2961
3289
  const next = applyScoreOperation(score, {
2962
3290
  type: "updateTrack",
2963
3291
  trackId: requestedTrack,
@@ -3079,7 +3407,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
3079
3407
  const latest = attempt === 0 ? record : await port.load();
3080
3408
  const latestScore = scoreFromJSON(latest.composition);
3081
3409
  const note = {
3082
- id: `${requestedTrack}-${latest.revision + 1}-${randomUUID().slice(0, 6)}`,
3410
+ id: newId(requestedTrack),
3083
3411
  trackId: requestedTrack,
3084
3412
  startTick: Math.round(parsed.start * latestScore.ticksPerBeat),
3085
3413
  durationTicks: Math.max(
@@ -3116,17 +3444,26 @@ async function submit(prompt: string): Promise<string | Receipt> {
3116
3444
  * not in the score yet. A track another live window has focused stays
3117
3445
  * theirs; focus goes through the port so presence is right everywhere.
3118
3446
  */
3119
- async function focusTrack(trackId: string): Promise<Receipt> {
3447
+ async function focusTrack(
3448
+ trackId: string,
3449
+ options: { follow?: boolean } = {},
3450
+ ): Promise<Receipt> {
3120
3451
  if (trackId === requestedTrack && !draftTrack)
3121
3452
  return warn(`already on ${trackId}`);
3453
+ if (paneOptions.pinned)
3454
+ return warn(`pinned to ${requestedTrack} · unpin to change`);
3455
+ // Typing a track yourself stops following (§12.5).
3456
+ if (!options.follow && paneOptions.follow) paneOptions.follow = undefined;
3457
+ // Focus is not exclusive (§12.5): a track another pane shows is a soft
3458
+ // note on the receipt, never a refusal.
3122
3459
  const clients = await port.presence().catch(() => []);
3123
- if (
3124
- clients.some(
3125
- (client) =>
3126
- client.clientId !== port.clientId && client.focusedTrackId === trackId,
3127
- )
3128
- )
3129
- return warn(`${trackId} is open in another window`);
3460
+ const sharing = clients.find(
3461
+ (client) =>
3462
+ client.clientId !== port.clientId && client.focusedTrackId === trackId,
3463
+ );
3464
+ const shared = sharing
3465
+ ? ` · ${sharing.pane ? `pane ${sharing.pane}` : "another pane"} is on ${trackId} too`
3466
+ : "";
3130
3467
  const existing = score.tracks.find((track) => track.id === trackId);
3131
3468
  const exists = existing !== undefined;
3132
3469
  // `track cloud`, `track hold-2`: a new granular track with that preset.
@@ -3165,11 +3502,11 @@ async function focusTrack(trackId: string): Promise<Receipt> {
3165
3502
  `track · ${trackId} is a ${existing.instrument} track · grain ${grainPreset} makes it granular`,
3166
3503
  );
3167
3504
  return ok(
3168
- exists
3505
+ (exists
3169
3506
  ? `track · ${trackId}`
3170
3507
  : grainPreset
3171
3508
  ? `track created · ${trackId} · granular ${grainPreset} · nothing to download`
3172
- : `track created · ${trackId}`,
3509
+ : `track created · ${trackId}`) + shared,
3173
3510
  );
3174
3511
  }
3175
3512
 
@@ -3892,6 +4229,7 @@ async function switchSession(sessionId: string): Promise<void> {
3892
4229
  setCurrent: true,
3893
4230
  });
3894
4231
  port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
4232
+ actor,
3895
4233
  paths: next.paths,
3896
4234
  sessionId,
3897
4235
  label: "window",
@@ -3918,13 +4256,269 @@ function liveEngine(): LiveEngine | undefined {
3918
4256
  return previewEngine();
3919
4257
  }
3920
4258
 
4259
+ /**
4260
+ * The engine play mode sounds through: dawgd's shared engine when the
4261
+ * daemon offers one (every pane's notes and one click mix there, §12.3),
4262
+ * else this window's own monitor. Never both.
4263
+ */
4264
+ function playEngine(): LiveEngine | undefined {
4265
+ const link = port.liveLink();
4266
+ if (!link) return previewEngine();
4267
+ if (sharedLive?.link !== link)
4268
+ sharedLive = { link, engine: new SharedLiveEngine(link) };
4269
+ return sharedLive.engine;
4270
+ }
4271
+
4272
+ /**
4273
+ * What this pane shows, for the other panes' presence markers (§12.7):
4274
+ * sent only when it changes, and only to a daemon that has panes.
4275
+ */
4276
+ function paneView(): PaneView {
4277
+ const session = play;
4278
+ const view: PaneView = {
4279
+ screen: session?.on
4280
+ ? "play"
4281
+ : tape.on
4282
+ ? "tape"
4283
+ : fader
4284
+ ? "sound"
4285
+ : menu.open
4286
+ ? "menu"
4287
+ : "home",
4288
+ };
4289
+ if (fader) view.param = fader.label.slice(0, 64);
4290
+ if (session?.on) view.playing = true;
4291
+ if (session?.recording)
4292
+ view.recording = session.replace ? "replace" : "overdub";
4293
+ if (paneOptions.pinned) view.pinned = true;
4294
+ if (paneOptions.follow) view.follow = paneOptions.follow;
4295
+ return view;
4296
+ }
4297
+ function reportView(): void {
4298
+ const view = paneView();
4299
+ const key = JSON.stringify(view);
4300
+ if (key === reportedView) return;
4301
+ reportedView = key;
4302
+ port.setView(view);
4303
+ }
4304
+
4305
+ /**
4306
+ * `dawg pane <screen> …` at launch: focus its track, then open the screen
4307
+ * through the same commands a person would type, so the pane starts where
4308
+ * the shell line said.
4309
+ */
4310
+ async function openPane(pane: PaneArgs): Promise<void> {
4311
+ paneOptions.pinned = pane.pin;
4312
+ paneOptions.follow = pane.follow;
4313
+ let result: string | Receipt | undefined;
4314
+ if (pane.screen === "play") result = await enterPlay();
4315
+ else if (pane.screen === "tape") result = await enterTape();
4316
+ else if (pane.screen === "sound")
4317
+ result = await submit(pane.param ?? "volume");
4318
+ else if (pane.screen === "menu")
4319
+ result = await submit(`/menu${pane.param ? ` ${pane.param}` : ""}`);
4320
+ if (result !== undefined) receipt(result);
4321
+ }
4322
+
4323
+ /**
4324
+ * Other panes on `trackId`, for a track row's gutter (§12.7): their letters,
4325
+ * a red-dot `●` after a pane that is recording. Empty for a solo window.
4326
+ */
4327
+ function paneMarks(trackId: string): string {
4328
+ const marks = panePresence
4329
+ .filter(
4330
+ (entry) =>
4331
+ entry.clientId !== port.clientId &&
4332
+ entry.pane &&
4333
+ entry.focusedTrackId === trackId,
4334
+ )
4335
+ .map((entry) => `${entry.pane}${entry.recording ? "●" : ""}`);
4336
+ return marks.length ? ` ${marks.join(" ")}` : "";
4337
+ }
4338
+
4339
+ /**
4340
+ * Another pane recording on `trackId` (§12.4): `pane B`, else undefined.
4341
+ * Play refuses to replace a pass while one is, naming it.
4342
+ */
4343
+ function recordingPane(trackId: string): string | undefined {
4344
+ const other = panePresence.find(
4345
+ (entry) =>
4346
+ entry.clientId !== port.clientId &&
4347
+ entry.recording !== undefined &&
4348
+ entry.focusedTrackId === trackId,
4349
+ );
4350
+ if (!other) return undefined;
4351
+ return other.pane ? `pane ${other.pane}` : "another pane";
4352
+ }
4353
+
4354
+ /** A drawer row another pane has open on this track shows its letter. */
4355
+ function withPaneMarks(view: DrawerView): DrawerView {
4356
+ const others = panePresence.filter(
4357
+ (entry) =>
4358
+ entry.clientId !== port.clientId &&
4359
+ entry.pane &&
4360
+ entry.param &&
4361
+ entry.screen === "sound" &&
4362
+ entry.focusedTrackId === requestedTrack,
4363
+ );
4364
+ if (others.length === 0) return view;
4365
+ return {
4366
+ ...view,
4367
+ fields: view.fields.map((field) => {
4368
+ const peers = others
4369
+ .filter((entry) => entry.param === field.label)
4370
+ .map((entry) => entry.pane)
4371
+ .join(" ");
4372
+ return peers ? { ...field, peers } : field;
4373
+ }),
4374
+ };
4375
+ }
4376
+
4377
+ /** One pane's line for `pane`: letter, screen, track, parameter, state. */
4378
+ function paneLine(entry: PresenceEntry): string {
4379
+ const bits = [
4380
+ `${entry.pane ?? "·"} ${entry.screen ?? "home"}`,
4381
+ entry.focusedTrackId ?? "no track",
4382
+ ];
4383
+ if (entry.param) bits.push(entry.param);
4384
+ if (entry.recording) bits.push(`● ${entry.recording}`);
4385
+ if (entry.pinned) bits.push("pinned");
4386
+ if (entry.follow) bits.push(`follows ${entry.follow}`);
4387
+ if (entry.clientId === port.clientId) bits.push("this pane");
4388
+ return bits.join(" · ");
4389
+ }
4390
+
4391
+ /**
4392
+ * `pane` lists panes; `pane <screen> [track]…` prints the shell line that
4393
+ * opens one (dawg can't open terminal windows portably), with the tmux
4394
+ * split line inside tmux, and copies it via OSC 52. `pin`, `unpin`,
4395
+ * `follow [letter]`, `unfollow`. Undefined: not a pane command.
4396
+ */
4397
+ function paneCommand(command: string): Receipt | undefined {
4398
+ const text = command.trim();
4399
+ if (/^\/?panes?$/i.test(text)) {
4400
+ if (port.mode !== "daemon")
4401
+ return ok("solo pane · no daemon · DAWG_DAEMON=0 is set");
4402
+ const entries = [...panePresence].sort((a, b) =>
4403
+ (a.pane ?? "~").localeCompare(b.pane ?? "~"),
4404
+ );
4405
+ if (entries.length === 0) return ok("1 pane · this one");
4406
+ return ok(`${entries.length} panes │ ${entries.map(paneLine).join(" │ ")}`);
4407
+ }
4408
+ const open = text.match(/^\/?pane\s+(.+)$/i);
4409
+ if (open) {
4410
+ const parsed = parsePaneArgs(open[1]!.split(/\s+/));
4411
+ if (!parsed.ok) return fail(parsed.problem);
4412
+ const line = `dawg pane ${open[1]!.trim().toLowerCase()}`;
4413
+ copyToClipboard(line);
4414
+ return ok(
4415
+ process.env.TMUX
4416
+ ? `run · tmux split-window ${line} · copied`
4417
+ : `run in another terminal · ${line} · copied`,
4418
+ );
4419
+ }
4420
+ if (/^\/?pin$/i.test(text)) {
4421
+ paneOptions.pinned = true;
4422
+ paneOptions.follow = undefined;
4423
+ return ok(`pinned to ${requestedTrack} · unpin releases`);
4424
+ }
4425
+ if (/^\/?unpin$/i.test(text)) {
4426
+ if (!paneOptions.pinned) return warn("not pinned");
4427
+ paneOptions.pinned = false;
4428
+ return ok("unpinned");
4429
+ }
4430
+ const follow = text.match(/^\/?follow(?:\s+([a-z]))?$/i);
4431
+ if (follow) {
4432
+ if (port.mode !== "daemon") return warn("follow needs dawgd · solo pane");
4433
+ paneOptions.pinned = false;
4434
+ paneOptions.follow = follow[1]?.toUpperCase() ?? "*";
4435
+ followPresence([], panePresence);
4436
+ return ok(
4437
+ paneOptions.follow === "*"
4438
+ ? "following the latest pane's track"
4439
+ : `following pane ${paneOptions.follow}`,
4440
+ );
4441
+ }
4442
+ if (/^\/?unfollow$/i.test(text)) {
4443
+ if (!paneOptions.follow) return warn("not following");
4444
+ paneOptions.follow = undefined;
4445
+ return ok("unfollowed");
4446
+ }
4447
+ return undefined;
4448
+ }
4449
+
4450
+ /** OSC 52: the terminal copies `text` where it supports it; else a no-op. */
4451
+ function copyToClipboard(text: string): void {
4452
+ if (!stdout.isTTY) return;
4453
+ stdout.write(`\u001b]52;c;${Buffer.from(text).toString("base64")}\u0007`);
4454
+ }
4455
+
4456
+ /**
4457
+ * A following pane takes the track its target focuses: one letter, or for
4458
+ * `*` the other pane whose focus changed most recently. Pinned panes never
4459
+ * move on someone else's focus.
4460
+ */
4461
+ function followPresence(
4462
+ before: readonly PresenceEntry[],
4463
+ after: readonly PresenceEntry[],
4464
+ ): void {
4465
+ const target = paneOptions.follow;
4466
+ if (!target || paneOptions.pinned) return;
4467
+ const others = after.filter((entry) => entry.clientId !== port.clientId);
4468
+ let next: string | null | undefined;
4469
+ if (target !== "*")
4470
+ next = others.find((entry) => entry.pane === target)?.focusedTrackId;
4471
+ else {
4472
+ const moved = others.filter(
4473
+ (entry) =>
4474
+ before.find((old) => old.clientId === entry.clientId)
4475
+ ?.focusedTrackId !== entry.focusedTrackId,
4476
+ );
4477
+ next = (moved.at(-1) ?? (before.length === 0 ? others.at(-1) : undefined))
4478
+ ?.focusedTrackId;
4479
+ }
4480
+ if (!next || next === requestedTrack) return;
4481
+ void focusTrack(next, { follow: true }).then((result) => {
4482
+ if (result.ok) {
4483
+ receipt(
4484
+ ok(
4485
+ `follows ${target === "*" ? "latest pane" : `pane ${target}`} · ${next}`,
4486
+ ),
4487
+ );
4488
+ tickUi();
4489
+ }
4490
+ });
4491
+ }
4492
+
3921
4493
  /** The engine this window hears itself through (its own in daemon mode). */
3922
4494
  function previewEngine(): AudioEngine {
3923
4495
  if (audio instanceof AudioEngine) return audio;
3924
- monitorEngine ??= new AudioEngine({ projectRoot: process.cwd() });
4496
+ monitorEngine ??= new AudioEngine({
4497
+ projectRoot: process.cwd(),
4498
+ onStatus: (status) => {
4499
+ if (status.state === "device")
4500
+ tui.activity.pushCard(`audio · ${status.message}`, { tone: "warning" });
4501
+ },
4502
+ });
3925
4503
  return monitorEngine;
3926
4504
  }
3927
4505
 
4506
+ /** This machine's player, without starting an engine just to ask. */
4507
+ function audioInfo(): AudioBackendInfo {
4508
+ if (audio instanceof AudioEngine) return audio.info;
4509
+ if (monitorEngine) return monitorEngine.info;
4510
+ audioInfoCache ??= detectAudioBackend();
4511
+ return audioInfoCache;
4512
+ }
4513
+
4514
+ /** Project › audio's rows; device lists are re-read at most every 3 s. */
4515
+ function audioMenu(): MenuAudioDevices {
4516
+ const now = performance.now();
4517
+ if (!audioMenuCache || now - audioMenuCache.at > 3000)
4518
+ audioMenuCache = { at: now, state: audioMenuState(audioInfo()) };
4519
+ return audioMenuCache.state;
4520
+ }
4521
+
3928
4522
  /** The play session for the focused track (re-made when focus moves). */
3929
4523
  function menuContext(): MenuContext {
3930
4524
  const session = play;
@@ -3949,6 +4543,7 @@ function menuContext(): MenuContext {
3949
4543
  clickOn: session?.clickOn ?? false,
3950
4544
  countInBars: session?.countInBars ?? 1,
3951
4545
  showMe: showMe.level,
4546
+ audio: audioMenu(),
3952
4547
  chords: stagedChordSettings() ?? session?.chords.settings ?? chordSettings,
3953
4548
  projectRoot: process.cwd(),
3954
4549
  };
@@ -3973,6 +4568,7 @@ function keysScreen(): readonly KeySection[] | undefined {
3973
4568
  if (prompt.value.length > 0) return undefined;
3974
4569
  if (play?.on)
3975
4570
  return play.chords.on ? [...KEYS.play, ...KEYS.chords] : KEYS.play;
4571
+ if (tape.on) return KEYS.tape;
3976
4572
  return KEYS.prompt;
3977
4573
  }
3978
4574
 
@@ -4456,14 +5052,20 @@ function refreshMenu(): void {
4456
5052
  if (fields.length === 0) closeFader();
4457
5053
  else {
4458
5054
  const focusedField = fields[focusIndex(fader, fields)];
4459
- tui.drawer = drawerView(fader, fields, menu.faderCommitted(context), {
5055
+ const drawer = drawerView(fader, fields, menu.faderCommitted(context), {
4460
5056
  title: menu.crumbs,
4461
5057
  dirty: context.audition?.dirty ?? false,
4462
5058
  status: context.audition?.status,
4463
5059
  atOnce:
4464
5060
  focusedField !== undefined &&
4465
5061
  !stageableNow(faderCommand(focusedField)),
5062
+ knobs: menu.knobPage,
4466
5063
  });
5064
+ tui.drawer = withPaneMarks(
5065
+ menu.knobPage === "knobs"
5066
+ ? { ...drawer, aside: drawerAside(menu.pageFields(context), fields) }
5067
+ : drawer,
5068
+ );
4467
5069
  }
4468
5070
  }
4469
5071
  const view = menu.view(context);
@@ -4531,6 +5133,50 @@ async function exportWav(path: string, stems: boolean): Promise<Receipt> {
4531
5133
  }
4532
5134
  }
4533
5135
 
5136
+ /** `mix`: Mix › mixer in the drawer, on the focused track's level. */
5137
+ function openMixer(context: MenuContext): string | undefined {
5138
+ if (context.score.tracks.length === 0) return undefined;
5139
+ menu.show(context, "mix");
5140
+ if (!menu.enter(context, "mixer")) {
5141
+ menu.close();
5142
+ return undefined;
5143
+ }
5144
+ const first = menu.openKnobs(context);
5145
+ const fields = menu.faderFields(context);
5146
+ const here = fields.find((field) => field.label.includes("›"));
5147
+ if (!first || fields.length === 0) {
5148
+ menu.close();
5149
+ return undefined;
5150
+ }
5151
+ return here?.label ?? first;
5152
+ }
5153
+
5154
+ /** `knobs <page>`: open that page's level and its four knobs. */
5155
+ function openKnobPage(
5156
+ context: MenuContext,
5157
+ word: string | undefined,
5158
+ track: TrackScore["tracks"][number] | undefined,
5159
+ ): string | undefined {
5160
+ const page = knobPageId(word, track);
5161
+ if (!page) return undefined;
5162
+ if (page.startsWith("fx:")) {
5163
+ if (!menu.showFader(context, `fx ${page.slice(3)}`)) return undefined;
5164
+ } else {
5165
+ const section = page.startsWith("sound:")
5166
+ ? "sound"
5167
+ : page === "tempo"
5168
+ ? "project"
5169
+ : page;
5170
+ menu.show(context, section);
5171
+ }
5172
+ const label = menu.openKnobs(context);
5173
+ if (!label || menu.knobPageId !== page) {
5174
+ menu.close();
5175
+ return undefined;
5176
+ }
5177
+ return label;
5178
+ }
5179
+
4534
5180
  /** `tempo`, `bars`, `meter`: open Project and name its fader, if it has one. */
4535
5181
  function songFader(context: MenuContext, command: string): string | undefined {
4536
5182
  // Bare song scalars and the Project fader each opens.
@@ -4628,6 +5274,10 @@ function mouseInput(event: MouseEvent): string[] {
4628
5274
  const frame = tui.frame;
4629
5275
  if (!frame) return [];
4630
5276
  const target = frame.hits.at(event.x, event.y)?.target;
5277
+ if (tape.on && !play?.on && !tui.ui.overlay && !menu.open) {
5278
+ const handled = tapeMouse(event, target);
5279
+ if (handled) return handled;
5280
+ }
4631
5281
  if (event.kind === "wheel") {
4632
5282
  const fields = fader && menu.open ? menu.faderFields(menuContext()) : [];
4633
5283
  if (
@@ -4784,12 +5434,325 @@ async function enterPlay(): Promise<Receipt> {
4784
5434
  );
4785
5435
  }
4786
5436
 
5437
+ /**
5438
+ * `keys record [replace|off]` (op1-ux §4.7, r / R on TAPE): opens PLAY on
5439
+ * the focused track and arms recording. With a loop set, every pass over
5440
+ * it commits once, so one undo step; esc goes back to TAPE.
5441
+ */
5442
+ async function keysRecord(
5443
+ mode: "overdub" | "replace" | "off",
5444
+ ): Promise<Receipt> {
5445
+ if (mode === "off" && !play?.on) return ok("record off");
5446
+ if (!play?.on) {
5447
+ const entered = await enterPlay();
5448
+ if (!play?.on) return entered;
5449
+ }
5450
+ const result = playSession().recordCommand(mode);
5451
+ reportView();
5452
+ return result.ok ? ok(result.message) : fail(result.message);
5453
+ }
5454
+
4787
5455
  async function exitPlay(): Promise<Receipt> {
4788
5456
  if (!play?.on) return ok("play mode is off");
4789
5457
  await play.exit();
4790
5458
  return ok("play off");
4791
5459
  }
4792
5460
 
5461
+ // ── TAPE (op1-ux §6) ─────────────────────────────────────────────────
5462
+
5463
+ /** This pane's clipboard file in `.dawg` (§6.3); undefined in a demo. */
5464
+ function paneClipboardPath(): string | undefined {
5465
+ if (demo || !record.sessionId) return undefined;
5466
+ return clipboardPath(process.cwd(), record.sessionId, port.pane);
5467
+ }
5468
+
5469
+ /** The paste's target beat until its `jump` lands (or a moment passes). */
5470
+ function pendingTapeBeat(value: TrackScore): number | undefined {
5471
+ const pending = tape.pending;
5472
+ if (!pending) return undefined;
5473
+ const here = scoreBeatAt(value, clock.beatAt());
5474
+ if (Date.now() > pending.until || clock.playing || here >= pending.beat) {
5475
+ tape.pending = undefined;
5476
+ return undefined;
5477
+ }
5478
+ return pending.beat;
5479
+ }
5480
+
5481
+ /** What TAPE's reducer and view read: the score, focus, playhead, clipboard. */
5482
+ function tapeContext(
5483
+ value: TrackScore = score,
5484
+ beat = clock.beatAt(),
5485
+ ): TapeContext {
5486
+ const board = rangeClipboard;
5487
+ return {
5488
+ score: value,
5489
+ trackId: requestedTrack,
5490
+ beat: pendingTapeBeat(value) ?? scoreBeatAt(value, beat),
5491
+ clipboard: board ? { source: board.source, range: board.range } : undefined,
5492
+ cut: cutArmed(tape.cut?.after, value),
5493
+ };
5494
+ }
5495
+
5496
+ function currentTapeView(value: TrackScore, beat: number): TapeView {
5497
+ const view = tapeView({
5498
+ ...tapeContext(value, beat),
5499
+ // A pending paste's jump has not landed: its bar's first pass shows.
5500
+ transportBeat: pendingTapeBeat(value) === undefined ? beat : undefined,
5501
+ zoom: tape.zoom,
5502
+ selected: tape.knobs.selected,
5503
+ marks: paneMarks,
5504
+ });
5505
+ // §9.1 and §9.3: reels turn on the transport beat; the loop brackets
5506
+ // flash as each pass closes. Both are still with `/motion off`.
5507
+ const reducedMotion = tui.ui.reducedMotion;
5508
+ const withDelight: TapeView = {
5509
+ ...view,
5510
+ reel: reelGlyph({
5511
+ playing: clock.playing,
5512
+ beat,
5513
+ reducedMotion,
5514
+ unicode: tui.capabilities.unicode,
5515
+ }),
5516
+ loopFlash: loopFlash({
5517
+ playing: clock.playing,
5518
+ beat,
5519
+ loopBeats: loopedSection(value)
5520
+ ? loopTicksOf(value) / value.ticksPerBeat
5521
+ : undefined,
5522
+ bpm: bpmAtTick(value, scoreBeatAt(value, beat) * value.ticksPerBeat),
5523
+ reducedMotion,
5524
+ }),
5525
+ };
5526
+ tape.view = withDelight;
5527
+ return withDelight;
5528
+ }
5529
+
5530
+ /** Whether the clipboard's source bars differ from what the score holds now. */
5531
+ function clipboardStale(): boolean {
5532
+ const board = rangeClipboard;
5533
+ if (!board) return false;
5534
+ try {
5535
+ const ids = board.clip.all ? undefined : [board.source];
5536
+ const now = extractRange(score, ids, board.range);
5537
+ return JSON.stringify(now) !== JSON.stringify(board.clip);
5538
+ } catch {
5539
+ return true;
5540
+ }
5541
+ }
5542
+
5543
+ /** Typed commands from a TAPE gesture: each echoes and runs as if typed. */
5544
+ function runTyped(commands: readonly string[]): void {
5545
+ echoGesture(commands);
5546
+ runPromptsLater(commands);
5547
+ }
5548
+
5549
+ /**
5550
+ * The prompt row echoes a gesture's typed command dimly for one beat
5551
+ * (op1-ux §10.1: type what you see), at least long enough to read.
5552
+ */
5553
+ function echoGesture(commands: readonly string[]): void {
5554
+ if (commands.length === 0) return;
5555
+ gestureEcho.text = commands.join(" · ");
5556
+ if (gestureEcho.clear) clearTimeout(gestureEcho.clear);
5557
+ const beatMs = 60_000 / Math.max(1, score.tempoBpm);
5558
+ gestureEcho.clear = setTimeout(
5559
+ () => {
5560
+ gestureEcho.text = undefined;
5561
+ gestureEcho.clear = undefined;
5562
+ requestFrame();
5563
+ },
5564
+ Math.max(800, beatMs),
5565
+ );
5566
+ gestureEcho.clear.unref?.();
5567
+ requestFrame();
5568
+ }
5569
+
5570
+ async function enterTape(): Promise<Receipt> {
5571
+ if (play?.on) await play.exit();
5572
+ if (tape.on) return ok("tape · esc goes home");
5573
+ closeFader();
5574
+ if (menu.open) menu.close();
5575
+ tape.on = true;
5576
+ tape.knobs.selected = 0;
5577
+ if (!rangeClipboard) {
5578
+ rangeClipboard = await loadClipboard(paneClipboardPath());
5579
+ }
5580
+ return ok(
5581
+ `tape · ${score.tracks.length} track${score.tracks.length === 1 ? "" : "s"} · ${score.bars} bars · ? keys · esc home`,
5582
+ );
5583
+ }
5584
+
5585
+ function exitTape(): Receipt {
5586
+ if (!tape.on) return ok("tape is off");
5587
+ tape.on = false;
5588
+ tape.view = undefined;
5589
+ tape.drag = undefined;
5590
+ return ok("home");
5591
+ }
5592
+
5593
+ /**
5594
+ * One key on TAPE. True when TAPE consumed it; false sends it on (the
5595
+ * prompt once a command is typed, overlays, ctrl keys).
5596
+ */
5597
+ function tapeInput(value: string): boolean {
5598
+ if (!tape.on || play?.on) return false;
5599
+ if (tui.ui.overlay !== undefined || menu.open) return false;
5600
+ if (prompt.value.length > 0) return false;
5601
+ if (value === "/" || value === "\u0003") return false;
5602
+ const base = tapeContext();
5603
+ // After a cut the source bars are empty on purpose: not stale.
5604
+ const context = { ...base, stale: !base.cut && clipboardStale() };
5605
+ const action = tapeKey(context, value);
5606
+ switch (action.type) {
5607
+ case "run":
5608
+ if (action.cut) tape.cutArmed = score;
5609
+ runTyped(action.commands);
5610
+ return true;
5611
+ case "paste": {
5612
+ if (action.fold) tape.foldArmed = true;
5613
+ const commands = [action.command];
5614
+ // The playhead moves past the paste so `v v v` tiles.
5615
+ if (action.end < score.bars) {
5616
+ commands.push(`jump ${action.end + 1}`);
5617
+ tape.pending = {
5618
+ beat: beatOfBar(score, action.end),
5619
+ until: Date.now() + 2000,
5620
+ };
5621
+ }
5622
+ runTyped(commands);
5623
+ return true;
5624
+ }
5625
+ case "note":
5626
+ tui.activity.pushCard(action.message, { tone: "info" });
5627
+ return true;
5628
+ case "zoom": {
5629
+ const index = TAPE_ZOOMS.indexOf(tape.zoom) + action.direction;
5630
+ tape.zoom =
5631
+ TAPE_ZOOMS[Math.max(0, Math.min(TAPE_ZOOMS.length - 1, index))]!;
5632
+ return true;
5633
+ }
5634
+ case "focus": {
5635
+ const command = focusCommand(score, action.row);
5636
+ if (command) runTyped([command]);
5637
+ return true;
5638
+ }
5639
+ case "transport":
5640
+ void toggleTransport().catch((error: unknown) => transportFailed(error));
5641
+ return true;
5642
+ case "keys":
5643
+ showKeys();
5644
+ return true;
5645
+ case "exit":
5646
+ receipt(exitTape());
5647
+ return true;
5648
+ case "pass":
5649
+ break;
5650
+ }
5651
+ const knob = knobKey(tape.knobs, tapeKnobs(context), value);
5652
+ if (knob.type === "run") {
5653
+ runTyped([knob.command]);
5654
+ return true;
5655
+ }
5656
+ if (knob.type === "open") {
5657
+ runTyped([knobNoun(context, knob.knob)]);
5658
+ return true;
5659
+ }
5660
+ if (knob.type === "handled") return true;
5661
+ // Unmapped printable keys are swallowed (as in play mode); control keys
5662
+ // (enter, ctrl-z, ctrl-k) keep their bindings.
5663
+ return value.length === 1 && value >= " " && value !== "\u007f";
5664
+ }
5665
+
5666
+ /**
5667
+ * The mouse on TAPE: a ruler click jumps there and a ruler drag sets the
5668
+ * loop, a row click focuses it, the clipboard chip pastes, and the wheel
5669
+ * turns the selected knob. Each runs its typed command. Undefined: not a
5670
+ * TAPE gesture, so the ordinary mouse path takes it.
5671
+ */
5672
+ function tapeMouse(
5673
+ event: MouseEvent,
5674
+ target: HitTarget | undefined,
5675
+ ): string[] | undefined {
5676
+ if (event.kind === "wheel") {
5677
+ if (!target || (target.kind !== "tape-ruler" && target.kind !== "tape-row"))
5678
+ return undefined;
5679
+ // The wheel walks the playhead a bar at a time (never a tempo knob).
5680
+ const bar = playheadBar();
5681
+ const next = Math.max(
5682
+ 0,
5683
+ Math.min(score.bars - 1, bar + (event.delta < 0 ? -1 : 1)),
5684
+ );
5685
+ if (next !== bar) runTyped([`jump ${next + 1}`]);
5686
+ return [];
5687
+ }
5688
+ const drag = tape.drag;
5689
+ if (drag && (event.kind === "drag" || event.kind === "up")) {
5690
+ const view = tape.view;
5691
+ const first = drag.first;
5692
+ const bar =
5693
+ view && first !== undefined
5694
+ ? tapeBarAt(event.x, drag.left, first)
5695
+ : undefined;
5696
+ if (bar !== undefined) drag.to = bar;
5697
+ if (event.kind === "up") {
5698
+ tape.drag = undefined;
5699
+ if (drag.to !== drag.from) {
5700
+ const start = Math.min(drag.from, drag.to);
5701
+ const end = Math.max(drag.from, drag.to);
5702
+ runTyped([`loop ${start + 1}-${end + 1}`]);
5703
+ }
5704
+ }
5705
+ return [];
5706
+ }
5707
+ if (event.kind !== "down" || event.button !== "left" || !target)
5708
+ return undefined;
5709
+ if (target.kind === "tape-ruler") {
5710
+ const bar = tapeBarAt(event.x, target.left, target.firstCell);
5711
+ if (bar === undefined) return [];
5712
+ tape.drag = {
5713
+ from: bar,
5714
+ to: bar,
5715
+ left: target.left,
5716
+ first: target.firstCell,
5717
+ };
5718
+ runTyped([`jump ${bar + 1}`]);
5719
+ return [];
5720
+ }
5721
+ if (target.kind === "tape-row") {
5722
+ // A drag along a row sets the loop over those bars, like the ruler.
5723
+ const bar = tapeBarAt(event.x, target.left, target.firstCell);
5724
+ if (bar !== undefined)
5725
+ tape.drag = {
5726
+ from: bar,
5727
+ to: bar,
5728
+ left: target.left,
5729
+ first: target.firstCell,
5730
+ };
5731
+ const command = focusCommand(score, target.row);
5732
+ if (command && score.tracks[target.row]?.id !== requestedTrack)
5733
+ runTyped([command]);
5734
+ return [];
5735
+ }
5736
+ if (target.kind === "tape-clipboard") {
5737
+ tapeInput("v");
5738
+ return [];
5739
+ }
5740
+ return undefined;
5741
+ }
5742
+
5743
+ /** The bar under a TAPE column, from the last painted view. */
5744
+ function tapeBarAt(
5745
+ x: number,
5746
+ left: number,
5747
+ firstCell: number,
5748
+ ): number | undefined {
5749
+ const view = tape.view;
5750
+ if (!view) return undefined;
5751
+ const cell = x - left + firstCell;
5752
+ if (cell < 0 || cell >= view.cellBars.length) return undefined;
5753
+ return view.cellBars[cell];
5754
+ }
5755
+
4793
5756
  /**
4794
5757
  * One key in play mode. True when play mode consumed it; false sends it on
4795
5758
  * to the normal bindings (overlays, a command being typed, Ctrl-C, arrows).
@@ -4828,8 +5791,11 @@ function playHost() {
4828
5791
  playing: () => clock.playing,
4829
5792
  beatAt: (ms: number) => clock.beatAt(ms),
4830
5793
  scoreBeat: (beat: number) => scoreBeatAt(score, beat),
4831
- engine: liveEngine,
5794
+ engine: playEngine,
4832
5795
  samples: () => liveSampleBank,
5796
+ // A take is a plain commitScore (it diffs into operations, so dawgd
5797
+ // rebases a pass over other panes' edits); a stale record reloads and
5798
+ // replays the take's operations on what landed meanwhile.
4833
5799
  async commit(
4834
5800
  next: TrackScore,
4835
5801
  kind: string,
@@ -4839,15 +5805,7 @@ function playHost() {
4839
5805
  let target = next;
4840
5806
  for (let attempt = 0; attempt < 3; attempt += 1) {
4841
5807
  try {
4842
- record = await port.appendOperations(
4843
- record,
4844
- { kind, payload },
4845
- operations,
4846
- target.toJSON(),
4847
- );
4848
- score = scoreFromJSON(record.composition);
4849
- if (clock.playing) void audio.play(score);
4850
- projectSync?.scoreChanged(score);
5808
+ await commitScore(target, kind, payload);
4851
5809
  return;
4852
5810
  } catch (error) {
4853
5811
  if (!(error instanceof SessionConflictError)) throw error;
@@ -4861,6 +5819,7 @@ function playHost() {
4861
5819
  }
4862
5820
  throw new Error("session busy");
4863
5821
  },
5822
+ recordingElsewhere: (trackId: string) => recordingPane(trackId),
4864
5823
  async startTransport(beat: number): Promise<void> {
4865
5824
  if (port.mode === "daemon") {
4866
5825
  await port.transport("play", { beat });
@@ -4886,7 +5845,7 @@ function playHost() {
4886
5845
  tone === "success" ? shownRevision(record) : undefined,
4887
5846
  });
4888
5847
  },
4889
- newNoteId: () => randomUUID().slice(0, 12),
5848
+ newNoteId: () => newId("n"),
4890
5849
  };
4891
5850
  }
4892
5851
 
@@ -4907,15 +5866,48 @@ async function commitScore(
4907
5866
  if (retimed) clock.follow(score);
4908
5867
  return;
4909
5868
  }
4910
- record = await port.append(record, { kind, payload }, next.toJSON());
4911
- score = next;
4912
- if (retimed) clock.follow(score);
5869
+ // Every edit travels as score operations (snapshot when the diff cannot
5870
+ // express it), so dawgd rebases it over other panes' concurrent edits per
5871
+ // (track, property) and the log replays as operations (design §12.6).
5872
+ const operations = diffOps(score, next);
5873
+ if (operations) {
5874
+ record = await port.appendOperations(
5875
+ record,
5876
+ { kind, payload },
5877
+ operations,
5878
+ next.toJSON(),
5879
+ );
5880
+ // A rebased edit lands on top of what other panes wrote meanwhile.
5881
+ score = deepEqual(record.composition, next.toJSON())
5882
+ ? next
5883
+ : scoreFromJSON(record.composition);
5884
+ } else {
5885
+ record = await port.append(record, { kind, payload }, next.toJSON());
5886
+ score = next;
5887
+ }
5888
+ if (retimed || score !== next) clock.follow(score);
4913
5889
  if (clock.playing) void audio.play(score);
4914
5890
  projectSync?.scoreChanged(score);
4915
5891
  reportSampleProblems(score);
4916
5892
  void updateCredits(score);
4917
5893
  }
4918
5894
 
5895
+ /** The ops from `previous` to `next`, or undefined when they do not diff. */
5896
+ function diffOps(
5897
+ previous: TrackScore,
5898
+ next: TrackScore,
5899
+ ): readonly ScoreOperation[] | undefined {
5900
+ try {
5901
+ const operations = diffScores(previous, next);
5902
+ return operations.length > 0 && operations.length <= 256
5903
+ ? operations
5904
+ : undefined;
5905
+ } catch (error) {
5906
+ if (error instanceof DiffError) return undefined;
5907
+ throw error;
5908
+ }
5909
+ }
5910
+
4919
5911
  /** True when the transport clock must follow `next` (tempo, meter, loop). */
4920
5912
  function timingChanged(previous: TrackScore, next: TrackScore): boolean {
4921
5913
  return (
@@ -4968,39 +5960,91 @@ function syncHost(): SyncHost {
4968
5960
  };
4969
5961
  }
4970
5962
 
4971
- async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
5963
+ /**
5964
+ * Undo and redo (design §12.6). By default a pane steps its own edits: the
5965
+ * newest edit this client made, inverted and rebased over whatever other
5966
+ * panes did since. `undo all` / `redo all` step the shared history, whoever
5967
+ * made the edit, and the receipt names the other pane. A lone pane (or one
5968
+ * that has made no edit since it opened) falls back to the shared history,
5969
+ * so single-pane sessions behave exactly as before.
5970
+ */
5971
+ async function stepHistory(
5972
+ direction: "undo" | "redo",
5973
+ scope: "pane" | "all" = "pane",
5974
+ ): Promise<Receipt> {
4972
5975
  const latest = await port.load();
4973
5976
  // A fork's undo continues into its parent's history past the fork point.
4974
- const target = historyTarget(
4975
- latest.composition,
4976
- await historyEvents(process.cwd(), latest),
4977
- direction,
5977
+ const events = await historyEvents(process.cwd(), latest);
5978
+ const before = scoreFromJSON(latest.composition);
5979
+ const kind = direction === "undo" ? UNDO_KIND : REDO_KIND;
5980
+ const key = direction === "undo" ? "undoneRevision" : "redoneRevision";
5981
+ const otherPanes = panePresence.filter(
5982
+ (entry) => entry.clientId !== port.clientId,
4978
5983
  );
4979
- if (!target) return warn(`nothing to ${direction}`);
5984
+ let next: TrackScore;
5985
+ let revision: number;
5986
+ let author = "";
5987
+ if (scope === "pane") {
5988
+ const step = paneHistoryStep(
5989
+ latest.composition,
5990
+ events,
5991
+ direction,
5992
+ port.clientId,
5993
+ );
5994
+ if (!step || (!step.ok && step.others.length === 0)) {
5995
+ if (otherPanes.length === 0) return stepHistory(direction, "all");
5996
+ if (!step)
5997
+ return warn(
5998
+ `nothing of this pane's to ${direction} · ${direction} all steps everyone's`,
5999
+ );
6000
+ }
6001
+ if (!step.ok) {
6002
+ if (step.reason === "session-wide")
6003
+ return warn(`that edit is session-wide · ${direction} all`);
6004
+ const who = step.others.map(paneName).join(", ");
6005
+ return warn(
6006
+ `${who} changed ${step.reason.replace(/ changed$/, "")} since · ${direction} all ${direction === "undo" ? "undoes" : "redoes"} theirs too`,
6007
+ );
6008
+ }
6009
+ next = step.next;
6010
+ revision = step.revision;
6011
+ } else {
6012
+ const target = historyTarget(latest.composition, events, direction);
6013
+ if (!target) return warn(`nothing to ${direction}`);
6014
+ next = scoreFromJSON(target.composition);
6015
+ revision = target.revision;
6016
+ const writer = events.find((event) => event.revision === revision)?.actor
6017
+ ?.clientId;
6018
+ if (writer && writer !== port.clientId && otherPanes.length > 0)
6019
+ author = ` (${paneName(writer)})`;
6020
+ }
4980
6021
  try {
4981
- const restored = scoreFromJSON(target.composition);
4982
- record = await port.append(
6022
+ const event = {
6023
+ kind,
6024
+ payload: { [key]: revision, ...(scope === "pane" ? {} : { scope }) },
6025
+ };
6026
+ let operations: readonly ScoreOperation[] = [];
6027
+ try {
6028
+ operations = diffScores(before, next);
6029
+ } catch (error) {
6030
+ if (!(error instanceof DiffError)) throw error;
6031
+ }
6032
+ record = await port.appendOperations(
4983
6033
  latest,
4984
- {
4985
- kind: direction === "undo" ? UNDO_KIND : REDO_KIND,
4986
- payload: {
4987
- [direction === "undo" ? "undoneRevision" : "redoneRevision"]:
4988
- target.revision,
4989
- },
4990
- },
4991
- restored.toJSON(),
6034
+ event,
6035
+ operations,
6036
+ next.toJSON(),
4992
6037
  );
4993
6038
  // Compare with what was undone, which another window may have written.
4994
- const before = scoreFromJSON(latest.composition);
4995
- score = restored;
6039
+ score = scoreFromJSON(record.composition);
4996
6040
  if (clock.playing) void audio.play(score);
4997
6041
  return ok(
4998
6042
  historyReceipt(
4999
6043
  direction,
5000
6044
  before,
5001
- restored,
5002
- shownRevision(record, target.revision),
5003
- ),
6045
+ score,
6046
+ shownRevision(record, revision),
6047
+ ) + author,
5004
6048
  );
5005
6049
  } catch (error) {
5006
6050
  if (error instanceof SessionConflictError)
@@ -5011,6 +6055,12 @@ async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
5011
6055
  }
5012
6056
  }
5013
6057
 
6058
+ /** `pane B` for a client in presence, `another pane` once it has left. */
6059
+ function paneName(clientId: string): string {
6060
+ const pane = panePresence.find((entry) => entry.clientId === clientId)?.pane;
6061
+ return pane ? `pane ${pane}` : "another pane";
6062
+ }
6063
+
5014
6064
  /** Space, the menu and a header click all report a failed toggle the same way. */
5015
6065
  function transportFailed(error: unknown): void {
5016
6066
  tui.activity.pushCard(
@@ -5091,6 +6141,12 @@ async function setTransport(
5091
6141
  }
5092
6142
 
5093
6143
  /** Move the playhead to transport `beat`, playing or not (section jump). */
6144
+ /** The bar under the playhead, 0-based on the score. */
6145
+ function playheadBar(): number {
6146
+ const beat = scoreBeatAt(score, clock.beatAt());
6147
+ return barAt(score, Math.max(0, Math.round(beat * score.ticksPerBeat))).bar;
6148
+ }
6149
+
5094
6150
  async function seekTransport(beat: number): Promise<void> {
5095
6151
  if (port.mode === "daemon") {
5096
6152
  await port.transport("seek", { beat });