@hraness/dawg 0.6.1 → 0.7.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 +92 -0
- package/DAWG.md +343 -116
- package/README.md +27 -25
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +182 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +5 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +832 -13
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +276 -3
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +1849 -42
- package/core/sections.ts +292 -26
- package/core/sing.ts +815 -0
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +30 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -7
- package/guides/chords.md +15 -15
- package/guides/effects.md +17 -16
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +12 -6
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +25 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +17 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -13
- package/guides/styles.md +31 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +5 -2
- package/src/agent/agent.ts +126 -14
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +369 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +1 -1
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +12 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +13 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +4 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +108 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +11 -7
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +37 -3
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/doctor.ts +86 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +10 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/effects/chain.ts +3 -1
- package/src/audio/effects/common.ts +43 -0
- package/src/audio/effects/convolution.ts +7 -4
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/engine.ts +160 -28
- package/src/audio/fit.ts +35 -3
- package/src/audio/instrument-check.ts +59 -43
- package/src/audio/instruments.ts +4 -0
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +8 -1
- package/src/audio/keys/engine.ts +13 -1
- package/src/audio/keys/piano.ts +22 -2
- package/src/audio/kits.ts +135 -6
- package/src/audio/live.ts +114 -20
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +30 -2
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +2 -1
- package/src/audio/sampler.ts +85 -4
- package/src/audio/samples.ts +20 -2
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/bow.ts +48 -5
- package/src/audio/strings/engine.ts +8 -2
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +500 -77
- package/src/audio/winds/engine.ts +5 -1
- package/src/audio/winds/trim.ts +28 -4
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +15 -2
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +44 -29
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +1 -1
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +101 -32
- package/src/commands/grammar.ts +558 -0
- package/src/commands/help.ts +610 -380
- package/src/commands/history.ts +18 -0
- package/src/commands/keys.ts +8 -8
- package/src/commands/modal.ts +1 -1
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +130 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sing.ts +478 -0
- package/src/commands/strum.ts +13 -1
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +616 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +2 -2
- package/src/fs/durable.ts +50 -0
- package/src/lang/glossary.ts +493 -0
- package/src/launch-args.ts +163 -0
- package/src/main.ts +1397 -253
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +21 -2
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +20 -1
- package/src/session/daemon.ts +3 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +154 -0
- package/src/session/port.ts +22 -4
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +5 -1
- package/src/session/rebase.ts +18 -5
- package/src/session/receipt.ts +258 -0
- package/src/session/store.ts +65 -43
- package/src/tui/arrange-menu.ts +65 -31
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +18 -13
- package/src/tui/fader.ts +228 -41
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +759 -177
- package/src/tui/modal-menu.ts +6 -6
- package/src/tui/performance-menu.ts +5 -2
- package/src/tui/play-chords.ts +4 -2
- package/src/tui/play-mode.ts +15 -1
- package/src/tui/play-session.ts +47 -7
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +3 -3
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +274 -40
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +144 -0
- package/tui/drawer.ts +70 -22
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +112 -63
- package/tui/guide.ts +42 -4
- package/tui/highway.ts +269 -25
- package/tui/hints.ts +192 -0
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/play-strip.ts +68 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +144 -14
- package/tui/theme.ts +27 -0
package/src/main.ts
CHANGED
|
@@ -1,11 +1,43 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { TOPIC_ALIASES } from "./lang/glossary.ts";
|
|
3
|
+
import { isGuideInstrument, vocalChainPatch } from "../core/clips.ts";
|
|
4
|
+
import { isSingWord } from "../core/sing.ts";
|
|
5
|
+
import { commandParses, parseExact } from "./commands/parses.ts";
|
|
6
|
+
import { paramRangeError } from "./commands/param-range.ts";
|
|
7
|
+
import { noteName as midiNoteName } from "./media/notes.ts";
|
|
2
8
|
import {
|
|
9
|
+
EVERYDAY_VERBS,
|
|
10
|
+
EXPORT_USAGE,
|
|
11
|
+
elsewhereHint,
|
|
12
|
+
FREE_TEXT_HINTS,
|
|
13
|
+
noTrack,
|
|
14
|
+
stemsReceipt,
|
|
15
|
+
LOOP_USAGE,
|
|
16
|
+
WINDOW_VERBS,
|
|
17
|
+
NO_AGENT,
|
|
18
|
+
RANGES,
|
|
19
|
+
canonicalWindowForm,
|
|
20
|
+
friendlyCoreError,
|
|
21
|
+
knownVerbs,
|
|
22
|
+
nearest,
|
|
23
|
+
noNote,
|
|
24
|
+
parseExportCommand,
|
|
25
|
+
parseLoopCommand,
|
|
26
|
+
recover,
|
|
27
|
+
usageCard,
|
|
28
|
+
usageError,
|
|
29
|
+
verbOf,
|
|
30
|
+
} from "./commands/grammar.ts";
|
|
31
|
+
import {
|
|
32
|
+
instrumentListLines,
|
|
3
33
|
isUnknownInstrument,
|
|
4
34
|
plainSineAdvice,
|
|
35
|
+
unknownInstrumentMessage,
|
|
5
36
|
} from "./audio/instrument-check.ts";
|
|
6
37
|
import { randomUUID } from "node:crypto";
|
|
7
38
|
import { readFileSync, writeSync } from "node:fs";
|
|
8
|
-
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
39
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
40
|
+
import { tmpdir } from "node:os";
|
|
9
41
|
import { join, relative, resolve } from "node:path";
|
|
10
42
|
import { stdin, stdout } from "node:process";
|
|
11
43
|
import {
|
|
@@ -14,6 +46,12 @@ import {
|
|
|
14
46
|
type SessionRecord,
|
|
15
47
|
} from "./session/store.ts";
|
|
16
48
|
import { openSessionPort } from "./session/port.ts";
|
|
49
|
+
import {
|
|
50
|
+
OwnWrites,
|
|
51
|
+
editedTrack,
|
|
52
|
+
foreignEvents,
|
|
53
|
+
otherWindowName,
|
|
54
|
+
} from "./session/origin.ts";
|
|
17
55
|
import { compositionDigest, monotonicEpochMs } from "./session/protocol.ts";
|
|
18
56
|
import {
|
|
19
57
|
formatSessionLine,
|
|
@@ -69,6 +107,10 @@ import {
|
|
|
69
107
|
rigTrackFields,
|
|
70
108
|
rigWordPatch,
|
|
71
109
|
} from "./commands/rig.ts";
|
|
110
|
+
import {
|
|
111
|
+
applyProgressionCommand,
|
|
112
|
+
parseProgressionCommand,
|
|
113
|
+
} from "./commands/progression.ts";
|
|
72
114
|
import {
|
|
73
115
|
applyGuitarCommand,
|
|
74
116
|
applyStrumCommand,
|
|
@@ -85,6 +127,11 @@ import {
|
|
|
85
127
|
parseWindCommand,
|
|
86
128
|
windListLines,
|
|
87
129
|
} from "./commands/wind.ts";
|
|
130
|
+
import {
|
|
131
|
+
applySingCommand,
|
|
132
|
+
parseSingCommand,
|
|
133
|
+
singListLines,
|
|
134
|
+
} from "./commands/sing.ts";
|
|
88
135
|
import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
|
|
89
136
|
import { applyStringCommand, parseStringCommand } from "./commands/string.ts";
|
|
90
137
|
import { instrumentPatchForWord, isModalWord } from "../core/resonators.ts";
|
|
@@ -105,26 +152,38 @@ import {
|
|
|
105
152
|
parseTuningCommand,
|
|
106
153
|
type TuningCommand,
|
|
107
154
|
} from "./commands/tuning.ts";
|
|
155
|
+
import {
|
|
156
|
+
applyCalibrationCommand,
|
|
157
|
+
parseCalibrationCommand,
|
|
158
|
+
} from "./commands/calibration.ts";
|
|
108
159
|
import { TuningError, displayTag, resolveTuning } from "../core/tuning.ts";
|
|
109
160
|
import {
|
|
110
161
|
applyMasterCommand,
|
|
111
162
|
measurementLine,
|
|
112
163
|
parseMasterCommand,
|
|
113
164
|
} from "./commands/master.ts";
|
|
165
|
+
import { applyStyleCommand, parseStyleCommand } from "./commands/style.ts";
|
|
114
166
|
import { exportSampleRate, measureScoreOffThread } from "./audio/measure.ts";
|
|
115
167
|
import {
|
|
116
168
|
applySectionCommand,
|
|
169
|
+
loopSpan,
|
|
117
170
|
parseSectionCommand,
|
|
118
171
|
} from "./commands/arrange.ts";
|
|
119
172
|
import {
|
|
120
|
-
|
|
173
|
+
helpMiss,
|
|
121
174
|
helpText,
|
|
175
|
+
helpTitle,
|
|
122
176
|
helpTopicLines,
|
|
123
177
|
nearestCommand,
|
|
124
178
|
typoFix,
|
|
125
179
|
usageHint,
|
|
126
180
|
} from "./commands/help.ts";
|
|
127
|
-
import {
|
|
181
|
+
import {
|
|
182
|
+
historyReceipt,
|
|
183
|
+
historyTarget,
|
|
184
|
+
REDO_KIND,
|
|
185
|
+
UNDO_KIND,
|
|
186
|
+
} from "./commands/history.ts";
|
|
128
187
|
|
|
129
188
|
import {
|
|
130
189
|
SamplePlacementError,
|
|
@@ -139,6 +198,30 @@ import {
|
|
|
139
198
|
} from "./commands/sample.ts";
|
|
140
199
|
import { applyFitCommand, fitVoice, parseFitCommand } from "./commands/fit.ts";
|
|
141
200
|
import { applyShiftCommand, parseShiftCommand } from "./commands/shift.ts";
|
|
201
|
+
import { parseVocalCommand, runVocalCommand } from "./commands/vocal.ts";
|
|
202
|
+
import {
|
|
203
|
+
applyFormantCommand,
|
|
204
|
+
parseFormantCommand,
|
|
205
|
+
parseVowelCommand,
|
|
206
|
+
} from "./commands/formant.ts";
|
|
207
|
+
import {
|
|
208
|
+
applyLyrics,
|
|
209
|
+
parseClipCommand,
|
|
210
|
+
parseLyricsCommand,
|
|
211
|
+
runClipCommand,
|
|
212
|
+
setClipImportDeps,
|
|
213
|
+
} from "./commands/clips.ts";
|
|
214
|
+
import { pitchTraceFor } from "./commands/vocal-pitch.ts";
|
|
215
|
+
import {
|
|
216
|
+
applyVocoderCommand,
|
|
217
|
+
parseVocoderCommand,
|
|
218
|
+
vocoderListLines,
|
|
219
|
+
} from "./commands/vocoder.ts";
|
|
220
|
+
import {
|
|
221
|
+
applyAutotuneCommand,
|
|
222
|
+
autotuneListLines,
|
|
223
|
+
parseAutotuneCommand,
|
|
224
|
+
} from "./commands/autotune.ts";
|
|
142
225
|
import { parseResampleCommand, runResample } from "./commands/resample.ts";
|
|
143
226
|
import { suggestFitMode } from "./audio/dsp/onset.ts";
|
|
144
227
|
import {
|
|
@@ -148,7 +231,14 @@ import {
|
|
|
148
231
|
type SampleBank,
|
|
149
232
|
type SampleProblem,
|
|
150
233
|
} from "./audio/samples.ts";
|
|
234
|
+
import {
|
|
235
|
+
ANALYSIS_CACHE_BYTES,
|
|
236
|
+
analysisCacheStatus,
|
|
237
|
+
analysisDir,
|
|
238
|
+
pruneAnalysisCache,
|
|
239
|
+
} from "./audio/analysis.ts";
|
|
151
240
|
import { drumSnapshotFields, samplerSnapshotFields } from "../tui/drums.ts";
|
|
241
|
+
import { clipSnapshots, loadClipPeaks } from "../tui/clip-row.ts";
|
|
152
242
|
import { highwayLayers } from "../tui/layers.ts";
|
|
153
243
|
import { drumVoicePitch, isDrumInstrument } from "../core/drums.ts";
|
|
154
244
|
import {
|
|
@@ -157,6 +247,7 @@ import {
|
|
|
157
247
|
type AgentEvent,
|
|
158
248
|
type AgentHost,
|
|
159
249
|
} from "./agent/agent.ts";
|
|
250
|
+
import { routeDuringTurn } from "./agent/steer.ts";
|
|
160
251
|
import {
|
|
161
252
|
isApiSelection,
|
|
162
253
|
providerFingerprint,
|
|
@@ -175,8 +266,24 @@ import {
|
|
|
175
266
|
spendLine as formatSpendLine,
|
|
176
267
|
webHostFor,
|
|
177
268
|
} from "./agent/usage.ts";
|
|
178
|
-
import { configDir } from "./auth/credentials.ts";
|
|
179
|
-
import {
|
|
269
|
+
import { configDir, readConfig, writeConfig } from "./auth/credentials.ts";
|
|
270
|
+
import type { CommandOutcome } from "./agent/command-agent.ts";
|
|
271
|
+
import {
|
|
272
|
+
GLIDE_STEP_MS,
|
|
273
|
+
NoteScheduler,
|
|
274
|
+
finishHint,
|
|
275
|
+
gestureFor,
|
|
276
|
+
glideValues,
|
|
277
|
+
agentPathsAllowed,
|
|
278
|
+
isAgentCommand,
|
|
279
|
+
isBrokenCommand,
|
|
280
|
+
brokenCommandReceipt,
|
|
281
|
+
parseShowMe,
|
|
282
|
+
toolCaption,
|
|
283
|
+
type ShowMeLevel,
|
|
284
|
+
} from "./agent/show-me.ts";
|
|
285
|
+
import { firstRunCard, runAuthCommand, runTuiLogin } from "./auth/cli.ts";
|
|
286
|
+
import { musicalReceipt } from "./session/receipt.ts";
|
|
180
287
|
import {
|
|
181
288
|
modelPickerItems,
|
|
182
289
|
tuiAuthCommand,
|
|
@@ -192,10 +299,16 @@ import {
|
|
|
192
299
|
PlaySession,
|
|
193
300
|
type LiveEngine,
|
|
194
301
|
} from "./tui/play-session.ts";
|
|
195
|
-
import {
|
|
302
|
+
import {
|
|
303
|
+
EditMenu,
|
|
304
|
+
menuUsage,
|
|
305
|
+
menuSectionPath,
|
|
306
|
+
type MenuContext,
|
|
307
|
+
} from "./tui/menu.ts";
|
|
196
308
|
import {
|
|
197
309
|
drawerView,
|
|
198
310
|
faderChoose,
|
|
311
|
+
faderCommand,
|
|
199
312
|
faderKeyPress,
|
|
200
313
|
faderSetPosition,
|
|
201
314
|
faderStep,
|
|
@@ -217,7 +330,15 @@ import {
|
|
|
217
330
|
isStageable,
|
|
218
331
|
} from "./tui/audition.ts";
|
|
219
332
|
import { EuclidEditor, type EuclidContext } from "./tui/euclid.ts";
|
|
220
|
-
import {
|
|
333
|
+
import {
|
|
334
|
+
ARROW_DOWN,
|
|
335
|
+
ARROW_UP,
|
|
336
|
+
closesKeys,
|
|
337
|
+
HINTS,
|
|
338
|
+
KEYS,
|
|
339
|
+
keyLines,
|
|
340
|
+
type KeySection,
|
|
341
|
+
} from "../tui/grammar.ts";
|
|
221
342
|
import { renderAudition } from "./audio/audition.ts";
|
|
222
343
|
import { exportScore, playbackTime, scoreBeatAt } from "./audio/arrange.ts";
|
|
223
344
|
import { formatForm } from "../core/sections.ts";
|
|
@@ -265,6 +386,7 @@ import {
|
|
|
265
386
|
applyScoreOperation,
|
|
266
387
|
createScore,
|
|
267
388
|
isSamplerInstrument,
|
|
389
|
+
isTrackAudible,
|
|
268
390
|
PACK_PREFIX,
|
|
269
391
|
scoreFromJSON,
|
|
270
392
|
SCORE_LIMITS,
|
|
@@ -277,8 +399,19 @@ import { decodeLoop, encodeLoop } from "../core/loop.ts";
|
|
|
277
399
|
import { scoreToMidi } from "../core/midi.ts";
|
|
278
400
|
import type { TrackScoreSnapshot } from "../tui/render.ts";
|
|
279
401
|
import { PromptModel } from "../tui/prompt.ts";
|
|
280
|
-
import { TerminalInputDecoder } from "../tui/input.ts";
|
|
281
402
|
import {
|
|
403
|
+
ESCAPE_FLUSH_MS,
|
|
404
|
+
INPUT_FLUSH,
|
|
405
|
+
PASTE_FLUSH_MS,
|
|
406
|
+
TerminalInputDecoder,
|
|
407
|
+
} from "../tui/input.ts";
|
|
408
|
+
import {
|
|
409
|
+
FrameGate,
|
|
410
|
+
IDLE_HEARTBEAT_MS,
|
|
411
|
+
MIN_FRAME_GAP_MS,
|
|
412
|
+
} from "../tui/frame-gate.ts";
|
|
413
|
+
import {
|
|
414
|
+
CARD_GLOW_MS,
|
|
282
415
|
composeFrame,
|
|
283
416
|
TuiApp,
|
|
284
417
|
type AppView,
|
|
@@ -286,13 +419,19 @@ import {
|
|
|
286
419
|
type SyncState,
|
|
287
420
|
type TypesIndicator,
|
|
288
421
|
} from "../tui/app.ts";
|
|
289
|
-
import { fail, ok, toneOf, warn, type Receipt } from "../tui/activity.ts";
|
|
422
|
+
import { fail, note, ok, toneOf, warn, type Receipt } from "../tui/activity.ts";
|
|
290
423
|
import { systemRunner } from "./auth/runner.ts";
|
|
291
424
|
import type { MediaServices } from "./media/types.ts";
|
|
292
425
|
import { encodeBuffer } from "../tui/screen.ts";
|
|
293
426
|
import { parseThemeName } from "../tui/theme.ts";
|
|
294
427
|
import { formatDiagnostic } from "../core/sdk/eval.ts";
|
|
295
428
|
import { isProject } from "./project/init.ts";
|
|
429
|
+
import {
|
|
430
|
+
parseLaunchArgs,
|
|
431
|
+
parseSimpleArgv,
|
|
432
|
+
resolveTrackArg,
|
|
433
|
+
} from "./launch-args.ts";
|
|
434
|
+
import { RENDER_USAGE, runRenderCommand } from "./render.ts";
|
|
296
435
|
import { typecheckProject } from "./project/typecheck.ts";
|
|
297
436
|
import {
|
|
298
437
|
startProjectSync,
|
|
@@ -300,112 +439,100 @@ import {
|
|
|
300
439
|
type SyncHost,
|
|
301
440
|
} from "./project/sync.ts";
|
|
302
441
|
|
|
442
|
+
/** Set while `submit` runs a grammar reading, so it reads only once. */
|
|
443
|
+
let recovering = false;
|
|
444
|
+
|
|
445
|
+
/** Slash words `submit` handles itself (no parser in commandParses). */
|
|
446
|
+
const SLASH_HANDLED: ReadonlySet<string> = new Set([
|
|
447
|
+
...WINDOW_VERBS,
|
|
448
|
+
"chords",
|
|
449
|
+
"click",
|
|
450
|
+
"help",
|
|
451
|
+
]);
|
|
452
|
+
|
|
303
453
|
/** Whether a bare command parses (no side effects): for typo suggestions. */
|
|
304
454
|
function parsesLocally(text: string): boolean {
|
|
305
|
-
return
|
|
306
|
-
parsePrompt,
|
|
307
|
-
parseMusicCommand,
|
|
308
|
-
parseEditCommand,
|
|
309
|
-
parseRhythmCommand,
|
|
310
|
-
parseFxCommand,
|
|
311
|
-
parseSynthCommand,
|
|
312
|
-
parseStringCommand,
|
|
313
|
-
parseGranularCommand,
|
|
314
|
-
parseKeysCommand,
|
|
315
|
-
parseExpressionCommand,
|
|
316
|
-
parseMasterCommand,
|
|
317
|
-
(value: string) => parseSectionCommand(value, score),
|
|
318
|
-
parsePatternCommand,
|
|
319
|
-
parseKitCommand,
|
|
320
|
-
parsePackCommand,
|
|
321
|
-
parseSampleCommand,
|
|
322
|
-
parseFitCommand,
|
|
323
|
-
parseShiftCommand,
|
|
324
|
-
parseResampleCommand,
|
|
325
|
-
parseWavetableCommand,
|
|
326
|
-
parseTimeCommand,
|
|
327
|
-
parseTuningCommand,
|
|
328
|
-
parseRigCommand,
|
|
329
|
-
parseModalCommand,
|
|
330
|
-
parseGuitarCommand,
|
|
331
|
-
parseStrumCommand,
|
|
332
|
-
parseWindCommand,
|
|
333
|
-
].some((parse) => parse(text) !== undefined);
|
|
455
|
+
return commandParses(text, score);
|
|
334
456
|
}
|
|
335
457
|
|
|
336
458
|
const ESC = "\u001b[";
|
|
337
459
|
/** `/sessions` rows shown in the overlay. */
|
|
338
460
|
const MAX_LISTED_SESSIONS = 64;
|
|
339
461
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
"
|
|
344
|
-
"
|
|
345
|
-
"
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
"--help",
|
|
355
|
-
"-h",
|
|
356
|
-
"--version",
|
|
357
|
-
"-v",
|
|
358
|
-
"--reduce-motion",
|
|
359
|
-
"--no-mouse",
|
|
360
|
-
];
|
|
462
|
+
/** Wraps a usage line at spaces before `width`, indenting continuations. */
|
|
463
|
+
function wrapUsage(text: string, width: number, indent: string): string {
|
|
464
|
+
const lines: string[] = [];
|
|
465
|
+
let line = "";
|
|
466
|
+
for (const word of text.split(" ")) {
|
|
467
|
+
const prefix = lines.length === 0 ? " " : indent;
|
|
468
|
+
if (line && prefix.length + line.length + 1 + word.length > width) {
|
|
469
|
+
lines.push(line);
|
|
470
|
+
line = word;
|
|
471
|
+
} else line = line ? `${line} ${word}` : word;
|
|
472
|
+
}
|
|
473
|
+
lines.push(line);
|
|
474
|
+
return lines.join(`\n${indent}`);
|
|
475
|
+
}
|
|
361
476
|
const HELP_TEXT = `dawg · local-first terminal music workstation
|
|
362
477
|
|
|
363
478
|
Usage:
|
|
364
479
|
dawg [--new] [--session <name|id>] [--track <name>]
|
|
365
|
-
dawg --import <file> --export <file>
|
|
480
|
+
dawg --import <file> --export <file> convert a loop file (no session)
|
|
366
481
|
dawg sessions
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
dawg init [dir] project files: song.ts, tracks/<slug>/track.ts, .dawg/sdk
|
|
482
|
+
${wrapUsage(RENDER_USAGE.replace(/^usage: /, ""), 76, " ")}
|
|
483
|
+
dawg init [dir] song.ts, tracks/<slug>/track.ts and .dawg/sdk
|
|
370
484
|
dawg check typecheck + evaluate the project; exit 1 on problems
|
|
371
|
-
dawg
|
|
485
|
+
dawg <command> --help usage: sessions render init check media model
|
|
486
|
+
dawg doctor audio backend, native sink and devices
|
|
487
|
+
dawg media doctor|download|stems|analyze|notes|sample|lyrics (--help)
|
|
372
488
|
dawg --version
|
|
373
489
|
|
|
374
|
-
|
|
490
|
+
Display options:
|
|
375
491
|
--reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
|
|
376
492
|
--theme <name> default | high-contrast | mono (NO_COLOR forces mono)
|
|
377
493
|
--no-mouse keys only; no click/wheel reporting (also DAWG_MOUSE=0)
|
|
378
494
|
|
|
379
495
|
Prompt:
|
|
380
|
-
Enter submit · Shift-Enter newline · Alt-Enter
|
|
381
|
-
Ctrl-Z undo · Ctrl-Y redo · Ctrl-O transcript · Esc
|
|
496
|
+
Enter submit · Shift-Enter newline · Alt-Enter next · Ctrl-Q now / next
|
|
497
|
+
Ctrl-Z undo · Ctrl-Y redo · Ctrl-O transcript · Esc close · Ctrl-C exit
|
|
382
498
|
Space on an empty prompt toggles playback
|
|
383
499
|
|
|
384
500
|
Commands (bare music words; app commands take a slash):
|
|
385
501
|
${helpText()}
|
|
386
502
|
|
|
387
|
-
|
|
388
|
-
dawg
|
|
389
|
-
dawg
|
|
390
|
-
dawg model [alias] pick a model, with the
|
|
503
|
+
Agent (optional; the choice is saved and reused until you log out):
|
|
504
|
+
dawg model key find existing setups and pick a provider
|
|
505
|
+
dawg model key gateway|openrouter|codex|claude
|
|
506
|
+
dawg model [alias] pick a model, with the cost per prompt
|
|
391
507
|
dawg logout [provider] · dawg auth status [--check]
|
|
392
508
|
Unrecognized requests go to the agent once a provider is configured
|
|
393
509
|
(DAWG_PROVIDER=gateway|openrouter|codex|claude|auto, DAWG_MODEL=<alias>;
|
|
394
510
|
DAWG_AI=0 disables the agent).`;
|
|
395
511
|
const args = new Set(process.argv.slice(2));
|
|
396
|
-
|
|
397
|
-
const
|
|
512
|
+
/** One parse for every launch flag; problems are reported below. */
|
|
513
|
+
const launch = parseLaunchArgs(process.argv.slice(2));
|
|
514
|
+
const launchArgs = launch.ok ? launch.args : undefined;
|
|
515
|
+
const requestedSession = launchArgs?.session;
|
|
516
|
+
/** `--track` normalized as `/track` does; matched to an existing track below. */
|
|
517
|
+
let explicitTrack = launchArgs?.track?.id;
|
|
398
518
|
/** The focused track; claimed at startup unless `--track` is given. */
|
|
399
519
|
let requestedTrack = explicitTrack ?? "main";
|
|
400
520
|
const initialInstrument = isDrumInstrument(requestedTrack) ? "kit" : "sine";
|
|
401
|
-
const importPath =
|
|
402
|
-
const exportPath =
|
|
521
|
+
const importPath = launchArgs?.importPath;
|
|
522
|
+
const exportPath = launchArgs?.exportPath;
|
|
523
|
+
// `dawg model key [provider]` is the canonical spelling of `dawg login`.
|
|
524
|
+
if (process.argv[2] === "model" && process.argv[3] === "key")
|
|
525
|
+
process.exit(await runAuthCommand(["login", ...process.argv.slice(4)]));
|
|
403
526
|
if (["login", "logout", "auth", "model"].includes(process.argv[2] ?? ""))
|
|
404
527
|
process.exit(await runAuthCommand(process.argv.slice(2)));
|
|
405
528
|
{
|
|
406
529
|
// An unknown DAWG_MODEL is an error, never a silent fallback.
|
|
407
530
|
const fromEnv = process.env.DAWG_MODEL?.trim();
|
|
408
|
-
if (
|
|
531
|
+
if (
|
|
532
|
+
fromEnv &&
|
|
533
|
+
!resolveModelChoice("gateway", fromEnv) &&
|
|
534
|
+
!resolveModelChoice("openrouter", fromEnv)
|
|
535
|
+
) {
|
|
409
536
|
process.stderr.write(
|
|
410
537
|
`dawg: unknown DAWG_MODEL "${fromEnv.slice(0, 40)}"; use one of ${MODEL_CATALOG.map((row) => row.alias).join(", ")} or a vendor/model ID\n`,
|
|
411
538
|
);
|
|
@@ -413,10 +540,14 @@ if (["login", "logout", "auth", "model"].includes(process.argv[2] ?? ""))
|
|
|
413
540
|
}
|
|
414
541
|
}
|
|
415
542
|
if (process.argv[2] === "sessions") {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
543
|
+
const usage =
|
|
544
|
+
"usage: dawg sessions · lists this workspace's sessions, newest first (* marks the current one)";
|
|
545
|
+
const parsed = parseSimpleArgv(process.argv.slice(3), 0);
|
|
546
|
+
if (parsed.kind === "error") {
|
|
547
|
+
process.stderr.write(`${parsed.problem} · ${usage}\n`);
|
|
548
|
+
process.exit(2);
|
|
549
|
+
}
|
|
550
|
+
if (parsed.kind === "help") stdout.write(`${usage}\n`);
|
|
420
551
|
else await printSessions(process.cwd(), stdout);
|
|
421
552
|
process.exit(0);
|
|
422
553
|
}
|
|
@@ -429,6 +560,10 @@ if (process.argv[2] === "init" || process.argv[2] === "check") {
|
|
|
429
560
|
await command(process.argv.slice(2), process.cwd(), stdout, process.stderr),
|
|
430
561
|
);
|
|
431
562
|
}
|
|
563
|
+
if (process.argv[2] === "doctor") {
|
|
564
|
+
const { runDoctorCommand } = await import("./audio/doctor.ts");
|
|
565
|
+
process.exit(await runDoctorCommand(process.argv.slice(3), stdout));
|
|
566
|
+
}
|
|
432
567
|
if (process.argv[2] === "media") {
|
|
433
568
|
const { runMediaCommand } = await import("./media/cli.ts");
|
|
434
569
|
process.exit(
|
|
@@ -461,18 +596,37 @@ if (args.has("--help") || args.has("-h")) {
|
|
|
461
596
|
}
|
|
462
597
|
// Argument mistakes are rejected here, before `.dawg/` could be created.
|
|
463
598
|
{
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
process.stderr.write(`${problem} · dawg --help\n`);
|
|
599
|
+
if (!launch.ok) {
|
|
600
|
+
process.stderr.write(`${launch.problem} · dawg --help\n`);
|
|
467
601
|
process.exit(2);
|
|
468
602
|
}
|
|
469
603
|
}
|
|
604
|
+
// `--import X --export Y` converts one loop file to another: no session.
|
|
605
|
+
if (importPath && exportPath) {
|
|
606
|
+
try {
|
|
607
|
+
const converted = decodeLoop(await readLoopFile(importPath));
|
|
608
|
+
await writeFile(resolve(exportPath), encodeLoop(converted), "utf8");
|
|
609
|
+
} catch (error) {
|
|
610
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
611
|
+
const midi = /\.midi?$/i.test(importPath)
|
|
612
|
+
? " · MIDI files are not imported; --import takes a .track.json loop"
|
|
613
|
+
: "";
|
|
614
|
+
process.stderr.write(
|
|
615
|
+
`dawg: cannot import ${importPath} · ${reason.split("\n")[0]!.slice(0, 160)}${midi}\n`,
|
|
616
|
+
);
|
|
617
|
+
process.exit(1);
|
|
618
|
+
}
|
|
619
|
+
stdout.write(`converted ${importPath} → ${exportPath}\n`);
|
|
620
|
+
process.exit(0);
|
|
621
|
+
}
|
|
470
622
|
const demo =
|
|
471
623
|
args.has("--demo") || process.env.DAWG_DEMO === "1" || !stdin.isTTY;
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
624
|
+
// Music first: the TUI opens directly. The first session with no provider
|
|
625
|
+
// (or a saved one that stopped working) gets one card instead of a picker.
|
|
626
|
+
const launchCard =
|
|
627
|
+
!demo && process.env.DAWG_AI !== "0" && stdout.isTTY
|
|
628
|
+
? firstRunCard().catch(() => undefined)
|
|
629
|
+
: undefined;
|
|
476
630
|
|
|
477
631
|
const initial = createScore({
|
|
478
632
|
tracks: [
|
|
@@ -521,7 +675,16 @@ if (importPath) {
|
|
|
521
675
|
process.exit(1);
|
|
522
676
|
}
|
|
523
677
|
}
|
|
524
|
-
|
|
678
|
+
// A demo frame only renders: with no workspace yet (and nothing imported),
|
|
679
|
+
// it runs on a throwaway session so the cwd gets no `.dawg/`.
|
|
680
|
+
const ephemeralWorkspace =
|
|
681
|
+
demo && freshWorkspace && !importPath && selectedSession === undefined
|
|
682
|
+
? await mkdtemp(join(tmpdir(), "dawg-demo-"))
|
|
683
|
+
: undefined;
|
|
684
|
+
const session = await ensureSession(initial.toJSON(), {
|
|
685
|
+
...sessionOptions,
|
|
686
|
+
...(ephemeralWorkspace ? { workspace: ephemeralWorkspace } : {}),
|
|
687
|
+
});
|
|
525
688
|
// dawgd when connected, the file-lock path otherwise (see src/session/port.ts).
|
|
526
689
|
let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
|
|
527
690
|
paths: session.paths,
|
|
@@ -533,6 +696,12 @@ let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
|
|
|
533
696
|
let record: SessionRecord<ReturnType<TrackScore["toJSON"]>> =
|
|
534
697
|
port.mode === "daemon" ? await port.load() : session.record;
|
|
535
698
|
let score = scoreFromJSON(record.composition);
|
|
699
|
+
if (importPath && importedScore) score = importedScore;
|
|
700
|
+
// `--track Bass` focuses an existing `bass` (or a track named "Bass").
|
|
701
|
+
if (launchArgs?.track) {
|
|
702
|
+
explicitTrack = resolveTrackArg(score.tracks, launchArgs.track);
|
|
703
|
+
requestedTrack = explicitTrack;
|
|
704
|
+
}
|
|
536
705
|
if (importPath && importedScore) {
|
|
537
706
|
score = importedScore;
|
|
538
707
|
record = await port.append(
|
|
@@ -573,7 +742,7 @@ const tui = new TuiApp({
|
|
|
573
742
|
rows: () => stdout.rows ?? 24,
|
|
574
743
|
},
|
|
575
744
|
prompt,
|
|
576
|
-
theme:
|
|
745
|
+
theme: launchArgs?.theme ?? parseThemeName(process.env.DAWG_THEME),
|
|
577
746
|
reducedMotion:
|
|
578
747
|
args.has("--reduce-motion") || process.env.DAWG_REDUCE_MOTION === "1",
|
|
579
748
|
});
|
|
@@ -630,8 +799,25 @@ let requestFrame: () => void = () => undefined;
|
|
|
630
799
|
let runPromptLater: (command: string) => void = () => undefined;
|
|
631
800
|
/** The fader drawer's focus and typing, while one is open over the menu. */
|
|
632
801
|
let fader: FaderState | undefined;
|
|
633
|
-
|
|
634
|
-
const
|
|
802
|
+
/** Show-me state (see the show-me section below). */
|
|
803
|
+
const showMe: {
|
|
804
|
+
level: ShowMeLevel;
|
|
805
|
+
ghost?: string | undefined;
|
|
806
|
+
caption?: string | undefined;
|
|
807
|
+
commands: string[];
|
|
808
|
+
notes: NoteScheduler;
|
|
809
|
+
clear?: ReturnType<typeof setTimeout> | undefined;
|
|
810
|
+
} = {
|
|
811
|
+
level: parseShowMe(process.env.DAWG_SHOWME ?? "") ?? "on",
|
|
812
|
+
commands: [],
|
|
813
|
+
notes: new NoteScheduler(() => score.tempoBpm),
|
|
814
|
+
};
|
|
815
|
+
if (!process.env.DAWG_SHOWME)
|
|
816
|
+
void readConfig({ dir: configDir() })
|
|
817
|
+
.then((config) => {
|
|
818
|
+
if (config.showMe) showMe.level = config.showMe;
|
|
819
|
+
})
|
|
820
|
+
.catch(() => undefined);
|
|
635
821
|
/** The fader bar a left-button drag started on. */
|
|
636
822
|
let dragging: { field: number; left: number; width: number } | undefined;
|
|
637
823
|
/** The open drawer came from a command, not the menu. */
|
|
@@ -664,7 +850,8 @@ let handoff: <T>(flow: () => Promise<T>) => Promise<T> = (flow) => flow();
|
|
|
664
850
|
function spendLine(): string {
|
|
665
851
|
const width = stdout.columns ?? 80;
|
|
666
852
|
if (!providerName || providerName === "offline")
|
|
667
|
-
|
|
853
|
+
// A state, not a nag: the first session's card already named /login.
|
|
854
|
+
return width >= 40 ? formatSpendLine({ kind: "offline" }) : "";
|
|
668
855
|
const [model, provider] = providerName.split(" · ");
|
|
669
856
|
return formatSpendLine(
|
|
670
857
|
{
|
|
@@ -689,6 +876,10 @@ if (demo) {
|
|
|
689
876
|
"add C4 at 0 for 1",
|
|
690
877
|
0,
|
|
691
878
|
);
|
|
879
|
+
if (ephemeralWorkspace) {
|
|
880
|
+
await port.close().catch(() => undefined);
|
|
881
|
+
await rm(ephemeralWorkspace, { recursive: true, force: true });
|
|
882
|
+
}
|
|
692
883
|
process.exit(0);
|
|
693
884
|
}
|
|
694
885
|
|
|
@@ -699,36 +890,8 @@ if (exportPath) {
|
|
|
699
890
|
|
|
700
891
|
await runInteractive();
|
|
701
892
|
|
|
702
|
-
/** Describes an unknown subcommand or option in `argv`, or undefined. */
|
|
703
|
-
function argumentProblem(argv: readonly string[]): string | undefined {
|
|
704
|
-
if (argv[0] && !argv[0].startsWith("-") && !SUBCOMMANDS.includes(argv[0]))
|
|
705
|
-
return `unknown command · ${argv[0]}`;
|
|
706
|
-
if (argv[0] && SUBCOMMANDS.includes(argv[0])) return undefined;
|
|
707
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
708
|
-
const arg = argv[index]!;
|
|
709
|
-
if (VALUE_FLAGS.includes(arg)) {
|
|
710
|
-
index += 1;
|
|
711
|
-
continue;
|
|
712
|
-
}
|
|
713
|
-
if (arg.startsWith("-") && !FLAGS.includes(arg))
|
|
714
|
-
return `unknown option · ${arg}`;
|
|
715
|
-
if (!arg.startsWith("-")) return `unknown command · ${arg}`;
|
|
716
|
-
}
|
|
717
|
-
return undefined;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
893
|
async function packageVersion(): Promise<string> {
|
|
721
|
-
|
|
722
|
-
new URL("../package.json", import.meta.url),
|
|
723
|
-
"utf8",
|
|
724
|
-
);
|
|
725
|
-
return (JSON.parse(raw) as { version?: string }).version ?? "0.0.0";
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
function optionValue(name: string): string | undefined {
|
|
729
|
-
const index = process.argv.indexOf(name);
|
|
730
|
-
const value = index >= 0 ? process.argv[index + 1] : undefined;
|
|
731
|
-
return value && !value.startsWith("--") ? value : undefined;
|
|
894
|
+
return (await import("./version.ts")).VERSION;
|
|
732
895
|
}
|
|
733
896
|
|
|
734
897
|
function seedDemo(value: TrackScore, trackId: string): TrackScore {
|
|
@@ -818,16 +981,24 @@ function snapshot(
|
|
|
818
981
|
muted: focused?.muted,
|
|
819
982
|
...(tag?.cents !== undefined ? { cents: tag.cents } : {}),
|
|
820
983
|
...(tag?.name !== undefined ? { centsFrom: tag.name } : {}),
|
|
984
|
+
...(note.lyric !== undefined ? { lyric: note.lyric } : {}),
|
|
821
985
|
};
|
|
822
986
|
});
|
|
987
|
+
// 0.7 clips: the clip row; peaks load off the frame path, then redraw.
|
|
988
|
+
const clips = clipSnapshots(value, requestedTrack);
|
|
989
|
+
if (clips)
|
|
990
|
+
void loadClipPeaks(process.cwd(), focused).then((changed) => {
|
|
991
|
+
if (changed) requestFrame();
|
|
992
|
+
});
|
|
823
993
|
return {
|
|
824
994
|
notes,
|
|
995
|
+
...(clips ? { clips } : {}),
|
|
825
996
|
trackName:
|
|
826
997
|
value.tracks.find((track) => track.id === requestedTrack)?.name ??
|
|
827
998
|
requestedTrack,
|
|
828
999
|
trackId: requestedTrack,
|
|
829
1000
|
sessionId: record.sessionId,
|
|
830
|
-
revision: record
|
|
1001
|
+
revision: shownRevision(record),
|
|
831
1002
|
bpm: value.time?.tempo
|
|
832
1003
|
? bpmAtTick(value, beat * value.ticksPerBeat)
|
|
833
1004
|
: value.tempoBpm,
|
|
@@ -846,6 +1017,7 @@ function snapshot(
|
|
|
846
1017
|
currentBeat: scoreBeatAt(value, beat),
|
|
847
1018
|
playing: clock.playing,
|
|
848
1019
|
activity,
|
|
1020
|
+
...(focused?.instrument === "vocal" ? { vocal: true } : {}),
|
|
849
1021
|
...drumSnapshotFields(
|
|
850
1022
|
value.tracks.find((track) => track.id === requestedTrack)?.instrument,
|
|
851
1023
|
notes,
|
|
@@ -857,6 +1029,7 @@ function snapshot(
|
|
|
857
1029
|
...(table && table.linear && table.size !== 12
|
|
858
1030
|
? { tuningPeriod: { size: table.size, root: table.root } }
|
|
859
1031
|
: {}),
|
|
1032
|
+
pitchTrace: pitchTraceFor(requestedTrack, value),
|
|
860
1033
|
layers:
|
|
861
1034
|
tui.highwayView === "all"
|
|
862
1035
|
? highwayLayers(
|
|
@@ -898,6 +1071,10 @@ function appView(value: TrackScore, beat: number): AppView {
|
|
|
898
1071
|
providerName && providerName !== "offline" ? providerName : undefined,
|
|
899
1072
|
spend: spendLine(),
|
|
900
1073
|
agentOffline: providerName === "offline" || process.env.DAWG_AI === "0",
|
|
1074
|
+
showMe:
|
|
1075
|
+
showMe.ghost || showMe.caption
|
|
1076
|
+
? { ghost: showMe.ghost, caption: showMe.caption }
|
|
1077
|
+
: undefined,
|
|
901
1078
|
sync: syncState,
|
|
902
1079
|
sessionName: record.meta.name,
|
|
903
1080
|
windows: windowCount,
|
|
@@ -1001,17 +1178,20 @@ function receipt(result: string | Receipt, base?: Baseline): void {
|
|
|
1001
1178
|
tui.activity.pushError(message);
|
|
1002
1179
|
return;
|
|
1003
1180
|
}
|
|
1181
|
+
// A receipt that names its own undo key needs no second hint.
|
|
1004
1182
|
const hint =
|
|
1005
|
-
scoreChanged &&
|
|
1183
|
+
scoreChanged &&
|
|
1184
|
+
undoHintsShown < MAX_UNDO_HINTS &&
|
|
1185
|
+
!message.endsWith("ctrl-z undo")
|
|
1006
1186
|
? message.startsWith("undid")
|
|
1007
|
-
? "
|
|
1008
|
-
: "
|
|
1187
|
+
? "ctrl-y redo"
|
|
1188
|
+
: "ctrl-z undo"
|
|
1009
1189
|
: undefined;
|
|
1010
1190
|
if (hint) undoHintsShown += 1;
|
|
1011
1191
|
tui.activity.pushCard(message, {
|
|
1012
1192
|
tone,
|
|
1013
|
-
baseRevision: changed ? base.revision : undefined,
|
|
1014
|
-
resultRevision: changed ? record
|
|
1193
|
+
baseRevision: changed ? shownRevision(record, base.revision) : undefined,
|
|
1194
|
+
resultRevision: changed ? shownRevision(record) : undefined,
|
|
1015
1195
|
hint,
|
|
1016
1196
|
trackId: requestedTrack,
|
|
1017
1197
|
});
|
|
@@ -1023,6 +1203,10 @@ function describeError(command: string, error: unknown): string {
|
|
|
1023
1203
|
const message = error instanceof Error ? error.message : String(error);
|
|
1024
1204
|
if (detail?.code === "ENOENT" && typeof detail.path === "string")
|
|
1025
1205
|
return `no such file · ${relative(process.cwd(), detail.path)}`;
|
|
1206
|
+
// `tempo 900` → `tempo 900 · tempo takes 20…300 BPM · tempo 128`: no raw
|
|
1207
|
+
// core keys (`tempoBpm`) on a card.
|
|
1208
|
+
const friendly = friendlyCoreError(truncateForCard(command), message);
|
|
1209
|
+
if (friendly) return friendly;
|
|
1026
1210
|
const verb = command.trim().split(/\s+/)[0]?.replace(/^\//, "") || "command";
|
|
1027
1211
|
return `${verb} failed · ${message}`;
|
|
1028
1212
|
}
|
|
@@ -1049,25 +1233,108 @@ function mouseOn(): string {
|
|
|
1049
1233
|
return mouseEnabled() ? MOUSE_ON : "";
|
|
1050
1234
|
}
|
|
1051
1235
|
|
|
1236
|
+
/**
|
|
1237
|
+
* Mouse off, plain colors, plain paste, cursor shown, main screen. A
|
|
1238
|
+
* function, not a const: `await runInteractive()` runs above this line.
|
|
1239
|
+
*/
|
|
1240
|
+
function terminalRestore(): string {
|
|
1241
|
+
return `${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/** `dawg: <stack>` for a crash printed after leaving the alternate screen. */
|
|
1245
|
+
function crashText(error: unknown): string {
|
|
1246
|
+
const text =
|
|
1247
|
+
error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
1248
|
+
return `dawg: ${text}\n`;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1052
1251
|
async function runInteractive(): Promise<void> {
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1252
|
+
// Every way out (quit, a failing teardown step, a crash, a kill, a stray
|
|
1253
|
+
// process.exit) restores the whole terminal, once, synchronously: the
|
|
1254
|
+
// shell must not stay on the alternate screen with paste brackets on.
|
|
1255
|
+
let terminalRestored = false;
|
|
1256
|
+
const restoreTerminal = () => {
|
|
1257
|
+
if (terminalRestored) return;
|
|
1258
|
+
terminalRestored = true;
|
|
1259
|
+
try {
|
|
1260
|
+
stdin.setRawMode?.(false);
|
|
1261
|
+
} catch {
|
|
1262
|
+
// stdin may already be closed.
|
|
1263
|
+
}
|
|
1264
|
+
try {
|
|
1265
|
+
writeSync(1, terminalRestore());
|
|
1266
|
+
} catch {
|
|
1267
|
+
// The terminal may already be gone (SIGHUP).
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1270
|
+
process.once("exit", restoreTerminal);
|
|
1271
|
+
// A kill or a closed terminal: restore it, then exit as killed.
|
|
1059
1272
|
for (const [signal, code] of [
|
|
1060
1273
|
["SIGTERM", 143],
|
|
1061
1274
|
["SIGHUP", 129],
|
|
1062
1275
|
] as const)
|
|
1063
1276
|
process.once(signal, () => {
|
|
1277
|
+
restoreTerminal();
|
|
1278
|
+
process.exit(code);
|
|
1279
|
+
});
|
|
1280
|
+
// An exception nothing caught leaves the editor in an unknown state: leave
|
|
1281
|
+
// the alternate screen first, so the stack lands in the shell, then exit.
|
|
1282
|
+
const onUncaught = (error: unknown) => {
|
|
1283
|
+
restoreTerminal();
|
|
1284
|
+
try {
|
|
1285
|
+
writeSync(2, crashText(error));
|
|
1286
|
+
} catch {
|
|
1287
|
+
// Nowhere left to report it.
|
|
1288
|
+
}
|
|
1289
|
+
process.exit(1);
|
|
1290
|
+
};
|
|
1291
|
+
// A rejected fire-and-forget promise is one failed action, not a broken
|
|
1292
|
+
// editor: report it on the activity strip and repaint over anything the
|
|
1293
|
+
// runtime wrote.
|
|
1294
|
+
const onRejection = (error: unknown) => {
|
|
1295
|
+
if (terminalRestored || screenSuspended) {
|
|
1064
1296
|
try {
|
|
1065
|
-
writeSync(
|
|
1297
|
+
writeSync(2, crashText(error));
|
|
1066
1298
|
} catch {
|
|
1067
|
-
//
|
|
1299
|
+
// Nowhere left to report it.
|
|
1068
1300
|
}
|
|
1069
|
-
|
|
1070
|
-
}
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
tui.activity.pushError(
|
|
1304
|
+
`failed · ${error instanceof Error ? error.message : String(error)}`,
|
|
1305
|
+
);
|
|
1306
|
+
tui.invalidate();
|
|
1307
|
+
requestFrame();
|
|
1308
|
+
};
|
|
1309
|
+
// Runtime warnings and console output would land on top of the frame; the
|
|
1310
|
+
// writer never repaints rows it did not change. Route them to the
|
|
1311
|
+
// transcript instead, and repaint.
|
|
1312
|
+
const onWarning = (warning: Error) => {
|
|
1313
|
+
if (screenSuspended || terminalRestored) return;
|
|
1314
|
+
tui.activity.pushNote(`warning · ${warning.message}`, "error");
|
|
1315
|
+
tui.invalidate();
|
|
1316
|
+
requestFrame();
|
|
1317
|
+
};
|
|
1318
|
+
const consoleError = console.error;
|
|
1319
|
+
const consoleWarn = console.warn;
|
|
1320
|
+
const consoleToTranscript =
|
|
1321
|
+
(original: (...data: unknown[]) => void) =>
|
|
1322
|
+
(...data: unknown[]) => {
|
|
1323
|
+
if (screenSuspended || terminalRestored) return original(...data);
|
|
1324
|
+
tui.activity.pushNote(
|
|
1325
|
+
data
|
|
1326
|
+
.map((item) => (item instanceof Error ? item.message : String(item)))
|
|
1327
|
+
.join(" "),
|
|
1328
|
+
"error",
|
|
1329
|
+
);
|
|
1330
|
+
tui.invalidate();
|
|
1331
|
+
requestFrame();
|
|
1332
|
+
};
|
|
1333
|
+
process.on("uncaughtException", onUncaught);
|
|
1334
|
+
process.on("unhandledRejection", onRejection);
|
|
1335
|
+
process.on("warning", onWarning);
|
|
1336
|
+
console.error = consoleToTranscript(consoleError);
|
|
1337
|
+
console.warn = consoleToTranscript(consoleWarn);
|
|
1071
1338
|
stdin.setRawMode?.(true);
|
|
1072
1339
|
stdin.resume();
|
|
1073
1340
|
// Alternate screen, hidden cursor, bracketed paste.
|
|
@@ -1078,7 +1345,8 @@ async function runInteractive(): Promise<void> {
|
|
|
1078
1345
|
const runPrompt = async (text: string): Promise<void> => {
|
|
1079
1346
|
const ui = tui.command(text);
|
|
1080
1347
|
if (ui !== undefined) {
|
|
1081
|
-
tui.activity.pushCard(ui, { tone: "info" });
|
|
1348
|
+
if (typeof ui === "string") tui.activity.pushCard(ui, { tone: "info" });
|
|
1349
|
+
else tui.activity.pushCard(ui.text, { tone: toneOf(ui) });
|
|
1082
1350
|
return;
|
|
1083
1351
|
}
|
|
1084
1352
|
tui.activity.pushRequest(text);
|
|
@@ -1102,6 +1370,16 @@ async function runInteractive(): Promise<void> {
|
|
|
1102
1370
|
}
|
|
1103
1371
|
};
|
|
1104
1372
|
let agentReported = false;
|
|
1373
|
+
/** A typed command during an agent turn; leaves the turn's receipt alone. */
|
|
1374
|
+
const runLocalBesideAgent = async (text: string): Promise<void> => {
|
|
1375
|
+
tui.activity.pushRequest(text);
|
|
1376
|
+
const base = baseline();
|
|
1377
|
+
try {
|
|
1378
|
+
receipt(await submit(text), base);
|
|
1379
|
+
} catch (error) {
|
|
1380
|
+
tui.activity.pushError(describeError(text, error));
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1105
1383
|
const drainQueue = async (): Promise<void> => {
|
|
1106
1384
|
if (processingQueue) return;
|
|
1107
1385
|
processingQueue = true;
|
|
@@ -1122,17 +1400,64 @@ async function runInteractive(): Promise<void> {
|
|
|
1122
1400
|
tui.activity.setQueueDepth(queuedPrompts.length);
|
|
1123
1401
|
}
|
|
1124
1402
|
};
|
|
1403
|
+
// Builds a frame only when something can have changed (tui/frame-gate.ts):
|
|
1404
|
+
// an idle editor no longer rebuilds the whole view 30 times a second.
|
|
1405
|
+
const frameGate = new FrameGate(IDLE_HEARTBEAT_MS, MIN_FRAME_GAP_MS);
|
|
1406
|
+
const unwatchActivity = tui.activity.subscribe(() => frameGate.markDirty());
|
|
1407
|
+
const animating = (): boolean => {
|
|
1408
|
+
if (clock.playing || play?.on || auditionLoop?.looping) return true;
|
|
1409
|
+
const activity = tui.activity;
|
|
1410
|
+
if (activity.spinner || activity.streaming) return true;
|
|
1411
|
+
if (tui.delight.animating(Date.now())) return true;
|
|
1412
|
+
const latest = activity.latest;
|
|
1413
|
+
return (
|
|
1414
|
+
!tui.ui.reducedMotion &&
|
|
1415
|
+
latest !== undefined &&
|
|
1416
|
+
Date.now() - latest.atMs < CARD_GLOW_MS
|
|
1417
|
+
);
|
|
1418
|
+
};
|
|
1125
1419
|
const tick = (force = false) => {
|
|
1126
1420
|
if (screenSuspended) return;
|
|
1421
|
+
if (
|
|
1422
|
+
!frameGate.shouldBuild({
|
|
1423
|
+
nowMs: Date.now(),
|
|
1424
|
+
force,
|
|
1425
|
+
animating: animating(),
|
|
1426
|
+
keys: [
|
|
1427
|
+
score,
|
|
1428
|
+
record.revision,
|
|
1429
|
+
record.sessionId,
|
|
1430
|
+
record.meta.name,
|
|
1431
|
+
syncState,
|
|
1432
|
+
windowCount,
|
|
1433
|
+
typesIndicator,
|
|
1434
|
+
menu.open,
|
|
1435
|
+
euclid.open,
|
|
1436
|
+
tui.ui.overlay,
|
|
1437
|
+
],
|
|
1438
|
+
})
|
|
1439
|
+
)
|
|
1440
|
+
return;
|
|
1127
1441
|
followCommitted();
|
|
1442
|
+
tui.delight.song(record.sessionId, record.meta.heardLoop === true);
|
|
1128
1443
|
play?.tick();
|
|
1129
1444
|
// Values in the menu follow the score as edits land.
|
|
1130
1445
|
if (menu.open) refreshMenu();
|
|
1131
1446
|
if (euclid.open) refreshEuclid();
|
|
1132
1447
|
refreshAuditionPicker();
|
|
1133
|
-
|
|
1448
|
+
// The gate already paces frames; the app's own throttle would drop an
|
|
1449
|
+
// approved frame that lands just after a forced one, and the gate would
|
|
1450
|
+
// not ask again until the heartbeat.
|
|
1451
|
+
tui.render(appView(score, clock.beatAt()), { force: true });
|
|
1134
1452
|
};
|
|
1135
1453
|
requestFrame = () => tick(true);
|
|
1454
|
+
// The first wrap is remembered per song in .dawg metadata, never the score.
|
|
1455
|
+
tui.onFirstLoop = () => {
|
|
1456
|
+
void port
|
|
1457
|
+
.updateMeta({ heardLoop: true })
|
|
1458
|
+
.then((result) => adoptMeta(result.meta))
|
|
1459
|
+
.catch(() => undefined);
|
|
1460
|
+
};
|
|
1136
1461
|
runPromptLater = (command) => {
|
|
1137
1462
|
queuedPrompts.unshift(command);
|
|
1138
1463
|
void drainQueue();
|
|
@@ -1142,6 +1467,16 @@ async function runInteractive(): Promise<void> {
|
|
|
1142
1467
|
};
|
|
1143
1468
|
agentEventSink = (event) => {
|
|
1144
1469
|
if (event.type === "usage") return;
|
|
1470
|
+
if (event.type === "command-typing") {
|
|
1471
|
+
showMeTyping(event.text);
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
if (event.type === "command") return;
|
|
1475
|
+
if (event.type === "tool-start") {
|
|
1476
|
+
const caption = toolCaption(event.name);
|
|
1477
|
+
if (caption) showMeCaption(caption);
|
|
1478
|
+
}
|
|
1479
|
+
if (event.type === "done" || event.type === "error") showMeFinish();
|
|
1145
1480
|
tui.activity.applyAgentEvent(event);
|
|
1146
1481
|
if (event.type === "done" || event.type === "error") agentReported = true;
|
|
1147
1482
|
};
|
|
@@ -1152,13 +1487,21 @@ async function runInteractive(): Promise<void> {
|
|
|
1152
1487
|
tick(true);
|
|
1153
1488
|
};
|
|
1154
1489
|
stdout.on("resize", onResize);
|
|
1490
|
+
const ownWrites = new OwnWrites();
|
|
1491
|
+
let presenceClients: readonly {
|
|
1492
|
+
clientId: string;
|
|
1493
|
+
focusedTrackId?: string | null;
|
|
1494
|
+
}[] = [];
|
|
1155
1495
|
let applying: Promise<void> = Promise.resolve();
|
|
1156
1496
|
const applyLatest = (latest: typeof record): Promise<void> =>
|
|
1157
1497
|
(applying = applying.then(() => applyRecord(latest)));
|
|
1158
1498
|
const applyRecord = async (latest: typeof record): Promise<void> => {
|
|
1159
1499
|
try {
|
|
1500
|
+
// Queued before a /fork or /resume swapped the session: stale.
|
|
1501
|
+
if (latest.sessionId !== record.sessionId) return;
|
|
1160
1502
|
if (latest.revision > record.revision) {
|
|
1161
1503
|
const previousRevision = record.revision;
|
|
1504
|
+
const before = score;
|
|
1162
1505
|
record = latest;
|
|
1163
1506
|
if (stageCapture)
|
|
1164
1507
|
stageCapture.committed = scoreFromJSON(record.composition);
|
|
@@ -1195,11 +1538,27 @@ async function runInteractive(): Promise<void> {
|
|
|
1195
1538
|
else if (payload.action === "toggle") await setTransport("toggle");
|
|
1196
1539
|
}
|
|
1197
1540
|
projectSync?.scoreChanged(score);
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1541
|
+
// Another window's play/pause is followed, not announced, and this
|
|
1542
|
+
// window's own writes (which can echo back before the append
|
|
1543
|
+
// resolves) are never "synced".
|
|
1544
|
+
const from = shownRevision(record, previousRevision);
|
|
1545
|
+
const to = shownRevision(record);
|
|
1546
|
+
await ownWrites.settled();
|
|
1547
|
+
const foreign = foreignEvents(latest, previousRevision, ownWrites).some(
|
|
1548
|
+
(event) => event.kind !== "transport",
|
|
1549
|
+
);
|
|
1550
|
+
if (to !== from && foreign)
|
|
1551
|
+
tui.activity.pushCard(
|
|
1552
|
+
`synced · ${otherWindowName(presenceClients, port.clientId, {
|
|
1553
|
+
editedTrackId: editedTrack(
|
|
1554
|
+
before,
|
|
1555
|
+
scoreFromJSON(latest.composition),
|
|
1556
|
+
),
|
|
1557
|
+
trackName: (id) =>
|
|
1558
|
+
score.tracks.find((track) => track.id === id)?.name ?? id,
|
|
1559
|
+
})}`,
|
|
1560
|
+
{ tone: "info", baseRevision: from, resultRevision: to },
|
|
1561
|
+
);
|
|
1203
1562
|
}
|
|
1204
1563
|
} catch {
|
|
1205
1564
|
// An invalid composition is skipped; the next update retries.
|
|
@@ -1210,8 +1569,10 @@ async function runInteractive(): Promise<void> {
|
|
|
1210
1569
|
void applyLatest(update.record);
|
|
1211
1570
|
adoptMeta(update.record.meta);
|
|
1212
1571
|
} else if (update.type === "meta") adoptMeta(update.meta);
|
|
1213
|
-
else if (update.type === "presence")
|
|
1214
|
-
|
|
1572
|
+
else if (update.type === "presence") {
|
|
1573
|
+
presenceClients = update.clients;
|
|
1574
|
+
windowCount = update.clients.length;
|
|
1575
|
+
} else if (update.type === "sync") syncState = update.sync;
|
|
1215
1576
|
else if (update.type === "transport") {
|
|
1216
1577
|
// Every window renders the same hit line from dawgd's timestamp.
|
|
1217
1578
|
const { playing, beat, bpm, atMs } = update.transport;
|
|
@@ -1225,11 +1586,23 @@ async function runInteractive(): Promise<void> {
|
|
|
1225
1586
|
});
|
|
1226
1587
|
}
|
|
1227
1588
|
};
|
|
1228
|
-
|
|
1589
|
+
// Each subscription answers only while its port is the live one: /fork and
|
|
1590
|
+
// /resume swap the port, and a load the old port started before the swap
|
|
1591
|
+
// could otherwise land afterwards and pull the old session's name and
|
|
1592
|
+
// score into the new one.
|
|
1593
|
+
const subscribeLive = (): (() => void) => {
|
|
1594
|
+
const bound = port;
|
|
1595
|
+
ownWrites.attach(bound);
|
|
1596
|
+
return bound.subscribe((update) => {
|
|
1597
|
+
if (bound === port) onUpdate(update);
|
|
1598
|
+
});
|
|
1599
|
+
};
|
|
1600
|
+
let unsubscribe = subscribeLive();
|
|
1229
1601
|
const refreshPresence = () =>
|
|
1230
1602
|
void port
|
|
1231
1603
|
.presence()
|
|
1232
1604
|
.then((clients) => {
|
|
1605
|
+
presenceClients = clients;
|
|
1233
1606
|
windowCount = Math.max(1, clients.length);
|
|
1234
1607
|
})
|
|
1235
1608
|
.catch(() => undefined);
|
|
@@ -1241,12 +1614,13 @@ async function runInteractive(): Promise<void> {
|
|
|
1241
1614
|
rebindPort = () => {
|
|
1242
1615
|
unsubscribe();
|
|
1243
1616
|
syncState = port.sync;
|
|
1244
|
-
unsubscribe =
|
|
1617
|
+
unsubscribe = subscribeLive();
|
|
1245
1618
|
refreshPresence();
|
|
1246
1619
|
};
|
|
1247
1620
|
if (freshWorkspace)
|
|
1248
1621
|
tui.activity.pushCard("created .dawg/ · add it to .gitignore", {
|
|
1249
1622
|
tone: "info",
|
|
1623
|
+
once: true,
|
|
1250
1624
|
});
|
|
1251
1625
|
if (attachNotice)
|
|
1252
1626
|
tui.activity.pushCard(attachNotice, {
|
|
@@ -1256,6 +1630,9 @@ async function runInteractive(): Promise<void> {
|
|
|
1256
1630
|
});
|
|
1257
1631
|
if (port.status !== "file session")
|
|
1258
1632
|
tui.activity.pushCard(port.status, { tone: "info" });
|
|
1633
|
+
void launchCard?.then((card) => {
|
|
1634
|
+
if (card) tui.activity.pushCard(card.text, { tone: card.tone, once: true });
|
|
1635
|
+
});
|
|
1259
1636
|
if (await isProject(process.cwd()))
|
|
1260
1637
|
projectSync = startProjectSync(syncHost());
|
|
1261
1638
|
void currentProvider().then(() => tick(true));
|
|
@@ -1277,8 +1654,28 @@ async function runInteractive(): Promise<void> {
|
|
|
1277
1654
|
stdin.on("end", onEnd);
|
|
1278
1655
|
async function* chunks(): AsyncGenerator<string> {
|
|
1279
1656
|
for (;;) {
|
|
1280
|
-
while (inbox.length === 0)
|
|
1281
|
-
|
|
1657
|
+
while (inbox.length === 0) {
|
|
1658
|
+
// A partial escape (Alt+[, a cut-off paste) is flushed after a short
|
|
1659
|
+
// idle so it never holds back the keys behind it.
|
|
1660
|
+
const held = inputDecoder.pending();
|
|
1661
|
+
const idle =
|
|
1662
|
+
held === "paste"
|
|
1663
|
+
? PASTE_FLUSH_MS
|
|
1664
|
+
: held === "escape"
|
|
1665
|
+
? ESCAPE_FLUSH_MS
|
|
1666
|
+
: undefined;
|
|
1667
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
1668
|
+
const timedOut = await new Promise<boolean>((resolve) => {
|
|
1669
|
+
wake = () => resolve(false);
|
|
1670
|
+
if (idle !== undefined)
|
|
1671
|
+
timeout = setTimeout(() => resolve(true), idle);
|
|
1672
|
+
});
|
|
1673
|
+
clearTimeout(timeout);
|
|
1674
|
+
if (timedOut && inbox.length === 0) {
|
|
1675
|
+
wake = undefined;
|
|
1676
|
+
yield INPUT_FLUSH;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1282
1679
|
wake = undefined;
|
|
1283
1680
|
const next = inbox.shift()!;
|
|
1284
1681
|
if (next === "\u0000eof") return;
|
|
@@ -1293,7 +1690,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1293
1690
|
stdin.pause();
|
|
1294
1691
|
stdin.setRawMode?.(false);
|
|
1295
1692
|
// Leave the alternate screen; restore the cursor and plain paste.
|
|
1296
|
-
stdout.write(
|
|
1693
|
+
stdout.write(terminalRestore());
|
|
1297
1694
|
try {
|
|
1298
1695
|
return await flow();
|
|
1299
1696
|
} finally {
|
|
@@ -1312,10 +1709,13 @@ async function runInteractive(): Promise<void> {
|
|
|
1312
1709
|
let exiting = false;
|
|
1313
1710
|
for await (const text of chunks()) {
|
|
1314
1711
|
// A read that is exactly ESC is the Esc key, not the start of a sequence.
|
|
1315
|
-
const values =
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1712
|
+
const values =
|
|
1713
|
+
text === INPUT_FLUSH
|
|
1714
|
+
? inputDecoder.flush()
|
|
1715
|
+
: [
|
|
1716
|
+
...inputDecoder.push(text),
|
|
1717
|
+
...(text === "\u001b" ? inputDecoder.flush() : []),
|
|
1718
|
+
];
|
|
1319
1719
|
while (values.length) {
|
|
1320
1720
|
const value = values.shift()!;
|
|
1321
1721
|
// A mouse report acts on what the last frame painted under it; some
|
|
@@ -1341,9 +1741,9 @@ async function runInteractive(): Promise<void> {
|
|
|
1341
1741
|
continue;
|
|
1342
1742
|
}
|
|
1343
1743
|
}
|
|
1344
|
-
// The `?` panel closes on
|
|
1744
|
+
// The `?` panel closes on esc or ?; other keys wait (Ctrl-C quits).
|
|
1345
1745
|
if (tui.ui.keys && typeof value === "string" && value !== "\u0003") {
|
|
1346
|
-
tui.closeKeys();
|
|
1746
|
+
if (closesKeys(value)) tui.closeKeys();
|
|
1347
1747
|
tick(true);
|
|
1348
1748
|
continue;
|
|
1349
1749
|
}
|
|
@@ -1474,12 +1874,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1474
1874
|
// Never await a daemon round trip here: the key loop must stay
|
|
1475
1875
|
// live for Esc, quit and redraws while the toggle is in flight.
|
|
1476
1876
|
void toggleTransport()
|
|
1477
|
-
.catch((error: unknown) =>
|
|
1478
|
-
tui.activity.pushCard(
|
|
1479
|
-
`transport failed · ${error instanceof Error ? error.message : String(error)}`,
|
|
1480
|
-
{ tone: "error" },
|
|
1481
|
-
);
|
|
1482
|
-
})
|
|
1877
|
+
.catch((error: unknown) => transportFailed(error))
|
|
1483
1878
|
.finally(() => tick(true));
|
|
1484
1879
|
} else {
|
|
1485
1880
|
const input = tui.input(value);
|
|
@@ -1508,7 +1903,9 @@ async function runInteractive(): Promise<void> {
|
|
|
1508
1903
|
if (auditionLoop?.looping && isStageable(input.value))
|
|
1509
1904
|
hoverItem(input.value, `picker:${input.picker}`);
|
|
1510
1905
|
else if (input.picker === "pattern")
|
|
1511
|
-
previewPattern(
|
|
1906
|
+
previewPattern(
|
|
1907
|
+
input.value.replace(/^\/(?:pattern|groove)\s+/, ""),
|
|
1908
|
+
);
|
|
1512
1909
|
} else if (input.type === "pick-audition") {
|
|
1513
1910
|
pickerAuditionKey(input.key);
|
|
1514
1911
|
} else if (input.type === "pick-cancel") {
|
|
@@ -1560,6 +1957,16 @@ async function runInteractive(): Promise<void> {
|
|
|
1560
1957
|
else if (action?.kind === "cancel" && agentTurn) {
|
|
1561
1958
|
agentTurn.controller.abort();
|
|
1562
1959
|
tui.activity.setSpinner("cancelling");
|
|
1960
|
+
} else if (
|
|
1961
|
+
action?.kind === "submit" &&
|
|
1962
|
+
action.value &&
|
|
1963
|
+
agentTurn &&
|
|
1964
|
+
routeDuringTurn(action.value, parsesLocally) === "local"
|
|
1965
|
+
) {
|
|
1966
|
+
// A typed command never waits on the agent: it commits its own
|
|
1967
|
+
// revision now, and the agent's next call re-reads the score.
|
|
1968
|
+
const value = action.value;
|
|
1969
|
+
void runLocalBesideAgent(value).finally(() => tick(true));
|
|
1563
1970
|
} else if (action?.kind === "submit" && action.value && agentTurn) {
|
|
1564
1971
|
// Enter during a turn steers it; Alt-Enter still queues a follow-up.
|
|
1565
1972
|
agentTurn.steering.push(action.value);
|
|
@@ -1590,17 +1997,36 @@ async function runInteractive(): Promise<void> {
|
|
|
1590
1997
|
stdin.off("end", onEnd);
|
|
1591
1998
|
clearInterval(timer);
|
|
1592
1999
|
clearInterval(presenceTimer);
|
|
1593
|
-
await projectSync?.stop();
|
|
1594
|
-
namer.dispose();
|
|
1595
2000
|
stdout.off("resize", onResize);
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
audio.stop();
|
|
1600
|
-
await port.close();
|
|
1601
|
-
stdin.setRawMode?.(false);
|
|
2001
|
+
// The terminal first: a teardown step that throws (a read-only .dawg,
|
|
2002
|
+
// a dead engine) must not leave the shell on the alternate screen.
|
|
2003
|
+
restoreTerminal();
|
|
1602
2004
|
stdin.pause();
|
|
1603
|
-
|
|
2005
|
+
console.error = consoleError;
|
|
2006
|
+
console.warn = consoleWarn;
|
|
2007
|
+
process.off("warning", onWarning);
|
|
2008
|
+
unwatchActivity();
|
|
2009
|
+
// Each step on its own: one failure does not skip the rest.
|
|
2010
|
+
const failures: unknown[] = [];
|
|
2011
|
+
const steps: Array<() => unknown> = [
|
|
2012
|
+
() => projectSync?.stop(),
|
|
2013
|
+
() => namer.dispose(),
|
|
2014
|
+
() => unsubscribe(),
|
|
2015
|
+
() => play?.exit(),
|
|
2016
|
+
() => monitorEngine?.dispose(),
|
|
2017
|
+
() => audio.stop(),
|
|
2018
|
+
() => port.close(),
|
|
2019
|
+
];
|
|
2020
|
+
for (const step of steps) {
|
|
2021
|
+
try {
|
|
2022
|
+
await step();
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
failures.push(error);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
process.off("unhandledRejection", onRejection);
|
|
2028
|
+
process.off("uncaughtException", onUncaught);
|
|
2029
|
+
if (failures.length > 0) throw failures[0];
|
|
1604
2030
|
}
|
|
1605
2031
|
}
|
|
1606
2032
|
|
|
@@ -1615,23 +2041,23 @@ function unknownCommand(command: string): string {
|
|
|
1615
2041
|
|
|
1616
2042
|
async function submit(prompt: string): Promise<string | Receipt> {
|
|
1617
2043
|
const command = prompt.trim();
|
|
2044
|
+
// Canonical names for window commands: `model key` (login), `models`,
|
|
2045
|
+
// `voice` (the Voice topic).
|
|
2046
|
+
const canonical = canonicalWindowForm(command);
|
|
2047
|
+
if (canonical) return submit(canonical);
|
|
1618
2048
|
const helpCommand = command.match(/^\/?(?:help|\?)(?:\s+(\S+))?$/i);
|
|
1619
2049
|
if (helpCommand) {
|
|
1620
2050
|
const topic = helpCommand[1];
|
|
2051
|
+
// The panel's inner width (tui/app.ts paintText): rows clip there with
|
|
2052
|
+
// an ellipsis, the same as every other panel.
|
|
2053
|
+
const columns = stdout.columns ?? 80;
|
|
1621
2054
|
const lines = helpTopicLines(
|
|
1622
2055
|
topic,
|
|
1623
|
-
Math.max(
|
|
1624
|
-
);
|
|
1625
|
-
if (!lines)
|
|
1626
|
-
return fail(
|
|
1627
|
-
`no help topic ${topic} · /help all · ${HELP_TOPICS.join(" ")}`,
|
|
1628
|
-
);
|
|
1629
|
-
tui.openText(topic ? `help · ${topic.toLowerCase()}` : "help", lines);
|
|
1630
|
-
return ok(
|
|
1631
|
-
topic
|
|
1632
|
-
? `help · ${topic.toLowerCase()}`
|
|
1633
|
-
: "help · /help all for every command",
|
|
2056
|
+
Math.max(10, columns - (columns >= 60 ? 8 : 4)),
|
|
1634
2057
|
);
|
|
2058
|
+
if (!lines) return fail(helpMiss(topic!));
|
|
2059
|
+
tui.openText(helpTitle(topic), lines);
|
|
2060
|
+
return ok(topic ? helpTitle(topic) : "help · help all for every command");
|
|
1635
2061
|
}
|
|
1636
2062
|
if (/^\/?tracks$/i.test(command)) {
|
|
1637
2063
|
const problems = await sampleProblems(score);
|
|
@@ -1663,7 +2089,32 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1663
2089
|
items.findIndex((item) => item.current),
|
|
1664
2090
|
),
|
|
1665
2091
|
});
|
|
1666
|
-
return
|
|
2092
|
+
return note(`${items.length} track${items.length === 1 ? "" : "s"}`);
|
|
2093
|
+
}
|
|
2094
|
+
if (/^\/?(?:instruments|instrument\s+(?:list|ls|presets))$/i.test(command)) {
|
|
2095
|
+
// `instrument list`: every word `instrument <name>` takes, by family.
|
|
2096
|
+
const columns = stdout.columns ?? 80;
|
|
2097
|
+
const lines = instrumentListLines(
|
|
2098
|
+
Math.max(10, columns - (columns >= 60 ? 8 : 4)),
|
|
2099
|
+
);
|
|
2100
|
+
tui.openText("instruments", lines);
|
|
2101
|
+
return note("instruments · instrument <name> on the focused track");
|
|
2102
|
+
}
|
|
2103
|
+
if (/^\/?notes(?:\s+(?:list|ls))?$/i.test(command)) {
|
|
2104
|
+
// `notes`: the focused track's note ids, the names `remove <id>` takes.
|
|
2105
|
+
const notes = score.notes
|
|
2106
|
+
.filter((entry) => entry.trackId === requestedTrack)
|
|
2107
|
+
.slice()
|
|
2108
|
+
.sort((a, b) => a.startTick - b.startTick);
|
|
2109
|
+
const lines = notes.map(
|
|
2110
|
+
(entry) =>
|
|
2111
|
+
`${entry.id} · ${midiNoteName(entry.pitch)} · beat ${+(entry.startTick / score.ticksPerBeat).toFixed(2)}`,
|
|
2112
|
+
);
|
|
2113
|
+
if (lines.length === 0) return note(`notes · ${requestedTrack} has none`);
|
|
2114
|
+
tui.openText(`notes · ${requestedTrack}`, lines);
|
|
2115
|
+
return note(
|
|
2116
|
+
`${lines.length} note${lines.length === 1 ? "" : "s"} · ${requestedTrack} · remove <id>`,
|
|
2117
|
+
);
|
|
1667
2118
|
}
|
|
1668
2119
|
const playCommand = command.match(
|
|
1669
2120
|
/^\/play(?:\s+(on|off|degrees|in-key|chromatic))?$/i,
|
|
@@ -1696,6 +2147,24 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1696
2147
|
);
|
|
1697
2148
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1698
2149
|
}
|
|
2150
|
+
const showMeCommand = command.match(/^\/show-?me(?:\s+(\S+))?$/i);
|
|
2151
|
+
if (showMeCommand) {
|
|
2152
|
+
if (!showMeCommand[1])
|
|
2153
|
+
return ok(`show me · ${showMe.level} · /showme on|quiet|off`);
|
|
2154
|
+
const level = parseShowMe(showMeCommand[1]);
|
|
2155
|
+
if (!level) return fail("usage · /showme on|quiet|off");
|
|
2156
|
+
showMe.level = level;
|
|
2157
|
+
void writeConfig({ dir: configDir() }, { showMe: level }).catch(
|
|
2158
|
+
() => undefined,
|
|
2159
|
+
);
|
|
2160
|
+
return ok(
|
|
2161
|
+
level === "off"
|
|
2162
|
+
? "show me off · the agent edits with tools"
|
|
2163
|
+
: level === "quiet"
|
|
2164
|
+
? "show me quiet · the agent types commands, no captions"
|
|
2165
|
+
: "show me on · the agent types commands, slides faders, plays keys",
|
|
2166
|
+
);
|
|
2167
|
+
}
|
|
1699
2168
|
const countIn = command.match(/^\/count-?in\s+([0-2])$/i);
|
|
1700
2169
|
if (countIn) return ok(playSession().setCountIn(Number(countIn[1])));
|
|
1701
2170
|
const euclidCommand = command.match(/^\/euclid(?:\s+(\S+))?$/i);
|
|
@@ -1710,21 +2179,45 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1710
2179
|
}
|
|
1711
2180
|
const tryCommand = command.match(/^\/try(?:\s+(.+))?$/i);
|
|
1712
2181
|
if (tryCommand) return tryPrompt(tryCommand[1]?.trim() ?? "");
|
|
1713
|
-
// A bare
|
|
1714
|
-
// opens the fader drawer on it, with its
|
|
1715
|
-
|
|
1716
|
-
|
|
2182
|
+
// A bare scalar (`volume`, `pan`, `fx reverb mix`, and the song's
|
|
2183
|
+
// `tempo`, `bars` and `meter`) opens the fader drawer on it, with its
|
|
2184
|
+
// related params stacked below.
|
|
2185
|
+
if (
|
|
2186
|
+
/^\/?(?:volume|pan|tempo|bpm|bars|meter|fx\s+\S+(?:\s+\S+)?)$/i.test(
|
|
2187
|
+
command,
|
|
2188
|
+
)
|
|
2189
|
+
) {
|
|
2190
|
+
const context = menuContext();
|
|
2191
|
+
const label =
|
|
2192
|
+
menu.showFader(context, command) ?? songFader(context, command);
|
|
1717
2193
|
if (label) {
|
|
2194
|
+
const field = menu
|
|
2195
|
+
.faderFields(context)
|
|
2196
|
+
.find((candidate) => candidate.label === label);
|
|
2197
|
+
// The receipt names the value it opened on: `tempo · 120 BPM · …`.
|
|
2198
|
+
const value =
|
|
2199
|
+
field?.kind === "number"
|
|
2200
|
+
? field.value !== undefined
|
|
2201
|
+
? field.format(field.value)
|
|
2202
|
+
: field.off
|
|
2203
|
+
: undefined;
|
|
1718
2204
|
openFader(label, true);
|
|
1719
2205
|
refreshMenu();
|
|
1720
|
-
return ok(
|
|
2206
|
+
return ok(
|
|
2207
|
+
`${label}${value ? ` · ${value}` : ""} · ←→ adjust · enter keep · esc revert`,
|
|
2208
|
+
);
|
|
1721
2209
|
}
|
|
1722
2210
|
}
|
|
1723
2211
|
const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
|
|
1724
2212
|
if (menuCommand) {
|
|
1725
2213
|
const section = menuCommand[1]?.toLowerCase();
|
|
1726
|
-
|
|
1727
|
-
|
|
2214
|
+
// §4: the keys topic has no menu page; it opens the ? panel.
|
|
2215
|
+
if (section && (section === "keys" || TOPIC_ALIASES[section] === "keys")) {
|
|
2216
|
+
tui.showKeys("keys", keyLines(KEYS.prompt));
|
|
2217
|
+
return ok("keys");
|
|
2218
|
+
}
|
|
2219
|
+
if (section && !menuSectionPath(menuContext(), section))
|
|
2220
|
+
return fail(menuUsage(menuContext(), section));
|
|
1728
2221
|
openMenu(section);
|
|
1729
2222
|
return ok("menu");
|
|
1730
2223
|
}
|
|
@@ -1754,6 +2247,73 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1754
2247
|
if (requestedTrack !== grainTrack[1]!.toLowerCase()) return focused;
|
|
1755
2248
|
return submit(grainTrack[2]!);
|
|
1756
2249
|
}
|
|
2250
|
+
// `/track rm <name>` (aliases remove, delete) and `/track move <name>
|
|
2251
|
+
// <position>`: the human surface for the removeTrack and moveTrack
|
|
2252
|
+
// operations. Removing a track also drops any vocoder src or autotune from
|
|
2253
|
+
// that named it; ^z brings everything back.
|
|
2254
|
+
const trackEdit = command.match(
|
|
2255
|
+
/^\/?track\s+(rm|remove|delete|move)\s+(.{1,64}?)\s*$/i,
|
|
2256
|
+
);
|
|
2257
|
+
if (trackEdit) {
|
|
2258
|
+
const verb = trackEdit[1]!.toLowerCase() === "move" ? "move" : "rm";
|
|
2259
|
+
let rest = trackEdit[2]!.trim();
|
|
2260
|
+
let position: number | undefined;
|
|
2261
|
+
if (verb === "move") {
|
|
2262
|
+
const tail = rest.match(/^(.+?)\s+(\d{1,3})$/);
|
|
2263
|
+
if (!tail)
|
|
2264
|
+
return fail(`usage · /track move <name> <1..${score.tracks.length}>`);
|
|
2265
|
+
rest = tail[1]!;
|
|
2266
|
+
position = Number(tail[2]);
|
|
2267
|
+
}
|
|
2268
|
+
const wanted = rest
|
|
2269
|
+
.replace(/^["']|["']$/g, "")
|
|
2270
|
+
.trim()
|
|
2271
|
+
.replace(/\s+/g, " ")
|
|
2272
|
+
.toLowerCase();
|
|
2273
|
+
const found = score.tracks.find(
|
|
2274
|
+
(track) =>
|
|
2275
|
+
track.id.toLowerCase() === wanted ||
|
|
2276
|
+
track.id.toLowerCase() === wanted.replace(/ /g, "-") ||
|
|
2277
|
+
(track.name ?? "").toLowerCase() === wanted,
|
|
2278
|
+
);
|
|
2279
|
+
if (!found) return fail(noTrack(rest));
|
|
2280
|
+
if (verb === "move") {
|
|
2281
|
+
if (position! < 1 || position! > score.tracks.length)
|
|
2282
|
+
return fail(`usage · /track move <name> <1..${score.tracks.length}>`);
|
|
2283
|
+
const next = applyScoreOperation(score, {
|
|
2284
|
+
type: "moveTrack",
|
|
2285
|
+
trackId: found.id,
|
|
2286
|
+
index: position! - 1,
|
|
2287
|
+
});
|
|
2288
|
+
await commitScore(next, "track.move", { trackId: found.id });
|
|
2289
|
+
await projectSync?.flushScore();
|
|
2290
|
+
return ok(`moved ${found.id} to position ${position}`);
|
|
2291
|
+
}
|
|
2292
|
+
if (score.tracks.length <= 1)
|
|
2293
|
+
return fail("the last track stays · /clear empties it");
|
|
2294
|
+
const next = applyScoreOperation(score, {
|
|
2295
|
+
type: "removeTrack",
|
|
2296
|
+
trackId: found.id,
|
|
2297
|
+
});
|
|
2298
|
+
const dropped = score.tracks
|
|
2299
|
+
.filter((track) => track.id !== found.id)
|
|
2300
|
+
.filter((track) => {
|
|
2301
|
+
const after = next.tracks.find((t) => t.id === track.id);
|
|
2302
|
+
return (
|
|
2303
|
+
(track.vocoder?.src !== undefined &&
|
|
2304
|
+
after?.vocoder?.src === undefined) ||
|
|
2305
|
+
(track.autotune?.from !== undefined &&
|
|
2306
|
+
after?.autotune?.from === undefined)
|
|
2307
|
+
);
|
|
2308
|
+
})
|
|
2309
|
+
.map((track) => track.id);
|
|
2310
|
+
await commitScore(next, "track.remove", { trackId: found.id });
|
|
2311
|
+
await projectSync?.flushScore();
|
|
2312
|
+
if (found.id === requestedTrack) await focusTrack(next.tracks[0]!.id);
|
|
2313
|
+
return ok(
|
|
2314
|
+
`removed ${found.id}${dropped.length ? ` · dropped references on ${dropped.join(", ")}` : ""} · ctrl-z undoes`,
|
|
2315
|
+
);
|
|
2316
|
+
}
|
|
1757
2317
|
// `/track piano b`: a name with spaces focuses the track of that name, or
|
|
1758
2318
|
// creates `piano-b` named "piano b".
|
|
1759
2319
|
const namedTrack = command.match(/^\/track\s+([a-z0-9._ -]{1,64})$/i);
|
|
@@ -1778,12 +2338,90 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1778
2338
|
}
|
|
1779
2339
|
const resample = parseResampleCommand(command);
|
|
1780
2340
|
if (resample) return resampleCommand(resample);
|
|
2341
|
+
// 0.7 clips: `/clip` edits and `/lyrics` on the focused track.
|
|
2342
|
+
const clipCommand = parseClipCommand(command);
|
|
2343
|
+
if (clipCommand) {
|
|
2344
|
+
if ("error" in clipCommand) return fail(clipCommand.error);
|
|
2345
|
+
const result = await runClipCommand(clipCommand, {
|
|
2346
|
+
score,
|
|
2347
|
+
trackId: requestedTrack,
|
|
2348
|
+
cwd: process.cwd(),
|
|
2349
|
+
});
|
|
2350
|
+
if (!result.ok) return fail(result.message);
|
|
2351
|
+
if (result.next && result.kind) {
|
|
2352
|
+
await commitScore(result.next, result.kind, {
|
|
2353
|
+
trackId: requestedTrack,
|
|
2354
|
+
...result.payload,
|
|
2355
|
+
});
|
|
2356
|
+
await projectSync?.flushScore();
|
|
2357
|
+
}
|
|
2358
|
+
return ok(result.message);
|
|
2359
|
+
}
|
|
2360
|
+
const lyricsCommand = parseLyricsCommand(command);
|
|
2361
|
+
if (lyricsCommand) {
|
|
2362
|
+
const result = applyLyrics(score, requestedTrack, lyricsCommand);
|
|
2363
|
+
if (!result.ok) return fail(result.message);
|
|
2364
|
+
if (result.next && result.kind) {
|
|
2365
|
+
await commitScore(result.next, result.kind, {
|
|
2366
|
+
trackId: requestedTrack,
|
|
2367
|
+
...result.payload,
|
|
2368
|
+
});
|
|
2369
|
+
await projectSync?.flushScore();
|
|
2370
|
+
}
|
|
2371
|
+
return ok(result.message);
|
|
2372
|
+
}
|
|
2373
|
+
// 0.7 autotune: `/autotune …` (and `/tune <preset>` pointing here).
|
|
2374
|
+
// `/vocal autotune …` takes the same path (presets panel, draft, commit).
|
|
2375
|
+
const autotune = parseAutotuneCommand(
|
|
2376
|
+
command.replace(/^\/?vocal\s+(?=autotune\b)/i, ""),
|
|
2377
|
+
);
|
|
2378
|
+
if (autotune) {
|
|
2379
|
+
if (autotune.type === "autotune-list")
|
|
2380
|
+
tui.openText("autotune presets", autotuneListLines());
|
|
2381
|
+
if (autotune.type !== "autotune-usage" && autotune.type !== "autotune-list")
|
|
2382
|
+
await materializeDraft();
|
|
2383
|
+
const result = applyAutotuneCommand(score, requestedTrack, autotune);
|
|
2384
|
+
if (result.next && result.kind)
|
|
2385
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2386
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2387
|
+
}
|
|
2388
|
+
// 0.7 Voice: `/vocal <verb>`; lanes register verbs in VOCAL_VERBS.
|
|
2389
|
+
setClipImportDeps({
|
|
2390
|
+
media: {
|
|
2391
|
+
...mediaServices(),
|
|
2392
|
+
signal: new AbortController().signal,
|
|
2393
|
+
progress: () => undefined,
|
|
2394
|
+
},
|
|
2395
|
+
});
|
|
2396
|
+
const vocal = parseVocalCommand(command);
|
|
2397
|
+
if (vocal) {
|
|
2398
|
+
if (vocal.kind === "verb") await materializeDraft();
|
|
2399
|
+
const result = await runVocalCommand(vocal, {
|
|
2400
|
+
score,
|
|
2401
|
+
trackId: requestedTrack,
|
|
2402
|
+
cwd: process.cwd(),
|
|
2403
|
+
});
|
|
2404
|
+
if (!result.ok) return fail(result.message);
|
|
2405
|
+
if (result.next && result.kind) {
|
|
2406
|
+
await commitScore(result.next, result.kind, {
|
|
2407
|
+
trackId: requestedTrack,
|
|
2408
|
+
...result.payload,
|
|
2409
|
+
});
|
|
2410
|
+
await projectSync?.flushScore();
|
|
2411
|
+
}
|
|
2412
|
+
// A verb that made or chose another track (a vocoder carrier): focus it.
|
|
2413
|
+
if (result.trackId && result.trackId !== requestedTrack) {
|
|
2414
|
+
await port.focus(result.trackId);
|
|
2415
|
+
requestedTrack = result.trackId;
|
|
2416
|
+
}
|
|
2417
|
+
return ok(result.message);
|
|
2418
|
+
}
|
|
1781
2419
|
const pack = parsePackCommand(command);
|
|
1782
2420
|
if (pack) return packCommand(pack);
|
|
1783
2421
|
const pattern = parsePatternCommand(command);
|
|
1784
2422
|
if (pattern) return patternCommand(pattern);
|
|
1785
2423
|
const kit = parseKitCommand(command);
|
|
1786
|
-
if (kit) return kitCommand(kit,
|
|
2424
|
+
if (kit) return kitCommand(kit, /^\/?kits?\s*$/i.test(command.trim()));
|
|
1787
2425
|
const wavetable = parseWavetableCommand(command);
|
|
1788
2426
|
if (wavetable) return wavetableCommand(wavetable);
|
|
1789
2427
|
const sessionReply = await sessionCommand(command);
|
|
@@ -1821,6 +2459,19 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1821
2459
|
await commitScore(result.next, result.kind, result.payload);
|
|
1822
2460
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1823
2461
|
}
|
|
2462
|
+
const progression = parseProgressionCommand(command);
|
|
2463
|
+
if (progression) {
|
|
2464
|
+
if (progression.type === "progression") await materializeDraft();
|
|
2465
|
+
const result = applyProgressionCommand(
|
|
2466
|
+
score,
|
|
2467
|
+
requestedTrack,
|
|
2468
|
+
progression,
|
|
2469
|
+
() => randomUUID().slice(0, 12),
|
|
2470
|
+
);
|
|
2471
|
+
if (result.next && result.kind)
|
|
2472
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2473
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2474
|
+
}
|
|
1824
2475
|
const strum = parseStrumCommand(command);
|
|
1825
2476
|
if (strum) {
|
|
1826
2477
|
if (strum.type === "strum") await materializeDraft();
|
|
@@ -1836,6 +2487,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1836
2487
|
if (rig.type !== "rig-show" && rig.type !== "rig-hint")
|
|
1837
2488
|
await materializeDraft();
|
|
1838
2489
|
const result = applyRigCommand(score, requestedTrack, rig);
|
|
2490
|
+
if (result.next && result.kind)
|
|
2491
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2492
|
+
return readOrDone(result);
|
|
2493
|
+
}
|
|
2494
|
+
// 0.7 `/formant` and `/vowel`: short forms of `fx formant|vowel`.
|
|
2495
|
+
const formant = parseFormantCommand(command) ?? parseVowelCommand(command);
|
|
2496
|
+
if (formant) {
|
|
2497
|
+
if (formant.type === "fx") await materializeDraft();
|
|
2498
|
+
const result = applyFormantCommand(score, requestedTrack, formant);
|
|
1839
2499
|
if (result.next && result.kind)
|
|
1840
2500
|
await commitScore(result.next, result.kind, result.payload);
|
|
1841
2501
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
@@ -1857,6 +2517,8 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1857
2517
|
const result = applyFxCommand(score, requestedTrack, fx, pinnedIr);
|
|
1858
2518
|
if (result.next && result.kind)
|
|
1859
2519
|
await commitScore(result.next, result.kind, result.payload);
|
|
2520
|
+
// Bare `fx` reads the chain (`•`); it changes nothing.
|
|
2521
|
+
if (result.ok && fx.type === "fx-list") return note(result.message);
|
|
1860
2522
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1861
2523
|
}
|
|
1862
2524
|
const unknownFx = unknownFxMessage(command);
|
|
@@ -1872,6 +2534,13 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1872
2534
|
}
|
|
1873
2535
|
const tuning = parseTuningCommand(command);
|
|
1874
2536
|
if (tuning) return tuningCommand(tuning);
|
|
2537
|
+
const calibration = parseCalibrationCommand(command);
|
|
2538
|
+
if (calibration) {
|
|
2539
|
+
const result = applyCalibrationCommand(score, calibration);
|
|
2540
|
+
if (result.next && result.kind)
|
|
2541
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2542
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2543
|
+
}
|
|
1875
2544
|
const keysCommand = parseKeysCommand(command);
|
|
1876
2545
|
if (keysCommand) {
|
|
1877
2546
|
const reads =
|
|
@@ -1893,6 +2562,25 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1893
2562
|
await commitScore(result.next, result.kind, result.payload);
|
|
1894
2563
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1895
2564
|
}
|
|
2565
|
+
const vocoderCommand = parseVocoderCommand(command);
|
|
2566
|
+
if (vocoderCommand) {
|
|
2567
|
+
if (vocoderCommand.type === "vocoder-list")
|
|
2568
|
+
tui.openText("vocoder presets", vocoderListLines());
|
|
2569
|
+
if (
|
|
2570
|
+
vocoderCommand.type !== "vocoder-list" &&
|
|
2571
|
+
vocoderCommand.type !== "vocoder-usage"
|
|
2572
|
+
)
|
|
2573
|
+
await materializeDraft();
|
|
2574
|
+
const result = applyVocoderCommand(score, requestedTrack, vocoderCommand);
|
|
2575
|
+
if (result.next && result.kind)
|
|
2576
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2577
|
+
// `/vocoder` on a vocal makes a carrier track: focus it.
|
|
2578
|
+
if (result.trackId && result.trackId !== requestedTrack) {
|
|
2579
|
+
await port.focus(result.trackId);
|
|
2580
|
+
requestedTrack = result.trackId;
|
|
2581
|
+
}
|
|
2582
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2583
|
+
}
|
|
1896
2584
|
const windCommand = parseWindCommand(command);
|
|
1897
2585
|
if (windCommand) {
|
|
1898
2586
|
if (windCommand.type === "wind-list")
|
|
@@ -1904,13 +2592,24 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1904
2592
|
await commitScore(result.next, result.kind, result.payload);
|
|
1905
2593
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1906
2594
|
}
|
|
2595
|
+
const singCommand = parseSingCommand(command);
|
|
2596
|
+
if (singCommand) {
|
|
2597
|
+
if (singCommand.type === "sing-list")
|
|
2598
|
+
tui.openText("sing presets", singListLines());
|
|
2599
|
+
if (singCommand.type !== "sing-show" && singCommand.type !== "sing-list")
|
|
2600
|
+
await materializeDraft();
|
|
2601
|
+
const result = applySingCommand(score, requestedTrack, singCommand);
|
|
2602
|
+
if (result.next && result.kind)
|
|
2603
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2604
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2605
|
+
}
|
|
1907
2606
|
const synth = parseSynthCommand(command);
|
|
1908
2607
|
if (synth) {
|
|
1909
2608
|
if (synth.type !== "synth-list") await materializeDraft();
|
|
1910
2609
|
const result = applySynthCommand(score, requestedTrack, synth);
|
|
1911
2610
|
if (result.next && result.kind)
|
|
1912
2611
|
await commitScore(result.next, result.kind, result.payload);
|
|
1913
|
-
return
|
|
2612
|
+
return readOrDone(result);
|
|
1914
2613
|
}
|
|
1915
2614
|
const stringCommand = parseStringCommand(command);
|
|
1916
2615
|
if (stringCommand) {
|
|
@@ -1922,7 +2621,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1922
2621
|
const result = applyStringCommand(score, requestedTrack, stringCommand);
|
|
1923
2622
|
if (result.next && result.kind)
|
|
1924
2623
|
await commitScore(result.next, result.kind, result.payload);
|
|
1925
|
-
return
|
|
2624
|
+
return readOrDone(result);
|
|
1926
2625
|
}
|
|
1927
2626
|
const grainHint = grainSrcHint(command);
|
|
1928
2627
|
if (grainHint) return fail(grainHint);
|
|
@@ -1935,6 +2634,50 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1935
2634
|
await commitScore(result.next, result.kind, result.payload);
|
|
1936
2635
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1937
2636
|
}
|
|
2637
|
+
const styleCommand = parseStyleCommand(
|
|
2638
|
+
/^\/?styles?\s+presets$/i.test(command) ? "style list" : command,
|
|
2639
|
+
);
|
|
2640
|
+
if (styleCommand) {
|
|
2641
|
+
const writes =
|
|
2642
|
+
styleCommand.type === "style-apply" ||
|
|
2643
|
+
styleCommand.type === "style-again";
|
|
2644
|
+
// Bare `style` opens Arrange › style; `style list|ls|presets` lists.
|
|
2645
|
+
if (
|
|
2646
|
+
styleCommand.type === "style-families" &&
|
|
2647
|
+
/^\/?styles?$/i.test(command)
|
|
2648
|
+
) {
|
|
2649
|
+
openMenu("style");
|
|
2650
|
+
return ok(
|
|
2651
|
+
"style · ↑/↓ browse · enter makes the song · style list prints it",
|
|
2652
|
+
);
|
|
2653
|
+
}
|
|
2654
|
+
if (writes) await materializeDraft();
|
|
2655
|
+
const result = applyStyleCommand(score, styleCommand);
|
|
2656
|
+
if (result.next && result.kind)
|
|
2657
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2658
|
+
if (result.log) tui.activity.pushNote(result.log);
|
|
2659
|
+
if (!result.ok) return fail(result.message);
|
|
2660
|
+
// A multi-line listing opens as text; the card keeps its first line
|
|
2661
|
+
// so lines never run together on the one-line strip.
|
|
2662
|
+
if (!writes && result.message.includes("\n")) {
|
|
2663
|
+
const [head = "styles", ...rest] = result.message.split("\n");
|
|
2664
|
+
tui.openText(head, rest);
|
|
2665
|
+
return note(head);
|
|
2666
|
+
}
|
|
2667
|
+
// A new song: focus its first melodic track, so play and the menu
|
|
2668
|
+
// land on something tonal instead of a track the style replaced.
|
|
2669
|
+
const melodic = writes
|
|
2670
|
+
? result.next?.tracks.find((track) => !isDrumInstrument(track.instrument))
|
|
2671
|
+
: undefined;
|
|
2672
|
+
if (melodic && melodic.id !== requestedTrack) {
|
|
2673
|
+
await port.focus(melodic.id);
|
|
2674
|
+
requestedTrack = melodic.id;
|
|
2675
|
+
draftTrack = false;
|
|
2676
|
+
}
|
|
2677
|
+
return ok(
|
|
2678
|
+
melodic ? `${result.message} · focused ${melodic.id}` : result.message,
|
|
2679
|
+
);
|
|
2680
|
+
}
|
|
1938
2681
|
const masterCommand = parseMasterCommand(command);
|
|
1939
2682
|
if (masterCommand) {
|
|
1940
2683
|
if (masterCommand.type === "master-measure")
|
|
@@ -1947,7 +2690,33 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1947
2690
|
const result = applyMasterCommand(score, masterCommand);
|
|
1948
2691
|
if (result.next && result.kind)
|
|
1949
2692
|
await commitScore(result.next, result.kind, result.payload);
|
|
1950
|
-
return
|
|
2693
|
+
return readOrDone(result);
|
|
2694
|
+
}
|
|
2695
|
+
// `loop <section> | <a>-<b> | off`: what playback loops, a section.
|
|
2696
|
+
const loop = parseLoopCommand(command);
|
|
2697
|
+
if (loop) {
|
|
2698
|
+
if (loop.type === "loop-show")
|
|
2699
|
+
return note(
|
|
2700
|
+
score.loopSection
|
|
2701
|
+
? `loop · ${score.loopSection} · loop off plays the song`
|
|
2702
|
+
: `loop · the song · ${LOOP_USAGE}`,
|
|
2703
|
+
);
|
|
2704
|
+
if (loop.type === "loop-off") return submit("section loop off");
|
|
2705
|
+
if (loop.type === "loop-section")
|
|
2706
|
+
return submit(`section loop ${loop.name}`);
|
|
2707
|
+
const span = score.sections.find(
|
|
2708
|
+
(section) =>
|
|
2709
|
+
section.startBar + 1 === loop.from &&
|
|
2710
|
+
section.startBar + section.bars === loop.to,
|
|
2711
|
+
);
|
|
2712
|
+
if (span) return submit(`section loop ${span.name}`);
|
|
2713
|
+
// No section spans the bars: mark (or move) the section named `loop`
|
|
2714
|
+
// and loop it, as one revision.
|
|
2715
|
+
await materializeDraft();
|
|
2716
|
+
const result = loopSpan(score, requestedTrack, loop.from, loop.to);
|
|
2717
|
+
if (!result.ok) return fail(result.message);
|
|
2718
|
+
await commitScore(result.next, "score.sections", { loop: result.name });
|
|
2719
|
+
return ok(result.message);
|
|
1951
2720
|
}
|
|
1952
2721
|
const arrange = parseSectionCommand(command, score);
|
|
1953
2722
|
if (arrange) {
|
|
@@ -1961,7 +2730,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1961
2730
|
if (result.next && result.kind)
|
|
1962
2731
|
await commitScore(result.next, result.kind, result.payload);
|
|
1963
2732
|
if (result.seekBeat !== undefined) await seekTransport(result.seekBeat);
|
|
1964
|
-
return
|
|
2733
|
+
return readOrDone(result);
|
|
1965
2734
|
}
|
|
1966
2735
|
const music = parseMusicCommand(command);
|
|
1967
2736
|
if (music) {
|
|
@@ -1973,20 +2742,21 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1973
2742
|
await commitScore(result.next, result.kind, result.payload);
|
|
1974
2743
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1975
2744
|
}
|
|
1976
|
-
const exportCommand = command
|
|
2745
|
+
const exportCommand = parseExportCommand(command);
|
|
1977
2746
|
if (exportCommand) {
|
|
1978
|
-
const path = resolve(exportCommand
|
|
1979
|
-
if (
|
|
2747
|
+
const path = resolve(exportCommand.path);
|
|
2748
|
+
if (exportCommand.format === "mid") {
|
|
1980
2749
|
await writeFile(path, scoreToMidi(exportScore(score)));
|
|
1981
|
-
return `exported midi · ${exportCommand
|
|
2750
|
+
return `exported midi · ${exportCommand.path}`;
|
|
1982
2751
|
}
|
|
1983
|
-
// Audio
|
|
1984
|
-
if (
|
|
1985
|
-
return
|
|
1986
|
-
|
|
1987
|
-
|
|
2752
|
+
// Audio goes through the `dawg render` path, the same bytes as the CLI.
|
|
2753
|
+
if (exportCommand.format === "wav")
|
|
2754
|
+
return exportWav(exportCommand.path, exportCommand.stems);
|
|
2755
|
+
// Other audio names are not formats dawg writes.
|
|
2756
|
+
if (/\.(aiff?|flac|mp3|ogg|m4a)$/i.test(path))
|
|
2757
|
+
return fail(`export · ${exportCommand.path} · ${EXPORT_USAGE}`);
|
|
1988
2758
|
await writeFile(path, encodeLoop(score), "utf8");
|
|
1989
|
-
return `exported · ${exportCommand
|
|
2759
|
+
return `exported · ${exportCommand.path}`;
|
|
1990
2760
|
}
|
|
1991
2761
|
const importCommand = command.match(/^\/?import\s+([^\s]+)$/i);
|
|
1992
2762
|
if (importCommand) {
|
|
@@ -2021,14 +2791,14 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2021
2791
|
tui.activity.setSpinner(undefined);
|
|
2022
2792
|
}
|
|
2023
2793
|
}
|
|
2024
|
-
const
|
|
2794
|
+
const result = await tuiSetModel(modelMatch[1]);
|
|
2025
2795
|
provider = undefined;
|
|
2026
2796
|
await currentProvider();
|
|
2027
|
-
return
|
|
2797
|
+
return result.ok ? result.text : fail(result.text);
|
|
2028
2798
|
}
|
|
2029
2799
|
if (/^\/login\b/i.test(prompt.trim())) {
|
|
2030
2800
|
const parsed = tuiLoginArgs(prompt.trim());
|
|
2031
|
-
if (typeof parsed === "string") return `
|
|
2801
|
+
if (typeof parsed === "string") return `model key · ${parsed}`;
|
|
2032
2802
|
await handoff(() =>
|
|
2033
2803
|
runTuiLogin(
|
|
2034
2804
|
parsed.target === "auto" ? "pick" : parsed.target,
|
|
@@ -2038,8 +2808,8 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2038
2808
|
provider = undefined;
|
|
2039
2809
|
await currentProvider();
|
|
2040
2810
|
return providerName === "offline"
|
|
2041
|
-
? "
|
|
2042
|
-
: `
|
|
2811
|
+
? "no agent key · model key adds one · direct commands still work"
|
|
2812
|
+
: `agent key · ${providerName}`;
|
|
2043
2813
|
}
|
|
2044
2814
|
if (/^\/(logout|auth)\b/i.test(prompt.trim())) {
|
|
2045
2815
|
tui.activity.setSpinner(prompt.trim().split(/\s+/)[0]!.slice(1));
|
|
@@ -2054,8 +2824,46 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2054
2824
|
tui.activity.setSpinner(undefined);
|
|
2055
2825
|
}
|
|
2056
2826
|
}
|
|
2827
|
+
// The grammar's second reading (design §3): a line no handler took runs
|
|
2828
|
+
// in its other spellings — `/x` ≡ `x`, aliases, `rm|remove|delete`,
|
|
2829
|
+
// `list|presets|ls` — so only lines that failed before get one.
|
|
2830
|
+
// A line the core prompt parser takes is already understood.
|
|
2831
|
+
if (!recovering && !parsePrompt(prompt)) {
|
|
2832
|
+
const retry = recover(
|
|
2833
|
+
command,
|
|
2834
|
+
(candidate) =>
|
|
2835
|
+
parseExact(candidate, score) !== undefined ||
|
|
2836
|
+
// Window verbs get only the slash toggle, never a listing word.
|
|
2837
|
+
(candidate.startsWith("/") &&
|
|
2838
|
+
SLASH_HANDLED.has(verbOf(candidate)) &&
|
|
2839
|
+
candidate.split(/\s+/).length === command.trim().split(/\s+/).length),
|
|
2840
|
+
);
|
|
2841
|
+
if (retry !== undefined) {
|
|
2842
|
+
recovering = true;
|
|
2843
|
+
try {
|
|
2844
|
+
return await submit(retry);
|
|
2845
|
+
} finally {
|
|
2846
|
+
recovering = false;
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
// Bare `rename`, `fork`, `resume`, `login` take free text: a hint only.
|
|
2851
|
+
const freeText = FREE_TEXT_HINTS[command.toLowerCase()];
|
|
2852
|
+
if (freeText !== undefined) return note(freeText);
|
|
2853
|
+
const elsewhere = elsewhereHint(command);
|
|
2854
|
+
if (elsewhere !== undefined) return note(elsewhere);
|
|
2057
2855
|
// `/model` belongs to its own handler above; every other slash word that
|
|
2058
2856
|
// reached here is unknown or misused, and never a question for the agent.
|
|
2857
|
+
// `/instrument aah` is `instrument aah`; `instrument sing choir` is
|
|
2858
|
+
// `instrument choir`.
|
|
2859
|
+
const singWord = command.match(/^\/?instrument\s+sing\s+([a-z]+)$/i);
|
|
2860
|
+
if (singWord && isSingWord(singWord[1]!.toLowerCase()))
|
|
2861
|
+
return submit(`instrument ${singWord[1]!.toLowerCase()}`);
|
|
2862
|
+
if (/^\/instrument\s/i.test(command) && parsePrompt(command.slice(1)))
|
|
2863
|
+
return submit(command.slice(1));
|
|
2864
|
+
// A synth or effect number out of range names its range.
|
|
2865
|
+
const range = paramRangeError(command);
|
|
2866
|
+
if (range) return fail(range);
|
|
2059
2867
|
if (command.startsWith("/") && !/^\/model\b/i.test(command)) {
|
|
2060
2868
|
const hint = usageHint(command);
|
|
2061
2869
|
return fail(
|
|
@@ -2070,6 +2878,10 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2070
2878
|
// A one-letter slip on a command whose arguments parse stays local.
|
|
2071
2879
|
const fix = typoFix(command, parsesLocally);
|
|
2072
2880
|
if (fix) return fail(`${truncateForCard(command)} · did you mean ${fix}?`);
|
|
2881
|
+
// A grammar verb (not everyday English) never reaches the agent.
|
|
2882
|
+
const verb = verbOf(command);
|
|
2883
|
+
if (knownVerbs().has(verb) && !EVERYDAY_VERBS.has(verb))
|
|
2884
|
+
return fail(usageCard(truncateForCard(command), `/help ${verb}`));
|
|
2073
2885
|
if (process.env.DAWG_AI === "0")
|
|
2074
2886
|
return fail(`unrecognized · ${truncateForCard(command)} · /help`);
|
|
2075
2887
|
await materializeDraft();
|
|
@@ -2099,6 +2911,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2099
2911
|
return parsed.action;
|
|
2100
2912
|
}
|
|
2101
2913
|
if (parsed.type === "set-tempo") {
|
|
2914
|
+
const range = RANGES.tempo!;
|
|
2915
|
+
if (parsed.tempoBpm < range.min || parsed.tempoBpm > range.max)
|
|
2916
|
+
return fail(usageError(truncateForCard(command), range));
|
|
2102
2917
|
const next = score.withTempo(parsed.tempoBpm);
|
|
2103
2918
|
await commitScore(next, "score.tempo", { tempoBpm: parsed.tempoBpm });
|
|
2104
2919
|
clock.follow(next);
|
|
@@ -2126,15 +2941,10 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2126
2941
|
if (parsed.type === "track") {
|
|
2127
2942
|
// An unknown word would store and play a plain sine: say so instead.
|
|
2128
2943
|
const word = parsed.patch.instrument;
|
|
2129
|
-
if (word !== undefined && isUnknownInstrument(word))
|
|
2130
|
-
|
|
2131
|
-
const near = advice.match(/did you mean (\S+)\?/)?.[1];
|
|
2132
|
-
return fail(
|
|
2133
|
-
`instrument ${truncateForCard(word)} · not a dawg instrument${near ? ` · did you mean ${near}?` : ""} · /menu sounds lists them`,
|
|
2134
|
-
);
|
|
2135
|
-
}
|
|
2944
|
+
if (word !== undefined && isUnknownInstrument(word))
|
|
2945
|
+
return fail(unknownInstrumentMessage(word));
|
|
2136
2946
|
// `instrument jangle`: a guitar alias also loads its rig.
|
|
2137
|
-
const
|
|
2947
|
+
const rigged = parsed.word
|
|
2138
2948
|
? {
|
|
2139
2949
|
...parsed.patch,
|
|
2140
2950
|
...rigWordPatch(
|
|
@@ -2143,6 +2953,11 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2143
2953
|
),
|
|
2144
2954
|
}
|
|
2145
2955
|
: parsed.patch;
|
|
2956
|
+
// `instrument vocal` (0.7): the vocal chain fills unset effects.
|
|
2957
|
+
const chain = isGuideInstrument(parsed.patch.instrument)
|
|
2958
|
+
? vocalChainPatch(score.tracks.find((t) => t.id === requestedTrack))
|
|
2959
|
+
: {};
|
|
2960
|
+
const patch = { ...chain, ...rigged };
|
|
2146
2961
|
const next = applyScoreOperation(score, {
|
|
2147
2962
|
type: "updateTrack",
|
|
2148
2963
|
trackId: requestedTrack,
|
|
@@ -2163,6 +2978,13 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2163
2978
|
? plainSineAdvice(word)
|
|
2164
2979
|
: undefined;
|
|
2165
2980
|
if (sine) return `track · ${requestedTrack} · ${sine}`;
|
|
2981
|
+
const added = [
|
|
2982
|
+
chain.filter ? "hpf 90 Hz" : "",
|
|
2983
|
+
chain.fx?.compressor ? "compressor 3:1" : "",
|
|
2984
|
+
chain.reverb ? "plate 0.14" : "",
|
|
2985
|
+
].filter(Boolean);
|
|
2986
|
+
if (added.length)
|
|
2987
|
+
return `track · ${requestedTrack} · vocal · added ${added.join(", ")} · fx … off to remove`;
|
|
2166
2988
|
return `track · ${requestedTrack}`;
|
|
2167
2989
|
}
|
|
2168
2990
|
if (parsed.type === "automation") {
|
|
@@ -2207,6 +3029,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2207
3029
|
return `cleared · ${requestedTrack}`;
|
|
2208
3030
|
}
|
|
2209
3031
|
if (parsed.type === "remove-note") {
|
|
3032
|
+
// A receipt never claims a removal that did not happen.
|
|
3033
|
+
if (!score.notes.some((note) => note.id === parsed.noteId))
|
|
3034
|
+
return fail(noNote(parsed.noteId));
|
|
2210
3035
|
const next = applyScoreOperation(score, {
|
|
2211
3036
|
type: "removeNote",
|
|
2212
3037
|
noteId: parsed.noteId,
|
|
@@ -2324,6 +3149,10 @@ async function focusTrack(trackId: string): Promise<Receipt> {
|
|
|
2324
3149
|
...newPianoTrack(trackId),
|
|
2325
3150
|
// `track vibes`: a 0.6 modal word names the track and its preset.
|
|
2326
3151
|
...(isModalWord(trackId) ? instrumentPatchForWord(trackId) : {}),
|
|
3152
|
+
// `track vocal` (0.7): a clip track with the vocal chain.
|
|
3153
|
+
...(isGuideInstrument(trackId)
|
|
3154
|
+
? { instrument: "vocal", ...vocalChainPatch(undefined) }
|
|
3155
|
+
: {}),
|
|
2327
3156
|
},
|
|
2328
3157
|
});
|
|
2329
3158
|
await commitScore(next, "track.create", { trackId });
|
|
@@ -2624,11 +3453,19 @@ async function packCommand(command: PackCommand): Promise<Receipt> {
|
|
|
2624
3453
|
const store = packs();
|
|
2625
3454
|
const a = await store.pruneCache(to(store.maxFileCacheBytes));
|
|
2626
3455
|
const b = await library.pruneCache(to(library.maxCacheBytes));
|
|
2627
|
-
|
|
3456
|
+
const c = await pruneAnalysisCache(
|
|
3457
|
+
analysisDir(process.cwd()),
|
|
3458
|
+
to(ANALYSIS_CACHE_BYTES),
|
|
3459
|
+
);
|
|
3460
|
+
freed = {
|
|
3461
|
+
files: a.removed + b.removed + c.removed,
|
|
3462
|
+
bytes: a.freed + b.freed + c.freed,
|
|
3463
|
+
};
|
|
2628
3464
|
}
|
|
2629
3465
|
const lines = cacheLines({
|
|
2630
3466
|
packs: await packs().cacheStatus(),
|
|
2631
3467
|
assets: await library.cacheStatus(),
|
|
3468
|
+
analysis: await analysisCacheStatus(process.cwd()),
|
|
2632
3469
|
...(freed ? { freed } : {}),
|
|
2633
3470
|
});
|
|
2634
3471
|
tui.openText("cache", lines);
|
|
@@ -2703,27 +3540,38 @@ async function packCommand(command: PackCommand): Promise<Receipt> {
|
|
|
2703
3540
|
}
|
|
2704
3541
|
}
|
|
2705
3542
|
|
|
3543
|
+
/**
|
|
3544
|
+
* A command result as a receipt: a refusal is ✗, a change is ✓, and a read
|
|
3545
|
+
* that changed nothing (a listing or a show) is •.
|
|
3546
|
+
*/
|
|
3547
|
+
function readOrDone(
|
|
3548
|
+
result: Readonly<{ ok: boolean; message: string; next?: unknown }>,
|
|
3549
|
+
): Receipt {
|
|
3550
|
+
if (!result.ok) return fail(result.message);
|
|
3551
|
+
return result.next ? ok(result.message) : note(result.message);
|
|
3552
|
+
}
|
|
3553
|
+
|
|
2706
3554
|
async function patternCommand(command: PatternCommand): Promise<Receipt> {
|
|
2707
3555
|
if (command.kind === "list") {
|
|
2708
|
-
tui.openText("
|
|
2709
|
-
return
|
|
3556
|
+
tui.openText("grooves", DRUM_PATTERNS.map(patternLine));
|
|
3557
|
+
return note(`grooves · ${DRUM_PATTERNS.length} · groove <name>`);
|
|
2710
3558
|
}
|
|
2711
3559
|
if (command.kind === "browse") {
|
|
2712
3560
|
tui.openPicker({
|
|
2713
3561
|
id: "pattern",
|
|
2714
|
-
title: "
|
|
3562
|
+
title: "grooves",
|
|
2715
3563
|
hint: HINTS.audition,
|
|
2716
3564
|
audition: true,
|
|
2717
3565
|
items: DRUM_PATTERNS.map((entry) => ({
|
|
2718
3566
|
label: `${entry.label.padEnd(22)} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
|
|
2719
|
-
value: `/
|
|
3567
|
+
value: `/groove ${entry.name}`,
|
|
2720
3568
|
})),
|
|
2721
3569
|
filterable: true,
|
|
2722
3570
|
index: 0,
|
|
2723
3571
|
});
|
|
2724
3572
|
patternPreview = undefined;
|
|
2725
3573
|
previewPattern(DRUM_PATTERNS[0]?.name);
|
|
2726
|
-
return
|
|
3574
|
+
return note("grooves · ↑/↓ preview · Enter applies on the focused track");
|
|
2727
3575
|
}
|
|
2728
3576
|
await materializeDraft();
|
|
2729
3577
|
const result = applyDrumPattern(
|
|
@@ -2784,7 +3632,7 @@ function openKitPicker(): Receipt {
|
|
|
2784
3632
|
}));
|
|
2785
3633
|
tui.openPicker({
|
|
2786
3634
|
id: "kit",
|
|
2787
|
-
title: "
|
|
3635
|
+
title: "kits",
|
|
2788
3636
|
hint: HINTS.audition,
|
|
2789
3637
|
audition: true,
|
|
2790
3638
|
items,
|
|
@@ -2816,7 +3664,7 @@ async function kitCommand(command: KitCommand, bare = false): Promise<Receipt> {
|
|
|
2816
3664
|
.map((entry) => `${entry.name} · synth · ${entry.detail}`);
|
|
2817
3665
|
lines.push(...kitListLines(await packs().bankAliases(ALIASED_PACK)));
|
|
2818
3666
|
tui.openText("kits", lines);
|
|
2819
|
-
return
|
|
3667
|
+
return note("kits · /kit <name or nickname> on the focused drum track");
|
|
2820
3668
|
}
|
|
2821
3669
|
await materializeDraft();
|
|
2822
3670
|
const trackId = kitTarget(score, requestedTrack);
|
|
@@ -2927,12 +3775,10 @@ function adoptMeta(meta: typeof record.meta): void {
|
|
|
2927
3775
|
record = { ...record, meta };
|
|
2928
3776
|
if (meta.name !== announcedName) {
|
|
2929
3777
|
announcedName = meta.name;
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
{ tone: "info" },
|
|
2935
|
-
);
|
|
3778
|
+
// The auto-name joins the receipt that earned it, as a quiet suffix.
|
|
3779
|
+
if (meta.nameSource === "auto")
|
|
3780
|
+
tui.activity.attachNote(`named “${meta.name}” · /rename`);
|
|
3781
|
+
else tui.activity.pushCard(`session · ${meta.name}`, { tone: "info" });
|
|
2936
3782
|
}
|
|
2937
3783
|
}
|
|
2938
3784
|
|
|
@@ -3096,11 +3942,13 @@ function menuContext(): MenuContext {
|
|
|
3096
3942
|
committedChords: chordSettings,
|
|
3097
3943
|
},
|
|
3098
3944
|
trackId: requestedTrack,
|
|
3945
|
+
sessionName: record.meta.name,
|
|
3099
3946
|
playing: clock.playing,
|
|
3100
3947
|
grid: session?.grid ?? DEFAULT_GRID,
|
|
3101
3948
|
grids: GRIDS.map((grid) => grid.label),
|
|
3102
3949
|
clickOn: session?.clickOn ?? false,
|
|
3103
3950
|
countInBars: session?.countInBars ?? 1,
|
|
3951
|
+
showMe: showMe.level,
|
|
3104
3952
|
chords: stagedChordSettings() ?? session?.chords.settings ?? chordSettings,
|
|
3105
3953
|
projectRoot: process.cwd(),
|
|
3106
3954
|
};
|
|
@@ -3390,7 +4238,7 @@ async function startAuditionLoop(): Promise<void> {
|
|
|
3390
4238
|
loop.focus(requestedTrack);
|
|
3391
4239
|
// One sound at a time: the song stops while the loop plays.
|
|
3392
4240
|
if (clock.playing) await setTransport("pause");
|
|
3393
|
-
previewEngine().setLeadMs(
|
|
4241
|
+
previewEngine().setLeadMs(previewEngine().playLeadMs);
|
|
3394
4242
|
loop.start();
|
|
3395
4243
|
}
|
|
3396
4244
|
|
|
@@ -3606,12 +4454,17 @@ function refreshMenu(): void {
|
|
|
3606
4454
|
if (fader) {
|
|
3607
4455
|
const fields = menu.faderFields(context);
|
|
3608
4456
|
if (fields.length === 0) closeFader();
|
|
3609
|
-
else
|
|
4457
|
+
else {
|
|
4458
|
+
const focusedField = fields[focusIndex(fader, fields)];
|
|
3610
4459
|
tui.drawer = drawerView(fader, fields, menu.faderCommitted(context), {
|
|
3611
4460
|
title: menu.crumbs,
|
|
3612
4461
|
dirty: context.audition?.dirty ?? false,
|
|
3613
4462
|
status: context.audition?.status,
|
|
4463
|
+
atOnce:
|
|
4464
|
+
focusedField !== undefined &&
|
|
4465
|
+
!stageableNow(faderCommand(focusedField)),
|
|
3614
4466
|
});
|
|
4467
|
+
}
|
|
3615
4468
|
}
|
|
3616
4469
|
const view = menu.view(context);
|
|
3617
4470
|
tui.openPicker({
|
|
@@ -3629,6 +4482,73 @@ function refreshMenu(): void {
|
|
|
3629
4482
|
// ── the fader drawer ─────────────────────────────────────────────────
|
|
3630
4483
|
|
|
3631
4484
|
/** Enter on a number row (or a bare `volume`, `fx filter`): the drawer. */
|
|
4485
|
+
/**
|
|
4486
|
+
* `export song.wav [stems]`: render the song as `dawg render song.wav` would
|
|
4487
|
+
* (the offline renderer, deterministic), then with `stems` one WAV per
|
|
4488
|
+
* audible track beside it (`song-bass.wav`), each that track soloed.
|
|
4489
|
+
*/
|
|
4490
|
+
async function exportWav(path: string, stems: boolean): Promise<Receipt> {
|
|
4491
|
+
const directory = await mkdtemp(join(tmpdir(), "dawg-export-"));
|
|
4492
|
+
const errors: string[] = [];
|
|
4493
|
+
const sink = { write: (text: string) => errors.push(text.trim()) };
|
|
4494
|
+
const quiet = { write: () => undefined };
|
|
4495
|
+
const render = async (song: TrackScore, target: string): Promise<boolean> => {
|
|
4496
|
+
const file = join(directory, "song.track.json");
|
|
4497
|
+
await writeFile(file, encodeLoop(song), "utf8");
|
|
4498
|
+
const code = await runRenderCommand(
|
|
4499
|
+
["render", target, "--import", file],
|
|
4500
|
+
process.cwd(),
|
|
4501
|
+
quiet,
|
|
4502
|
+
sink,
|
|
4503
|
+
);
|
|
4504
|
+
return code === 0;
|
|
4505
|
+
};
|
|
4506
|
+
tui.activity.setSpinner(`export ${path}`);
|
|
4507
|
+
try {
|
|
4508
|
+
if (!(await render(score, path)))
|
|
4509
|
+
return fail(errors.at(-1) ?? `export failed · ${path}`);
|
|
4510
|
+
if (!stems) return ok(`exported · ${path}`);
|
|
4511
|
+
const written: string[] = [];
|
|
4512
|
+
const base = path.replace(/\.wav$/i, "");
|
|
4513
|
+
for (const track of score.tracks) {
|
|
4514
|
+
if (!isTrackAudible(score, track.id)) continue;
|
|
4515
|
+
let solo = score;
|
|
4516
|
+
for (const other of score.tracks)
|
|
4517
|
+
solo = applyScoreOperation(solo, {
|
|
4518
|
+
type: "updateTrack",
|
|
4519
|
+
trackId: other.id,
|
|
4520
|
+
patch: { solo: other.id === track.id },
|
|
4521
|
+
});
|
|
4522
|
+
const target = `${base}-${track.id}.wav`;
|
|
4523
|
+
if (!(await render(solo, target)))
|
|
4524
|
+
return fail(errors.at(-1) ?? `export failed · ${target}`);
|
|
4525
|
+
written.push(target);
|
|
4526
|
+
}
|
|
4527
|
+
return ok(stemsReceipt(path, written));
|
|
4528
|
+
} finally {
|
|
4529
|
+
tui.activity.setSpinner(undefined);
|
|
4530
|
+
await rm(directory, { recursive: true, force: true });
|
|
4531
|
+
}
|
|
4532
|
+
}
|
|
4533
|
+
|
|
4534
|
+
/** `tempo`, `bars`, `meter`: open Project and name its fader, if it has one. */
|
|
4535
|
+
function songFader(context: MenuContext, command: string): string | undefined {
|
|
4536
|
+
// Bare song scalars and the Project fader each opens.
|
|
4537
|
+
const faders: Readonly<Record<string, string>> = {
|
|
4538
|
+
tempo: "tempo",
|
|
4539
|
+
bpm: "tempo",
|
|
4540
|
+
bars: "loop length",
|
|
4541
|
+
meter: "beats per bar",
|
|
4542
|
+
};
|
|
4543
|
+
const label = faders[command.replace(/^\//, "").toLowerCase()];
|
|
4544
|
+
if (!label) return undefined;
|
|
4545
|
+
menu.show(context, "project");
|
|
4546
|
+
if (menu.faderFields(context).some((field) => field.label === label))
|
|
4547
|
+
return label;
|
|
4548
|
+
menu.close();
|
|
4549
|
+
return undefined;
|
|
4550
|
+
}
|
|
4551
|
+
|
|
3632
4552
|
/**
|
|
3633
4553
|
* Open the drawer on `label`. `standalone` drawers came from a command or a
|
|
3634
4554
|
* click, not from inside the menu, so closing one closes the menu too.
|
|
@@ -3731,7 +4651,7 @@ function mouseInput(event: MouseEvent): string[] {
|
|
|
3731
4651
|
return [];
|
|
3732
4652
|
}
|
|
3733
4653
|
if (fader) return [];
|
|
3734
|
-
if (tui.ui.overlay) return [event.delta < 0 ?
|
|
4654
|
+
if (tui.ui.overlay) return [event.delta < 0 ? ARROW_UP : ARROW_DOWN];
|
|
3735
4655
|
return [];
|
|
3736
4656
|
}
|
|
3737
4657
|
// A drag keeps moving the fader it started on, even past the bar's ends.
|
|
@@ -3821,7 +4741,9 @@ function mouseInput(event: MouseEvent): string[] {
|
|
|
3821
4741
|
}
|
|
3822
4742
|
case "transport":
|
|
3823
4743
|
if (tui.ui.overlay) return [];
|
|
3824
|
-
void toggleTransport()
|
|
4744
|
+
void toggleTransport()
|
|
4745
|
+
.catch((error: unknown) => transportFailed(error))
|
|
4746
|
+
.finally(() => requestFrame());
|
|
3825
4747
|
return [];
|
|
3826
4748
|
case "tracks":
|
|
3827
4749
|
if (tui.ui.overlay) return [];
|
|
@@ -3852,13 +4774,13 @@ function playSession(): PlaySession {
|
|
|
3852
4774
|
}
|
|
3853
4775
|
|
|
3854
4776
|
async function enterPlay(): Promise<Receipt> {
|
|
3855
|
-
if (play?.on) return ok(`play · ${play.track} · esc leaves`);
|
|
4777
|
+
if (play?.on) return ok(`play mode · ${play.track} · esc leaves`);
|
|
3856
4778
|
await materializeDraft();
|
|
3857
4779
|
const session = playSession();
|
|
3858
4780
|
await session.enter();
|
|
3859
4781
|
if (hasSamplerTracks(score)) void sampleProblems(score);
|
|
3860
4782
|
return ok(
|
|
3861
|
-
`play · ${session.track} · ${session.keyboard.range} · ? keys · esc leaves`,
|
|
4783
|
+
`play mode · ${session.track} · ${session.layout.drums ? "kit" : session.keyboard.range} · ? keys · esc leaves`,
|
|
3862
4784
|
);
|
|
3863
4785
|
}
|
|
3864
4786
|
|
|
@@ -3888,9 +4810,7 @@ function playKey(value: string): boolean {
|
|
|
3888
4810
|
else if (result.command === "transport") {
|
|
3889
4811
|
if (!session.startWithCountIn())
|
|
3890
4812
|
void toggleTransport().catch((error: unknown) =>
|
|
3891
|
-
|
|
3892
|
-
`transport failed · ${error instanceof Error ? error.message : String(error)}`,
|
|
3893
|
-
),
|
|
4813
|
+
transportFailed(error),
|
|
3894
4814
|
);
|
|
3895
4815
|
} else if (result.command === "menu") return false;
|
|
3896
4816
|
return true;
|
|
@@ -3952,13 +4872,18 @@ function playHost() {
|
|
|
3952
4872
|
async stopTransport(): Promise<void> {
|
|
3953
4873
|
await setTransport("pause");
|
|
3954
4874
|
},
|
|
4875
|
+
ghost(pitch: number) {
|
|
4876
|
+
tui.delight.ghost(pitch, Date.now());
|
|
4877
|
+
requestFrame();
|
|
4878
|
+
},
|
|
3955
4879
|
card(text: string, tone: "info" | "success" | "warning" | "error") {
|
|
3956
4880
|
if (tone === "error") tui.activity.pushError(text);
|
|
3957
4881
|
else
|
|
3958
4882
|
tui.activity.pushCard(text, {
|
|
3959
4883
|
tone,
|
|
3960
4884
|
trackId: requestedTrack,
|
|
3961
|
-
resultRevision:
|
|
4885
|
+
resultRevision:
|
|
4886
|
+
tone === "success" ? shownRevision(record) : undefined,
|
|
3962
4887
|
});
|
|
3963
4888
|
},
|
|
3964
4889
|
newNoteId: () => randomUUID().slice(0, 12),
|
|
@@ -4005,6 +4930,9 @@ function timingChanged(previous: TrackScore, next: TrackScore): boolean {
|
|
|
4005
4930
|
function syncHost(): SyncHost {
|
|
4006
4931
|
return {
|
|
4007
4932
|
project: process.cwd(),
|
|
4933
|
+
// The files belong to one session at a time; another session's window
|
|
4934
|
+
// stays detached instead of reprinting over them.
|
|
4935
|
+
sessionId: () => record.sessionId,
|
|
4008
4936
|
current: () => score,
|
|
4009
4937
|
async commit(plan, summary) {
|
|
4010
4938
|
const baseRevision = record.revision;
|
|
@@ -4030,7 +4958,8 @@ function syncHost(): SyncHost {
|
|
|
4030
4958
|
tui.activity.pushCard(text, {
|
|
4031
4959
|
tone,
|
|
4032
4960
|
hint,
|
|
4033
|
-
resultRevision:
|
|
4961
|
+
resultRevision:
|
|
4962
|
+
tone === "success" ? shownRevision(record) : undefined,
|
|
4034
4963
|
});
|
|
4035
4964
|
},
|
|
4036
4965
|
types(state) {
|
|
@@ -4061,10 +4990,17 @@ async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
|
|
|
4061
4990
|
},
|
|
4062
4991
|
restored.toJSON(),
|
|
4063
4992
|
);
|
|
4993
|
+
// Compare with what was undone, which another window may have written.
|
|
4994
|
+
const before = scoreFromJSON(latest.composition);
|
|
4064
4995
|
score = restored;
|
|
4065
4996
|
if (clock.playing) void audio.play(score);
|
|
4066
4997
|
return ok(
|
|
4067
|
-
|
|
4998
|
+
historyReceipt(
|
|
4999
|
+
direction,
|
|
5000
|
+
before,
|
|
5001
|
+
restored,
|
|
5002
|
+
shownRevision(record, target.revision),
|
|
5003
|
+
),
|
|
4068
5004
|
);
|
|
4069
5005
|
} catch (error) {
|
|
4070
5006
|
if (error instanceof SessionConflictError)
|
|
@@ -4075,7 +5011,36 @@ async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
|
|
|
4075
5011
|
}
|
|
4076
5012
|
}
|
|
4077
5013
|
|
|
4078
|
-
/**
|
|
5014
|
+
/** Space, the menu and a header click all report a failed toggle the same way. */
|
|
5015
|
+
function transportFailed(error: unknown): void {
|
|
5016
|
+
tui.activity.pushCard(
|
|
5017
|
+
`transport failed · ${error instanceof Error ? error.message : String(error)}`,
|
|
5018
|
+
{ tone: "error" },
|
|
5019
|
+
);
|
|
5020
|
+
}
|
|
5021
|
+
|
|
5022
|
+
/**
|
|
5023
|
+
* The revision dawg shows (header, receipts, undo): edits to the song.
|
|
5024
|
+
* Play and pause are logged so other windows follow them, but they are not
|
|
5025
|
+
* edits, so they never move the number: a revision shows as itself minus
|
|
5026
|
+
* the transport events at or before it.
|
|
5027
|
+
*/
|
|
5028
|
+
function shownRevision(
|
|
5029
|
+
value: typeof record,
|
|
5030
|
+
revision = value.revision,
|
|
5031
|
+
): number {
|
|
5032
|
+
let transports = 0;
|
|
5033
|
+
for (const event of value.events)
|
|
5034
|
+
if (event.revision <= revision && event.kind === "transport")
|
|
5035
|
+
transports += 1;
|
|
5036
|
+
return revision - transports;
|
|
5037
|
+
}
|
|
5038
|
+
|
|
5039
|
+
/**
|
|
5040
|
+
* The space-bar toggle: flip the transport, then record it for other
|
|
5041
|
+
* windows. The log entry keeps windows in step; the header's revision
|
|
5042
|
+
* (`shownRevision`) skips it, since play/pause is not an edit.
|
|
5043
|
+
*/
|
|
4079
5044
|
async function toggleTransport(): Promise<void> {
|
|
4080
5045
|
await setTransport("toggle");
|
|
4081
5046
|
try {
|
|
@@ -4145,11 +5110,11 @@ async function runAgent(text: string): Promise<string | Receipt> {
|
|
|
4145
5110
|
if (agentTurn) return warn("agent busy · Esc cancels");
|
|
4146
5111
|
const selection = await currentProvider();
|
|
4147
5112
|
if (selection.kind === "offline")
|
|
4148
|
-
return fail(
|
|
4149
|
-
`unrecognized · ${truncateForCard(text)} · ${selection.reason}`,
|
|
4150
|
-
);
|
|
5113
|
+
return fail(`${truncateForCard(text)} · ${NO_AGENT}`);
|
|
4151
5114
|
const turn = { controller: new AbortController(), steering: [] as string[] };
|
|
4152
5115
|
agentTurn = turn;
|
|
5116
|
+
// The turn ends on one musical receipt of what it changed.
|
|
5117
|
+
const before = score;
|
|
4153
5118
|
reportAgentActivity(`${providerName} · thinking…`);
|
|
4154
5119
|
// xcb admits a pending account on its first call, which takes longer.
|
|
4155
5120
|
let admitting = selection.kind === "xcb" && selection.admissionPending;
|
|
@@ -4182,6 +5147,8 @@ async function runAgent(text: string): Promise<string | Receipt> {
|
|
|
4182
5147
|
}
|
|
4183
5148
|
return;
|
|
4184
5149
|
}
|
|
5150
|
+
if (event.type === "done")
|
|
5151
|
+
tui.activity.setTurnReceipt(musicalReceipt(before, score));
|
|
4185
5152
|
agentEventSink(event);
|
|
4186
5153
|
},
|
|
4187
5154
|
});
|
|
@@ -4213,7 +5180,7 @@ function currentProvider(): Promise<ProviderSelection> {
|
|
|
4213
5180
|
provider = selectProvider().catch((): ProviderSelection => ({
|
|
4214
5181
|
kind: "offline",
|
|
4215
5182
|
choice: "auto",
|
|
4216
|
-
reason: "provider unavailable; run `dawg
|
|
5183
|
+
reason: "provider unavailable; run `dawg model key`",
|
|
4217
5184
|
}));
|
|
4218
5185
|
}
|
|
4219
5186
|
return provider.then((selection) => {
|
|
@@ -4225,9 +5192,9 @@ function currentProvider(): Promise<ProviderSelection> {
|
|
|
4225
5192
|
!invalidNoticeShown
|
|
4226
5193
|
) {
|
|
4227
5194
|
invalidNoticeShown = true;
|
|
4228
|
-
tui.activity.pushCard(`
|
|
5195
|
+
tui.activity.pushCard(`agent key stopped working · ${selection.reason}`, {
|
|
4229
5196
|
tone: "warning",
|
|
4230
|
-
hint: "/
|
|
5197
|
+
hint: "/model key",
|
|
4231
5198
|
});
|
|
4232
5199
|
}
|
|
4233
5200
|
providerStamp.offline = selection.kind === "offline";
|
|
@@ -4302,6 +5269,180 @@ function agentPreviewHost(): PreviewHost {
|
|
|
4302
5269
|
};
|
|
4303
5270
|
}
|
|
4304
5271
|
|
|
5272
|
+
// ── show-me ──────────────────────────────────────────────────────────
|
|
5273
|
+
|
|
5274
|
+
/**
|
|
5275
|
+
* Show-me (docs/show-me.md): with an API provider the agent writes prompt
|
|
5276
|
+
* commands, streamed. The line being written is ghost text in the empty
|
|
5277
|
+
* prompt bar at the model's own speed; each complete line runs through
|
|
5278
|
+
* `submit()`, the path a typed Enter takes, at once. A parameter value
|
|
5279
|
+
* glides there over ~150 ms while the loop plays (you hear it), notes and
|
|
5280
|
+
* hits sound as they arrive (in time when the stream is ahead of the tempo,
|
|
5281
|
+
* as step entry when it is behind), and the caption names the key or fader
|
|
5282
|
+
* a person would use. Nothing waits for the turn to end.
|
|
5283
|
+
*/
|
|
5284
|
+
|
|
5285
|
+
function showMeTyping(text: string): void {
|
|
5286
|
+
showMe.ghost = text || undefined;
|
|
5287
|
+
if (text && showMe.level === "on") showMe.caption = undefined;
|
|
5288
|
+
requestFrame();
|
|
5289
|
+
}
|
|
5290
|
+
|
|
5291
|
+
function showMeCaption(text: string | undefined): void {
|
|
5292
|
+
if (showMe.level !== "on") return;
|
|
5293
|
+
showMe.caption = text;
|
|
5294
|
+
if (showMe.clear) clearTimeout(showMe.clear);
|
|
5295
|
+
showMe.clear = undefined;
|
|
5296
|
+
requestFrame();
|
|
5297
|
+
}
|
|
5298
|
+
|
|
5299
|
+
/** End of a turn: the ghost goes, the caption becomes the do-it-yourself hint. */
|
|
5300
|
+
function showMeFinish(): void {
|
|
5301
|
+
showMe.ghost = undefined;
|
|
5302
|
+
showMe.notes.reset();
|
|
5303
|
+
const hint = finishHint(showMe.commands);
|
|
5304
|
+
showMe.commands = [];
|
|
5305
|
+
if (hint && showMe.level === "on") {
|
|
5306
|
+
showMeCaption(hint);
|
|
5307
|
+
showMe.clear = setTimeout(() => {
|
|
5308
|
+
showMe.caption = undefined;
|
|
5309
|
+
showMe.clear = undefined;
|
|
5310
|
+
requestFrame();
|
|
5311
|
+
}, 8_000);
|
|
5312
|
+
} else {
|
|
5313
|
+
showMe.caption = undefined;
|
|
5314
|
+
requestFrame();
|
|
5315
|
+
}
|
|
5316
|
+
}
|
|
5317
|
+
|
|
5318
|
+
/**
|
|
5319
|
+
* Slide a fader to its new value the way the drawer does: each eased step is
|
|
5320
|
+
* the field's own command, staged on a scratch score and played, so the loop
|
|
5321
|
+
* moves through the values. Only while the loop plays (otherwise nothing is
|
|
5322
|
+
* heard and the glide would only cost time); never longer than ~150 ms.
|
|
5323
|
+
*/
|
|
5324
|
+
async function glideFader(param: string, target: number): Promise<void> {
|
|
5325
|
+
if (!clock.playing || !stdout.isTTY) return;
|
|
5326
|
+
const scratch = new EditMenu();
|
|
5327
|
+
const context = { ...menuContext(), score };
|
|
5328
|
+
const label = scratch.showFader(context, param);
|
|
5329
|
+
if (!label) return;
|
|
5330
|
+
const field = scratch
|
|
5331
|
+
.faderFields(context)
|
|
5332
|
+
.find((candidate) => candidate.label === label);
|
|
5333
|
+
scratch.close();
|
|
5334
|
+
if (field?.kind !== "number") return;
|
|
5335
|
+
const from = field.value ?? field.start ?? target;
|
|
5336
|
+
const steps = glideValues(from, target).slice(0, -1);
|
|
5337
|
+
for (const value of steps) {
|
|
5338
|
+
const staged = await applyStaged(score, field.command(value));
|
|
5339
|
+
if (staged.next && clock.playing) void audio.play(staged.next);
|
|
5340
|
+
await Bun.sleep(GLIDE_STEP_MS);
|
|
5341
|
+
}
|
|
5342
|
+
}
|
|
5343
|
+
|
|
5344
|
+
/** Sound one streamed note on the audition voice (the loop is stopped). */
|
|
5345
|
+
function soundStreamedNote(
|
|
5346
|
+
value: TrackScore,
|
|
5347
|
+
trackId: string,
|
|
5348
|
+
pitch: number,
|
|
5349
|
+
startBeat: number,
|
|
5350
|
+
): void {
|
|
5351
|
+
if (clock.playing) return; // The loop itself plays it.
|
|
5352
|
+
const engine = liveEngine();
|
|
5353
|
+
if (!engine?.canMonitor) return;
|
|
5354
|
+
const tick = Math.round(startBeat * value.ticksPerBeat);
|
|
5355
|
+
const note = value.notes.find(
|
|
5356
|
+
(candidate) =>
|
|
5357
|
+
candidate.trackId === trackId &&
|
|
5358
|
+
candidate.pitch === pitch &&
|
|
5359
|
+
candidate.startTick === tick,
|
|
5360
|
+
);
|
|
5361
|
+
if (!note) return;
|
|
5362
|
+
try {
|
|
5363
|
+
const json = value.toJSON() as unknown as Record<string, unknown>;
|
|
5364
|
+
const single = scoreFromJSON({
|
|
5365
|
+
...json,
|
|
5366
|
+
notes: [{ ...note, startTick: 0 }],
|
|
5367
|
+
});
|
|
5368
|
+
const pcm = renderAudition({
|
|
5369
|
+
score: single,
|
|
5370
|
+
trackId,
|
|
5371
|
+
sampleRate: engine.sampleRate,
|
|
5372
|
+
...(liveSampleBank ? { samples: liveSampleBank } : {}),
|
|
5373
|
+
});
|
|
5374
|
+
if (!pcm) return;
|
|
5375
|
+
void engine
|
|
5376
|
+
.monitor(true)
|
|
5377
|
+
.then(() => engine.noteOn(AUDITION_VOICE, pcm))
|
|
5378
|
+
.catch(() => undefined);
|
|
5379
|
+
} catch {
|
|
5380
|
+
// A note that cannot render is still in the score.
|
|
5381
|
+
}
|
|
5382
|
+
}
|
|
5383
|
+
|
|
5384
|
+
/** The command host the show-me agent loop runs lines through. */
|
|
5385
|
+
function showMeCommandHost(): AgentHost["commands"] {
|
|
5386
|
+
return {
|
|
5387
|
+
isCommand: (line) =>
|
|
5388
|
+
isAgentCommand(line, score) || isBrokenCommand(line, score),
|
|
5389
|
+
async run(line): Promise<CommandOutcome> {
|
|
5390
|
+
if (!agentPathsAllowed(line)) {
|
|
5391
|
+
const revision = shownRevision(record);
|
|
5392
|
+
return {
|
|
5393
|
+
ok: false,
|
|
5394
|
+
message: `${line} · the agent works only inside this folder`,
|
|
5395
|
+
baseRevision: revision,
|
|
5396
|
+
resultRevision: revision,
|
|
5397
|
+
};
|
|
5398
|
+
}
|
|
5399
|
+
if (isBrokenCommand(line, score)) {
|
|
5400
|
+
// A command that does not parse is a red receipt, never prose.
|
|
5401
|
+
const base = baseline();
|
|
5402
|
+
receipt(fail(brokenCommandReceipt(line)), base);
|
|
5403
|
+
return {
|
|
5404
|
+
ok: false,
|
|
5405
|
+
message: brokenCommandReceipt(line),
|
|
5406
|
+
baseRevision: base.revision,
|
|
5407
|
+
resultRevision: base.revision,
|
|
5408
|
+
};
|
|
5409
|
+
}
|
|
5410
|
+
const gesture = gestureFor(line, { score, trackId: requestedTrack });
|
|
5411
|
+
showMeCaption(gesture.caption);
|
|
5412
|
+
showMe.commands.push(line);
|
|
5413
|
+
if (gesture.kind === "fader" && showMe.level === "on")
|
|
5414
|
+
await glideFader(gesture.param, gesture.value);
|
|
5415
|
+
const base = baseline();
|
|
5416
|
+
tui.activity.pushNote(`agent › ${line}`, "request");
|
|
5417
|
+
let result: string | Receipt;
|
|
5418
|
+
try {
|
|
5419
|
+
result = await submit(line);
|
|
5420
|
+
} catch (error) {
|
|
5421
|
+
result = fail(describeError(line, error));
|
|
5422
|
+
}
|
|
5423
|
+
receipt(result, base);
|
|
5424
|
+
if (gesture.kind === "keys" && showMe.level === "on") {
|
|
5425
|
+
const trackId = requestedTrack;
|
|
5426
|
+
const after = score;
|
|
5427
|
+
for (const note of gesture.notes) {
|
|
5428
|
+
const { atMs } = showMe.notes.schedule(note.start, performance.now());
|
|
5429
|
+
const delay = Math.max(0, atMs - performance.now());
|
|
5430
|
+
setTimeout(
|
|
5431
|
+
() => soundStreamedNote(after, trackId, note.pitch, note.start),
|
|
5432
|
+
delay,
|
|
5433
|
+
);
|
|
5434
|
+
}
|
|
5435
|
+
}
|
|
5436
|
+
return {
|
|
5437
|
+
ok: toneOf(result) !== "error",
|
|
5438
|
+
message: typeof result === "string" ? result : result.text,
|
|
5439
|
+
baseRevision: shownRevision(record, base.revision),
|
|
5440
|
+
resultRevision: shownRevision(record),
|
|
5441
|
+
};
|
|
5442
|
+
},
|
|
5443
|
+
};
|
|
5444
|
+
}
|
|
5445
|
+
|
|
4305
5446
|
function agentHost(
|
|
4306
5447
|
turn: { steering: string[] },
|
|
4307
5448
|
selection: ProviderSelection,
|
|
@@ -4376,6 +5517,9 @@ function agentHost(
|
|
|
4376
5517
|
}
|
|
4377
5518
|
},
|
|
4378
5519
|
takeSteering: () => turn.steering.splice(0),
|
|
5520
|
+
...(showMe.level !== "off" && stdout.isTTY
|
|
5521
|
+
? { commands: showMeCommandHost() }
|
|
5522
|
+
: {}),
|
|
4379
5523
|
workspace: { root: process.cwd() },
|
|
4380
5524
|
// A written project source is applied before the tool result returns,
|
|
4381
5525
|
// so the model reads the outcome and any type errors in the same step.
|