@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
@@ -382,6 +382,14 @@ export const HELP_SECTIONS: readonly HelpSection[] = [
382
382
  entries: [
383
383
  { command: "volume <0..1>", summary: "track level" },
384
384
  { command: "pan <-1..1>", summary: "left … right" },
385
+ {
386
+ command: "knobs [sound|mix|master|tempo|fx <effect>]",
387
+ summary: "four knobs ● moves ▲ sizes ■ shapes ◆ level · tab all params",
388
+ },
389
+ {
390
+ command: "mix | volume <track> <0..1> | pan <track> <-1..1>",
391
+ summary: "the mixer page · volume drums 0.5 sets drums, focus stays",
392
+ },
385
393
  { command: "mute", summary: "silence this track" },
386
394
  { command: "unmute", summary: "hear it again" },
387
395
  { command: "solo", summary: "only this track" },
@@ -463,8 +471,61 @@ export const HELP_SECTIONS: readonly HelpSection[] = [
463
471
  command: "style list|search|info · style blend <a> <b> [w]",
464
472
  summary: "the style tree · style search maqam",
465
473
  },
466
- { command: "bars <count>", summary: "loop length, 1–256" },
467
- { command: "extend <count> bars", summary: "lengthen the loop" },
474
+ { command: "bars <count>", summary: "song length, 1–256" },
475
+ { command: "extend <count> bars", summary: "lengthen the song" },
476
+ ],
477
+ },
478
+ {
479
+ group: "arrange · range",
480
+ entries: [
481
+ {
482
+ command: "/tape [on|off]",
483
+ summary: "every track across the bars · c copy x cut v paste · ctrl-t",
484
+ },
485
+ {
486
+ command: "keys record [replace|off]",
487
+ summary: "record the loop in play · one undo step a pass · r R on tape",
488
+ },
489
+ {
490
+ command: "loop <a>-<b> | <section> | next | prev | off",
491
+ summary: "the loop range · loop 5-6 · loop next steps it along",
492
+ },
493
+ {
494
+ command: "copy [<track>|all] <a>-<b> to <bar> [x<N>] [insert|merge]",
495
+ summary: "copy bass 5-6 to 7 x2 · copy all chorus to 17 insert",
496
+ },
497
+ {
498
+ command: "move [<track>|all] <a>-<b> to <bar> [insert]",
499
+ summary: "move bass 5-6 to 9 · the source empties",
500
+ },
501
+ {
502
+ command: "clear [<track>|all] <a>-<b>",
503
+ summary: "clear bass 5-6 · empty bars, the bars stay",
504
+ },
505
+ {
506
+ command: "copy [<track>|all] <a>-<b> · paste [at <bar>] [x<N>]",
507
+ summary: "the clipboard · copy all 1-4 · paste at 9",
508
+ },
509
+ {
510
+ command: "reverse [<track>|all] <a>-<b>",
511
+ summary: "mirror the bars · reverse bass 5-6",
512
+ },
513
+ {
514
+ command: "bars insert <n> at <bar> | bars remove <a>-<b>",
515
+ summary: "shift later music · sections, clips, automation, tempo",
516
+ },
517
+ {
518
+ command: "jump <bar>[.<beat>] | <section>",
519
+ summary: "move the playhead · jump 5 · jump 5.3 · jump chorus",
520
+ },
521
+ {
522
+ command: "section split <name> at <bar> | join <name>",
523
+ summary: "section split chorus at 13 · section join chorus",
524
+ },
525
+ {
526
+ command: "form print",
527
+ summary: "print form to tape · alias of form bake",
528
+ },
468
529
  ],
469
530
  },
470
531
  {
@@ -508,8 +569,14 @@ export const HELP_SECTIONS: readonly HelpSection[] = [
508
569
  command: "calibration [0|1|latest|off]",
509
570
  summary: "sound fixes · 1 chokes hats, levels keys · 0 legacy",
510
571
  },
511
- { command: "undo", summary: "step back · Ctrl-Z" },
512
- { command: "redo", summary: "step forward · Ctrl-Y" },
572
+ {
573
+ command: "undo [all]",
574
+ summary: "this pane's last edit · Ctrl-Z · all: anyone's",
575
+ },
576
+ {
577
+ command: "redo [all]",
578
+ summary: "step forward · Ctrl-Y · all: anyone's",
579
+ },
513
580
  { command: "/status", summary: "name · revision · digest · storage" },
514
581
  ],
515
582
  },
@@ -540,6 +607,39 @@ export const HELP_SECTIONS: readonly HelpSection[] = [
540
607
  { command: "/fork [<name>]", summary: "copy into a new session" },
541
608
  ],
542
609
  },
610
+ {
611
+ group: "project · audio",
612
+ entries: [
613
+ {
614
+ command: "audio",
615
+ summary: "the output and input in use, and the lists",
616
+ },
617
+ {
618
+ command: "audio out <name|default>",
619
+ summary: "play through one output · saved on this machine",
620
+ },
621
+ {
622
+ command: "audio in <name|default>",
623
+ summary: "the input to meter (recording takes comes later)",
624
+ },
625
+ { command: "audio test", summary: "a short tone, then the input level" },
626
+ ],
627
+ },
628
+ {
629
+ group: "project · panes",
630
+ entries: [
631
+ { command: "panes", summary: "who has this session open, on what" },
632
+ {
633
+ command: "pane <screen> [<track>]",
634
+ summary: "copies dawg pane … for another terminal",
635
+ },
636
+ { command: "pin · unpin", summary: "keep this pane on its track" },
637
+ {
638
+ command: "follow [<letter>] · unfollow",
639
+ summary: "follow another pane's track · follow b",
640
+ },
641
+ ],
642
+ },
543
643
  {
544
644
  group: "agent",
545
645
  entries: [
@@ -599,6 +699,65 @@ export const HELP_GUIDE: readonly HelpSection[] = [
599
699
  },
600
700
  ];
601
701
 
702
+ /**
703
+ * `/help arrange`: one screen around the range verbs (design F11), each row
704
+ * a line to type. The full syntax of every arranging command stays in
705
+ * `/help all` and `/help <command>` (`/help copy`, `/help section`).
706
+ */
707
+ export const ARRANGE_PAGE: HelpSection = {
708
+ group: "arrange",
709
+ entries: [
710
+ {
711
+ command: "loop 5-6 · loop chorus",
712
+ summary: "loop the bars · loop next · loop off",
713
+ },
714
+ {
715
+ command: "copy bass 5-6 to 7",
716
+ summary: "copy bars · x2 repeats · all = every track",
717
+ },
718
+ {
719
+ command: "move bass 5-6 to 9",
720
+ summary: "move bars · the source empties",
721
+ },
722
+ {
723
+ command: "clear bass 5-6",
724
+ summary: "empty bars · reverse bass 5-6 mirrors",
725
+ },
726
+ {
727
+ command: "copy all 1-4 · paste at 9",
728
+ summary: "the clipboard, for later",
729
+ },
730
+ { command: "jump 5 · jump chorus", summary: "move the playhead" },
731
+ { command: "ctrl-t · tape", summary: "every track across the bars" },
732
+ {
733
+ command: "section chorus 9-16",
734
+ summary: "name bars · section loop chorus",
735
+ },
736
+ {
737
+ command: "form verse verse chorus",
738
+ summary: "song form · form print lays it out",
739
+ },
740
+ {
741
+ command: "build · drop · fill chorus",
742
+ summary: "riser · pre-drop cut · drum fill",
743
+ },
744
+ { command: "style deep-house 16", summary: "a whole song in a style" },
745
+ ],
746
+ };
747
+
748
+ /**
749
+ * `/help panes`: several terminals on one session (§12). Its rows are the
750
+ * `project · panes` reference group under a heading of its own.
751
+ */
752
+ function panesLines(width: number): string[] {
753
+ const group = HELP_SECTIONS.find((s) => s.group === "project · panes")!;
754
+ return [
755
+ ...sectionLines([{ ...group, group: "panes" }], width),
756
+ "",
757
+ truncate("dawg pane tape in another terminal · guide panes", width),
758
+ ];
759
+ }
760
+
602
761
  /** Topics `/help <topic>` takes, besides `all` (aliases resolve too). */
603
762
  export const HELP_TOPICS = TOPICS;
604
763
 
@@ -656,6 +815,8 @@ export function topicSections(id: TopicId): readonly HelpSection[] {
656
815
  /** One topic's page: its groups, then where else the topic opens. */
657
816
  function topicLines(id: TopicId, width: number): string[] {
658
817
  if (id === "keys") return [...keysLines(width), ...doors(id, width)];
818
+ if (id === "arrange")
819
+ return [...sectionLines([ARRANGE_PAGE], width), ...doors(id, width)];
659
820
  return [...sectionLines(topicSections(id), width), ...doors(id, width)];
660
821
  }
661
822
 
@@ -674,6 +835,7 @@ export function helpTopicLines(
674
835
  return helpLines(width);
675
836
  if ((TOPICS as readonly string[]).includes(name))
676
837
  return topicLines(name as TopicId, width);
838
+ if (name === "panes" || name === "pane") return panesLines(width);
677
839
  const spelled = HELP_COMMAND_ALIASES[name];
678
840
  if (spelled) return commandTopic(spelled, width, name);
679
841
  const alias = resolveTopic(name);
@@ -865,11 +1027,25 @@ export const USAGE: Readonly<Record<string, string>> = {
865
1027
  put: "add <note> at <beat> [for <beats>] · add C4 at 0",
866
1028
  remove: "remove <id> · ids show in the transcript",
867
1029
  delete: "remove <id> · ids show in the transcript",
868
- move: "move <id> to <beat>",
1030
+ move: "move <id> to <beat> · move [<track>|all] <a>-<b> to <bar> [insert] · move bass 5-6 to 9",
869
1031
  length: "length <id> <beats>",
870
1032
  velocity: "velocity <id> <0..1>",
871
1033
  vel: "velocity <id> <0..1>",
872
- bars: "bars takes 1…256 · bars 8",
1034
+ bars: "bars <count> (1…256) | bars insert <n> at <bar> | bars remove <a>-<b> · bars 8 · bars insert 2 at 3",
1035
+ copy: "copy [<track>|all] [<a>-<b>|<section>] [to <bar>] [x<N>] [insert|merge] · copy bass 5-6 to 7 x2",
1036
+ paste:
1037
+ "paste [at <bar>] [x<N>] [insert|merge] · paste at 9 · copy bass 5-6 fills the clipboard",
1038
+ reverse: "reverse [<track>|all] [<a>-<b>|<section>] · reverse bass 5-6",
1039
+ jump: "jump <bar>[.<beat>] | <section> · jump 5 · jump 5.3 · jump chorus",
1040
+ loop: "loop <a>-<b> | <section> | next | prev | off · loop 5-6 · loop chorus · loop off",
1041
+ pane: "pane home|play|tape|sound|menu [<track>] [pin|follow [<letter>]] · pane tape · pane sound bass pin",
1042
+ follow: "follow [<letter>] · follow b · unfollow stops",
1043
+ panes: "panes · who has this session open, on what · pane tape",
1044
+ knobs:
1045
+ "knobs [sound|mix|master|tempo|fx <effect>] · knobs mix · knobs fx reverb",
1046
+ mix: "mix · the mixer page · volume drums 0.5 · pan drums -0.2",
1047
+ audio:
1048
+ "audio [out|in <name|default>] | test · audio out default · audio test",
873
1049
  extend: "extend <count> bars · extend 4 bars",
874
1050
  instrument:
875
1051
  "instrument <name> · sine piano pluck bass sawtooth square triangle wavetable kit · pianos: grand upright felt honkytonk prepared · electric: epiano suitcase dyno wurli clav funkclav · synth: supersaw pulse white pink z_square · voices: vocal aah ooh choir chorale khoomei sygyt kargyraa vocoder…",
@@ -890,7 +1066,8 @@ export const USAGE: Readonly<Record<string, string>> = {
890
1066
  automation: "automate <lane> at <beat> <value> · automate volume at 0 0.5",
891
1067
  hit: "hit <drum> at <beat> · hit kick at 0",
892
1068
  pattern: "pattern <drum> <beats...> | every <step> · pattern kick every 1",
893
- clear: "clear · clear <drum> · clear [<lane>] automation",
1069
+ clear:
1070
+ "clear · clear <drum> · clear [<lane>] automation · clear [<track>|all] <a>-<b> · clear bass 5-6",
894
1071
  track:
895
1072
  "track <name> · track drums · track rm <name> · track move <name> <position> · track rate <0.125..8>|<a>/<b>|off · track phase <beats> · track loop <beats> · track phasing <beats> [over <beats>]",
896
1073
  tracks: "tracks",
@@ -977,6 +1154,7 @@ export const USAGE: Readonly<Record<string, string>> = {
977
1154
  "master <unit> on|off|preset <name>|<param> <value> · master streaming|club|loud · master target -14 · master measure · master off",
978
1155
  try: "/try <sound command> · /try fx reverb mix 0.6",
979
1156
  play: "/play [on|off|degrees|in-key|chromatic] · ctrl-p play mode · i toggles degrees",
1157
+ tape: "/tape [on|off] · ctrl-t · every track across the bars · c copy · x cut · v paste · \\ loop",
980
1158
  meter:
981
1159
  "meter <1..16> · meter 3 · meter 7/8 [at bar <n>] · meter remove bar <n> · meter clear",
982
1160
  art: EXPRESSION_USAGE.art,
@@ -990,9 +1168,9 @@ export const USAGE: Readonly<Record<string, string>> = {
990
1168
  velcurve: EXPRESSION_USAGE.velcurve,
991
1169
  humanize: EXPRESSION_USAGE.humanize,
992
1170
  section:
993
- "section [mark] <name> <a>-<b> | add [<name>] [<n>] | dup | move <name> to <bar> | rename <name> to <new> | delete | unmark | mute | vary | reset | loop <name>|off | jump <name> · section chorus 9-16",
1171
+ "section [mark] <name> <a>-<b> | add [<name>] [<n>] | dup | move <name> to <bar> | rename <name> to <new> | delete | unmark | mute | vary | reset | loop <name>|off | jump <name> | split <name> at <bar> | join <name> · section chorus 9-16",
994
1172
  sections: "section · lists sections, the form and the loop",
995
- form: "form <section…> | off | bake · form verse verse chorus verse · form verse chorus*2",
1173
+ form: "form <section…> | off | bake (print) · form verse verse chorus verse · form verse chorus*2",
996
1174
  build:
997
1175
  "build [into <section> | <section> | <a>-<b>] [<n> bars] [riser] [roll] [sweep] [uplifter] · build into chorus",
998
1176
  drop: "drop [<section> | at <bar>] [cut <beats>] [no impact] · drop chorus",
@@ -17,7 +17,13 @@
17
17
  * every newer event. Events whose rewind was compacted away, and events that
18
18
  * did not change the composition (transport), take no part.
19
19
  */
20
- import type { TrackScore } from "../../core/score.ts";
20
+ import { DiffError, diffScores } from "../../core/diff.ts";
21
+ import {
22
+ scoreFromJSON,
23
+ type ScoreOperation,
24
+ type TrackScore,
25
+ } from "../../core/score.ts";
26
+ import { rebaseOperations } from "../session/rebase.ts";
21
27
  import { musicalReceipt } from "../session/receipt.ts";
22
28
  import {
23
29
  isIdentityRewind,
@@ -29,6 +35,9 @@ export type HistoryEvent = Readonly<{
29
35
  revision: number;
30
36
  kind: string;
31
37
  rewind?: Rewind | undefined;
38
+ payload?: unknown;
39
+ /** Who wrote it; per-pane undo keys on `clientId` (design §12.6). */
40
+ actor?: Readonly<{ clientId: string }> | undefined;
32
41
  }>;
33
42
 
34
43
  export type HistoryTarget = Readonly<{
@@ -41,31 +50,133 @@ export type HistoryTarget = Readonly<{
41
50
  export const UNDO_KIND = "score.undo";
42
51
  export const REDO_KIND = "score.redo";
43
52
 
44
- export function historyTarget(
45
- composition: unknown,
53
+ /**
54
+ * Replays the log into undo and redo stacks of event indexes, over the
55
+ * events `include` admits. An undo or redo event pops its stack only when
56
+ * it stepped that stack's top (its payload names the revision); otherwise
57
+ * it is an ordinary edit there. So a pane's own undo is an edit in the
58
+ * global history (`undo all` can undo it), and an `undo all` that undid
59
+ * another pane's edit is an edit in this pane's history. Old events without
60
+ * the payload revision pop unconditionally, as they always did.
61
+ */
62
+ function historyStacks(
46
63
  events: readonly HistoryEvent[],
47
- direction: "undo" | "redo",
48
- ): HistoryTarget | undefined {
64
+ include: (event: HistoryEvent) => boolean,
65
+ ): { undo: number[]; redo: number[] } {
49
66
  const undo: number[] = [];
50
67
  const redo: number[] = [];
68
+ const steps = (stack: number[], event: HistoryEvent, key: string) => {
69
+ const top = stack.at(-1);
70
+ if (top === undefined) return false;
71
+ const named = (event.payload as Record<string, unknown> | null)?.[key];
72
+ return typeof named !== "number" || named === events[top]!.revision;
73
+ };
51
74
  events.forEach((event, index) => {
52
- if (!changesComposition(event)) return;
53
- if (event.kind === UNDO_KIND) {
54
- if (undo.pop() !== undefined) redo.push(index);
55
- } else if (event.kind === REDO_KIND) {
56
- if (redo.pop() !== undefined) undo.push(index);
75
+ if (!changesComposition(event) || !include(event)) return;
76
+ if (event.kind === UNDO_KIND && steps(undo, event, "undoneRevision")) {
77
+ undo.pop();
78
+ redo.push(index);
79
+ } else if (
80
+ event.kind === REDO_KIND &&
81
+ steps(redo, event, "redoneRevision")
82
+ ) {
83
+ redo.pop();
84
+ undo.push(index);
57
85
  } else {
58
86
  undo.push(index);
59
87
  redo.length = 0;
60
88
  }
61
89
  });
62
- const top = (direction === "undo" ? undo : redo).at(-1);
90
+ return { undo, redo };
91
+ }
92
+
93
+ /** `undo all` / `redo all`: the global history, whoever made each edit. */
94
+ export function historyTarget(
95
+ composition: unknown,
96
+ events: readonly HistoryEvent[],
97
+ direction: "undo" | "redo",
98
+ ): HistoryTarget | undefined {
99
+ const stacks = historyStacks(events, () => true);
100
+ const top = stacks[direction].at(-1);
63
101
  if (top === undefined) return undefined;
64
102
  const restored = rewindComposition(composition, events, top);
65
103
  if (restored === undefined) return undefined;
66
104
  return { revision: events[top]!.revision, composition: restored };
67
105
  }
68
106
 
107
+ export type PaneStep =
108
+ | Readonly<{ ok: true; revision: number; next: TrackScore }>
109
+ | Readonly<{
110
+ ok: false;
111
+ revision: number;
112
+ /** Why the inverse cannot land (rebase reason or session-wide). */
113
+ reason: string;
114
+ /** Other clients that changed the composition since, oldest first. */
115
+ others: readonly string[];
116
+ }>;
117
+
118
+ /**
119
+ * Per-pane undo and redo (design §12.6): the newest edit `clientId` made,
120
+ * inverted as score operations and rebased onto the current score with the
121
+ * same touch rules as agent intents. Another pane's later edit to the same
122
+ * note or track property refuses with a reason; unrelated edits stay.
123
+ * Undefined when this pane has nothing to step.
124
+ */
125
+ export function paneHistoryStep(
126
+ composition: unknown,
127
+ events: readonly HistoryEvent[],
128
+ direction: "undo" | "redo",
129
+ clientId: string,
130
+ ): PaneStep | undefined {
131
+ const stacks = historyStacks(
132
+ events,
133
+ (event) => event.actor?.clientId === clientId,
134
+ );
135
+ const top = stacks[direction].at(-1);
136
+ if (top === undefined) return undefined;
137
+ const revision = events[top]!.revision;
138
+ const others = [
139
+ ...new Set(
140
+ events
141
+ .slice(top + 1)
142
+ .filter(
143
+ (event) =>
144
+ changesComposition(event) && event.actor?.clientId !== clientId,
145
+ )
146
+ .map((event) => event.actor?.clientId ?? ""),
147
+ ),
148
+ ];
149
+ const refuse = (reason: string): PaneStep => ({
150
+ ok: false,
151
+ revision,
152
+ reason,
153
+ others,
154
+ });
155
+ const before = rewindComposition(composition, events, top);
156
+ const after =
157
+ top + 1 === events.length
158
+ ? composition
159
+ : rewindComposition(composition, events, top + 1);
160
+ if (before === undefined || after === undefined)
161
+ return refuse("history was compacted");
162
+ let operations: readonly ScoreOperation[];
163
+ let base: TrackScore;
164
+ let current: TrackScore;
165
+ try {
166
+ base = scoreFromJSON(after);
167
+ current = scoreFromJSON(composition);
168
+ operations = diffScores(base, scoreFromJSON(before));
169
+ } catch (error) {
170
+ if (error instanceof DiffError) return refuse("session-wide");
171
+ return refuse(error instanceof Error ? error.message : String(error));
172
+ }
173
+ if (operations.length === 0) return refuse("session-wide");
174
+ const result = rebaseOperations(base, current, operations);
175
+ return result.ok
176
+ ? { ok: true, revision, next: result.next }
177
+ : refuse(result.reason);
178
+ }
179
+
69
180
  function changesComposition(event: HistoryEvent): boolean {
70
181
  return event.rewind !== undefined && !isIdentityRewind(event.rewind);
71
182
  }
@@ -210,6 +210,23 @@ export function keysRowMismatch(
210
210
  return `${instrument} has no ${wrong.join(" ")} (${familiesReading(wrong[0]!).join("/")} row); its parameters: ${rows.join(" ")}`;
211
211
  }
212
212
 
213
+ /**
214
+ * `keys record` / `keys record replace` / `keys record off` (op1-ux §4.7):
215
+ * arm recording in PLAY, overdub or replace each pass. Not a score write
216
+ * of its own; each pass the take commits is.
217
+ */
218
+ export function parseRecordCommand(
219
+ prompt: string,
220
+ ): "overdub" | "replace" | "off" | undefined {
221
+ const match = prompt
222
+ .trim()
223
+ .match(/^\/?keys\s+record(?:\s+(overdub|replace|off))?$/i);
224
+ if (!match) return undefined;
225
+ return (match[1]?.toLowerCase() ?? "overdub") as
226
+ "overdub" | "replace" | "off";
227
+ }
228
+
229
+ /** Parse one keys / piano / organ command. */
213
230
  export function parseKeysCommand(prompt: string): KeysCommand | undefined {
214
231
  if (prompt.length > 1_024) return undefined;
215
232
  let words = prompt.trim().toLowerCase().split(/\s+/);
@@ -7,6 +7,7 @@
7
7
  import type { TrackScore } from "../../core/score.ts";
8
8
  import { parsePrompt } from "../agent/ops.ts";
9
9
  import { parseSectionCommand } from "./arrange.ts";
10
+ import { parseRangeCommand } from "./range.ts";
10
11
  import { parseAutotuneCommand } from "./autotune.ts";
11
12
  import { parseClipCommand, parseLyricsCommand } from "./clips.ts";
12
13
  import { parsePatternCommand } from "./drums.ts";
@@ -17,7 +18,7 @@ import { parseFitCommand } from "./fit.ts";
17
18
  import { parseFormantCommand, parseVowelCommand } from "./formant.ts";
18
19
  import { parseFxCommand } from "./fx.ts";
19
20
  import { parseGranularCommand } from "./granular.ts";
20
- import { parseKeysCommand } from "./keys.ts";
21
+ import { parseKeysCommand, parseRecordCommand } from "./keys.ts";
21
22
  import { parseMasterCommand } from "./master.ts";
22
23
  import { parseModalCommand } from "./modal.ts";
23
24
  import { parseMusicCommand } from "./music.ts";
@@ -85,6 +86,9 @@ export function commandParses(text: string, score: TrackScore): boolean {
85
86
 
86
87
  function parsers(score: TrackScore): ((text: string) => unknown)[] {
87
88
  return [
89
+ function parseRange(value: string) {
90
+ return parseRangeCommand(value, score);
91
+ },
88
92
  parsePrompt,
89
93
  parseMusicCommand,
90
94
  parseEditCommand,
@@ -93,6 +97,7 @@ function parsers(score: TrackScore): ((text: string) => unknown)[] {
93
97
  parseSynthCommand,
94
98
  parseStringCommand,
95
99
  parseGranularCommand,
100
+ parseRecordCommand,
96
101
  parseKeysCommand,
97
102
  parseExpressionCommand,
98
103
  parseMasterCommand,