@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.
- package/CHANGELOG.md +79 -0
- package/DAWG.md +196 -11
- package/README.md +79 -30
- package/core/diff.ts +249 -0
- package/core/key.ts +43 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +324 -1
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +280 -14
- package/src/agent/brief.ts +23 -3
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/planner.ts +38 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +398 -1
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +27 -12
- package/src/agent/xcb.ts +233 -21
- package/src/audio/click.ts +125 -0
- package/src/audio/engine.ts +407 -33
- package/src/audio/live.ts +152 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +290 -76
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +24 -7
- package/src/commands/sample.ts +302 -0
- package/src/main.ts +1043 -166
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +28 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +3 -37
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +261 -18
- package/tui/drums.ts +44 -0
- package/tui/highway.ts +18 -2
- package/tui/layers.ts +14 -2
- 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
|
+
}
|
package/src/commands/history.ts
CHANGED
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Undo/redo over the shared session event log.
|
|
3
3
|
*
|
|
4
|
-
* Every
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* window, and survives restarts. Replaying the log rebuilds
|
|
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
|
|
14
|
-
* redo entry
|
|
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
|
-
|
|
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:
|
|
37
|
-
const redo:
|
|
38
|
-
|
|
39
|
-
if (!
|
|
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(
|
|
52
|
+
if (undo.pop() !== undefined) redo.push(index);
|
|
42
53
|
} else if (event.kind === REDO_KIND) {
|
|
43
|
-
if (redo.pop()) undo.push(
|
|
54
|
+
if (redo.pop() !== undefined) undo.push(index);
|
|
44
55
|
} else {
|
|
45
|
-
undo.push(
|
|
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 (
|
|
51
|
-
|
|
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
|
|
55
|
-
return
|
|
67
|
+
function changesComposition(event: HistoryEvent): boolean {
|
|
68
|
+
return event.rewind !== undefined && !isIdentityRewind(event.rewind);
|
|
56
69
|
}
|
package/src/commands/music.ts
CHANGED
|
@@ -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 {
|
|
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)
|
|
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)
|
|
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 {
|
|
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",
|