@hraness/dawg 0.2.0 → 0.4.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 +201 -0
- package/DAWG.md +762 -11
- package/README.md +79 -30
- package/core/chords.ts +1724 -0
- package/core/diff.ts +261 -0
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/key.ts +43 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/pitch.ts +60 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +1054 -14
- package/core/sdk/eval-child.ts +125 -0
- package/core/sdk/eval.ts +260 -0
- package/core/sdk/print.ts +675 -0
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +4159 -0
- package/core/slug.ts +19 -0
- package/core/synth.ts +1001 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +317 -16
- package/src/agent/brief.ts +50 -5
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +55 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +705 -14
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +35 -14
- package/src/agent/xcb.ts +233 -21
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +337 -0
- package/src/audio/engine.ts +430 -33
- package/src/audio/kits.ts +200 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +420 -0
- package/src/audio/samples.ts +1025 -0
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +550 -334
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- 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/drums.ts +299 -0
- package/src/commands/edit.ts +192 -0
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +535 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +34 -15
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +462 -0
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +2136 -170
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +186 -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 +397 -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/media/wavetable.ts +202 -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 +49 -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 +6 -38
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +2125 -0
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +984 -0
- package/src/tui/sketch.ts +108 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +684 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +398 -33
- package/tui/drums.ts +44 -0
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +24 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +188 -0
package/src/main.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join, relative, resolve } from "node:path";
|
|
5
5
|
import { stdin, stdout } from "node:process";
|
|
6
6
|
import {
|
|
7
7
|
ensureSession,
|
|
@@ -30,8 +30,48 @@ import { AutoNamer, providerNameGenerator } from "./session/naming.ts";
|
|
|
30
30
|
import { normalizeSessionName } from "./session/meta.ts";
|
|
31
31
|
import { parsePrompt } from "./agent/ops.ts";
|
|
32
32
|
import { applyMusicCommand, parseMusicCommand } from "./commands/music.ts";
|
|
33
|
+
import { applyRhythmCommand, parseRhythmCommand } from "./commands/rhythm.ts";
|
|
34
|
+
import {
|
|
35
|
+
applyDrumPattern,
|
|
36
|
+
applySynthKit,
|
|
37
|
+
DRUM_PATTERNS,
|
|
38
|
+
isSynthKitName,
|
|
39
|
+
parsePatternCommand,
|
|
40
|
+
patternLine,
|
|
41
|
+
type PatternCommand,
|
|
42
|
+
} from "./commands/drums.ts";
|
|
43
|
+
import { kitCatalog } from "./audio/kits.ts";
|
|
44
|
+
import { applyEditCommand, parseEditCommand } from "./commands/edit.ts";
|
|
45
|
+
import { applyFxCommand, parseFxCommand } from "./commands/fx.ts";
|
|
46
|
+
import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
|
|
47
|
+
import {
|
|
48
|
+
HELP_TOPICS,
|
|
49
|
+
helpText,
|
|
50
|
+
helpTopicLines,
|
|
51
|
+
nearestCommand,
|
|
52
|
+
typoFix,
|
|
53
|
+
usageHint,
|
|
54
|
+
} from "./commands/help.ts";
|
|
33
55
|
import { historyTarget, REDO_KIND, UNDO_KIND } from "./commands/history.ts";
|
|
34
|
-
|
|
56
|
+
|
|
57
|
+
import {
|
|
58
|
+
SamplePlacementError,
|
|
59
|
+
addSampleVoice,
|
|
60
|
+
freeVoiceName,
|
|
61
|
+
listSampleVoices,
|
|
62
|
+
parseSampleCommand,
|
|
63
|
+
setSampleControls,
|
|
64
|
+
placeSampleFile,
|
|
65
|
+
samplerTarget,
|
|
66
|
+
voiceNameFrom,
|
|
67
|
+
} from "./commands/sample.ts";
|
|
68
|
+
import {
|
|
69
|
+
SampleLibrary,
|
|
70
|
+
hasSamplerTracks,
|
|
71
|
+
type SampleBank,
|
|
72
|
+
type SampleProblem,
|
|
73
|
+
} from "./audio/samples.ts";
|
|
74
|
+
import { drumSnapshotFields, samplerSnapshotFields } from "../tui/drums.ts";
|
|
35
75
|
import { highwayLayers } from "../tui/layers.ts";
|
|
36
76
|
import { drumVoicePitch, isDrumInstrument } from "../core/drums.ts";
|
|
37
77
|
import {
|
|
@@ -40,26 +80,91 @@ import {
|
|
|
40
80
|
type AgentEvent,
|
|
41
81
|
type AgentHost,
|
|
42
82
|
} from "./agent/agent.ts";
|
|
43
|
-
import { type GatewayModel } from "./agent/gateway.ts";
|
|
44
83
|
import {
|
|
84
|
+
isApiSelection,
|
|
45
85
|
providerFingerprint,
|
|
46
86
|
providerLabel,
|
|
47
87
|
runProviderTurn,
|
|
48
88
|
selectProvider,
|
|
49
89
|
type ProviderSelection,
|
|
50
90
|
} from "./agent/provider.ts";
|
|
51
|
-
import {
|
|
52
|
-
|
|
91
|
+
import {
|
|
92
|
+
MODEL_CATALOG,
|
|
93
|
+
priceForModel,
|
|
94
|
+
resolveModelChoice,
|
|
95
|
+
} from "./agent/models.ts";
|
|
96
|
+
import {
|
|
97
|
+
SpendMeter,
|
|
98
|
+
spendLine as formatSpendLine,
|
|
99
|
+
webHostFor,
|
|
100
|
+
} from "./agent/usage.ts";
|
|
101
|
+
import { configDir } from "./auth/credentials.ts";
|
|
102
|
+
import { runAuthCommand, runFirstRunLogin, runTuiLogin } from "./auth/cli.ts";
|
|
103
|
+
import {
|
|
104
|
+
modelPickerItems,
|
|
105
|
+
tuiAuthCommand,
|
|
106
|
+
tuiLoginArgs,
|
|
107
|
+
tuiSetModel,
|
|
108
|
+
} from "./auth/tui.ts";
|
|
53
109
|
import { TransportClock } from "./audio/clock.ts";
|
|
110
|
+
import { AudioEngine } from "./audio/engine.ts";
|
|
111
|
+
import {
|
|
112
|
+
DEFAULT_GRID,
|
|
113
|
+
GRIDS,
|
|
114
|
+
PLAY_LEAD_MS,
|
|
115
|
+
PlaySession,
|
|
116
|
+
type LiveEngine,
|
|
117
|
+
} from "./tui/play-session.ts";
|
|
118
|
+
import { EditMenu, MENU_SECTIONS, type MenuContext } from "./tui/menu.ts";
|
|
119
|
+
import { Audition, SUPERSEDED, isStageable } from "./tui/audition.ts";
|
|
120
|
+
import { EuclidEditor } from "./tui/euclid.ts";
|
|
121
|
+
import { HINTS, KEYS, keyLines, type KeySection } from "../tui/grammar.ts";
|
|
122
|
+
import { renderAudition } from "./audio/audition.ts";
|
|
123
|
+
import { renderScorePcm } from "./audio/wav.ts";
|
|
124
|
+
import type { PreviewHost } from "./agent/preview-tool.ts";
|
|
125
|
+
import { rhythmVoicePitch } from "../core/rhythm.ts";
|
|
126
|
+
import { applyChordsCommand, defaultChordSettings } from "./tui/play-chords.ts";
|
|
127
|
+
import {
|
|
128
|
+
cacheLines,
|
|
129
|
+
kitListLines,
|
|
130
|
+
kitTarget,
|
|
131
|
+
packListLines,
|
|
132
|
+
parseKitCommand,
|
|
133
|
+
parsePackCommand,
|
|
134
|
+
useKit,
|
|
135
|
+
useSound,
|
|
136
|
+
type KitCommand,
|
|
137
|
+
type PackCommand,
|
|
138
|
+
} from "./commands/pack.ts";
|
|
139
|
+
import {
|
|
140
|
+
describeWavetable,
|
|
141
|
+
parseWavetableCommand,
|
|
142
|
+
pickWavetable,
|
|
143
|
+
wavetableListLines,
|
|
144
|
+
wavetableParamEdit,
|
|
145
|
+
type WavetableCommand,
|
|
146
|
+
} from "./commands/wavetable.ts";
|
|
147
|
+
import {
|
|
148
|
+
ALIASED_PACK,
|
|
149
|
+
DEFAULT_KITS,
|
|
150
|
+
PackError,
|
|
151
|
+
PackStore,
|
|
152
|
+
banksOf,
|
|
153
|
+
packCredits,
|
|
154
|
+
writeCredits,
|
|
155
|
+
} from "./audio/packs.ts";
|
|
54
156
|
import {
|
|
55
157
|
addNote,
|
|
56
158
|
applyScoreOperation,
|
|
57
159
|
createScore,
|
|
160
|
+
PACK_PREFIX,
|
|
58
161
|
scoreFromJSON,
|
|
59
162
|
SCORE_LIMITS,
|
|
163
|
+
type SampleRef,
|
|
60
164
|
type TrackScore,
|
|
61
165
|
type ScoreOperation,
|
|
62
166
|
} from "../core/score.ts";
|
|
167
|
+
import { reconcileRhythm } from "../core/rhythm.ts";
|
|
63
168
|
import { decodeLoop, encodeLoop } from "../core/loop.ts";
|
|
64
169
|
import type { TrackScoreSnapshot } from "../tui/render.ts";
|
|
65
170
|
import { PromptModel } from "../tui/prompt.ts";
|
|
@@ -69,12 +174,63 @@ import {
|
|
|
69
174
|
TuiApp,
|
|
70
175
|
type AppView,
|
|
71
176
|
type SyncState,
|
|
177
|
+
type TypesIndicator,
|
|
72
178
|
} from "../tui/app.ts";
|
|
73
|
-
import {
|
|
179
|
+
import { fail, ok, toneOf, warn, type Receipt } from "../tui/activity.ts";
|
|
180
|
+
import { systemRunner } from "./auth/runner.ts";
|
|
181
|
+
import type { MediaServices } from "./media/types.ts";
|
|
74
182
|
import { encodeBuffer } from "../tui/screen.ts";
|
|
75
183
|
import { parseThemeName } from "../tui/theme.ts";
|
|
184
|
+
import { formatDiagnostic } from "../core/sdk/eval.ts";
|
|
185
|
+
import { isProject } from "./project/init.ts";
|
|
186
|
+
import { typecheckProject } from "./project/typecheck.ts";
|
|
187
|
+
import {
|
|
188
|
+
startProjectSync,
|
|
189
|
+
type ProjectSync,
|
|
190
|
+
type SyncHost,
|
|
191
|
+
} from "./project/sync.ts";
|
|
192
|
+
|
|
193
|
+
/** Whether a bare command parses (no side effects): for typo suggestions. */
|
|
194
|
+
function parsesLocally(text: string): boolean {
|
|
195
|
+
return [
|
|
196
|
+
parsePrompt,
|
|
197
|
+
parseMusicCommand,
|
|
198
|
+
parseEditCommand,
|
|
199
|
+
parseRhythmCommand,
|
|
200
|
+
parseFxCommand,
|
|
201
|
+
parseSynthCommand,
|
|
202
|
+
parsePatternCommand,
|
|
203
|
+
parseKitCommand,
|
|
204
|
+
parsePackCommand,
|
|
205
|
+
parseSampleCommand,
|
|
206
|
+
parseWavetableCommand,
|
|
207
|
+
].some((parse) => parse(text) !== undefined);
|
|
208
|
+
}
|
|
76
209
|
|
|
77
210
|
const ESC = "\u001b[";
|
|
211
|
+
/** `/sessions` rows shown in the overlay. */
|
|
212
|
+
const MAX_LISTED_SESSIONS = 64;
|
|
213
|
+
|
|
214
|
+
const SUBCOMMANDS = [
|
|
215
|
+
"login",
|
|
216
|
+
"logout",
|
|
217
|
+
"auth",
|
|
218
|
+
"sessions",
|
|
219
|
+
"render",
|
|
220
|
+
"init",
|
|
221
|
+
"check",
|
|
222
|
+
];
|
|
223
|
+
const VALUE_FLAGS = ["--session", "--track", "--import", "--export", "--theme"];
|
|
224
|
+
const FLAGS = [
|
|
225
|
+
...VALUE_FLAGS,
|
|
226
|
+
"--new",
|
|
227
|
+
"--demo",
|
|
228
|
+
"--help",
|
|
229
|
+
"-h",
|
|
230
|
+
"--version",
|
|
231
|
+
"-v",
|
|
232
|
+
"--reduce-motion",
|
|
233
|
+
];
|
|
78
234
|
const HELP_TEXT = `dawg · local-first terminal music workstation
|
|
79
235
|
|
|
80
236
|
Usage:
|
|
@@ -82,6 +238,10 @@ Usage:
|
|
|
82
238
|
dawg --import <file> --export <file>
|
|
83
239
|
dawg sessions
|
|
84
240
|
dawg render <out.wav> [--session <name|id>] [--import <file>]
|
|
241
|
+
dawg init [dir] project files: song.ts, tracks/<slug>/track.ts, .dawg/sdk
|
|
242
|
+
dawg check typecheck + evaluate the project; exit 1 on problems
|
|
243
|
+
dawg media doctor|download|stems|analyze|notes|sample|lyrics … (dawg media --help)
|
|
244
|
+
dawg --version
|
|
85
245
|
|
|
86
246
|
Usage flags:
|
|
87
247
|
--reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
|
|
@@ -92,24 +252,17 @@ Prompt:
|
|
|
92
252
|
Ctrl-Z undo · Ctrl-Y redo · Ctrl-O transcript · Esc cancel/close · Ctrl-C exit
|
|
93
253
|
Space on an empty prompt toggles playback
|
|
94
254
|
|
|
95
|
-
Commands:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
/tracks, /status, /export <file>, /import <file>, /model opus-5.5|sol-6.1
|
|
104
|
-
/sessions, /resume [n|name|id], /rename <name>|--auto, /fork [name]
|
|
105
|
-
/log, /view focus|all, /theme default|high-contrast|mono, /motion on|off
|
|
106
|
-
/login [--xcb], /logout, /auth [--check]
|
|
107
|
-
|
|
108
|
-
Auth:
|
|
109
|
-
dawg login [--gateway|--key|--xcb] [--budget <dollars>]
|
|
110
|
-
dawg logout · dawg auth status [--check]
|
|
255
|
+
Commands (bare music words; app commands take a slash):
|
|
256
|
+
${helpText()}
|
|
257
|
+
|
|
258
|
+
Sign in (the choice is saved and reused until you log out):
|
|
259
|
+
dawg login find existing setups and pick a provider
|
|
260
|
+
dawg login gateway|openrouter|codex|claude
|
|
261
|
+
dawg model [alias] pick a model, with the estimated cost per prompt
|
|
262
|
+
dawg logout [provider] · dawg auth status [--check]
|
|
111
263
|
Unrecognized requests go to the agent once a provider is configured
|
|
112
|
-
(DAWG_PROVIDER=gateway|
|
|
264
|
+
(DAWG_PROVIDER=gateway|openrouter|codex|claude|auto, DAWG_MODEL=<alias>;
|
|
265
|
+
DAWG_AI=0 disables the agent).`;
|
|
113
266
|
const args = new Set(process.argv.slice(2));
|
|
114
267
|
const requestedSession = optionValue("--session");
|
|
115
268
|
const explicitTrack = optionValue("--track");
|
|
@@ -118,16 +271,46 @@ let requestedTrack = explicitTrack ?? "main";
|
|
|
118
271
|
const initialInstrument = isDrumInstrument(requestedTrack) ? "kit" : "sine";
|
|
119
272
|
const importPath = optionValue("--import");
|
|
120
273
|
const exportPath = optionValue("--export");
|
|
121
|
-
if (["login", "logout", "auth"].includes(process.argv[2] ?? ""))
|
|
274
|
+
if (["login", "logout", "auth", "model"].includes(process.argv[2] ?? ""))
|
|
122
275
|
process.exit(await runAuthCommand(process.argv.slice(2)));
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
process.
|
|
276
|
+
{
|
|
277
|
+
// An unknown DAWG_MODEL is an error, never a silent fallback.
|
|
278
|
+
const fromEnv = process.env.DAWG_MODEL?.trim();
|
|
279
|
+
if (fromEnv && !resolveModelChoice("gateway", fromEnv)) {
|
|
280
|
+
process.stderr.write(
|
|
281
|
+
`dawg: unknown DAWG_MODEL "${fromEnv.slice(0, 40)}"; use one of ${MODEL_CATALOG.map((row) => row.alias).join(", ")} or a vendor/model ID\n`,
|
|
282
|
+
);
|
|
283
|
+
process.exit(2);
|
|
284
|
+
}
|
|
126
285
|
}
|
|
127
286
|
if (process.argv[2] === "sessions") {
|
|
128
|
-
|
|
287
|
+
if (args.has("--help") || args.has("-h"))
|
|
288
|
+
stdout.write(
|
|
289
|
+
"usage: dawg sessions · lists this workspace's sessions, newest first (* marks the current one)\n",
|
|
290
|
+
);
|
|
291
|
+
else await printSessions(process.cwd(), stdout);
|
|
129
292
|
process.exit(0);
|
|
130
293
|
}
|
|
294
|
+
if (process.argv[2] === "init" || process.argv[2] === "check") {
|
|
295
|
+
const command =
|
|
296
|
+
process.argv[2] === "init"
|
|
297
|
+
? (await import("./project/init.ts")).runInitCommand
|
|
298
|
+
: (await import("./project/check.ts")).runCheckCommand;
|
|
299
|
+
process.exit(
|
|
300
|
+
await command(process.argv.slice(2), process.cwd(), stdout, process.stderr),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
if (process.argv[2] === "media") {
|
|
304
|
+
const { runMediaCommand } = await import("./media/cli.ts");
|
|
305
|
+
process.exit(
|
|
306
|
+
await runMediaCommand(
|
|
307
|
+
process.argv.slice(2),
|
|
308
|
+
process.cwd(),
|
|
309
|
+
stdout,
|
|
310
|
+
process.stderr,
|
|
311
|
+
),
|
|
312
|
+
);
|
|
313
|
+
}
|
|
131
314
|
if (process.argv[2] === "render") {
|
|
132
315
|
const { runRenderCommand } = await import("./render.ts");
|
|
133
316
|
process.exit(
|
|
@@ -139,8 +322,28 @@ if (process.argv[2] === "render") {
|
|
|
139
322
|
),
|
|
140
323
|
);
|
|
141
324
|
}
|
|
325
|
+
if (args.has("--version") || args.has("-v")) {
|
|
326
|
+
stdout.write(`dawg ${await packageVersion()}\n`);
|
|
327
|
+
process.exit(0);
|
|
328
|
+
}
|
|
329
|
+
if (args.has("--help") || args.has("-h")) {
|
|
330
|
+
stdout.write(`${HELP_TEXT}\n`);
|
|
331
|
+
process.exit(0);
|
|
332
|
+
}
|
|
333
|
+
// Argument mistakes are rejected here, before `.dawg/` could be created.
|
|
334
|
+
{
|
|
335
|
+
const problem = argumentProblem(process.argv.slice(2));
|
|
336
|
+
if (problem) {
|
|
337
|
+
process.stderr.write(`${problem} · dawg --help\n`);
|
|
338
|
+
process.exit(2);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
142
341
|
const demo =
|
|
143
342
|
args.has("--demo") || process.env.DAWG_DEMO === "1" || !stdin.isTTY;
|
|
343
|
+
// First run with no provider (or a saved one that stopped working): the
|
|
344
|
+
// sign-in picker, in the shell, before the TUI takes the screen.
|
|
345
|
+
if (!demo && process.env.DAWG_AI !== "0" && stdout.isTTY)
|
|
346
|
+
await runFirstRunLogin();
|
|
144
347
|
|
|
145
348
|
const initial = createScore({
|
|
146
349
|
tracks: [
|
|
@@ -162,6 +365,11 @@ const selectedSession = args.has("--new")
|
|
|
162
365
|
},
|
|
163
366
|
);
|
|
164
367
|
if (selectedSession !== undefined) sessionOptions.sessionId = selectedSession;
|
|
368
|
+
/** True when this launch creates `.dawg/`; the strip says so once. */
|
|
369
|
+
const freshWorkspace = !(await stat(join(process.cwd(), ".dawg")).then(
|
|
370
|
+
() => true,
|
|
371
|
+
() => false,
|
|
372
|
+
));
|
|
165
373
|
const session = await ensureSession(initial.toJSON(), sessionOptions);
|
|
166
374
|
// dawgd when connected, the file-lock path otherwise (see src/session/port.ts).
|
|
167
375
|
let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
|
|
@@ -181,7 +389,7 @@ if (importPath) {
|
|
|
181
389
|
record,
|
|
182
390
|
{
|
|
183
391
|
kind: "score.import",
|
|
184
|
-
payload: { path: importPath
|
|
392
|
+
payload: { path: importPath },
|
|
185
393
|
},
|
|
186
394
|
score.toJSON(),
|
|
187
395
|
);
|
|
@@ -204,8 +412,9 @@ if (!draftTrack) await ensureFocusedTrack();
|
|
|
204
412
|
|
|
205
413
|
const clock = new TransportClock(score.tempoBpm);
|
|
206
414
|
let audio = port.player;
|
|
207
|
-
|
|
208
|
-
|
|
415
|
+
/** Session and today's spend, from the usage each response reports. */
|
|
416
|
+
const meter = new SpendMeter(configDir());
|
|
417
|
+
void meter.load().catch(() => undefined);
|
|
209
418
|
const prompt = new PromptModel({ width: 72, maxVisualRows: 8 });
|
|
210
419
|
const tui = new TuiApp({
|
|
211
420
|
io: {
|
|
@@ -220,10 +429,51 @@ const tui = new TuiApp({
|
|
|
220
429
|
});
|
|
221
430
|
let syncState: SyncState = port.sync;
|
|
222
431
|
let windowCount = 1;
|
|
432
|
+
/** Project file sync when `dawg.json` is in the working directory. */
|
|
433
|
+
let projectSync: ProjectSync | undefined;
|
|
434
|
+
let packStore: PackStore | undefined;
|
|
435
|
+
let reportedSampleProblems = "";
|
|
436
|
+
let sampleLibrary: SampleLibrary | undefined;
|
|
437
|
+
let typesIndicator: TypesIndicator | undefined;
|
|
223
438
|
let announcedName = record.meta.name;
|
|
224
439
|
let namer = makeNamer();
|
|
225
440
|
/** Re-subscribes the TUI after /fork or /resume replaces `port`. */
|
|
226
441
|
let rebindPort: () => void = () => undefined;
|
|
442
|
+
/** Play mode's controller; kept across entries so settings persist. */
|
|
443
|
+
let play: PlaySession | undefined;
|
|
444
|
+
/** Chord-mode settings, shared by every track's play session. */
|
|
445
|
+
const chordSettings = defaultChordSettings();
|
|
446
|
+
/** The hand-editing menu (`/menu`, Ctrl-K), drawn as the picker overlay. */
|
|
447
|
+
const menu = new EditMenu();
|
|
448
|
+
/** The Euclidean rhythm editor (`/euclid`, Rhythm in `/menu`). */
|
|
449
|
+
const euclid = new EuclidEditor();
|
|
450
|
+
/** Live voice id for auditions (play-mode voices use small positive ids). */
|
|
451
|
+
const AUDITION_VOICE = 0x7fff_0001;
|
|
452
|
+
/** A voice to audition once the editor's queued command lands. */
|
|
453
|
+
let pendingAudition: string | undefined;
|
|
454
|
+
/** The pattern the /pattern picker last previewed. */
|
|
455
|
+
let patternPreview: string | undefined;
|
|
456
|
+
/** Daemon windows play no loop; play mode monitors through its own engine. */
|
|
457
|
+
let monitorEngine: AudioEngine | undefined;
|
|
458
|
+
/** The audition loop and staged edits (src/tui/audition.ts), made lazily. */
|
|
459
|
+
let auditionLoop: Audition | undefined;
|
|
460
|
+
/** Pickers that host the audition loop (Space, `a`, `c`, hover). */
|
|
461
|
+
const AUDITION_PICKERS = new Set(["kit", "pattern", "try"]);
|
|
462
|
+
const TRY_USAGE =
|
|
463
|
+
"/try <sound command> · /try fx reverb mix 0.6 · /try agent on|off";
|
|
464
|
+
/** Whether the agent's preview_sound plays its snippet in this window. */
|
|
465
|
+
let agentPreviewPlays = process.env.DAWG_AGENT_PREVIEW !== "off";
|
|
466
|
+
const AGENT_PREVIEW_VOICE = 0x7fff_0002;
|
|
467
|
+
/**
|
|
468
|
+
* Set while a staged command runs: `commitScore` hands its result here
|
|
469
|
+
* instead of appending, and a remote revision that lands meanwhile waits in
|
|
470
|
+
* `committed` (the window's `score` is the staged base until it finishes).
|
|
471
|
+
*/
|
|
472
|
+
let stageCapture: { next?: TrackScore; committed?: TrackScore } | undefined;
|
|
473
|
+
/** Redraw soon (the audition reports renders between frames). */
|
|
474
|
+
let requestFrame: () => void = () => undefined;
|
|
475
|
+
/** The decoded sampler voices, for play mode's live voices. */
|
|
476
|
+
let liveSampleBank: SampleBank | undefined;
|
|
227
477
|
/** The in-flight agent turn: Esc aborts it, Enter steers it. */
|
|
228
478
|
let agentTurn: { controller: AbortController; steering: string[] } | undefined;
|
|
229
479
|
let reportAgentActivity: (text: string) => void = () => undefined;
|
|
@@ -233,6 +483,35 @@ let agentEventSink: (event: AgentEvent) => void = () => undefined;
|
|
|
233
483
|
let provider: Promise<ProviderSelection> | undefined;
|
|
234
484
|
let providerStamp = { fingerprint: "", at: 0, offline: false };
|
|
235
485
|
let providerName = "";
|
|
486
|
+
/** Undo/redo key hints shown so far; only the first few receipts carry one. */
|
|
487
|
+
let undoHintsShown = 0;
|
|
488
|
+
const MAX_UNDO_HINTS = 3;
|
|
489
|
+
let invalidNoticeShown = false;
|
|
490
|
+
/** True while /login has handed the terminal to a shell flow. */
|
|
491
|
+
let screenSuspended = false;
|
|
492
|
+
/** Redraw from outside the input loop (usage arrives asynchronously). */
|
|
493
|
+
let tickUi: () => void = () => undefined;
|
|
494
|
+
/** Suspend the TUI around an interactive shell flow (set by runInteractive). */
|
|
495
|
+
let handoff: <T>(flow: () => Promise<T>) => Promise<T> = (flow) => flow();
|
|
496
|
+
|
|
497
|
+
/** `$0.12 session · $0.48 today · opus-5.5 · gateway`, sized to the width. */
|
|
498
|
+
function spendLine(): string {
|
|
499
|
+
const width = stdout.columns ?? 80;
|
|
500
|
+
if (!providerName || providerName === "offline")
|
|
501
|
+
return width >= 40 ? "no model · dawg login" : "";
|
|
502
|
+
const [model, provider] = providerName.split(" · ");
|
|
503
|
+
return formatSpendLine(
|
|
504
|
+
{
|
|
505
|
+
kind: providerSnapshot?.kind === "xcb" ? "subscription" : "api",
|
|
506
|
+
...(model ? { model } : {}),
|
|
507
|
+
...(provider ? { provider } : {}),
|
|
508
|
+
sessionUsd: meter.sessionUsd,
|
|
509
|
+
todayUsd: meter.todayUsd,
|
|
510
|
+
},
|
|
511
|
+
Math.max(0, width - 8),
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
let providerSnapshot: ProviderSelection | undefined;
|
|
236
515
|
if (demo) {
|
|
237
516
|
if (score.notes.length === 0) score = seedDemo(score, requestedTrack);
|
|
238
517
|
if (exportPath)
|
|
@@ -254,6 +533,32 @@ if (exportPath) {
|
|
|
254
533
|
|
|
255
534
|
await runInteractive();
|
|
256
535
|
|
|
536
|
+
/** Describes an unknown subcommand or option in `argv`, or undefined. */
|
|
537
|
+
function argumentProblem(argv: readonly string[]): string | undefined {
|
|
538
|
+
if (argv[0] && !argv[0].startsWith("-") && !SUBCOMMANDS.includes(argv[0]))
|
|
539
|
+
return `unknown command · ${argv[0]}`;
|
|
540
|
+
if (argv[0] && SUBCOMMANDS.includes(argv[0])) return undefined;
|
|
541
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
542
|
+
const arg = argv[index]!;
|
|
543
|
+
if (VALUE_FLAGS.includes(arg)) {
|
|
544
|
+
index += 1;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (arg.startsWith("-") && !FLAGS.includes(arg))
|
|
548
|
+
return `unknown option · ${arg}`;
|
|
549
|
+
if (!arg.startsWith("-")) return `unknown command · ${arg}`;
|
|
550
|
+
}
|
|
551
|
+
return undefined;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function packageVersion(): Promise<string> {
|
|
555
|
+
const raw = await readFile(
|
|
556
|
+
new URL("../package.json", import.meta.url),
|
|
557
|
+
"utf8",
|
|
558
|
+
);
|
|
559
|
+
return (JSON.parse(raw) as { version?: string }).version ?? "0.0.0";
|
|
560
|
+
}
|
|
561
|
+
|
|
257
562
|
function optionValue(name: string): string | undefined {
|
|
258
563
|
const index = process.argv.indexOf(name);
|
|
259
564
|
const value = index >= 0 ? process.argv[index + 1] : undefined;
|
|
@@ -356,6 +661,10 @@ function snapshot(
|
|
|
356
661
|
value.tracks.find((track) => track.id === requestedTrack)?.instrument,
|
|
357
662
|
notes,
|
|
358
663
|
),
|
|
664
|
+
...samplerSnapshotFields(
|
|
665
|
+
value.tracks.find((track) => track.id === requestedTrack),
|
|
666
|
+
notes,
|
|
667
|
+
),
|
|
359
668
|
layers:
|
|
360
669
|
tui.highwayView === "all"
|
|
361
670
|
? highwayLayers(
|
|
@@ -390,33 +699,65 @@ function appView(value: TrackScore, beat: number): AppView {
|
|
|
390
699
|
return {
|
|
391
700
|
score: snapshot(value, beat),
|
|
392
701
|
beat,
|
|
393
|
-
// `opus-5.5 · gateway`, `
|
|
702
|
+
// `opus-5.5 · gateway`, `sonnet · claude`; hidden when offline.
|
|
394
703
|
model:
|
|
395
704
|
providerName && providerName !== "offline" ? providerName : undefined,
|
|
705
|
+
spend: spendLine(),
|
|
706
|
+
agentOffline: providerName === "offline" || process.env.DAWG_AI === "0",
|
|
396
707
|
sync: syncState,
|
|
397
708
|
sessionName: record.meta.name,
|
|
398
709
|
windows: windowCount,
|
|
710
|
+
types: typesIndicator,
|
|
711
|
+
play: play?.on ? play.header() : undefined,
|
|
399
712
|
};
|
|
400
713
|
}
|
|
401
714
|
|
|
402
|
-
/**
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
715
|
+
/** What the strip compares a receipt against: the revision and score before. */
|
|
716
|
+
type Baseline = { revision: number; score: TrackScore };
|
|
717
|
+
function baseline(): Baseline {
|
|
718
|
+
return { revision: record.revision, score };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Show a command receipt in the activity strip. The tone comes from the
|
|
723
|
+
* receipt itself (strings fall back to a legacy regex); the revision label
|
|
724
|
+
* appears when the revision moved, and the undo hint only when the score
|
|
725
|
+
* changed in this session (not for forks, resumes or transport).
|
|
726
|
+
*/
|
|
727
|
+
function receipt(result: string | Receipt, base?: Baseline): void {
|
|
728
|
+
const message = typeof result === "string" ? result : result.text;
|
|
729
|
+
const tone = toneOf(result);
|
|
730
|
+
const changed = base !== undefined && record.revision !== base.revision;
|
|
731
|
+
const scoreChanged =
|
|
732
|
+
changed && record.sessionId !== undefined && score !== base.score;
|
|
733
|
+
if (tone === "error") {
|
|
734
|
+
tui.activity.pushError(message);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const hint =
|
|
738
|
+
scoreChanged && undoHintsShown < MAX_UNDO_HINTS
|
|
739
|
+
? message.startsWith("undid")
|
|
740
|
+
? "^y redo"
|
|
741
|
+
: "^z undo"
|
|
742
|
+
: undefined;
|
|
743
|
+
if (hint) undoHintsShown += 1;
|
|
744
|
+
tui.activity.pushCard(message, {
|
|
745
|
+
tone,
|
|
746
|
+
baseRevision: changed ? base.revision : undefined,
|
|
747
|
+
resultRevision: changed ? record.revision : undefined,
|
|
748
|
+
hint,
|
|
749
|
+
trackId: requestedTrack,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** One shape for thrown errors: `<what> · <why> · <next step>`. */
|
|
754
|
+
function describeError(command: string, error: unknown): string {
|
|
755
|
+
const detail = error as { code?: unknown; path?: unknown } | undefined;
|
|
756
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
757
|
+
if (detail?.code === "ENOENT" && typeof detail.path === "string")
|
|
758
|
+
return `no such file · ${relative(process.cwd(), detail.path)}`;
|
|
759
|
+
const verb = command.trim().split(/\s+/)[0]?.replace(/^\//, "") || "command";
|
|
760
|
+
return `${verb} failed · ${message}`;
|
|
420
761
|
}
|
|
421
762
|
|
|
422
763
|
function truncateForCard(value: string): string {
|
|
@@ -439,7 +780,7 @@ async function runInteractive(): Promise<void> {
|
|
|
439
780
|
return;
|
|
440
781
|
}
|
|
441
782
|
tui.activity.pushRequest(text);
|
|
442
|
-
const base =
|
|
783
|
+
const base = baseline();
|
|
443
784
|
const baseSession = record.sessionId;
|
|
444
785
|
try {
|
|
445
786
|
const message = await submit(text);
|
|
@@ -450,12 +791,10 @@ async function runInteractive(): Promise<void> {
|
|
|
450
791
|
namer.noteTurn({
|
|
451
792
|
score,
|
|
452
793
|
prompt: text,
|
|
453
|
-
accepted: record.revision !== base,
|
|
794
|
+
accepted: record.revision !== base.revision,
|
|
454
795
|
});
|
|
455
796
|
} catch (error) {
|
|
456
|
-
tui.activity.pushError(
|
|
457
|
-
`error · ${error instanceof Error ? error.message : String(error)}`,
|
|
458
|
-
);
|
|
797
|
+
tui.activity.pushError(describeError(text, error));
|
|
459
798
|
} finally {
|
|
460
799
|
agentReported = false;
|
|
461
800
|
}
|
|
@@ -469,6 +808,11 @@ async function runInteractive(): Promise<void> {
|
|
|
469
808
|
const nextPrompt = queuedPrompts.shift()!;
|
|
470
809
|
tui.activity.setQueueDepth(queuedPrompts.length);
|
|
471
810
|
await runPrompt(nextPrompt);
|
|
811
|
+
if (pendingAudition && queuedPrompts.length === 0) {
|
|
812
|
+
const voice = pendingAudition;
|
|
813
|
+
pendingAudition = undefined;
|
|
814
|
+
audition(voice);
|
|
815
|
+
}
|
|
472
816
|
tick(true);
|
|
473
817
|
}
|
|
474
818
|
} finally {
|
|
@@ -477,17 +821,26 @@ async function runInteractive(): Promise<void> {
|
|
|
477
821
|
}
|
|
478
822
|
};
|
|
479
823
|
const tick = (force = false) => {
|
|
824
|
+
if (screenSuspended) return;
|
|
825
|
+
followCommitted();
|
|
826
|
+
play?.tick();
|
|
827
|
+
// Values in the menu follow the score as edits land.
|
|
828
|
+
if (menu.open) refreshMenu();
|
|
829
|
+
if (euclid.open) refreshEuclid();
|
|
830
|
+
refreshAuditionPicker();
|
|
480
831
|
tui.render(appView(score, clock.beatAt()), { force });
|
|
481
832
|
};
|
|
833
|
+
requestFrame = () => tick(true);
|
|
482
834
|
reportAgentActivity = () => {
|
|
483
835
|
tui.activity.applyAgentEvent({ type: "start", model: providerName });
|
|
484
836
|
};
|
|
485
837
|
agentEventSink = (event) => {
|
|
838
|
+
if (event.type === "usage") return;
|
|
486
839
|
tui.activity.applyAgentEvent(event);
|
|
487
840
|
if (event.type === "done" || event.type === "error") agentReported = true;
|
|
488
841
|
};
|
|
489
842
|
// ~30 fps cap; the differential writer only emits changed rows.
|
|
490
|
-
|
|
843
|
+
let timer = setInterval(tick, tui.frameIntervalMs);
|
|
491
844
|
const onResize = () => {
|
|
492
845
|
tui.invalidate();
|
|
493
846
|
tick(true);
|
|
@@ -501,7 +854,9 @@ async function runInteractive(): Promise<void> {
|
|
|
501
854
|
if (latest.revision > record.revision) {
|
|
502
855
|
const previousRevision = record.revision;
|
|
503
856
|
record = latest;
|
|
504
|
-
|
|
857
|
+
if (stageCapture)
|
|
858
|
+
stageCapture.committed = scoreFromJSON(record.composition);
|
|
859
|
+
else score = scoreFromJSON(record.composition);
|
|
505
860
|
clock.setTempo(score.tempoBpm);
|
|
506
861
|
if (clock.playing) void audio.play(score);
|
|
507
862
|
// Connected windows follow dawgd's transport frames instead.
|
|
@@ -533,6 +888,7 @@ async function runInteractive(): Promise<void> {
|
|
|
533
888
|
else if (payload.action === "pause") await setTransport("pause");
|
|
534
889
|
else if (payload.action === "toggle") await setTransport("toggle");
|
|
535
890
|
}
|
|
891
|
+
projectSync?.scoreChanged(score);
|
|
536
892
|
tui.activity.pushCard("synced from another window", {
|
|
537
893
|
tone: "info",
|
|
538
894
|
baseRevision: previousRevision,
|
|
@@ -581,6 +937,10 @@ async function runInteractive(): Promise<void> {
|
|
|
581
937
|
unsubscribe = port.subscribe(onUpdate);
|
|
582
938
|
refreshPresence();
|
|
583
939
|
};
|
|
940
|
+
if (freshWorkspace)
|
|
941
|
+
tui.activity.pushCard("created .dawg/ · add it to .gitignore", {
|
|
942
|
+
tone: "info",
|
|
943
|
+
});
|
|
584
944
|
if (attachNotice)
|
|
585
945
|
tui.activity.pushCard(attachNotice, {
|
|
586
946
|
tone: "info",
|
|
@@ -589,45 +949,183 @@ async function runInteractive(): Promise<void> {
|
|
|
589
949
|
});
|
|
590
950
|
if (port.status !== "file session")
|
|
591
951
|
tui.activity.pushCard(port.status, { tone: "info" });
|
|
952
|
+
if (await isProject(process.cwd()))
|
|
953
|
+
projectSync = startProjectSync(syncHost());
|
|
592
954
|
void currentProvider().then(() => tick(true));
|
|
955
|
+
tickUi = () => tick(true);
|
|
956
|
+
reportSampleProblems(score);
|
|
957
|
+
// Input arrives through a detachable listener (not `for await`), so /login
|
|
958
|
+
// can hand the terminal to an interactive shell flow and take it back.
|
|
959
|
+
const inbox: string[] = [];
|
|
960
|
+
let wake: (() => void) | undefined;
|
|
961
|
+
const onData = (chunk: Buffer | string) => {
|
|
962
|
+
inbox.push(String(chunk));
|
|
963
|
+
wake?.();
|
|
964
|
+
};
|
|
965
|
+
const onEnd = () => {
|
|
966
|
+
inbox.push("\u0000eof");
|
|
967
|
+
wake?.();
|
|
968
|
+
};
|
|
969
|
+
stdin.on("data", onData);
|
|
970
|
+
stdin.on("end", onEnd);
|
|
971
|
+
async function* chunks(): AsyncGenerator<string> {
|
|
972
|
+
for (;;) {
|
|
973
|
+
while (inbox.length === 0)
|
|
974
|
+
await new Promise<void>((resolve) => (wake = resolve));
|
|
975
|
+
wake = undefined;
|
|
976
|
+
const next = inbox.shift()!;
|
|
977
|
+
if (next === "\u0000eof") return;
|
|
978
|
+
yield next;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
handoff = async <T>(flow: () => Promise<T>): Promise<T> => {
|
|
982
|
+
if (screenSuspended) return flow();
|
|
983
|
+
screenSuspended = true;
|
|
984
|
+
clearInterval(timer);
|
|
985
|
+
stdin.off("data", onData);
|
|
986
|
+
stdin.pause();
|
|
987
|
+
stdin.setRawMode?.(false);
|
|
988
|
+
// Leave the alternate screen; restore the cursor and plain paste.
|
|
989
|
+
stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
990
|
+
try {
|
|
991
|
+
return await flow();
|
|
992
|
+
} finally {
|
|
993
|
+
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
|
|
994
|
+
stdin.setRawMode?.(true);
|
|
995
|
+
stdin.on("data", onData);
|
|
996
|
+
stdin.resume();
|
|
997
|
+
screenSuspended = false;
|
|
998
|
+
timer = setInterval(tick, tui.frameIntervalMs);
|
|
999
|
+
tui.invalidate();
|
|
1000
|
+
tick(true);
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
593
1003
|
tick(true);
|
|
594
1004
|
try {
|
|
595
1005
|
let exiting = false;
|
|
596
|
-
for await (const
|
|
597
|
-
const text = String(chunk);
|
|
1006
|
+
for await (const text of chunks()) {
|
|
598
1007
|
// A read that is exactly ESC is the Esc key, not the start of a sequence.
|
|
599
1008
|
const values = [
|
|
600
1009
|
...inputDecoder.push(text),
|
|
601
1010
|
...(text === "\u001b" ? inputDecoder.flush() : []),
|
|
602
1011
|
];
|
|
603
1012
|
for (const value of values) {
|
|
1013
|
+
// The `?` panel closes on any key (Ctrl-C still quits).
|
|
1014
|
+
if (tui.ui.keys && typeof value === "string" && value !== "\u0003") {
|
|
1015
|
+
tui.closeKeys();
|
|
1016
|
+
tick(true);
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
if (value === "?" && keysScreen()) {
|
|
1020
|
+
showKeys();
|
|
1021
|
+
tick(true);
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
// The rhythm editor owns every key while it is up (Esc backs out).
|
|
1025
|
+
if (typeof value === "string" && euclid.open) {
|
|
1026
|
+
if (tui.ui.overlay !== "picker" || tui.ui.picker?.id !== "euclid")
|
|
1027
|
+
euclid.close();
|
|
1028
|
+
else {
|
|
1029
|
+
const result = euclid.key(value, euclidContext());
|
|
1030
|
+
if (result.type === "close") {
|
|
1031
|
+
const back = euclid.returnTo;
|
|
1032
|
+
euclid.close();
|
|
1033
|
+
tui.closePicker();
|
|
1034
|
+
if (back === "menu") openMenu();
|
|
1035
|
+
} else if (result.type === "run") {
|
|
1036
|
+
queuedPrompts.unshift(result.command);
|
|
1037
|
+
if (result.audition) pendingAudition = result.audition;
|
|
1038
|
+
void drainQueue();
|
|
1039
|
+
} else if (result.type === "audition") audition(result.voice);
|
|
1040
|
+
if (result.type !== "pass") {
|
|
1041
|
+
refreshEuclid();
|
|
1042
|
+
tick(true);
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
// The edit menu owns every key while it is up (Esc backs out).
|
|
1048
|
+
if (typeof value === "string" && menu.open) {
|
|
1049
|
+
if (tui.ui.overlay !== "picker" || tui.ui.picker?.id !== "menu")
|
|
1050
|
+
menu.close();
|
|
1051
|
+
else {
|
|
1052
|
+
const result = menu.key(value, menuContext());
|
|
1053
|
+
if (result.type === "close") {
|
|
1054
|
+
tui.closePicker();
|
|
1055
|
+
leaveAuditionScreen();
|
|
1056
|
+
} else if (result.type === "run") {
|
|
1057
|
+
if (!stageIfAuditioning(result.command)) {
|
|
1058
|
+
queuedPrompts.unshift(result.command);
|
|
1059
|
+
void drainQueue();
|
|
1060
|
+
}
|
|
1061
|
+
} else if (result.type === "audition")
|
|
1062
|
+
auditionKeyPressed(result.key);
|
|
1063
|
+
else if (result.type === "revert") revertStaged();
|
|
1064
|
+
else if (result.type === "hover")
|
|
1065
|
+
hoverItem(result.command, result.key);
|
|
1066
|
+
else if (result.type === "unhover")
|
|
1067
|
+
void auditionLoop
|
|
1068
|
+
?.unhover(result.key)
|
|
1069
|
+
.finally(() => requestFrame());
|
|
1070
|
+
else if (result.type === "choose")
|
|
1071
|
+
chooseItem(result.command, result.key);
|
|
1072
|
+
else if (result.type === "keep")
|
|
1073
|
+
void keepStaged()
|
|
1074
|
+
.then((outcome) => receipt(outcome))
|
|
1075
|
+
.catch((error) =>
|
|
1076
|
+
tui.activity.pushError(describeError("keep", error)),
|
|
1077
|
+
)
|
|
1078
|
+
.finally(() => tick(true));
|
|
1079
|
+
if (result.type !== "pass") {
|
|
1080
|
+
refreshMenu();
|
|
1081
|
+
tick(true);
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
// Ctrl-K opens the menu on an empty prompt (in play mode too); with
|
|
1087
|
+
// text it keeps its kill-to-end-of-line meaning.
|
|
1088
|
+
if (
|
|
1089
|
+
value === "\u000b" &&
|
|
1090
|
+
prompt.value.length === 0 &&
|
|
1091
|
+
tui.ui.overlay === undefined
|
|
1092
|
+
) {
|
|
1093
|
+
openMenu();
|
|
1094
|
+
tick(true);
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
if (typeof value === "string" && play?.on && playKey(value)) {
|
|
1098
|
+
tick(true);
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
// Ctrl-P enters play mode. A bare `p` would steal the first letter of
|
|
1102
|
+
// `pan`, `pattern`, `play` and every prose request starting with p.
|
|
1103
|
+
if (value === "\u0010" && tui.ui.overlay === undefined) {
|
|
1104
|
+
void enterPlay()
|
|
1105
|
+
.then((outcome) => receipt(outcome))
|
|
1106
|
+
.finally(() => tick(true));
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
604
1109
|
// The prompt is always focused, so ordinary `q` must remain typeable in
|
|
605
1110
|
// requests (for example, "quiet hi-hat"). Ctrl-C is the unambiguous
|
|
606
1111
|
// shell exit key; Ctrl-Q is reserved for prompt mode switching.
|
|
607
1112
|
// Keep the empty-prompt space shortcut for transport, while allowing
|
|
608
1113
|
// ordinary spaces once a request is being composed.
|
|
609
|
-
if (
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
},
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
);
|
|
624
|
-
} catch (error) {
|
|
625
|
-
if (error instanceof SessionConflictError)
|
|
626
|
-
tui.activity.pushCard("transport changed in another window", {
|
|
627
|
-
tone: "warning",
|
|
628
|
-
});
|
|
629
|
-
else throw error;
|
|
630
|
-
}
|
|
1114
|
+
if (
|
|
1115
|
+
value === " " &&
|
|
1116
|
+
prompt.value.length === 0 &&
|
|
1117
|
+
!(tui.ui.overlay === "picker" && tui.ui.picker?.audition)
|
|
1118
|
+
) {
|
|
1119
|
+
// Never await a daemon round trip here: the key loop must stay
|
|
1120
|
+
// live for Esc, quit and redraws while the toggle is in flight.
|
|
1121
|
+
void toggleTransport()
|
|
1122
|
+
.catch((error: unknown) => {
|
|
1123
|
+
tui.activity.pushCard(
|
|
1124
|
+
`transport failed · ${error instanceof Error ? error.message : String(error)}`,
|
|
1125
|
+
{ tone: "error" },
|
|
1126
|
+
);
|
|
1127
|
+
})
|
|
1128
|
+
.finally(() => tick(true));
|
|
631
1129
|
} else {
|
|
632
1130
|
const input = tui.input(value);
|
|
633
1131
|
const action = input.type === "action" ? input.action : undefined;
|
|
@@ -637,9 +1135,64 @@ async function runInteractive(): Promise<void> {
|
|
|
637
1135
|
(input.command === "undo" || input.command === "redo") &&
|
|
638
1136
|
!agentTurn
|
|
639
1137
|
) {
|
|
640
|
-
const base =
|
|
641
|
-
|
|
1138
|
+
const base = baseline();
|
|
1139
|
+
const command = input.command;
|
|
1140
|
+
void stepHistory(command)
|
|
1141
|
+
.then((outcome) => receipt(outcome, base))
|
|
1142
|
+
.catch((error: unknown) => {
|
|
1143
|
+
tui.activity.pushCard(
|
|
1144
|
+
`${command} failed · ${error instanceof Error ? error.message : String(error)}`,
|
|
1145
|
+
{ tone: "error" },
|
|
1146
|
+
);
|
|
1147
|
+
})
|
|
1148
|
+
.finally(() => tick(true));
|
|
642
1149
|
}
|
|
1150
|
+
} else if (input.type === "pick-move") {
|
|
1151
|
+
// Moving through a list auditions the row under the cursor on
|
|
1152
|
+
// the loop; with the loop off, /pattern plays one bar of it.
|
|
1153
|
+
if (auditionLoop?.looping && isStageable(input.value))
|
|
1154
|
+
hoverItem(input.value, `picker:${input.picker}`);
|
|
1155
|
+
else if (input.picker === "pattern")
|
|
1156
|
+
previewPattern(input.value.replace(/^\/pattern\s+/, ""));
|
|
1157
|
+
} else if (input.type === "pick-audition") {
|
|
1158
|
+
pickerAuditionKey(input.key);
|
|
1159
|
+
} else if (input.type === "pick-cancel") {
|
|
1160
|
+
if (AUDITION_PICKERS.has(input.picker)) leaveAuditionScreen();
|
|
1161
|
+
} else if (input.type === "pick" && input.picker === "try") {
|
|
1162
|
+
if (input.value === "keep")
|
|
1163
|
+
void keepStaged()
|
|
1164
|
+
.then((outcome) => receipt(outcome))
|
|
1165
|
+
.catch((error) =>
|
|
1166
|
+
tui.activity.pushError(describeError("keep", error)),
|
|
1167
|
+
)
|
|
1168
|
+
.finally(() => {
|
|
1169
|
+
stopAuditionLoop();
|
|
1170
|
+
tick(true);
|
|
1171
|
+
});
|
|
1172
|
+
else leaveAuditionScreen();
|
|
1173
|
+
} else if (
|
|
1174
|
+
input.type === "pick" &&
|
|
1175
|
+
AUDITION_PICKERS.has(input.picker) &&
|
|
1176
|
+
auditionLoop?.staging
|
|
1177
|
+
) {
|
|
1178
|
+
// Enter in an auditioning list keeps what you hear.
|
|
1179
|
+
const loop = auditionLoop;
|
|
1180
|
+
const key = `picker:${input.picker}`;
|
|
1181
|
+
void (
|
|
1182
|
+
isStageable(input.value)
|
|
1183
|
+
? loop.hover(input.value, key)
|
|
1184
|
+
: Promise.resolve()
|
|
1185
|
+
)
|
|
1186
|
+
.then(() => loop.settle(key))
|
|
1187
|
+
.then(() => keepStaged())
|
|
1188
|
+
.then((outcome) => receipt(outcome))
|
|
1189
|
+
.catch((error) =>
|
|
1190
|
+
tui.activity.pushError(describeError("keep", error)),
|
|
1191
|
+
)
|
|
1192
|
+
.finally(() => {
|
|
1193
|
+
stopAuditionLoop();
|
|
1194
|
+
tick(true);
|
|
1195
|
+
});
|
|
643
1196
|
} else if (input.type === "pick") {
|
|
644
1197
|
// Picker choices run as the command they stand for.
|
|
645
1198
|
queuedPrompts.unshift(
|
|
@@ -678,11 +1231,16 @@ async function runInteractive(): Promise<void> {
|
|
|
678
1231
|
if (exiting) break;
|
|
679
1232
|
}
|
|
680
1233
|
} finally {
|
|
1234
|
+
stdin.off("data", onData);
|
|
1235
|
+
stdin.off("end", onEnd);
|
|
681
1236
|
clearInterval(timer);
|
|
682
1237
|
clearInterval(presenceTimer);
|
|
1238
|
+
await projectSync?.stop();
|
|
683
1239
|
namer.dispose();
|
|
684
1240
|
stdout.off("resize", onResize);
|
|
685
1241
|
unsubscribe();
|
|
1242
|
+
await play?.exit().catch(() => undefined);
|
|
1243
|
+
await monitorEngine?.dispose().catch(() => undefined);
|
|
686
1244
|
audio.stop();
|
|
687
1245
|
await port.close();
|
|
688
1246
|
stdin.setRawMode?.(false);
|
|
@@ -691,23 +1249,165 @@ async function runInteractive(): Promise<void> {
|
|
|
691
1249
|
}
|
|
692
1250
|
}
|
|
693
1251
|
|
|
694
|
-
|
|
1252
|
+
/** `unknown command /clik · did you mean /click? · /help`. */
|
|
1253
|
+
function unknownCommand(command: string): string {
|
|
1254
|
+
const word = command.split(/\s+/)[0] ?? command;
|
|
1255
|
+
const near = nearestCommand(command);
|
|
1256
|
+
return near
|
|
1257
|
+
? `unknown command ${word} · did you mean ${near}? · /help`
|
|
1258
|
+
: `unknown command ${word} · /help`;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
async function submit(prompt: string): Promise<string | Receipt> {
|
|
695
1262
|
const command = prompt.trim();
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
1263
|
+
const helpCommand = command.match(/^\/?(?:help|\?)(?:\s+(\S+))?$/i);
|
|
1264
|
+
if (helpCommand) {
|
|
1265
|
+
const topic = helpCommand[1];
|
|
1266
|
+
const lines = helpTopicLines(
|
|
1267
|
+
topic,
|
|
1268
|
+
Math.max(40, (stdout.columns ?? 80) - 10),
|
|
1269
|
+
);
|
|
1270
|
+
if (!lines)
|
|
1271
|
+
return fail(
|
|
1272
|
+
`no help topic ${topic} · /help all · ${HELP_TOPICS.join(" ")}`,
|
|
1273
|
+
);
|
|
1274
|
+
tui.openText(topic ? `help · ${topic.toLowerCase()}` : "help", lines);
|
|
1275
|
+
return ok(
|
|
1276
|
+
topic
|
|
1277
|
+
? `help · ${topic.toLowerCase()}`
|
|
1278
|
+
: "help · /help all for every command",
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
if (/^\/?tracks$/i.test(command)) {
|
|
1282
|
+
const problems = await sampleProblems(score);
|
|
1283
|
+
const lines = score.tracks.map((track) => {
|
|
1284
|
+
const voices = track.sampler
|
|
1285
|
+
? Object.keys(track.sampler.voices).length
|
|
1286
|
+
: 0;
|
|
1287
|
+
const missing = problems.filter(
|
|
1288
|
+
(problem) => problem.trackId === track.id && problem.level === "error",
|
|
1289
|
+
).length;
|
|
1290
|
+
const samples = track.sampler
|
|
1291
|
+
? ` · ${voices} sample${voices === 1 ? "" : "s"}${missing ? ` · ${missing} missing` : ""}`
|
|
1292
|
+
: "";
|
|
1293
|
+
return `${track.id === requestedTrack ? "*" : " "} ${track.id} · ${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}`;
|
|
1294
|
+
});
|
|
1295
|
+
tui.openText("tracks · * focused", lines);
|
|
1296
|
+
return ok(`${lines.length} track${lines.length === 1 ? "" : "s"}`);
|
|
1297
|
+
}
|
|
1298
|
+
const playCommand = command.match(/^\/play(?:\s+(on|off))?$/i);
|
|
1299
|
+
if (playCommand) {
|
|
1300
|
+
const wanted = playCommand[1]?.toLowerCase();
|
|
1301
|
+
if (wanted === "off" || (wanted === undefined && play?.on))
|
|
1302
|
+
return exitPlay();
|
|
1303
|
+
return enterPlay();
|
|
1304
|
+
}
|
|
1305
|
+
const clickCommand = command.match(/^\/click(?:\s+(.+))?$/i);
|
|
1306
|
+
if (clickCommand) {
|
|
1307
|
+
const message = playSession().clickCommand(clickCommand[1] ?? "");
|
|
1308
|
+
return message.startsWith("usage") || message.startsWith("click volume")
|
|
1309
|
+
? fail(message)
|
|
1310
|
+
: ok(message);
|
|
1311
|
+
}
|
|
1312
|
+
const chordsCommand = command.match(/^\/chords(?:\s+(.+))?$/i);
|
|
1313
|
+
if (chordsCommand) {
|
|
1314
|
+
const result = applyChordsCommand(chordSettings, chordsCommand[1] ?? "");
|
|
1315
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1316
|
+
}
|
|
1317
|
+
const countIn = command.match(/^\/count-?in\s+([0-2])$/i);
|
|
1318
|
+
if (countIn) return ok(playSession().setCountIn(Number(countIn[1])));
|
|
1319
|
+
const euclidCommand = command.match(/^\/euclid(?:\s+(\S+))?$/i);
|
|
1320
|
+
if (euclidCommand) {
|
|
1321
|
+
const from = menu.open ? "menu" : undefined;
|
|
1322
|
+
if (menu.open) {
|
|
1323
|
+
menu.close();
|
|
1324
|
+
tui.closePicker();
|
|
1325
|
+
}
|
|
1326
|
+
openEuclid(euclidCommand[1]?.toLowerCase(), from);
|
|
1327
|
+
return ok("rhythm editor");
|
|
1328
|
+
}
|
|
1329
|
+
const tryCommand = command.match(/^\/try(?:\s+(.+))?$/i);
|
|
1330
|
+
if (tryCommand) return tryPrompt(tryCommand[1]?.trim() ?? "");
|
|
1331
|
+
const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
|
|
1332
|
+
if (menuCommand) {
|
|
1333
|
+
const section = menuCommand[1]?.toLowerCase();
|
|
1334
|
+
if (section && !(MENU_SECTIONS as readonly string[]).includes(section))
|
|
1335
|
+
return fail(`/menu [${MENU_SECTIONS.join("|")}]`);
|
|
1336
|
+
openMenu(section);
|
|
1337
|
+
return ok("menu");
|
|
1338
|
+
}
|
|
1339
|
+
const gridCommand = command.match(/^\/grid\s+(\S+)$/i);
|
|
1340
|
+
if (gridCommand) {
|
|
1341
|
+
const message = playSession().setGrid(gridCommand[1]!);
|
|
1342
|
+
return message
|
|
1343
|
+
? ok(message)
|
|
1344
|
+
: fail("usage: /grid 1/4|1/8|1/8T|1/16|1/16T|1/32");
|
|
1345
|
+
}
|
|
705
1346
|
if (/^\/status$/i.test(command))
|
|
706
|
-
return
|
|
1347
|
+
return ok(
|
|
1348
|
+
`status · ${record.meta.name} · rev ${record.revision} · ${compositionDigest(record.composition)} · ${port.mode === "daemon" ? "shared via dawgd" : "saved locally · no daemon"}`,
|
|
1349
|
+
);
|
|
707
1350
|
if (/^\/?undo$/i.test(command)) return stepHistory("undo");
|
|
708
1351
|
if (/^\/?redo$/i.test(command)) return stepHistory("redo");
|
|
1352
|
+
const trackCommand = command.match(
|
|
1353
|
+
/^\/?(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/i,
|
|
1354
|
+
);
|
|
1355
|
+
if (trackCommand) return focusTrack(trackCommand[1]!.toLowerCase());
|
|
1356
|
+
const sample = parseSampleCommand(command);
|
|
1357
|
+
if (sample) return sampleCommand(sample);
|
|
1358
|
+
const pack = parsePackCommand(command);
|
|
1359
|
+
if (pack) return packCommand(pack);
|
|
1360
|
+
const pattern = parsePatternCommand(command);
|
|
1361
|
+
if (pattern) return patternCommand(pattern);
|
|
1362
|
+
const kit = parseKitCommand(command);
|
|
1363
|
+
if (kit) return kitCommand(kit, /^\/kit\s*$/i.test(command.trim()));
|
|
1364
|
+
const wavetable = parseWavetableCommand(command);
|
|
1365
|
+
if (wavetable) return wavetableCommand(wavetable);
|
|
709
1366
|
const sessionReply = await sessionCommand(command);
|
|
710
1367
|
if (sessionReply !== undefined) return sessionReply;
|
|
1368
|
+
const edit = parseEditCommand(command);
|
|
1369
|
+
if (edit) {
|
|
1370
|
+
await materializeDraft();
|
|
1371
|
+
const result = applyEditCommand(score, requestedTrack, edit);
|
|
1372
|
+
if (result.next && result.kind)
|
|
1373
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1374
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1375
|
+
}
|
|
1376
|
+
const rhythm = parseRhythmCommand(command);
|
|
1377
|
+
if (rhythm) {
|
|
1378
|
+
await materializeDraft();
|
|
1379
|
+
const result = applyRhythmCommand(score, requestedTrack, rhythm);
|
|
1380
|
+
if (result.next && result.kind)
|
|
1381
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1382
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1383
|
+
}
|
|
1384
|
+
const fx = parseFxCommand(command);
|
|
1385
|
+
if (fx) {
|
|
1386
|
+
if (fx.type !== "fx-list") await materializeDraft();
|
|
1387
|
+
// A pack impulse is pinned (sha256 + url) once, as wavetable tables are.
|
|
1388
|
+
let pinnedIr: SampleRef | undefined;
|
|
1389
|
+
if (fx.type === "fx-ir" && fx.ir?.startsWith(PACK_PREFIX)) {
|
|
1390
|
+
try {
|
|
1391
|
+
pinnedIr = await packs().pin(fx.ir);
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
if (error instanceof PackError)
|
|
1394
|
+
return fail(`reverb ir · ${error.message}`);
|
|
1395
|
+
throw error;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
const result = applyFxCommand(score, requestedTrack, fx, pinnedIr);
|
|
1399
|
+
if (result.next && result.kind)
|
|
1400
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1401
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1402
|
+
}
|
|
1403
|
+
const synth = parseSynthCommand(command);
|
|
1404
|
+
if (synth) {
|
|
1405
|
+
if (synth.type !== "synth-list") await materializeDraft();
|
|
1406
|
+
const result = applySynthCommand(score, requestedTrack, synth);
|
|
1407
|
+
if (result.next && result.kind)
|
|
1408
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1409
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1410
|
+
}
|
|
711
1411
|
const music = parseMusicCommand(command);
|
|
712
1412
|
if (music) {
|
|
713
1413
|
await materializeDraft();
|
|
@@ -716,7 +1416,7 @@ async function submit(prompt: string): Promise<string> {
|
|
|
716
1416
|
);
|
|
717
1417
|
if (result.next && result.kind)
|
|
718
1418
|
await commitScore(result.next, result.kind, result.payload);
|
|
719
|
-
return result.message;
|
|
1419
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
720
1420
|
}
|
|
721
1421
|
const exportCommand = command.match(/^\/?export\s+([^\s]+)$/i);
|
|
722
1422
|
if (exportCommand) {
|
|
@@ -731,33 +1431,56 @@ async function submit(prompt: string): Promise<string> {
|
|
|
731
1431
|
await commitScore(imported, "score.import", { path: importCommand[1] });
|
|
732
1432
|
return `imported · ${importCommand[1]}`;
|
|
733
1433
|
}
|
|
734
|
-
const
|
|
735
|
-
if (
|
|
736
|
-
|
|
737
|
-
if (
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1434
|
+
const modelMatch = prompt.trim().match(/^\/model(?:\s+(\S+))?\s*$/i);
|
|
1435
|
+
if (modelMatch) {
|
|
1436
|
+
const selection = await currentProvider();
|
|
1437
|
+
if (!modelMatch[1]) {
|
|
1438
|
+
if (selection.kind === "offline")
|
|
1439
|
+
return `model · none · ${selection.reason}`;
|
|
1440
|
+
tui.activity.setSpinner("models");
|
|
1441
|
+
try {
|
|
1442
|
+
const items = await modelPickerItems(selection);
|
|
1443
|
+
if (items.length === 0)
|
|
1444
|
+
return `model · ${providerName} · no other models listed`;
|
|
745
1445
|
tui.openPicker({
|
|
746
|
-
id: "
|
|
747
|
-
title:
|
|
1446
|
+
id: "model",
|
|
1447
|
+
title: `model · ${providerName}`,
|
|
748
1448
|
items,
|
|
1449
|
+
filterable: true,
|
|
1450
|
+
index: Math.max(
|
|
1451
|
+
0,
|
|
1452
|
+
items.findIndex((item) => item.current),
|
|
1453
|
+
),
|
|
749
1454
|
});
|
|
750
|
-
return `
|
|
1455
|
+
return `model · ${providerName} · pick one (or /model <alias>)`;
|
|
1456
|
+
} finally {
|
|
1457
|
+
tui.activity.setSpinner(undefined);
|
|
751
1458
|
}
|
|
752
|
-
} finally {
|
|
753
|
-
tui.activity.setSpinner(undefined);
|
|
754
1459
|
}
|
|
755
|
-
|
|
1460
|
+
const receiptText = await tuiSetModel(modelMatch[1]);
|
|
1461
|
+
provider = undefined;
|
|
1462
|
+
await currentProvider();
|
|
1463
|
+
return receiptText;
|
|
1464
|
+
}
|
|
1465
|
+
if (/^\/login\b/i.test(prompt.trim())) {
|
|
1466
|
+
const parsed = tuiLoginArgs(prompt.trim());
|
|
1467
|
+
if (typeof parsed === "string") return `login · ${parsed}`;
|
|
1468
|
+
await handoff(() =>
|
|
1469
|
+
runTuiLogin(
|
|
1470
|
+
parsed.target === "auto" ? "pick" : parsed.target,
|
|
1471
|
+
parsed.options,
|
|
1472
|
+
),
|
|
1473
|
+
);
|
|
1474
|
+
provider = undefined;
|
|
1475
|
+
await currentProvider();
|
|
1476
|
+
return providerName === "offline"
|
|
1477
|
+
? "login · not signed in · direct commands still work"
|
|
1478
|
+
: `signed in · ${providerName}`;
|
|
756
1479
|
}
|
|
757
|
-
if (/^\/(
|
|
1480
|
+
if (/^\/(logout|auth)\b/i.test(prompt.trim())) {
|
|
758
1481
|
tui.activity.setSpinner(prompt.trim().split(/\s+/)[0]!.slice(1));
|
|
759
1482
|
try {
|
|
760
|
-
const lines = await tuiAuthCommand(prompt.trim()
|
|
1483
|
+
const lines = await tuiAuthCommand(prompt.trim());
|
|
761
1484
|
provider = undefined;
|
|
762
1485
|
await currentProvider();
|
|
763
1486
|
for (const line of lines.slice(0, -1))
|
|
@@ -767,9 +1490,24 @@ async function submit(prompt: string): Promise<string> {
|
|
|
767
1490
|
tui.activity.setSpinner(undefined);
|
|
768
1491
|
}
|
|
769
1492
|
}
|
|
1493
|
+
// `/model` belongs to its own handler above; every other slash word that
|
|
1494
|
+
// reached here is unknown or misused, and never a question for the agent.
|
|
1495
|
+
if (command.startsWith("/") && !/^\/model\b/i.test(command)) {
|
|
1496
|
+
const hint = usageHint(command);
|
|
1497
|
+
return fail(
|
|
1498
|
+
hint ? `${truncateForCard(command)} · ${hint}` : unknownCommand(command),
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
770
1501
|
const parsed = parsePrompt(prompt);
|
|
771
1502
|
if (!parsed) {
|
|
772
|
-
|
|
1503
|
+
// A known verb with bad arguments gets usage, not a model call.
|
|
1504
|
+
const hint = usageHint(command);
|
|
1505
|
+
if (hint) return fail(`${truncateForCard(command)} · ${hint}`);
|
|
1506
|
+
// A one-letter slip on a command whose arguments parse stays local.
|
|
1507
|
+
const fix = typoFix(command, parsesLocally);
|
|
1508
|
+
if (fix) return fail(`${truncateForCard(command)} · did you mean ${fix}?`);
|
|
1509
|
+
if (process.env.DAWG_AI === "0")
|
|
1510
|
+
return fail(`unrecognized · ${truncateForCard(command)} · /help`);
|
|
773
1511
|
await materializeDraft();
|
|
774
1512
|
return runAgent(prompt);
|
|
775
1513
|
}
|
|
@@ -791,7 +1529,7 @@ async function submit(prompt: string): Promise<string> {
|
|
|
791
1529
|
);
|
|
792
1530
|
} catch (error) {
|
|
793
1531
|
if (error instanceof SessionConflictError)
|
|
794
|
-
return "transport changed in another window";
|
|
1532
|
+
return warn("transport changed in another window");
|
|
795
1533
|
throw error;
|
|
796
1534
|
}
|
|
797
1535
|
return parsed.action;
|
|
@@ -802,20 +1540,7 @@ async function submit(prompt: string): Promise<string> {
|
|
|
802
1540
|
clock.setTempo?.(next.tempoBpm);
|
|
803
1541
|
return `tempo · ${next.tempoBpm} BPM`;
|
|
804
1542
|
}
|
|
805
|
-
if (parsed.type === "add-track")
|
|
806
|
-
if (score.tracks.some((track) => track.id === parsed.trackId))
|
|
807
|
-
return `track exists · ${parsed.trackId}`;
|
|
808
|
-
const next = applyScoreOperation(score, {
|
|
809
|
-
type: "addTrack",
|
|
810
|
-
track: {
|
|
811
|
-
id: parsed.trackId,
|
|
812
|
-
name: parsed.trackId,
|
|
813
|
-
instrument: isDrumInstrument(parsed.trackId) ? "kit" : "sine",
|
|
814
|
-
},
|
|
815
|
-
});
|
|
816
|
-
await commitScore(next, "track.create", { trackId: parsed.trackId });
|
|
817
|
-
return `track created · ${parsed.trackId}`;
|
|
818
|
-
}
|
|
1543
|
+
if (parsed.type === "add-track") return focusTrack(parsed.trackId);
|
|
819
1544
|
if (parsed.type === "set-bars" || parsed.type === "extend-bars") {
|
|
820
1545
|
const bars =
|
|
821
1546
|
parsed.type === "set-bars"
|
|
@@ -940,7 +1665,7 @@ async function submit(prompt: string): Promise<string> {
|
|
|
940
1665
|
latest,
|
|
941
1666
|
{
|
|
942
1667
|
kind: "score.operation",
|
|
943
|
-
payload: { operation
|
|
1668
|
+
payload: { operation },
|
|
944
1669
|
},
|
|
945
1670
|
next.toJSON(),
|
|
946
1671
|
);
|
|
@@ -951,7 +1676,484 @@ async function submit(prompt: string): Promise<string> {
|
|
|
951
1676
|
if (!(error instanceof SessionConflictError)) throw error;
|
|
952
1677
|
}
|
|
953
1678
|
}
|
|
954
|
-
return "session busy
|
|
1679
|
+
return warn("session busy · retry the note");
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* `/track <name>`: focus `trackId` in this window, creating it when it is
|
|
1684
|
+
* not in the score yet. A track another live window has focused stays
|
|
1685
|
+
* theirs; focus goes through the port so presence is right everywhere.
|
|
1686
|
+
*/
|
|
1687
|
+
async function focusTrack(trackId: string): Promise<Receipt> {
|
|
1688
|
+
if (trackId === requestedTrack && !draftTrack)
|
|
1689
|
+
return warn(`already on ${trackId}`);
|
|
1690
|
+
const clients = await port.presence().catch(() => []);
|
|
1691
|
+
if (
|
|
1692
|
+
clients.some(
|
|
1693
|
+
(client) =>
|
|
1694
|
+
client.clientId !== port.clientId && client.focusedTrackId === trackId,
|
|
1695
|
+
)
|
|
1696
|
+
)
|
|
1697
|
+
return warn(`${trackId} is open in another window`);
|
|
1698
|
+
const exists = score.tracks.some((track) => track.id === trackId);
|
|
1699
|
+
if (!exists) {
|
|
1700
|
+
const next = applyScoreOperation(score, {
|
|
1701
|
+
type: "addTrack",
|
|
1702
|
+
track: {
|
|
1703
|
+
id: trackId,
|
|
1704
|
+
name: trackId,
|
|
1705
|
+
instrument: isDrumInstrument(trackId) ? "kit" : "sine",
|
|
1706
|
+
},
|
|
1707
|
+
});
|
|
1708
|
+
await commitScore(next, "track.create", { trackId });
|
|
1709
|
+
}
|
|
1710
|
+
await port.focus(trackId);
|
|
1711
|
+
requestedTrack = trackId;
|
|
1712
|
+
draftTrack = false;
|
|
1713
|
+
return ok(exists ? `track · ${trackId}` : `track created · ${trackId}`);
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/** Decode every sampler voice (cached) and return what failed to load. */
|
|
1717
|
+
async function sampleProblems(
|
|
1718
|
+
value: TrackScore,
|
|
1719
|
+
): Promise<readonly SampleProblem[]> {
|
|
1720
|
+
if (!hasSamplerTracks(value)) return [];
|
|
1721
|
+
sampleLibrary ??= new SampleLibrary({
|
|
1722
|
+
projectRoot: process.cwd(),
|
|
1723
|
+
packs: packs(),
|
|
1724
|
+
});
|
|
1725
|
+
try {
|
|
1726
|
+
const bank = await sampleLibrary.load(value);
|
|
1727
|
+
liveSampleBank = bank;
|
|
1728
|
+
return bank.problems;
|
|
1729
|
+
} catch (error) {
|
|
1730
|
+
return [
|
|
1731
|
+
{
|
|
1732
|
+
trackId: "",
|
|
1733
|
+
voice: "",
|
|
1734
|
+
src: "",
|
|
1735
|
+
level: "error",
|
|
1736
|
+
message: `samples · ${error instanceof Error ? error.message : String(error)} · check the sample files`,
|
|
1737
|
+
},
|
|
1738
|
+
];
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/** Surface sample load problems once per distinct set, as receipts. */
|
|
1743
|
+
function reportSampleProblems(value: TrackScore): void {
|
|
1744
|
+
if (!hasSamplerTracks(value)) return;
|
|
1745
|
+
void sampleProblems(value).then((problems) => {
|
|
1746
|
+
const key = problems.map((problem) => problem.message).join("\n");
|
|
1747
|
+
if (key === reportedSampleProblems) return;
|
|
1748
|
+
reportedSampleProblems = key;
|
|
1749
|
+
for (const problem of problems.slice(0, 3))
|
|
1750
|
+
tui.activity.pushCard(`sample ${problem.voice} · ${problem.message}`, {
|
|
1751
|
+
tone: problem.level === "error" ? "error" : "warning",
|
|
1752
|
+
trackId: problem.trackId || undefined,
|
|
1753
|
+
});
|
|
1754
|
+
if (problems.length > 3)
|
|
1755
|
+
tui.activity.pushCard(
|
|
1756
|
+
`${problems.length - 3} more sample problems · /tracks`,
|
|
1757
|
+
{
|
|
1758
|
+
tone: "warning",
|
|
1759
|
+
},
|
|
1760
|
+
);
|
|
1761
|
+
tickUi();
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
async function sampleCommand(
|
|
1766
|
+
command: NonNullable<ReturnType<typeof parseSampleCommand>>,
|
|
1767
|
+
): Promise<Receipt> {
|
|
1768
|
+
if (command.kind === "list") {
|
|
1769
|
+
const track = score.tracks.find((item) => item.id === requestedTrack);
|
|
1770
|
+
const lines = listSampleVoices(track);
|
|
1771
|
+
if (lines.length === 0)
|
|
1772
|
+
return warn(
|
|
1773
|
+
`sample · ${requestedTrack} has no samples · /sample <path> [as <voice>]`,
|
|
1774
|
+
);
|
|
1775
|
+
tui.openText(`samples · ${requestedTrack}`, lines);
|
|
1776
|
+
return ok(`${lines.length} sample${lines.length === 1 ? "" : "s"}`);
|
|
1777
|
+
}
|
|
1778
|
+
if (command.kind === "set") {
|
|
1779
|
+
const result = setSampleControls(
|
|
1780
|
+
score,
|
|
1781
|
+
requestedTrack,
|
|
1782
|
+
command.voice,
|
|
1783
|
+
command.values,
|
|
1784
|
+
);
|
|
1785
|
+
if (!result.ok) return fail(result.message);
|
|
1786
|
+
await commitScore(result.next, "sample.set", {
|
|
1787
|
+
trackId: requestedTrack,
|
|
1788
|
+
voice: command.voice,
|
|
1789
|
+
});
|
|
1790
|
+
await projectSync?.flushScore();
|
|
1791
|
+
return ok(result.message);
|
|
1792
|
+
}
|
|
1793
|
+
await materializeDraft();
|
|
1794
|
+
const trackId = samplerTarget(score, requestedTrack);
|
|
1795
|
+
const voice =
|
|
1796
|
+
command.voice ?? freeVoiceName(score, trackId, voiceNameFrom(command.path));
|
|
1797
|
+
let placed;
|
|
1798
|
+
try {
|
|
1799
|
+
placed = await placeSampleFile({
|
|
1800
|
+
projectRoot: process.cwd(),
|
|
1801
|
+
cwd: process.cwd(),
|
|
1802
|
+
input: command.path,
|
|
1803
|
+
score,
|
|
1804
|
+
trackId,
|
|
1805
|
+
voice,
|
|
1806
|
+
});
|
|
1807
|
+
} catch (error) {
|
|
1808
|
+
if (error instanceof SamplePlacementError)
|
|
1809
|
+
return fail(`sample · ${error.message} · check the path and retry`);
|
|
1810
|
+
throw error;
|
|
1811
|
+
}
|
|
1812
|
+
const result = addSampleVoice(score, trackId, voice, {
|
|
1813
|
+
src: placed.src,
|
|
1814
|
+
sha256: placed.sha256,
|
|
1815
|
+
});
|
|
1816
|
+
if (!result.ok) return fail(result.message);
|
|
1817
|
+
// Decode before committing so a bad file is a receipt, not a silent voice.
|
|
1818
|
+
const problems = (await sampleProblems(result.next)).filter(
|
|
1819
|
+
(problem) => problem.trackId === trackId && problem.voice === voice,
|
|
1820
|
+
);
|
|
1821
|
+
if (problems.some((problem) => problem.level === "error"))
|
|
1822
|
+
return fail(`sample · ${problems[0]!.message}`);
|
|
1823
|
+
await commitScore(result.next, "sample.add", {
|
|
1824
|
+
trackId,
|
|
1825
|
+
voice,
|
|
1826
|
+
src: placed.src,
|
|
1827
|
+
});
|
|
1828
|
+
if (trackId !== requestedTrack) {
|
|
1829
|
+
await port.focus(trackId);
|
|
1830
|
+
requestedTrack = trackId;
|
|
1831
|
+
draftTrack = false;
|
|
1832
|
+
}
|
|
1833
|
+
await projectSync?.flushScore();
|
|
1834
|
+
return ok(
|
|
1835
|
+
`${result.message}${placed.copied ? ` · copied to ${placed.src}` : ""}`,
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function packs(): PackStore {
|
|
1840
|
+
packStore ??= new PackStore();
|
|
1841
|
+
return packStore;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/** Keeps CREDITS.md in step with the CC-BY packs the score uses (quietly). */
|
|
1845
|
+
async function updateCredits(value: TrackScore): Promise<void> {
|
|
1846
|
+
const refs = value.tracks.flatMap((track) =>
|
|
1847
|
+
Object.values(track.sampler?.voices ?? {}),
|
|
1848
|
+
);
|
|
1849
|
+
if (!refs.some((ref) => ref.src.startsWith("pack:"))) return;
|
|
1850
|
+
// Only a dawg project gets CREDITS.md; a bare session directory does not.
|
|
1851
|
+
if (!projectSync) return;
|
|
1852
|
+
try {
|
|
1853
|
+
await writeCredits(process.cwd(), packCredits(refs, await packs().list()));
|
|
1854
|
+
} catch {
|
|
1855
|
+
/* credits are best effort; render metadata carries them too */
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/** Applies a pack edit as one undo step and focuses its track. */
|
|
1860
|
+
async function commitPackEdit(
|
|
1861
|
+
operations: readonly ScoreOperation[],
|
|
1862
|
+
trackId: string,
|
|
1863
|
+
kind: string,
|
|
1864
|
+
payload: Record<string, unknown>,
|
|
1865
|
+
): Promise<Receipt | undefined> {
|
|
1866
|
+
let next = score;
|
|
1867
|
+
for (const operation of operations)
|
|
1868
|
+
next = applyScoreOperation(next, operation);
|
|
1869
|
+
const problems = (await sampleProblems(next)).filter(
|
|
1870
|
+
(problem) => problem.trackId === trackId && problem.level === "error",
|
|
1871
|
+
);
|
|
1872
|
+
if (problems.length > 0) return fail(`sound · ${problems[0]!.message}`);
|
|
1873
|
+
await commitScore(next, kind, payload);
|
|
1874
|
+
if (stageCapture) return undefined;
|
|
1875
|
+
if (trackId !== requestedTrack) {
|
|
1876
|
+
await port.focus(trackId);
|
|
1877
|
+
requestedTrack = trackId;
|
|
1878
|
+
draftTrack = false;
|
|
1879
|
+
}
|
|
1880
|
+
await projectSync?.flushScore();
|
|
1881
|
+
return undefined;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
async function packCommand(command: PackCommand): Promise<Receipt> {
|
|
1885
|
+
try {
|
|
1886
|
+
if (command.kind === "usage") return warn(command.message);
|
|
1887
|
+
if (command.kind === "list") {
|
|
1888
|
+
const lines = packListLines(await packs().list());
|
|
1889
|
+
tui.openText("packs", lines);
|
|
1890
|
+
return ok(`${lines.length} packs · /pack info <name>`);
|
|
1891
|
+
}
|
|
1892
|
+
if (command.kind === "cache") {
|
|
1893
|
+
const library = (sampleLibrary ??= new SampleLibrary({
|
|
1894
|
+
projectRoot: process.cwd(),
|
|
1895
|
+
packs: packs(),
|
|
1896
|
+
}));
|
|
1897
|
+
let freed: { files: number; bytes: number } | undefined;
|
|
1898
|
+
if (command.pruneTo !== undefined) {
|
|
1899
|
+
const to = (cap: number) =>
|
|
1900
|
+
command.pruneTo === "cap" ? cap : (command.pruneTo as number);
|
|
1901
|
+
const store = packs();
|
|
1902
|
+
const a = await store.pruneCache(to(store.maxFileCacheBytes));
|
|
1903
|
+
const b = await library.pruneCache(to(library.maxCacheBytes));
|
|
1904
|
+
freed = { files: a.removed + b.removed, bytes: a.freed + b.freed };
|
|
1905
|
+
}
|
|
1906
|
+
const lines = cacheLines({
|
|
1907
|
+
packs: await packs().cacheStatus(),
|
|
1908
|
+
assets: await library.cacheStatus(),
|
|
1909
|
+
...(freed ? { freed } : {}),
|
|
1910
|
+
});
|
|
1911
|
+
tui.openText("cache", lines);
|
|
1912
|
+
return ok(lines[0]!);
|
|
1913
|
+
}
|
|
1914
|
+
if (command.kind === "add") {
|
|
1915
|
+
const added = await packs().add(
|
|
1916
|
+
command.source,
|
|
1917
|
+
command.name ? { name: command.name } : {},
|
|
1918
|
+
);
|
|
1919
|
+
return ok(
|
|
1920
|
+
`pack added · ${added.pack.name} · ${added.sounds} sounds · /pack info ${added.pack.name}`,
|
|
1921
|
+
);
|
|
1922
|
+
}
|
|
1923
|
+
if (command.kind === "info") {
|
|
1924
|
+
const info = await packs().info(command.name);
|
|
1925
|
+
if (!info)
|
|
1926
|
+
return fail(
|
|
1927
|
+
`pack · no pack named ${command.name.slice(0, 40)} · /pack list`,
|
|
1928
|
+
);
|
|
1929
|
+
const manifest = await packs().manifest(info.name);
|
|
1930
|
+
const banks = banksOf(manifest);
|
|
1931
|
+
const names = [...manifest.sounds.keys()];
|
|
1932
|
+
const lines = [
|
|
1933
|
+
`${info.name} · ${info.title}`,
|
|
1934
|
+
`license · ${info.license}${info.attribution ? ` · ${info.attribution}` : ""}`,
|
|
1935
|
+
`manifest · ${info.manifestUrl}`,
|
|
1936
|
+
...(info.homepage ? [`home · ${info.homepage}`] : []),
|
|
1937
|
+
`${names.length} sounds${banks.length ? ` · ${banks.length} kits` : ""}`,
|
|
1938
|
+
...(banks.length ? [`kits · ${banks.join(" ")}`] : []),
|
|
1939
|
+
...names.slice(0, 400).map((name) => {
|
|
1940
|
+
const sound = manifest.sounds.get(name)!;
|
|
1941
|
+
return `${name} · ${sound.kind === "zones" ? `${sound.zones.length} notes (keyed)` : `${sound.files.length} file${sound.files.length === 1 ? "" : "s"}`}`;
|
|
1942
|
+
}),
|
|
1943
|
+
];
|
|
1944
|
+
tui.openText(`pack · ${info.name}`, lines);
|
|
1945
|
+
return ok(
|
|
1946
|
+
`${info.name} · ${names.length} sounds · /pack use ${info.name}/<sound>`,
|
|
1947
|
+
);
|
|
1948
|
+
}
|
|
1949
|
+
if (command.kind === "remove") {
|
|
1950
|
+
const known = await packs().remove(command.name);
|
|
1951
|
+
return known
|
|
1952
|
+
? ok(`pack removed · ${command.name}`)
|
|
1953
|
+
: fail(
|
|
1954
|
+
`pack · no pack named ${command.name.slice(0, 40)} · /pack list`,
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
await materializeDraft();
|
|
1958
|
+
const result = await useSound(
|
|
1959
|
+
packs(),
|
|
1960
|
+
score,
|
|
1961
|
+
requestedTrack,
|
|
1962
|
+
command.sound,
|
|
1963
|
+
{
|
|
1964
|
+
...(command.voice ? { voice: command.voice } : {}),
|
|
1965
|
+
},
|
|
1966
|
+
);
|
|
1967
|
+
const failed = await commitPackEdit(
|
|
1968
|
+
result.operations,
|
|
1969
|
+
result.trackId,
|
|
1970
|
+
"pack.use",
|
|
1971
|
+
{
|
|
1972
|
+
trackId: result.trackId,
|
|
1973
|
+
sound: command.sound,
|
|
1974
|
+
},
|
|
1975
|
+
);
|
|
1976
|
+
return failed ?? ok(`sound · ${result.summary}`);
|
|
1977
|
+
} catch (error) {
|
|
1978
|
+
if (error instanceof PackError) return fail(`pack · ${error.message}`);
|
|
1979
|
+
throw error;
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
async function patternCommand(command: PatternCommand): Promise<Receipt> {
|
|
1984
|
+
if (command.kind === "list") {
|
|
1985
|
+
tui.openText("patterns", DRUM_PATTERNS.map(patternLine));
|
|
1986
|
+
return ok(`patterns · ${DRUM_PATTERNS.length} · /pattern <name>`);
|
|
1987
|
+
}
|
|
1988
|
+
if (command.kind === "browse") {
|
|
1989
|
+
tui.openPicker({
|
|
1990
|
+
id: "pattern",
|
|
1991
|
+
title: "drum patterns",
|
|
1992
|
+
hint: HINTS.audition,
|
|
1993
|
+
audition: true,
|
|
1994
|
+
items: DRUM_PATTERNS.map((entry) => ({
|
|
1995
|
+
label: `${entry.label.padEnd(22)} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
|
|
1996
|
+
value: `/pattern ${entry.name}`,
|
|
1997
|
+
})),
|
|
1998
|
+
filterable: true,
|
|
1999
|
+
index: 0,
|
|
2000
|
+
});
|
|
2001
|
+
patternPreview = undefined;
|
|
2002
|
+
previewPattern(DRUM_PATTERNS[0]?.name);
|
|
2003
|
+
return ok("patterns · ↑/↓ preview · Enter applies on the focused track");
|
|
2004
|
+
}
|
|
2005
|
+
await materializeDraft();
|
|
2006
|
+
const result = applyDrumPattern(
|
|
2007
|
+
score,
|
|
2008
|
+
requestedTrack,
|
|
2009
|
+
command.name,
|
|
2010
|
+
command.tempo,
|
|
2011
|
+
);
|
|
2012
|
+
if (result.next && result.kind)
|
|
2013
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2014
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
/**
|
|
2018
|
+
* Play one bar of the pattern under the picker cursor, on a scratch copy
|
|
2019
|
+
* of the focused track (or a fresh kit track when the focus is melodic).
|
|
2020
|
+
* Silent while the loop plays, like the Euclidean editor's audition.
|
|
2021
|
+
*/
|
|
2022
|
+
function previewPattern(name: string | undefined): void {
|
|
2023
|
+
if (!name || name === patternPreview) return;
|
|
2024
|
+
patternPreview = name;
|
|
2025
|
+
if (clock.playing) return;
|
|
2026
|
+
const entry = DRUM_PATTERNS.find((candidate) => candidate.name === name);
|
|
2027
|
+
if (!entry) return;
|
|
2028
|
+
let result = applyDrumPattern(score, requestedTrack, entry.name, "set");
|
|
2029
|
+
let trackId = requestedTrack;
|
|
2030
|
+
if (!result.next) {
|
|
2031
|
+
trackId = "pattern-preview";
|
|
2032
|
+
result = applyDrumPattern(score, trackId, entry.name, "set");
|
|
2033
|
+
}
|
|
2034
|
+
if (!result.next) return;
|
|
2035
|
+
const engine = liveEngine();
|
|
2036
|
+
if (!engine?.canMonitor) return;
|
|
2037
|
+
const pcm = renderAudition({
|
|
2038
|
+
score: result.next,
|
|
2039
|
+
trackId,
|
|
2040
|
+
bars: 1,
|
|
2041
|
+
sampleRate: engine.sampleRate,
|
|
2042
|
+
...(liveSampleBank ? { samples: liveSampleBank } : {}),
|
|
2043
|
+
});
|
|
2044
|
+
if (!pcm) return;
|
|
2045
|
+
void engine
|
|
2046
|
+
.monitor(true)
|
|
2047
|
+
.then(() => engine.noteOn(AUDITION_VOICE, pcm))
|
|
2048
|
+
.catch(() => undefined);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
/** `/kit` with no name: one picker with the synth kits and the sample kits. */
|
|
2052
|
+
function openKitPicker(): Receipt {
|
|
2053
|
+
const current = score.tracks.find((track) => track.id === requestedTrack);
|
|
2054
|
+
const items = kitCatalog().map((entry) => ({
|
|
2055
|
+
label: `${entry.kind === "synth" ? "synth " : "sample"} ${entry.label.padEnd(18)} ${entry.detail}`,
|
|
2056
|
+
value: entry.command,
|
|
2057
|
+
current:
|
|
2058
|
+
entry.kind === "synth" &&
|
|
2059
|
+
isDrumInstrumentTrack(current) &&
|
|
2060
|
+
(current?.kit ?? "default") === entry.name,
|
|
2061
|
+
}));
|
|
2062
|
+
tui.openPicker({
|
|
2063
|
+
id: "kit",
|
|
2064
|
+
title: "drum kits",
|
|
2065
|
+
hint: HINTS.audition,
|
|
2066
|
+
audition: true,
|
|
2067
|
+
items,
|
|
2068
|
+
filterable: true,
|
|
2069
|
+
index: Math.max(
|
|
2070
|
+
0,
|
|
2071
|
+
items.findIndex((item) => item.current),
|
|
2072
|
+
),
|
|
2073
|
+
});
|
|
2074
|
+
return ok("kits · synth kits work offline · sample kits are fetched once");
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
function isDrumInstrumentTrack(track: { instrument: string } | undefined) {
|
|
2078
|
+
return track !== undefined && isDrumInstrument(track.instrument);
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
async function kitCommand(command: KitCommand, bare = false): Promise<Receipt> {
|
|
2082
|
+
if (bare) return openKitPicker();
|
|
2083
|
+
if (command.kind === "set" && isSynthKitName(command.bank)) {
|
|
2084
|
+
await materializeDraft();
|
|
2085
|
+
const result = applySynthKit(score, requestedTrack, command.bank);
|
|
2086
|
+
if (result.next && result.kind)
|
|
2087
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2088
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2089
|
+
}
|
|
2090
|
+
if (command.kind === "list") {
|
|
2091
|
+
const lines = kitCatalog()
|
|
2092
|
+
.filter((entry) => entry.kind === "synth")
|
|
2093
|
+
.map((entry) => `${entry.name} · synth · ${entry.detail}`);
|
|
2094
|
+
lines.push(...kitListLines(await packs().bankAliases(ALIASED_PACK)));
|
|
2095
|
+
tui.openText("kits", lines);
|
|
2096
|
+
return ok("kits · /kit <name or nickname> on the focused drum track");
|
|
2097
|
+
}
|
|
2098
|
+
await materializeDraft();
|
|
2099
|
+
const trackId = kitTarget(score, requestedTrack);
|
|
2100
|
+
if (!trackId)
|
|
2101
|
+
return fail(
|
|
2102
|
+
`kit · ${requestedTrack} is a melodic track · focus a drum track (/track drums) and retry`,
|
|
2103
|
+
);
|
|
2104
|
+
try {
|
|
2105
|
+
const result = await useKit(packs(), score, trackId, command.bank);
|
|
2106
|
+
const failed = await commitPackEdit(
|
|
2107
|
+
result.operations,
|
|
2108
|
+
trackId,
|
|
2109
|
+
"pack.kit",
|
|
2110
|
+
{
|
|
2111
|
+
trackId,
|
|
2112
|
+
bank: command.bank,
|
|
2113
|
+
},
|
|
2114
|
+
);
|
|
2115
|
+
return failed ?? ok(result.summary);
|
|
2116
|
+
} catch (error) {
|
|
2117
|
+
if (error instanceof PackError) return fail(`kit · ${error.message}`);
|
|
2118
|
+
throw error;
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
async function wavetableCommand(command: WavetableCommand): Promise<Receipt> {
|
|
2123
|
+
if (command.kind === "usage") return warn(command.message);
|
|
2124
|
+
if (command.kind === "show")
|
|
2125
|
+
return ok(describeWavetable(score, requestedTrack));
|
|
2126
|
+
if (command.kind === "list") {
|
|
2127
|
+
tui.openText(
|
|
2128
|
+
"wavetables",
|
|
2129
|
+
await wavetableListLines(packs(), process.cwd()),
|
|
2130
|
+
);
|
|
2131
|
+
return ok("wavetables · wt <table> sets the focused track");
|
|
2132
|
+
}
|
|
2133
|
+
await materializeDraft();
|
|
2134
|
+
const trackId = requestedTrack;
|
|
2135
|
+
try {
|
|
2136
|
+
const edit =
|
|
2137
|
+
command.kind === "table"
|
|
2138
|
+
? await pickWavetable(
|
|
2139
|
+
packs(),
|
|
2140
|
+
score,
|
|
2141
|
+
trackId,
|
|
2142
|
+
command.table,
|
|
2143
|
+
process.cwd(),
|
|
2144
|
+
)
|
|
2145
|
+
: wavetableParamEdit(score, trackId, command);
|
|
2146
|
+
const failed = await commitPackEdit(
|
|
2147
|
+
[edit.operation],
|
|
2148
|
+
trackId,
|
|
2149
|
+
"track.wavetable",
|
|
2150
|
+
{ trackId, ...command },
|
|
2151
|
+
);
|
|
2152
|
+
return failed ?? ok(edit.summary);
|
|
2153
|
+
} catch (error) {
|
|
2154
|
+
if (error instanceof PackError) return fail(`wavetable · ${error.message}`);
|
|
2155
|
+
throw error;
|
|
2156
|
+
}
|
|
955
2157
|
}
|
|
956
2158
|
|
|
957
2159
|
async function readLoopFile(path: string): Promise<string> {
|
|
@@ -1002,7 +2204,12 @@ function adoptMeta(meta: typeof record.meta): void {
|
|
|
1002
2204
|
record = { ...record, meta };
|
|
1003
2205
|
if (meta.name !== announcedName) {
|
|
1004
2206
|
announcedName = meta.name;
|
|
1005
|
-
tui.activity.pushCard(
|
|
2207
|
+
tui.activity.pushCard(
|
|
2208
|
+
meta.nameSource === "auto"
|
|
2209
|
+
? `${meta.name} (auto-named) · rename with /rename <name>`
|
|
2210
|
+
: `session · ${meta.name}`,
|
|
2211
|
+
{ tone: "info" },
|
|
2212
|
+
);
|
|
1006
2213
|
}
|
|
1007
2214
|
}
|
|
1008
2215
|
|
|
@@ -1022,7 +2229,9 @@ function makeNamer(): AutoNamer {
|
|
|
1022
2229
|
}
|
|
1023
2230
|
|
|
1024
2231
|
/** `/sessions`, `/resume`, `/rename`, `/fork`; undefined when not one. */
|
|
1025
|
-
async function sessionCommand(
|
|
2232
|
+
async function sessionCommand(
|
|
2233
|
+
command: string,
|
|
2234
|
+
): Promise<string | Receipt | undefined> {
|
|
1026
2235
|
const match = command.match(/^\/(sessions|resume|rename|fork)(?:\s+(.*))?$/i);
|
|
1027
2236
|
if (!match) return undefined;
|
|
1028
2237
|
const verb = match[1]!.toLowerCase();
|
|
@@ -1030,7 +2239,9 @@ async function sessionCommand(command: string): Promise<string | undefined> {
|
|
|
1030
2239
|
const workspace = process.cwd();
|
|
1031
2240
|
if (verb === "rename") {
|
|
1032
2241
|
if (!arg)
|
|
1033
|
-
return
|
|
2242
|
+
return record.meta.nameSource === "auto"
|
|
2243
|
+
? `${record.meta.name} (auto-named) · rename with /rename <name>`
|
|
2244
|
+
: `${record.meta.name} (named by you) · /rename --auto to auto-name`;
|
|
1034
2245
|
if (arg === "--auto") {
|
|
1035
2246
|
const result = await port.updateMeta({
|
|
1036
2247
|
nameSource: "auto",
|
|
@@ -1051,48 +2262,59 @@ async function sessionCommand(command: string): Promise<string | undefined> {
|
|
|
1051
2262
|
const sessions = await listSessions(workspace);
|
|
1052
2263
|
if (verb === "resume" && !arg) {
|
|
1053
2264
|
const readable = sessions.filter((session) => !session.error).slice(0, 64);
|
|
1054
|
-
if (readable.length === 0) return "no sessions to resume";
|
|
2265
|
+
if (readable.length === 0) return warn("no sessions to resume");
|
|
1055
2266
|
const current = readable.findIndex(
|
|
1056
2267
|
(session) => session.sessionId === record.sessionId,
|
|
1057
2268
|
);
|
|
1058
2269
|
tui.openPicker({
|
|
1059
2270
|
id: "resume",
|
|
1060
|
-
title: "resume session
|
|
2271
|
+
title: "resume a session",
|
|
1061
2272
|
items: readable.map((session) => ({
|
|
1062
2273
|
label: `${session.sessionId === record.sessionId ? "* " : " "}${formatSessionLine(session, sessions)}`,
|
|
1063
2274
|
value: session.sessionId,
|
|
1064
2275
|
})),
|
|
1065
2276
|
index: Math.max(0, current),
|
|
1066
2277
|
});
|
|
1067
|
-
return "resume ·
|
|
2278
|
+
return "resume · 1-9 shown · /resume <name|id>";
|
|
1068
2279
|
}
|
|
1069
2280
|
if (verb === "sessions") {
|
|
1070
|
-
const lines = pickerLines(
|
|
1071
|
-
|
|
2281
|
+
const lines = pickerLines(
|
|
2282
|
+
sessions,
|
|
2283
|
+
record.sessionId,
|
|
2284
|
+
(session) => formatSessionLine(session, sessions),
|
|
2285
|
+
MAX_LISTED_SESSIONS,
|
|
1072
2286
|
);
|
|
1073
|
-
|
|
1074
|
-
return `${lines.length}
|
|
2287
|
+
tui.openText("sessions · * current · /resume <n>", lines);
|
|
2288
|
+
return ok(`${lines.length} session${lines.length === 1 ? "" : "s"}`);
|
|
1075
2289
|
}
|
|
1076
|
-
if (agentTurn) return "agent busy
|
|
2290
|
+
if (agentTurn) return warn("agent busy · finish or Esc first");
|
|
1077
2291
|
if (verb === "fork") {
|
|
1078
2292
|
const forked = await forkSession(workspace, record, arg || undefined);
|
|
1079
2293
|
await switchSession(forked.sessionId);
|
|
1080
2294
|
return `forked · ${forked.meta.name}`;
|
|
1081
2295
|
}
|
|
1082
2296
|
const readable = sessions.filter((session) => !session.error);
|
|
1083
|
-
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
2297
|
+
// Any list index works, not only the digits the picker binds.
|
|
2298
|
+
const index = /^\d+$/.test(arg) ? Number(arg) - 1 : -1;
|
|
2299
|
+
let sessionId = readable[index]?.sessionId;
|
|
2300
|
+
if (sessionId === undefined)
|
|
2301
|
+
try {
|
|
2302
|
+
sessionId = await resolveSessionArg(workspace, arg, sessions);
|
|
2303
|
+
} catch (error) {
|
|
2304
|
+
if (!(error instanceof SessionLookupError)) throw error;
|
|
2305
|
+
return fail(error.message.replace(" · dawg sessions", " · /sessions"));
|
|
2306
|
+
}
|
|
2307
|
+
if (sessionId === record.sessionId)
|
|
2308
|
+
return warn(`already in ${record.meta.name}`);
|
|
1090
2309
|
await switchSession(sessionId);
|
|
1091
2310
|
return `resumed · ${record.meta.name} · ${requestedTrack}${draftTrack ? " (new)" : ""}`;
|
|
1092
2311
|
}
|
|
1093
2312
|
|
|
1094
2313
|
/** Leaves the current session and attaches this window to `sessionId`. */
|
|
1095
2314
|
async function switchSession(sessionId: string): Promise<void> {
|
|
2315
|
+
// Play mode belongs to the old session's engine and track.
|
|
2316
|
+
await play?.exit();
|
|
2317
|
+
play = undefined;
|
|
1096
2318
|
if (clock.playing) await setTransport("pause");
|
|
1097
2319
|
namer.dispose();
|
|
1098
2320
|
await port.close();
|
|
@@ -1123,29 +2345,651 @@ async function switchSession(sessionId: string): Promise<void> {
|
|
|
1123
2345
|
});
|
|
1124
2346
|
}
|
|
1125
2347
|
|
|
2348
|
+
function liveEngine(): LiveEngine | undefined {
|
|
2349
|
+
return previewEngine();
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/** The engine this window hears itself through (its own in daemon mode). */
|
|
2353
|
+
function previewEngine(): AudioEngine {
|
|
2354
|
+
if (audio instanceof AudioEngine) return audio;
|
|
2355
|
+
monitorEngine ??= new AudioEngine({ projectRoot: process.cwd() });
|
|
2356
|
+
return monitorEngine;
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
/** The play session for the focused track (re-made when focus moves). */
|
|
2360
|
+
function menuContext(): MenuContext {
|
|
2361
|
+
const session = play;
|
|
2362
|
+
const loop = auditionController();
|
|
2363
|
+
loop.focus(requestedTrack);
|
|
2364
|
+
return {
|
|
2365
|
+
score: loop.staging ? loop.score : score,
|
|
2366
|
+
audition: {
|
|
2367
|
+
looping: loop.looping,
|
|
2368
|
+
dirty: loop.dirtyEdits,
|
|
2369
|
+
committed: score,
|
|
2370
|
+
hint: loop.hint(),
|
|
2371
|
+
status: loop.status(),
|
|
2372
|
+
},
|
|
2373
|
+
trackId: requestedTrack,
|
|
2374
|
+
playing: clock.playing,
|
|
2375
|
+
grid: session?.grid ?? DEFAULT_GRID,
|
|
2376
|
+
grids: GRIDS.map((grid) => grid.label),
|
|
2377
|
+
clickOn: session?.clickOn ?? false,
|
|
2378
|
+
countInBars: session?.countInBars ?? 1,
|
|
2379
|
+
chords: session?.chords.settings ?? chordSettings,
|
|
2380
|
+
projectRoot: process.cwd(),
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
/** The screen `?` describes, or undefined while `?` is typed text. */
|
|
2385
|
+
function keysScreen(): readonly KeySection[] | undefined {
|
|
2386
|
+
if (euclid.open) return euclid.typing ? undefined : KEYS.euclid;
|
|
2387
|
+
if (menu.open) return menu.typing ? undefined : KEYS.menu;
|
|
2388
|
+
const overlay = tui.ui.overlay;
|
|
2389
|
+
if (overlay === "picker")
|
|
2390
|
+
return tui.pickerTyping
|
|
2391
|
+
? undefined
|
|
2392
|
+
: AUDITION_PICKERS.has(tui.ui.picker?.id ?? "")
|
|
2393
|
+
? KEYS.audition
|
|
2394
|
+
: KEYS.list;
|
|
2395
|
+
if (overlay === "text") return KEYS.text;
|
|
2396
|
+
if (overlay === "log") return KEYS.log;
|
|
2397
|
+
if (prompt.value.length > 0) return undefined;
|
|
2398
|
+
if (play?.on)
|
|
2399
|
+
return play.chords.on ? [...KEYS.play, ...KEYS.chords] : KEYS.play;
|
|
2400
|
+
return KEYS.prompt;
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
function showKeys(): void {
|
|
2404
|
+
const sections = keysScreen();
|
|
2405
|
+
if (!sections) return;
|
|
2406
|
+
const lines = keyLines(sections);
|
|
2407
|
+
// Play mode's secondary state lives here instead of the header.
|
|
2408
|
+
if (play?.on && !tui.ui.overlay && !menu.open)
|
|
2409
|
+
lines.unshift(...play.details(), "");
|
|
2410
|
+
tui.showKeys("keys", lines);
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
function openMenu(section?: string): void {
|
|
2414
|
+
menu.show(menuContext(), section);
|
|
2415
|
+
refreshMenu();
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
function euclidContext() {
|
|
2419
|
+
return { score, trackId: requestedTrack };
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
function openEuclid(voice?: string, origin?: string): void {
|
|
2423
|
+
euclid.show(euclidContext(), voice, origin);
|
|
2424
|
+
refreshEuclid();
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
function refreshEuclid(): void {
|
|
2428
|
+
if (!euclid.open) return;
|
|
2429
|
+
if (tui.ui.overlay !== undefined && tui.ui.picker?.id !== "euclid") {
|
|
2430
|
+
euclid.close();
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
const view = euclid.view(euclidContext());
|
|
2434
|
+
tui.openPicker({
|
|
2435
|
+
id: "euclid",
|
|
2436
|
+
title: view.title,
|
|
2437
|
+
items: view.items,
|
|
2438
|
+
index: view.index,
|
|
2439
|
+
hint: view.hint,
|
|
2440
|
+
});
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
/**
|
|
2444
|
+
* Play one bar of `voice` on the focused track over silence. While the loop
|
|
2445
|
+
* is playing the edit is already audible, so nothing extra sounds.
|
|
2446
|
+
*/
|
|
2447
|
+
function audition(voice: string): void {
|
|
2448
|
+
if (clock.playing) return;
|
|
2449
|
+
const track = score.tracks.find(
|
|
2450
|
+
(candidate) => candidate.id === requestedTrack,
|
|
2451
|
+
);
|
|
2452
|
+
if (!track) return;
|
|
2453
|
+
const pitch = rhythmVoicePitch(track, voice);
|
|
2454
|
+
if (pitch === undefined) return;
|
|
2455
|
+
const engine = liveEngine();
|
|
2456
|
+
if (!engine?.canMonitor) return;
|
|
2457
|
+
const pcm = renderAudition({
|
|
2458
|
+
score,
|
|
2459
|
+
trackId: track.id,
|
|
2460
|
+
pitches: new Set([pitch]),
|
|
2461
|
+
bars: 1,
|
|
2462
|
+
sampleRate: engine.sampleRate,
|
|
2463
|
+
...(liveSampleBank ? { samples: liveSampleBank } : {}),
|
|
2464
|
+
});
|
|
2465
|
+
if (!pcm) return;
|
|
2466
|
+
void engine
|
|
2467
|
+
.monitor(true)
|
|
2468
|
+
.then(() => engine.noteOn(AUDITION_VOICE, pcm))
|
|
2469
|
+
.catch(() => undefined);
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
/**
|
|
2473
|
+
* The window's audition controller. Its loop plays through the ordinary
|
|
2474
|
+
* engine (the render worker and stem cache), so a preview sounds exactly
|
|
2475
|
+
* like the same bars of the song; staged commands run through `submit`
|
|
2476
|
+
* with `commitScore` captured, so a staged edit is made by the same code
|
|
2477
|
+
* as a committed one.
|
|
2478
|
+
*/
|
|
2479
|
+
function auditionController(): Audition {
|
|
2480
|
+
auditionLoop ??= new Audition(
|
|
2481
|
+
{
|
|
2482
|
+
apply: applyStaged,
|
|
2483
|
+
play: (preview) => previewEngine().play(preview),
|
|
2484
|
+
stop() {
|
|
2485
|
+
const engine = previewEngine();
|
|
2486
|
+
engine.stop();
|
|
2487
|
+
if (!play?.on) engine.setLeadMs(undefined);
|
|
2488
|
+
},
|
|
2489
|
+
leadMs: () => previewEngine().leadMs,
|
|
2490
|
+
now: () => performance.now(),
|
|
2491
|
+
beat: () => clock.beatAt(),
|
|
2492
|
+
setTimer: (callback, ms) => setTimeout(callback, ms),
|
|
2493
|
+
clearTimer: (handle) => clearTimeout(handle as Timer),
|
|
2494
|
+
changed: () => requestFrame(),
|
|
2495
|
+
level: () => previewEngine().level,
|
|
2496
|
+
},
|
|
2497
|
+
score,
|
|
2498
|
+
requestedTrack,
|
|
2499
|
+
);
|
|
2500
|
+
return auditionLoop;
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
/**
|
|
2504
|
+
* Warm the pack cache for a staged command before it runs, so a lazy fetch
|
|
2505
|
+
* (a sample kit, a pack sound or table) happens outside the capture and the
|
|
2506
|
+
* window's score is swapped only for the instant the command takes.
|
|
2507
|
+
*/
|
|
2508
|
+
async function prefetchStaged(
|
|
2509
|
+
base: TrackScore,
|
|
2510
|
+
command: string,
|
|
2511
|
+
): Promise<void> {
|
|
2512
|
+
try {
|
|
2513
|
+
const kit = parseKitCommand(command);
|
|
2514
|
+
if (kit?.kind === "set" && !isSynthKitName(kit.bank)) {
|
|
2515
|
+
const trackId = kitTarget(base, requestedTrack);
|
|
2516
|
+
if (trackId) await useKit(packs(), base, trackId, kit.bank);
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
2519
|
+
const pack = parsePackCommand(command);
|
|
2520
|
+
if (pack?.kind === "use") {
|
|
2521
|
+
await useSound(packs(), base, requestedTrack, pack.sound, {
|
|
2522
|
+
...(pack.voice ? { voice: pack.voice } : {}),
|
|
2523
|
+
});
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
const wavetable = parseWavetableCommand(command);
|
|
2527
|
+
if (wavetable?.kind === "table")
|
|
2528
|
+
await pickWavetable(
|
|
2529
|
+
packs(),
|
|
2530
|
+
base,
|
|
2531
|
+
requestedTrack,
|
|
2532
|
+
wavetable.table,
|
|
2533
|
+
process.cwd(),
|
|
2534
|
+
);
|
|
2535
|
+
} catch {
|
|
2536
|
+
// The command itself reports the failure.
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
/** Apply one prompt command to `base` without committing (see stageCapture). */
|
|
2541
|
+
async function applyStaged(
|
|
2542
|
+
base: TrackScore,
|
|
2543
|
+
command: string,
|
|
2544
|
+
): Promise<{ next?: TrackScore; ok: boolean; message: string }> {
|
|
2545
|
+
await prefetchStaged(base, command);
|
|
2546
|
+
// Staged commands run one at a time, between queued prompts.
|
|
2547
|
+
while (stageCapture) await Bun.sleep(1);
|
|
2548
|
+
const committed = score;
|
|
2549
|
+
stageCapture = {};
|
|
2550
|
+
score = base;
|
|
2551
|
+
try {
|
|
2552
|
+
const result = await submit(command);
|
|
2553
|
+
const text = typeof result === "string" ? result : result.text;
|
|
2554
|
+
const good = toneOf(result) !== "error";
|
|
2555
|
+
return { next: stageCapture.next, ok: good, message: text };
|
|
2556
|
+
} catch (error) {
|
|
2557
|
+
return { ok: false, message: describeError(command, error) };
|
|
2558
|
+
} finally {
|
|
2559
|
+
score = stageCapture?.committed ?? committed;
|
|
2560
|
+
stageCapture = undefined;
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
/** Keep the controller on the committed score (remote edits, undo, agent). */
|
|
2565
|
+
function followCommitted(): void {
|
|
2566
|
+
const loop = auditionLoop;
|
|
2567
|
+
if (!loop || stageCapture || loop.committed === score) return;
|
|
2568
|
+
if (!loop.dirtyEdits) {
|
|
2569
|
+
loop.committedNow(score);
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
void loop.rebase(score).then(({ dropped }) => {
|
|
2573
|
+
tui.activity.pushCard(
|
|
2574
|
+
dropped.length
|
|
2575
|
+
? `score changed · dropped staged ${dropped.join(", ")}`
|
|
2576
|
+
: "score changed · staged edits re-applied on top",
|
|
2577
|
+
{ tone: "warning" },
|
|
2578
|
+
);
|
|
2579
|
+
requestFrame();
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
async function startAuditionLoop(): Promise<void> {
|
|
2584
|
+
const loop = auditionController();
|
|
2585
|
+
await materializeDraft();
|
|
2586
|
+
loop.committedNow(score);
|
|
2587
|
+
loop.focus(requestedTrack);
|
|
2588
|
+
// One sound at a time: the song stops while the loop plays.
|
|
2589
|
+
if (clock.playing) await setTransport("pause");
|
|
2590
|
+
previewEngine().setLeadMs(PLAY_LEAD_MS);
|
|
2591
|
+
loop.start();
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
function stopAuditionLoop(): void {
|
|
2595
|
+
auditionLoop?.stop();
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
/** A menu or picker audition key: Space, `a` (A/B) or `c` (context). */
|
|
2599
|
+
function auditionKeyPressed(
|
|
2600
|
+
key: "loop" | "ab" | "context",
|
|
2601
|
+
): Promise<void> | undefined {
|
|
2602
|
+
const loop = auditionController();
|
|
2603
|
+
if (key === "loop") {
|
|
2604
|
+
if (loop.looping) stopAuditionLoop();
|
|
2605
|
+
else return startAuditionLoop().finally(() => requestFrame());
|
|
2606
|
+
} else if (key === "ab") {
|
|
2607
|
+
if (!loop.dirtyEdits) {
|
|
2608
|
+
tui.activity.pushCard("A/B · nothing staged yet · ←→ to change a value", {
|
|
2609
|
+
tone: "info",
|
|
2610
|
+
});
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
loop.toggleAB();
|
|
2614
|
+
} else loop.toggleContext();
|
|
2615
|
+
return undefined;
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
/** An auditioning picker's title carries the loop status (`♪ solo · B`). */
|
|
2619
|
+
function refreshAuditionPicker(): void {
|
|
2620
|
+
const picker = tui.ui.picker as
|
|
2621
|
+
(NonNullable<typeof tui.ui.picker> & { baseTitle?: string }) | undefined;
|
|
2622
|
+
if (tui.ui.overlay !== "picker" || !picker?.audition) return;
|
|
2623
|
+
// Kept on the picker so a filter's copy of it keeps the plain title too.
|
|
2624
|
+
const base = (picker.baseTitle ??= picker.title);
|
|
2625
|
+
const status = auditionLoop?.status();
|
|
2626
|
+
const dirty = auditionLoop?.dirtyEdits ? "● " : "";
|
|
2627
|
+
picker.title = status ? `${dirty}${base} · ${status}` : base;
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
/**
|
|
2631
|
+
* `/try <command>`: hear a sound command on the audition loop before it
|
|
2632
|
+
* lands. A small picker offers keep (one undo step) or revert; Space, `a`
|
|
2633
|
+
* and `c` work as in the menu.
|
|
2634
|
+
*/
|
|
2635
|
+
async function tryPrompt(command: string): Promise<Receipt> {
|
|
2636
|
+
if (!command) return fail(TRY_USAGE);
|
|
2637
|
+
const agentToggle = command.match(/^agent\s+(on|off)$/i);
|
|
2638
|
+
if (agentToggle) {
|
|
2639
|
+
agentPreviewPlays = agentToggle[1]!.toLowerCase() === "on";
|
|
2640
|
+
return ok(
|
|
2641
|
+
`try · agent previews ${agentPreviewPlays ? "play once here" : "stay silent (numbers only)"}`,
|
|
2642
|
+
);
|
|
2643
|
+
}
|
|
2644
|
+
if (!isStageable(command))
|
|
2645
|
+
return fail(`try · only sound changes can be tried · ${TRY_USAGE}`);
|
|
2646
|
+
if (menu.open || euclid.open)
|
|
2647
|
+
return fail("try · close the open screen first");
|
|
2648
|
+
const loop = auditionController();
|
|
2649
|
+
if (!loop.looping) await startAuditionLoop();
|
|
2650
|
+
const result = await loop.stage(command);
|
|
2651
|
+
if (!result.ok) {
|
|
2652
|
+
if (!loop.dirtyEdits) stopAuditionLoop();
|
|
2653
|
+
return fail(`try · ${result.message}`);
|
|
2654
|
+
}
|
|
2655
|
+
tui.openPicker({
|
|
2656
|
+
id: "try",
|
|
2657
|
+
title: `try · ${command.slice(0, 48)}`,
|
|
2658
|
+
hint: HINTS.audition,
|
|
2659
|
+
audition: true,
|
|
2660
|
+
items: [
|
|
2661
|
+
{ label: "keep", value: "keep", detail: "commit it · one undo step" },
|
|
2662
|
+
{ label: "revert", value: "revert", detail: "drop it" },
|
|
2663
|
+
],
|
|
2664
|
+
});
|
|
2665
|
+
return ok(`trying · ${command} · a A/B · enter keep · esc revert`);
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
/** Audition a list's highlighted item in place of its previous hover. */
|
|
2669
|
+
function hoverItem(command: string, key: string): void {
|
|
2670
|
+
const loop = auditionLoop;
|
|
2671
|
+
if (!loop?.looping) return;
|
|
2672
|
+
requestFrame();
|
|
2673
|
+
void loop.hover(command, key).then((result) => {
|
|
2674
|
+
if (!result.ok && result.message !== SUPERSEDED)
|
|
2675
|
+
tui.activity.pushCard(result.message, { tone: "warning" });
|
|
2676
|
+
requestFrame();
|
|
2677
|
+
});
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
/** Enter on a list item while auditioning: it stays staged for good. */
|
|
2681
|
+
function chooseItem(command: string, key: string): void {
|
|
2682
|
+
const loop = auditionLoop;
|
|
2683
|
+
if (!loop) return;
|
|
2684
|
+
void loop
|
|
2685
|
+
.hover(command, key)
|
|
2686
|
+
.then((result) => {
|
|
2687
|
+
if (!result.ok && result.message !== SUPERSEDED)
|
|
2688
|
+
tui.activity.pushCard(result.message, { tone: "warning" });
|
|
2689
|
+
loop.settle(key);
|
|
2690
|
+
})
|
|
2691
|
+
.finally(() => requestFrame());
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
/** Space, `a` or `c` in a picker: starting the loop hovers the cursor row. */
|
|
2695
|
+
function pickerAuditionKey(key: "loop" | "ab" | "context"): void {
|
|
2696
|
+
const started = auditionKeyPressed(key);
|
|
2697
|
+
if (!started) return;
|
|
2698
|
+
const picker = tui.ui.picker;
|
|
2699
|
+
const item = picker?.items[picker.index];
|
|
2700
|
+
if (!picker || !item || !isStageable(item.value)) return;
|
|
2701
|
+
void started.then(() => {
|
|
2702
|
+
const now = tui.ui.picker;
|
|
2703
|
+
const current = now?.id === picker.id ? now.items[now.index] : undefined;
|
|
2704
|
+
if (current && isStageable(current.value))
|
|
2705
|
+
hoverItem(current.value, `picker:${picker.id}`);
|
|
2706
|
+
});
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
/** Stage a menu command while auditioning; undefined when it should run. */
|
|
2710
|
+
function stageIfAuditioning(command: string): boolean {
|
|
2711
|
+
const loop = auditionLoop;
|
|
2712
|
+
if (!loop?.staging || !isStageable(command)) return false;
|
|
2713
|
+
void loop.stage(command).then((result) => {
|
|
2714
|
+
if (!result.ok)
|
|
2715
|
+
tui.activity.pushCard(result.message, {
|
|
2716
|
+
tone: result.message.includes("failed") ? "error" : "warning",
|
|
2717
|
+
});
|
|
2718
|
+
requestFrame();
|
|
2719
|
+
});
|
|
2720
|
+
return true;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
/** Enter: every staged edit becomes ONE revision (one undo step). */
|
|
2724
|
+
async function keepStaged(): Promise<Receipt> {
|
|
2725
|
+
const loop = auditionLoop;
|
|
2726
|
+
await loop?.settled();
|
|
2727
|
+
const taken = loop?.take();
|
|
2728
|
+
if (!loop || !taken) return warn("nothing staged");
|
|
2729
|
+
await materializeDraft();
|
|
2730
|
+
let next = taken.score;
|
|
2731
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
2732
|
+
try {
|
|
2733
|
+
await commitScore(next, "preview.commit", {
|
|
2734
|
+
trackId: loop.trackId,
|
|
2735
|
+
commands: taken.commands,
|
|
2736
|
+
});
|
|
2737
|
+
break;
|
|
2738
|
+
} catch (error) {
|
|
2739
|
+
if (!(error instanceof SessionConflictError) || attempt > 0) throw error;
|
|
2740
|
+
// Another window committed first: replay the staged commands on it.
|
|
2741
|
+
record = await port.load();
|
|
2742
|
+
score = scoreFromJSON(record.composition);
|
|
2743
|
+
next = score;
|
|
2744
|
+
for (const command of taken.commands) {
|
|
2745
|
+
const result = await applyStaged(next, command);
|
|
2746
|
+
if (result.next) next = result.next;
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
loop.committedNow(score);
|
|
2751
|
+
await projectSync?.flushScore();
|
|
2752
|
+
return ok(
|
|
2753
|
+
`kept ${taken.commands.length} change${taken.commands.length === 1 ? "" : "s"} · one undo step`,
|
|
2754
|
+
);
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
function revertStaged(): void {
|
|
2758
|
+
const loop = auditionLoop;
|
|
2759
|
+
if (!loop?.dirtyEdits) return;
|
|
2760
|
+
const count = loop.commands.length;
|
|
2761
|
+
loop.revert();
|
|
2762
|
+
tui.activity.pushCard(
|
|
2763
|
+
`reverted ${count} staged change${count === 1 ? "" : "s"}`,
|
|
2764
|
+
{ tone: "info" },
|
|
2765
|
+
);
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
/** Leaving an auditioning screen stops the loop and drops staged edits. */
|
|
2769
|
+
function leaveAuditionScreen(): void {
|
|
2770
|
+
if (auditionLoop?.dirtyEdits) revertStaged();
|
|
2771
|
+
stopAuditionLoop();
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
function refreshMenu(): void {
|
|
2775
|
+
if (!menu.open) return;
|
|
2776
|
+
if (tui.ui.overlay !== undefined && tui.ui.picker?.id !== "menu") {
|
|
2777
|
+
// Another overlay (help, a picker) replaced the menu.
|
|
2778
|
+
menu.close();
|
|
2779
|
+
leaveAuditionScreen();
|
|
2780
|
+
return;
|
|
2781
|
+
}
|
|
2782
|
+
const view = menu.view(menuContext());
|
|
2783
|
+
tui.openPicker({
|
|
2784
|
+
id: "menu",
|
|
2785
|
+
title: view.title,
|
|
2786
|
+
items: view.items.length
|
|
2787
|
+
? view.items
|
|
2788
|
+
: [{ label: "no matches", value: "none" }],
|
|
2789
|
+
index: view.index,
|
|
2790
|
+
hint: view.hint,
|
|
2791
|
+
note: view.note,
|
|
2792
|
+
});
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
function playSession(): PlaySession {
|
|
2796
|
+
if (play && play.track === requestedTrack) return play;
|
|
2797
|
+
const previous = play;
|
|
2798
|
+
play = new PlaySession(playHost(), {
|
|
2799
|
+
clickOn: previous?.clickOn,
|
|
2800
|
+
clickVolume: previous?.clickVolume,
|
|
2801
|
+
chords: chordSettings,
|
|
2802
|
+
});
|
|
2803
|
+
if (previous) {
|
|
2804
|
+
play.countInBars = previous.countInBars;
|
|
2805
|
+
play.grid = previous.grid;
|
|
2806
|
+
}
|
|
2807
|
+
return play;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
async function enterPlay(): Promise<Receipt> {
|
|
2811
|
+
if (play?.on) return ok(`play · ${play.track} · esc leaves`);
|
|
2812
|
+
await materializeDraft();
|
|
2813
|
+
const session = playSession();
|
|
2814
|
+
await session.enter();
|
|
2815
|
+
if (hasSamplerTracks(score)) void sampleProblems(score);
|
|
2816
|
+
return ok(
|
|
2817
|
+
`play · ${session.track} · ${session.keyboard.range} · ? keys · esc leaves`,
|
|
2818
|
+
);
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
async function exitPlay(): Promise<Receipt> {
|
|
2822
|
+
if (!play?.on) return ok("play mode is off");
|
|
2823
|
+
await play.exit();
|
|
2824
|
+
return ok("play off");
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
/**
|
|
2828
|
+
* One key in play mode. True when play mode consumed it; false sends it on
|
|
2829
|
+
* to the normal bindings (overlays, a command being typed, Ctrl-C, arrows).
|
|
2830
|
+
*/
|
|
2831
|
+
function playKey(value: string): boolean {
|
|
2832
|
+
const session = play;
|
|
2833
|
+
if (!session?.on) return false;
|
|
2834
|
+
if (tui.ui.overlay !== undefined) return false;
|
|
2835
|
+
// `/play off`, `/click 50%`: once a command is being typed, keys are text.
|
|
2836
|
+
if (prompt.value.length > 0) return false;
|
|
2837
|
+
if (value === "/") return false;
|
|
2838
|
+
if (value === "\u0003") return false;
|
|
2839
|
+
const result = session.press(value);
|
|
2840
|
+
if (result.type === "handled") return true;
|
|
2841
|
+
if (result.type === "command") {
|
|
2842
|
+
if (result.command === "exit")
|
|
2843
|
+
void exitPlay().then((outcome) => receipt(outcome));
|
|
2844
|
+
else if (result.command === "transport") {
|
|
2845
|
+
if (!session.startWithCountIn())
|
|
2846
|
+
void toggleTransport().catch((error: unknown) =>
|
|
2847
|
+
tui.activity.pushError(
|
|
2848
|
+
`transport failed · ${error instanceof Error ? error.message : String(error)}`,
|
|
2849
|
+
),
|
|
2850
|
+
);
|
|
2851
|
+
} else if (result.command === "menu") return false;
|
|
2852
|
+
return true;
|
|
2853
|
+
}
|
|
2854
|
+
// Unmapped: printable keys are swallowed so a stray letter never lands in
|
|
2855
|
+
// the prompt; control keys (Enter, arrows, Ctrl-Z) keep their bindings.
|
|
2856
|
+
return value.length === 1 && value >= " " && value !== "\u007f";
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
function playHost() {
|
|
2860
|
+
return {
|
|
2861
|
+
score: () => score,
|
|
2862
|
+
trackId: () => requestedTrack,
|
|
2863
|
+
now: () => performance.now(),
|
|
2864
|
+
playing: () => clock.playing,
|
|
2865
|
+
beatAt: (ms: number) => clock.beatAt(ms),
|
|
2866
|
+
engine: liveEngine,
|
|
2867
|
+
samples: () => liveSampleBank,
|
|
2868
|
+
async commit(
|
|
2869
|
+
next: TrackScore,
|
|
2870
|
+
kind: string,
|
|
2871
|
+
payload: Record<string, unknown>,
|
|
2872
|
+
): Promise<void> {
|
|
2873
|
+
const operations = (payload.operations ?? []) as ScoreOperation[];
|
|
2874
|
+
let target = next;
|
|
2875
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2876
|
+
try {
|
|
2877
|
+
record = await port.appendOperations(
|
|
2878
|
+
record,
|
|
2879
|
+
{ kind, payload },
|
|
2880
|
+
operations,
|
|
2881
|
+
target.toJSON(),
|
|
2882
|
+
);
|
|
2883
|
+
score = scoreFromJSON(record.composition);
|
|
2884
|
+
if (clock.playing) void audio.play(score);
|
|
2885
|
+
projectSync?.scoreChanged(score);
|
|
2886
|
+
return;
|
|
2887
|
+
} catch (error) {
|
|
2888
|
+
if (!(error instanceof SessionConflictError)) throw error;
|
|
2889
|
+
record = await port.load();
|
|
2890
|
+
score = scoreFromJSON(record.composition);
|
|
2891
|
+
target = operations.reduce(
|
|
2892
|
+
(value, operation) => applyScoreOperation(value, operation),
|
|
2893
|
+
score,
|
|
2894
|
+
);
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
throw new Error("session busy");
|
|
2898
|
+
},
|
|
2899
|
+
async startTransport(beat: number): Promise<void> {
|
|
2900
|
+
if (port.mode === "daemon") {
|
|
2901
|
+
await port.transport("play", { beat });
|
|
2902
|
+
return;
|
|
2903
|
+
}
|
|
2904
|
+
clock.sync(beat, false);
|
|
2905
|
+
await setTransport("play");
|
|
2906
|
+
},
|
|
2907
|
+
async stopTransport(): Promise<void> {
|
|
2908
|
+
await setTransport("pause");
|
|
2909
|
+
},
|
|
2910
|
+
card(text: string, tone: "info" | "success" | "warning" | "error") {
|
|
2911
|
+
if (tone === "error") tui.activity.pushError(text);
|
|
2912
|
+
else
|
|
2913
|
+
tui.activity.pushCard(text, {
|
|
2914
|
+
tone,
|
|
2915
|
+
trackId: requestedTrack,
|
|
2916
|
+
resultRevision: tone === "success" ? record.revision : undefined,
|
|
2917
|
+
});
|
|
2918
|
+
},
|
|
2919
|
+
newNoteId: () => randomUUID().slice(0, 12),
|
|
2920
|
+
};
|
|
2921
|
+
}
|
|
2922
|
+
|
|
1126
2923
|
async function commitScore(
|
|
1127
2924
|
next: TrackScore,
|
|
1128
2925
|
kind: string,
|
|
1129
2926
|
payload: Record<string, unknown> = {},
|
|
1130
2927
|
): Promise<void> {
|
|
1131
2928
|
if (next === score) return;
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
2929
|
+
// Rhythm rows regenerate after a loop resize and freeze when their lane
|
|
2930
|
+
// is hand-edited, so rows and notes never disagree.
|
|
2931
|
+
next = reconcileRhythm(score, next);
|
|
2932
|
+
if (stageCapture) {
|
|
2933
|
+
// A staged edit: the audition plays it; nothing is written yet.
|
|
2934
|
+
stageCapture.next = next;
|
|
2935
|
+
score = next;
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
record = await port.append(record, { kind, payload }, next.toJSON());
|
|
1137
2939
|
score = next;
|
|
1138
2940
|
if (clock.playing) void audio.play(score);
|
|
2941
|
+
projectSync?.scoreChanged(score);
|
|
2942
|
+
reportSampleProblems(score);
|
|
2943
|
+
void updateCredits(score);
|
|
1139
2944
|
}
|
|
1140
2945
|
|
|
1141
|
-
|
|
2946
|
+
/** The window's side of the project file sync (see src/project/sync.ts). */
|
|
2947
|
+
function syncHost(): SyncHost {
|
|
2948
|
+
return {
|
|
2949
|
+
project: process.cwd(),
|
|
2950
|
+
current: () => score,
|
|
2951
|
+
async commit(plan, summary) {
|
|
2952
|
+
const baseRevision = record.revision;
|
|
2953
|
+
record = await port.appendOperations(
|
|
2954
|
+
record,
|
|
2955
|
+
{
|
|
2956
|
+
kind: "files.apply",
|
|
2957
|
+
payload: { summary },
|
|
2958
|
+
},
|
|
2959
|
+
plan.operations,
|
|
2960
|
+
plan.next.toJSON(),
|
|
2961
|
+
);
|
|
2962
|
+
score = scoreFromJSON(record.composition);
|
|
2963
|
+
clock.setTempo(score.tempoBpm);
|
|
2964
|
+
if (clock.playing) void audio.play(score);
|
|
2965
|
+
reportSampleProblems(score);
|
|
2966
|
+
void baseRevision;
|
|
2967
|
+
return score;
|
|
2968
|
+
},
|
|
2969
|
+
card(text, tone, hint) {
|
|
2970
|
+
if (tone === "error") tui.activity.pushError(text);
|
|
2971
|
+
else
|
|
2972
|
+
tui.activity.pushCard(text, {
|
|
2973
|
+
tone,
|
|
2974
|
+
hint,
|
|
2975
|
+
resultRevision: tone === "success" ? record.revision : undefined,
|
|
2976
|
+
});
|
|
2977
|
+
},
|
|
2978
|
+
types(state) {
|
|
2979
|
+
typesIndicator = state;
|
|
2980
|
+
},
|
|
2981
|
+
};
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
|
|
1142
2985
|
const latest = await port.load();
|
|
1143
2986
|
// A fork's undo continues into its parent's history past the fork point.
|
|
1144
2987
|
const target = historyTarget(
|
|
2988
|
+
latest.composition,
|
|
1145
2989
|
await historyEvents(process.cwd(), latest),
|
|
1146
2990
|
direction,
|
|
1147
2991
|
);
|
|
1148
|
-
if (!target) return `nothing to ${direction}
|
|
2992
|
+
if (!target) return warn(`nothing to ${direction}`);
|
|
1149
2993
|
try {
|
|
1150
2994
|
const restored = scoreFromJSON(target.composition);
|
|
1151
2995
|
record = await port.append(
|
|
@@ -1155,24 +2999,54 @@ async function stepHistory(direction: "undo" | "redo"): Promise<string> {
|
|
|
1155
2999
|
payload: {
|
|
1156
3000
|
[direction === "undo" ? "undoneRevision" : "redoneRevision"]:
|
|
1157
3001
|
target.revision,
|
|
1158
|
-
before: latest.composition,
|
|
1159
3002
|
},
|
|
1160
3003
|
},
|
|
1161
3004
|
restored.toJSON(),
|
|
1162
3005
|
);
|
|
1163
3006
|
score = restored;
|
|
1164
3007
|
if (clock.playing) void audio.play(score);
|
|
1165
|
-
return
|
|
3008
|
+
return ok(
|
|
3009
|
+
`${direction === "undo" ? "undid" : "redid"} · rev ${target.revision}`,
|
|
3010
|
+
);
|
|
3011
|
+
} catch (error) {
|
|
3012
|
+
if (error instanceof SessionConflictError)
|
|
3013
|
+
return warn(`session changed · retry ${direction}`);
|
|
3014
|
+
return fail(
|
|
3015
|
+
`${direction} failed · ${error instanceof Error ? error.message : String(error)}`,
|
|
3016
|
+
);
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
/** The space-bar toggle: flip the transport, then record it for other windows. */
|
|
3021
|
+
async function toggleTransport(): Promise<void> {
|
|
3022
|
+
await setTransport("toggle");
|
|
3023
|
+
try {
|
|
3024
|
+
record = await port.append(
|
|
3025
|
+
record,
|
|
3026
|
+
{
|
|
3027
|
+
kind: "transport",
|
|
3028
|
+
payload: {
|
|
3029
|
+
action: "toggle",
|
|
3030
|
+
playing: clock.playing,
|
|
3031
|
+
beat: clock.beatAt(),
|
|
3032
|
+
},
|
|
3033
|
+
},
|
|
3034
|
+
score.toJSON(),
|
|
3035
|
+
);
|
|
1166
3036
|
} catch (error) {
|
|
1167
3037
|
if (error instanceof SessionConflictError)
|
|
1168
|
-
|
|
1169
|
-
|
|
3038
|
+
tui.activity.pushCard("transport changed in another window", {
|
|
3039
|
+
tone: "warning",
|
|
3040
|
+
});
|
|
3041
|
+
else throw error;
|
|
1170
3042
|
}
|
|
1171
3043
|
}
|
|
1172
3044
|
|
|
1173
3045
|
async function setTransport(
|
|
1174
3046
|
action: "play" | "pause" | "toggle",
|
|
1175
3047
|
): Promise<void> {
|
|
3048
|
+
// One sound at a time: starting the song stops an audition loop.
|
|
3049
|
+
if (action !== "pause" && auditionLoop?.looping) stopAuditionLoop();
|
|
1176
3050
|
if (port.mode === "daemon") {
|
|
1177
3051
|
// dawgd owns the only transport; its broadcast updates `clock`.
|
|
1178
3052
|
await port.transport(action);
|
|
@@ -1198,11 +3072,13 @@ async function setTransport(
|
|
|
1198
3072
|
* own revision through `agentHost`, so cancelling keeps every accepted change
|
|
1199
3073
|
* and never leaves a half-applied call.
|
|
1200
3074
|
*/
|
|
1201
|
-
async function runAgent(text: string): Promise<string> {
|
|
1202
|
-
if (agentTurn) return "agent busy";
|
|
3075
|
+
async function runAgent(text: string): Promise<string | Receipt> {
|
|
3076
|
+
if (agentTurn) return warn("agent busy · Esc cancels");
|
|
1203
3077
|
const selection = await currentProvider();
|
|
1204
3078
|
if (selection.kind === "offline")
|
|
1205
|
-
return
|
|
3079
|
+
return fail(
|
|
3080
|
+
`unrecognized · ${truncateForCard(text)} · ${selection.reason}`,
|
|
3081
|
+
);
|
|
1206
3082
|
const turn = { controller: new AbortController(), steering: [] as string[] };
|
|
1207
3083
|
agentTurn = turn;
|
|
1208
3084
|
reportAgentActivity(`${providerName} · thinking…`);
|
|
@@ -1213,8 +3089,7 @@ async function runAgent(text: string): Promise<string> {
|
|
|
1213
3089
|
const result = await runProviderTurn({
|
|
1214
3090
|
selection,
|
|
1215
3091
|
prompt: text,
|
|
1216
|
-
|
|
1217
|
-
host: agentHost(turn),
|
|
3092
|
+
host: agentHost(turn, selection),
|
|
1218
3093
|
signal: turn.controller.signal,
|
|
1219
3094
|
onEvent: (event) => {
|
|
1220
3095
|
if (admitting && event.type === "step" && event.step <= 1) return;
|
|
@@ -1222,6 +3097,22 @@ async function runAgent(text: string): Promise<string> {
|
|
|
1222
3097
|
admitting = false;
|
|
1223
3098
|
provider = undefined; // Re-read capabilities: now admitted.
|
|
1224
3099
|
}
|
|
3100
|
+
if (event.type === "usage") {
|
|
3101
|
+
// Subscriptions are included; API responses are priced once.
|
|
3102
|
+
if (isApiSelection(selection)) {
|
|
3103
|
+
const { type: _type, ...usage } = event;
|
|
3104
|
+
void priceForModel(selection.kind, selection.modelId, {
|
|
3105
|
+
configDir: configDir(),
|
|
3106
|
+
apiKey: selection.apiKey,
|
|
3107
|
+
})
|
|
3108
|
+
.catch(() => undefined)
|
|
3109
|
+
.then((price) => {
|
|
3110
|
+
meter.add(usage, price);
|
|
3111
|
+
tickUi();
|
|
3112
|
+
});
|
|
3113
|
+
}
|
|
3114
|
+
return;
|
|
3115
|
+
}
|
|
1225
3116
|
agentEventSink(event);
|
|
1226
3117
|
},
|
|
1227
3118
|
});
|
|
@@ -1257,13 +3148,66 @@ function currentProvider(): Promise<ProviderSelection> {
|
|
|
1257
3148
|
}));
|
|
1258
3149
|
}
|
|
1259
3150
|
return provider.then((selection) => {
|
|
1260
|
-
|
|
3151
|
+
providerSnapshot = selection;
|
|
3152
|
+
providerName = providerLabel(selection);
|
|
3153
|
+
if (
|
|
3154
|
+
selection.kind === "offline" &&
|
|
3155
|
+
selection.invalidSaved &&
|
|
3156
|
+
!invalidNoticeShown
|
|
3157
|
+
) {
|
|
3158
|
+
invalidNoticeShown = true;
|
|
3159
|
+
tui.activity.pushCard(`sign-in stopped working · ${selection.reason}`, {
|
|
3160
|
+
tone: "warning",
|
|
3161
|
+
hint: "/login",
|
|
3162
|
+
});
|
|
3163
|
+
}
|
|
1261
3164
|
providerStamp.offline = selection.kind === "offline";
|
|
1262
3165
|
return selection;
|
|
1263
3166
|
});
|
|
1264
3167
|
}
|
|
1265
3168
|
|
|
1266
|
-
|
|
3169
|
+
/** Media tools write under the workspace's `tracks/<slug>/downloads/`. */
|
|
3170
|
+
function mediaServices(): MediaServices {
|
|
3171
|
+
return { runner: systemRunner, env: process.env };
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
/**
|
|
3175
|
+
* How the agent's preview_sound renders (with this window's decoded
|
|
3176
|
+
* samples, at the engine's rate) and plays: once, over silence, unless the
|
|
3177
|
+
* song or the audition loop is already sounding or `/try agent off`.
|
|
3178
|
+
*/
|
|
3179
|
+
function agentPreviewHost(): PreviewHost {
|
|
3180
|
+
return {
|
|
3181
|
+
render: async (value) => {
|
|
3182
|
+
await sampleProblems(value);
|
|
3183
|
+
return renderScorePcm(value, {
|
|
3184
|
+
sampleRate: previewEngine().sampleRate,
|
|
3185
|
+
...(liveSampleBank ? { samples: liveSampleBank } : {}),
|
|
3186
|
+
});
|
|
3187
|
+
},
|
|
3188
|
+
play: async (rendered) => {
|
|
3189
|
+
if (!agentPreviewPlays || clock.playing || auditionLoop?.looping)
|
|
3190
|
+
return false;
|
|
3191
|
+
const engine = liveEngine();
|
|
3192
|
+
if (!engine?.canMonitor || rendered.frames === 0) return false;
|
|
3193
|
+
try {
|
|
3194
|
+
await engine.monitor(true);
|
|
3195
|
+
engine.noteOn(AGENT_PREVIEW_VOICE, {
|
|
3196
|
+
pcm: rendered.pcm,
|
|
3197
|
+
frames: rendered.frames,
|
|
3198
|
+
});
|
|
3199
|
+
return true;
|
|
3200
|
+
} catch {
|
|
3201
|
+
return false;
|
|
3202
|
+
}
|
|
3203
|
+
},
|
|
3204
|
+
};
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
function agentHost(
|
|
3208
|
+
turn: { steering: string[] },
|
|
3209
|
+
selection: ProviderSelection,
|
|
3210
|
+
): AgentHost {
|
|
1267
3211
|
return {
|
|
1268
3212
|
snapshot: () => ({
|
|
1269
3213
|
score,
|
|
@@ -1276,6 +3220,8 @@ function agentHost(turn: { steering: string[] }): AgentHost {
|
|
|
1276
3220
|
: event.kind;
|
|
1277
3221
|
}),
|
|
1278
3222
|
}),
|
|
3223
|
+
media: mediaServices(),
|
|
3224
|
+
preview: agentPreviewHost(),
|
|
1279
3225
|
async commit(change) {
|
|
1280
3226
|
// dawgd rebases operation intents onto newer revisions when nothing
|
|
1281
3227
|
// they touch changed; the file port keeps the strict base check.
|
|
@@ -1291,7 +3237,6 @@ function agentHost(turn: { steering: string[] }): AgentHost {
|
|
|
1291
3237
|
callId: change.callId,
|
|
1292
3238
|
summary: change.summary,
|
|
1293
3239
|
operations: change.operations,
|
|
1294
|
-
before: record.composition,
|
|
1295
3240
|
},
|
|
1296
3241
|
},
|
|
1297
3242
|
change.operations,
|
|
@@ -1328,5 +3273,26 @@ function agentHost(turn: { steering: string[] }): AgentHost {
|
|
|
1328
3273
|
}
|
|
1329
3274
|
},
|
|
1330
3275
|
takeSteering: () => turn.steering.splice(0),
|
|
3276
|
+
workspace: { root: process.cwd() },
|
|
3277
|
+
// A written project source is applied before the tool result returns,
|
|
3278
|
+
// so the model reads the outcome and any type errors in the same step.
|
|
3279
|
+
async onWorkspaceWrite(path) {
|
|
3280
|
+
if (!projectSync || !/\.ts$/.test(path)) return;
|
|
3281
|
+
const outcome = await projectSync.checkFiles();
|
|
3282
|
+
const types = await typecheckProject(process.cwd());
|
|
3283
|
+
const errors = types.diagnostics.slice(0, 8).map(formatDiagnostic);
|
|
3284
|
+
return [
|
|
3285
|
+
outcome,
|
|
3286
|
+
types.ok ? "types ✓" : `types ✗ ${types.diagnostics.length}`,
|
|
3287
|
+
...errors,
|
|
3288
|
+
].join("\n");
|
|
3289
|
+
},
|
|
3290
|
+
// web_search uses the turn's own gateway or OpenRouter key; billed
|
|
3291
|
+
// searches go to the spend meter and daily ledger.
|
|
3292
|
+
web: webHostFor(
|
|
3293
|
+
isApiSelection(selection) ? selection : { kind: selection.kind },
|
|
3294
|
+
meter,
|
|
3295
|
+
tickUi,
|
|
3296
|
+
),
|
|
1331
3297
|
};
|
|
1332
3298
|
}
|