@hraness/dawg 0.2.0 → 0.3.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 (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. package/tui/play-strip.ts +143 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Hand-editing commands the menus emit that the prompt grammar lacked. Each
3
+ * is one ScoreOperation, so it is one revision and one undo step.
4
+ *
5
+ * track name <text> rename the focused track
6
+ * meter <beats per bar> time signature numerator (1..16)
7
+ * automate <lane> points <beat:value>... merge points into a lane
8
+ * automate <lane> remove <beat> drop the point at a beat
9
+ *
10
+ * Lanes are every AUTOMATION_LANES key plus the effect aliases (`cutoff`,
11
+ * `res`, `feedback`, `delay-fb`). Parsing is pure; applying returns the next
12
+ * score and the session event, leaving persistence to the caller.
13
+ */
14
+ import {
15
+ AUTOMATION_LANES,
16
+ SCORE_LIMITS,
17
+ applyScoreOperation,
18
+ type AutomationParameter,
19
+ type AutomationPoint,
20
+ type ScoreOperation,
21
+ type TrackScore,
22
+ } from "../../core/score.ts";
23
+ import { parseEffectLane } from "./music.ts";
24
+
25
+ export type EditCommand =
26
+ | { type: "track-name"; name: string }
27
+ | { type: "meter"; beatsPerBar: number }
28
+ | {
29
+ type: "automation-points";
30
+ parameter: AutomationParameter;
31
+ points: readonly { beat: number; value: number }[];
32
+ }
33
+ | { type: "automation-remove"; parameter: AutomationParameter; beat: number };
34
+
35
+ export function parseLane(name: string): AutomationParameter | undefined {
36
+ const lower = name.toLowerCase();
37
+ if (lower === "volume" || lower === "vol") return "volume";
38
+ if (lower === "pan") return "pan";
39
+ return parseEffectLane(lower);
40
+ }
41
+
42
+ const NUMBER = /^-?\d+(?:\.\d+)?$/;
43
+
44
+ export function parseEditCommand(prompt: string): EditCommand | undefined {
45
+ const text = prompt.trim().replace(/\s+/g, " ");
46
+ if (text.length > 1_024) return undefined;
47
+ const name = text.match(/^track name (.+)$/i);
48
+ if (name) {
49
+ const value = name[1]!.trim();
50
+ return value.length > 0 && value.length <= SCORE_LIMITS.maxNameLength
51
+ ? { type: "track-name", name: value }
52
+ : undefined;
53
+ }
54
+ const meter = text.match(/^meter (\d+)$/i);
55
+ if (meter) {
56
+ const beatsPerBar = Number(meter[1]);
57
+ return beatsPerBar >= 1 && beatsPerBar <= SCORE_LIMITS.maxBeatsPerBar
58
+ ? { type: "meter", beatsPerBar }
59
+ : undefined;
60
+ }
61
+ const points = text.match(/^automate ([a-z-]+) points((?: \S+)+)$/i);
62
+ if (points) {
63
+ const parameter = parseLane(points[1]!);
64
+ if (!parameter) return undefined;
65
+ const { min, max } = AUTOMATION_LANES[parameter];
66
+ const parsed: { beat: number; value: number }[] = [];
67
+ for (const pair of points[2]!.trim().split(" ")) {
68
+ const [beatText, valueText, extra] = pair.split(":");
69
+ if (extra !== undefined || !beatText || !valueText) return undefined;
70
+ if (!NUMBER.test(beatText) || !NUMBER.test(valueText)) return undefined;
71
+ const beat = Number(beatText);
72
+ const value = Number(valueText);
73
+ if (beat > SCORE_LIMITS.maxTick) return undefined;
74
+ if (value < min || value > max) return undefined;
75
+ parsed.push({ beat, value });
76
+ }
77
+ if (parsed.length === 0 || parsed.length > SCORE_LIMITS.maxAutomationPoints)
78
+ return undefined;
79
+ return { type: "automation-points", parameter, points: parsed };
80
+ }
81
+ const remove = text.match(/^automate ([a-z-]+) remove (\d+(?:\.\d+)?)$/i);
82
+ if (remove) {
83
+ const parameter = parseLane(remove[1]!);
84
+ return parameter
85
+ ? { type: "automation-remove", parameter, beat: Number(remove[2]) }
86
+ : undefined;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ export type EditResult = Readonly<{
92
+ ok: boolean;
93
+ message: string;
94
+ next?: TrackScore;
95
+ kind?: string;
96
+ payload?: Record<string, unknown>;
97
+ }>;
98
+
99
+ /** Apply `command` to `trackId` as one ScoreOperation. */
100
+ export function applyEditCommand(
101
+ score: TrackScore,
102
+ trackId: string,
103
+ command: EditCommand,
104
+ ): EditResult {
105
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
106
+ if (command.type === "meter") {
107
+ const operation: ScoreOperation = {
108
+ type: "setMeter",
109
+ beatsPerBar: command.beatsPerBar,
110
+ };
111
+ return {
112
+ ok: true,
113
+ message: `meter · ${command.beatsPerBar}/4`,
114
+ next: applyScoreOperation(score, operation),
115
+ kind: "score.meter",
116
+ payload: { beatsPerBar: command.beatsPerBar },
117
+ };
118
+ }
119
+ if (!track) return { ok: false, message: `no track ${trackId}` };
120
+ if (command.type === "track-name") {
121
+ const patch = { name: command.name };
122
+ return {
123
+ ok: true,
124
+ message: `track name · ${command.name}`,
125
+ next: applyScoreOperation(score, {
126
+ type: "updateTrack",
127
+ trackId,
128
+ patch,
129
+ }),
130
+ kind: "score.track",
131
+ payload: { trackId, patch },
132
+ };
133
+ }
134
+ const field = AUTOMATION_LANES[command.parameter].field;
135
+ const current: readonly AutomationPoint[] = track[field] ?? [];
136
+ let points: AutomationPoint[];
137
+ if (command.type === "automation-points") {
138
+ const byTick = new Map(current.map((point) => [point.tick, point]));
139
+ for (const point of command.points) {
140
+ const tick = Math.max(0, Math.round(point.beat * score.ticksPerBeat));
141
+ byTick.set(tick, { tick, value: point.value });
142
+ }
143
+ points = [...byTick.values()].sort((left, right) => left.tick - right.tick);
144
+ if (points.length > SCORE_LIMITS.maxAutomationPoints)
145
+ return {
146
+ ok: false,
147
+ message: `${command.parameter} automation holds ${SCORE_LIMITS.maxAutomationPoints} points`,
148
+ };
149
+ } else {
150
+ const tick = Math.round(command.beat * score.ticksPerBeat);
151
+ points = current.filter((point) => point.tick !== tick);
152
+ if (points.length === current.length)
153
+ return {
154
+ ok: false,
155
+ message: `no ${command.parameter} point at beat ${command.beat}`,
156
+ };
157
+ }
158
+ return {
159
+ ok: true,
160
+ message: `automation · ${command.parameter} ${points.length} point${points.length === 1 ? "" : "s"}`,
161
+ next: applyScoreOperation(score, {
162
+ type: "setAutomation",
163
+ trackId,
164
+ parameter: command.parameter,
165
+ points,
166
+ }),
167
+ kind: "score.automation",
168
+ payload: { trackId, parameter: command.parameter, points },
169
+ };
170
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * The command reference, in one place: `/help` renders it in the overlay,
3
+ * `dawg --help` prints it, and the docs mirror it. Music words are bare;
4
+ * app commands take a slash (bare aliases keep working but are listed once,
5
+ * in their canonical form).
6
+ */
7
+
8
+ export type HelpGroup = "music" | "session" | "window" | "keys";
9
+
10
+ export type HelpEntry = Readonly<{
11
+ /** Canonical form, e.g. `pan <-1..1>`. */
12
+ command: string;
13
+ /** What it does, short enough for one overlay row. */
14
+ summary: string;
15
+ }>;
16
+
17
+ export type HelpSection = Readonly<{
18
+ group: HelpGroup;
19
+ entries: readonly HelpEntry[];
20
+ }>;
21
+
22
+ export const HELP_SECTIONS: readonly HelpSection[] = [
23
+ {
24
+ group: "music",
25
+ entries: [
26
+ { command: "play", summary: "start the transport" },
27
+ { command: "pause", summary: "stop the transport" },
28
+ { command: "tempo <bpm>", summary: "20–300 BPM" },
29
+ { command: "add <note> at <beat> [for <beats>]", summary: "add C4 at 0" },
30
+ { command: "remove <id>", summary: "delete a note" },
31
+ { command: "move <id> to <beat>", summary: "shift a note" },
32
+ { command: "length <id> <beats>", summary: "resize a note" },
33
+ { command: "velocity <id> <0..1>", summary: "note loudness" },
34
+ { command: "bars <count>", summary: "loop length, 1–256" },
35
+ { command: "extend <count> bars", summary: "lengthen the loop" },
36
+ {
37
+ command: "instrument <name>",
38
+ summary: "sine piano pluck bass saw square triangle kit",
39
+ },
40
+ { command: "volume <0..1>", summary: "track level" },
41
+ { command: "pan <-1..1>", summary: "left … right" },
42
+ { command: "mute", summary: "silence this track" },
43
+ { command: "unmute", summary: "hear it again" },
44
+ { command: "solo", summary: "only this track" },
45
+ { command: "unsolo", summary: "every track again" },
46
+ { command: "clear", summary: "remove this track's notes" },
47
+ { command: "filter <hz> [res]", summary: "low-pass · filter off" },
48
+ { command: "delay <beats> [fb] [mix]", summary: "ping-pong · delay off" },
49
+ { command: "reverb <mix> [size]", summary: "room · reverb off" },
50
+ {
51
+ command: "automate <lane> at <beat> <value>",
52
+ summary: "volume pan filter resonance delay-feedback delay-mix",
53
+ },
54
+ {
55
+ command: "automate <lane> points <b:v>...",
56
+ summary: "several points · automate pan points 0:-1 4:1",
57
+ },
58
+ {
59
+ command: "automate <lane> remove <beat>",
60
+ summary: "drop one point",
61
+ },
62
+ { command: "clear [<lane>] automation", summary: "drop a lane's points" },
63
+ { command: "track name <text>", summary: "rename this track" },
64
+ { command: "meter <1..16>", summary: "beats per bar" },
65
+ {
66
+ command: "hit <voice> at <beat>",
67
+ summary: "kit tracks · hit kick at 0",
68
+ },
69
+ {
70
+ command: "pattern <voice> <beats...> | every <step>",
71
+ summary: "pattern kick every 1",
72
+ },
73
+ { command: "clear <voice>", summary: "remove one drum voice" },
74
+ { command: "undo", summary: "step back · Ctrl-Z" },
75
+ { command: "redo", summary: "step forward · Ctrl-Y" },
76
+ ],
77
+ },
78
+ {
79
+ group: "session",
80
+ entries: [
81
+ { command: "/sessions", summary: "list sessions in this workspace" },
82
+ { command: "/resume [<n>|<name>|<id>]", summary: "switch session" },
83
+ { command: "/rename <name>|--auto", summary: "name this session" },
84
+ { command: "/fork [<name>]", summary: "copy into a new session" },
85
+ { command: "/status", summary: "name · revision · digest · storage" },
86
+ { command: "/export <file>", summary: "write track.loop/v1 JSON" },
87
+ { command: "/import <file>", summary: "replace the score from a file" },
88
+ {
89
+ command: "/sample [<path> [as <voice>]]",
90
+ summary: "add a sample voice · list voices",
91
+ },
92
+ ],
93
+ },
94
+ {
95
+ group: "window",
96
+ entries: [
97
+ {
98
+ command: "/track <name>",
99
+ summary: "focus a track, creating it if new",
100
+ },
101
+ { command: "/tracks", summary: "list tracks" },
102
+ { command: "/view focus|all", summary: "one track or every track" },
103
+ { command: "/transcript", summary: "scrollable log · Ctrl-O" },
104
+ { command: "/theme default|high-contrast|mono", summary: "colors" },
105
+ { command: "/motion on|off", summary: "animation" },
106
+ { command: "/model [alias]", summary: "pick a model · cost per prompt" },
107
+ {
108
+ command: "/login [gateway|openrouter|codex|claude]",
109
+ summary: "sign in to a provider",
110
+ },
111
+ {
112
+ command: "/logout [provider]",
113
+ summary: "forget keys and the saved choice",
114
+ },
115
+ { command: "/auth [--check]", summary: "provider and audio status" },
116
+ { command: "/play [on|off]", summary: "keyboard play mode · Ctrl-P" },
117
+ {
118
+ command: "/menu [section]",
119
+ summary: "edit by hand · Ctrl-K · track effects automation …",
120
+ },
121
+ {
122
+ command: "/click on|off|<volume>",
123
+ summary: "metronome · /count-in 0-2 · /grid 1/16",
124
+ },
125
+ { command: "/help", summary: "this list · ?" },
126
+ ],
127
+ },
128
+ {
129
+ group: "keys",
130
+ entries: [
131
+ { command: "Enter", summary: "submit" },
132
+ { command: "Shift-Enter", summary: "newline" },
133
+ { command: "Alt-Enter", summary: "queue the request" },
134
+ { command: "Ctrl-Q", summary: "toggle queue mode" },
135
+ { command: "Ctrl-Z / Ctrl-Y", summary: "undo / redo" },
136
+ { command: "Ctrl-O", summary: "transcript" },
137
+ { command: "Esc", summary: "cancel the agent · close an overlay" },
138
+ { command: "Space", summary: "play/pause on an empty prompt" },
139
+ {
140
+ command: "Ctrl-P",
141
+ summary: "play mode: A–' W–P keys · Z/X octave · R rec · M click",
142
+ },
143
+ {
144
+ command: "Ctrl-K",
145
+ summary: "menu: ↑↓/jk move · enter · ←→/+- nudge · / filter",
146
+ },
147
+ { command: "Ctrl-C", summary: "exit" },
148
+ ],
149
+ },
150
+ ];
151
+
152
+ /** Overlay rows: a heading per group, then `command summary` lines. */
153
+ export function helpLines(width = 80): string[] {
154
+ const lines: string[] = [];
155
+ const column = Math.min(
156
+ 40,
157
+ Math.max(
158
+ ...HELP_SECTIONS.flatMap((s) => s.entries.map((e) => e.command.length)),
159
+ ) + 2,
160
+ );
161
+ for (const section of HELP_SECTIONS) {
162
+ if (lines.length > 0) lines.push("");
163
+ lines.push(`── ${section.group}`);
164
+ for (const entry of section.entries) {
165
+ const pad = Math.max(1, column - entry.command.length);
166
+ lines.push(
167
+ `${entry.command}${" ".repeat(pad)}${entry.summary}`.slice(0, width),
168
+ );
169
+ }
170
+ }
171
+ return lines;
172
+ }
173
+
174
+ /** The `Commands:` block of `dawg --help`. */
175
+ export function helpText(): string {
176
+ return HELP_SECTIONS.filter((section) => section.group !== "keys")
177
+ .map(
178
+ (section) =>
179
+ `${section.group}:\n${section.entries
180
+ .map((entry) => ` ${entry.command}`)
181
+ .join("\n")}`,
182
+ )
183
+ .join("\n");
184
+ }
185
+
186
+ /** Usage for a known verb, shown instead of sending a near-miss to the agent. */
187
+ const USAGE: Readonly<Record<string, string>> = {
188
+ click: "/click on|off|<volume> · /click 50%",
189
+ "count-in": "/count-in 0|1|2",
190
+ grid: "/grid 1/4|1/8|1/8T|1/16|1/16T|1/32",
191
+ tempo: "tempo takes 20…300 · tempo 120",
192
+ bpm: "tempo takes 20…300 · tempo 120",
193
+ add: "add <note> at <beat> [for <beats>] · add C4 at 0",
194
+ put: "add <note> at <beat> [for <beats>] · add C4 at 0",
195
+ remove: "remove <id> · ids show in the transcript",
196
+ delete: "remove <id> · ids show in the transcript",
197
+ move: "move <id> to <beat>",
198
+ length: "length <id> <beats>",
199
+ velocity: "velocity <id> <0..1>",
200
+ vel: "velocity <id> <0..1>",
201
+ bars: "bars takes 1…256 · bars 8",
202
+ extend: "extend <count> bars · extend 4 bars",
203
+ instrument:
204
+ "instrument <name> · sine piano pluck bass saw square triangle kit",
205
+ volume: "volume takes 0…1 · volume 0.8",
206
+ vol: "volume takes 0…1 · volume 0.8",
207
+ pan: "pan takes -1…1 · pan -0.5",
208
+ filter: "filter <hz> [res] · filter 800 0.3 · filter off",
209
+ delay: "delay <beats> [fb] [mix] · delay 0.75 0.4 0.3 · delay off",
210
+ reverb: "reverb <mix> [size] · reverb 0.3 0.6 · reverb off",
211
+ automate: "automate <lane> at <beat> <value> · automate volume at 0 0.5",
212
+ automation: "automate <lane> at <beat> <value> · automate volume at 0 0.5",
213
+ hit: "hit <voice> at <beat> · hit kick at 0",
214
+ pattern: "pattern <voice> <beats...> | every <step> · pattern kick every 1",
215
+ clear: "clear · clear <voice> · clear [<lane>] automation",
216
+ track: "/track <name> · /track drums",
217
+ tracks: "/tracks",
218
+ sessions: "/sessions",
219
+ resume: "/resume [<n>|<name>|<id>]",
220
+ rename: "/rename <name> | --auto",
221
+ fork: "/fork [<name>]",
222
+ status: "/status",
223
+ export: "/export <file> · /export loop.track.json",
224
+ import: "/import <file> · /import loop.track.json",
225
+ sample: "/sample <path> [as <voice>] · /sample kick.wav as kick",
226
+ samples: "/sample · lists the focused track's voices",
227
+ view: "/view focus | all",
228
+ transcript: "/transcript",
229
+ log: "/transcript",
230
+ theme: "/theme default | high-contrast | mono",
231
+ motion: "/motion on | off",
232
+ model: "/model [alias | vendor/model]",
233
+ login: "/login [gateway | openrouter | codex | claude]",
234
+ logout: "/logout [provider]",
235
+ auth: "/auth [--check]",
236
+ help: "/help",
237
+ };
238
+
239
+ /**
240
+ * A usage hint when `command` starts with a known verb but did not parse
241
+ * (`pan 3`, `volume 2`, `add H4 at 0`, `/export` with no file); undefined for
242
+ * free text that should go to the agent.
243
+ */
244
+ export function usageHint(command: string): string | undefined {
245
+ const verb = command.trim().toLowerCase().replace(/^\//, "").split(/\s+/)[0];
246
+ return verb ? USAGE[verb] : undefined;
247
+ }
@@ -1,22 +1,32 @@
1
1
  /**
2
2
  * Undo/redo over the shared session event log.
3
3
  *
4
- * Every score edit records `before`, the composition it replaced. Undo and
5
- * redo append ordinary events (`score.undo` / `score.redo`) whose `before` is
6
- * the composition they replaced, so history is linear, shared by every
7
- * window, and survives restarts. Replaying the log rebuilds two stacks:
4
+ * Every event carries a `rewind` (see `src/session/delta.ts`): how to turn
5
+ * the composition it produced back into the one it replaced. Undo and redo
6
+ * append ordinary events (`score.undo` / `score.redo`), so history is linear,
7
+ * shared by every window, and survives restarts. Replaying the log rebuilds
8
+ * two stacks over the events that changed the composition:
8
9
  *
9
10
  * edit -> push onto undo, clear redo
10
11
  * undo -> pop undo, push the undo event onto redo
11
12
  * redo -> pop redo, push the redo event onto undo
12
13
  *
13
- * Undoing restores the top undo entry's `before`; redoing restores the top
14
- * redo entry's `before` (the composition the undo replaced).
14
+ * Undoing restores the composition before the top undo entry; redoing
15
+ * restores the composition before the top redo entry (the one the undo
16
+ * replaced). Both are recovered by rewinding the current composition through
17
+ * every newer event. Events whose rewind was compacted away, and events that
18
+ * did not change the composition (transport), take no part.
15
19
  */
20
+ import {
21
+ isIdentityRewind,
22
+ rewindComposition,
23
+ type Rewind,
24
+ } from "../session/delta.ts";
25
+
16
26
  export type HistoryEvent = Readonly<{
17
27
  revision: number;
18
28
  kind: string;
19
- payload: unknown;
29
+ rewind?: Rewind | undefined;
20
30
  }>;
21
31
 
22
32
  export type HistoryTarget = Readonly<{
@@ -30,27 +40,30 @@ export const UNDO_KIND = "score.undo";
30
40
  export const REDO_KIND = "score.redo";
31
41
 
32
42
  export function historyTarget(
43
+ composition: unknown,
33
44
  events: readonly HistoryEvent[],
34
45
  direction: "undo" | "redo",
35
46
  ): HistoryTarget | undefined {
36
- const undo: HistoryEvent[] = [];
37
- const redo: HistoryEvent[] = [];
38
- for (const event of events) {
39
- if (!hasBefore(event.payload)) continue;
47
+ const undo: number[] = [];
48
+ const redo: number[] = [];
49
+ events.forEach((event, index) => {
50
+ if (!changesComposition(event)) return;
40
51
  if (event.kind === UNDO_KIND) {
41
- if (undo.pop()) redo.push(event);
52
+ if (undo.pop() !== undefined) redo.push(index);
42
53
  } else if (event.kind === REDO_KIND) {
43
- if (redo.pop()) undo.push(event);
54
+ if (redo.pop() !== undefined) undo.push(index);
44
55
  } else {
45
- undo.push(event);
56
+ undo.push(index);
46
57
  redo.length = 0;
47
58
  }
48
- }
59
+ });
49
60
  const top = (direction === "undo" ? undo : redo).at(-1);
50
- if (!top || !hasBefore(top.payload)) return undefined;
51
- return { revision: top.revision, composition: top.payload.before };
61
+ if (top === undefined) return undefined;
62
+ const restored = rewindComposition(composition, events, top);
63
+ if (restored === undefined) return undefined;
64
+ return { revision: events[top]!.revision, composition: restored };
52
65
  }
53
66
 
54
- function hasBefore(payload: unknown): payload is { before: unknown } {
55
- return typeof payload === "object" && payload !== null && "before" in payload;
67
+ function changesComposition(event: HistoryEvent): boolean {
68
+ return event.rewind !== undefined && !isIdentityRewind(event.rewind);
56
69
  }
@@ -102,6 +102,8 @@ export function parseEffectLane(
102
102
  }
103
103
 
104
104
  export type MusicResult = Readonly<{
105
+ /** `true` when the command applied; `false` carries a failure message. */
106
+ ok: boolean;
105
107
  message: string;
106
108
  next?: TrackScore;
107
109
  kind?: string;
@@ -264,11 +266,12 @@ export function applyMusicCommand(
264
266
  newId: (index: number) => string,
265
267
  ): MusicResult {
266
268
  const track = score.tracks.find((candidate) => candidate.id === trackId);
267
- if (!track) return { message: `no track · ${trackId}` };
269
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
268
270
 
269
271
  if (command.type === "solo") {
270
272
  const next = updateTrack(score, trackId, { solo: command.solo });
271
273
  return {
274
+ ok: true,
272
275
  message: `${command.solo ? "solo" : "unsolo"} · ${trackId}`,
273
276
  next,
274
277
  kind: "score.track",
@@ -281,6 +284,7 @@ export function applyMusicCommand(
281
284
  ? { cutoff: command.cutoff, resonance: command.resonance }
282
285
  : null;
283
286
  return {
287
+ ok: true,
284
288
  message: filter
285
289
  ? `filter · ${filter.cutoff} Hz res ${filter.resonance}`
286
290
  : "filter · off",
@@ -295,6 +299,7 @@ export function applyMusicCommand(
295
299
  ? { beats: command.beats, feedback: command.feedback, mix: command.mix }
296
300
  : null;
297
301
  return {
302
+ ok: true,
298
303
  message: delay
299
304
  ? `delay · ${delay.beats} beats fb ${delay.feedback} mix ${delay.mix}`
300
305
  : "delay · off",
@@ -309,6 +314,7 @@ export function applyMusicCommand(
309
314
  ? { mix: command.mix, size: command.size }
310
315
  : null;
311
316
  return {
317
+ ok: true,
312
318
  message: reverb
313
319
  ? `reverb · mix ${reverb.mix} size ${reverb.size}`
314
320
  : "reverb · off",
@@ -334,9 +340,10 @@ export function applyMusicCommand(
334
340
  merged.set(tick, { tick, value: command.value });
335
341
  points = [...merged.values()].sort((a, b) => a.tick - b.tick);
336
342
  if (points.length > SCORE_LIMITS.maxAutomationPoints)
337
- return { message: `${parameter} automation is full` };
343
+ return { ok: false, message: `${parameter} automation is full` };
338
344
  }
339
345
  return {
346
+ ok: true,
340
347
  message: `automation · ${parameter} ${points.length} point${points.length === 1 ? "" : "s"}`,
341
348
  next: setTrackAutomation(score, trackId, parameter, points),
342
349
  kind: "score.automation",
@@ -345,7 +352,10 @@ export function applyMusicCommand(
345
352
  }
346
353
 
347
354
  if (!isDrumInstrument(track.instrument))
348
- return { message: `${trackId} is not a drum track · try instrument kit` };
355
+ return {
356
+ ok: false,
357
+ message: `${trackId} is not a drum track · try instrument kit`,
358
+ };
349
359
 
350
360
  if (command.type === "drum-clear") {
351
361
  const removed = score.notes.filter(
@@ -353,12 +363,14 @@ export function applyMusicCommand(
353
363
  note.trackId === trackId &&
354
364
  drumVoiceForPitch(note.pitch) === command.voice,
355
365
  );
356
- if (removed.length === 0) return { message: `no ${command.voice} hits` };
366
+ if (removed.length === 0)
367
+ return { ok: false, message: `no ${command.voice} hits` };
357
368
  const next = createScore({
358
369
  ...score.toJSON(),
359
370
  notes: score.notes.filter((note) => !removed.includes(note)),
360
371
  });
361
372
  return {
373
+ ok: true,
362
374
  message: `cleared ${removed.length} ${command.voice} hit${removed.length === 1 ? "" : "s"}`,
363
375
  next,
364
376
  kind: "score.drums",
@@ -371,7 +383,7 @@ export function applyMusicCommand(
371
383
  ? command.beats
372
384
  : everyBeats(command.from, command.step, score.bars * score.beatsPerBar);
373
385
  if (beats.length > MAX_PATTERN_HITS)
374
- return { message: `pattern exceeds ${MAX_PATTERN_HITS} hits` };
386
+ return { ok: false, message: `pattern exceeds ${MAX_PATTERN_HITS} hits` };
375
387
  const pitch = drumVoicePitch(command.voice);
376
388
  const existing = new Set(
377
389
  score.notes
@@ -393,14 +405,19 @@ export function applyMusicCommand(
393
405
  velocity: command.velocity,
394
406
  });
395
407
  }
396
- if (hits.length === 0) return { message: `${command.voice} already there` };
408
+ if (hits.length === 0)
409
+ return { ok: false, message: `${command.voice} already there` };
397
410
  if (score.notes.length + hits.length > SCORE_LIMITS.maxNotes)
398
- return { message: `score is full (${SCORE_LIMITS.maxNotes} notes)` };
411
+ return {
412
+ ok: false,
413
+ message: `score is full (${SCORE_LIMITS.maxNotes} notes)`,
414
+ };
399
415
  const next = createScore({
400
416
  ...score.toJSON(),
401
417
  notes: [...score.notes, ...hits],
402
418
  });
403
419
  return {
420
+ ok: true,
404
421
  message: `+${hits.length} ${command.voice} hit${hits.length === 1 ? "" : "s"}`,
405
422
  next,
406
423
  kind: "score.drums",