@hraness/dawg 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -0
- package/DAWG.md +454 -140
- package/README.md +30 -26
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +184 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/ids.ts +52 -0
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +8 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/range.ts +581 -0
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +955 -14
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +287 -5
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +2132 -46
- package/core/sections.ts +480 -35
- 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 +31 -0
- package/guides/audio.md +31 -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 +15 -7
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/panes.md +32 -0
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +26 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +18 -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/tape.md +32 -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 +6 -2
- package/src/agent/agent.ts +130 -18
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +378 -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 +27 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +25 -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/range-tools.ts +216 -0
- 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 +110 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +13 -10
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +49 -5
- package/src/audio/audio-command.ts +218 -0
- 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/devices.ts +264 -0
- package/src/audio/doctor.ts +157 -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 +360 -40
- 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 +82 -32
- 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 +560 -0
- package/src/commands/help.ts +774 -366
- package/src/commands/history.ts +139 -10
- package/src/commands/keys.ts +25 -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 +135 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +474 -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 +617 -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/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +511 -0
- package/src/launch-args.ts +267 -0
- package/src/main.ts +2508 -308
- 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/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +199 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +174 -0
- package/src/session/port.ts +109 -14
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +365 -7
- package/src/session/rebase.ts +63 -12
- package/src/session/receipt.ts +265 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +126 -45
- package/src/tui/arrange-menu.ts +225 -23
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +113 -27
- package/src/tui/fader.ts +282 -42
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- 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 +987 -179
- 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 +243 -28
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -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 +491 -84
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +193 -0
- package/tui/drawer.ts +233 -27
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +143 -65
- package/tui/guide.ts +50 -5
- package/tui/highway.ts +309 -30
- package/tui/hints.ts +197 -0
- package/tui/hits.ts +7 -1
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +80 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +151 -18
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +46 -1
package/src/main.ts
CHANGED
|
@@ -1,11 +1,46 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { TOPIC_ALIASES } from "./lang/glossary.ts";
|
|
3
|
+
import { isGuideInstrument, vocalChainPatch } from "../core/clips.ts";
|
|
4
|
+
import { newId } from "../core/ids.ts";
|
|
5
|
+
import { deepEqual, DiffError, diffScores } from "../core/diff.ts";
|
|
6
|
+
import { currentActor } from "./identity/actor.ts";
|
|
7
|
+
import { isSingWord } from "../core/sing.ts";
|
|
8
|
+
import { commandParses, parseExact } from "./commands/parses.ts";
|
|
9
|
+
import { paramRangeError } from "./commands/param-range.ts";
|
|
10
|
+
import { noteName as midiNoteName } from "./media/notes.ts";
|
|
2
11
|
import {
|
|
12
|
+
EVERYDAY_VERBS,
|
|
13
|
+
EXPORT_USAGE,
|
|
14
|
+
elsewhereHint,
|
|
15
|
+
FREE_TEXT_HINTS,
|
|
16
|
+
noTrack,
|
|
17
|
+
stemsReceipt,
|
|
18
|
+
LOOP_USAGE,
|
|
19
|
+
WINDOW_VERBS,
|
|
20
|
+
NO_AGENT,
|
|
21
|
+
RANGES,
|
|
22
|
+
canonicalWindowForm,
|
|
23
|
+
friendlyCoreError,
|
|
24
|
+
knownVerbs,
|
|
25
|
+
nearest,
|
|
26
|
+
noNote,
|
|
27
|
+
parseExportCommand,
|
|
28
|
+
parseLoopCommand,
|
|
29
|
+
recover,
|
|
30
|
+
usageCard,
|
|
31
|
+
usageError,
|
|
32
|
+
verbOf,
|
|
33
|
+
} from "./commands/grammar.ts";
|
|
34
|
+
import {
|
|
35
|
+
instrumentListLines,
|
|
3
36
|
isUnknownInstrument,
|
|
4
37
|
plainSineAdvice,
|
|
38
|
+
unknownInstrumentMessage,
|
|
5
39
|
} from "./audio/instrument-check.ts";
|
|
6
40
|
import { randomUUID } from "node:crypto";
|
|
7
|
-
import { readFileSync, writeSync } from "node:fs";
|
|
8
|
-
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
41
|
+
import { appendFileSync, readFileSync, writeSync } from "node:fs";
|
|
42
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
43
|
+
import { tmpdir } from "node:os";
|
|
9
44
|
import { join, relative, resolve } from "node:path";
|
|
10
45
|
import { stdin, stdout } from "node:process";
|
|
11
46
|
import {
|
|
@@ -14,7 +49,19 @@ import {
|
|
|
14
49
|
type SessionRecord,
|
|
15
50
|
} from "./session/store.ts";
|
|
16
51
|
import { openSessionPort } from "./session/port.ts";
|
|
17
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
OwnWrites,
|
|
54
|
+
editedTrack,
|
|
55
|
+
foreignEvents,
|
|
56
|
+
otherWindowName,
|
|
57
|
+
paneLetters,
|
|
58
|
+
} from "./session/origin.ts";
|
|
59
|
+
import {
|
|
60
|
+
compositionDigest,
|
|
61
|
+
monotonicEpochMs,
|
|
62
|
+
type PaneView,
|
|
63
|
+
type PresenceEntry,
|
|
64
|
+
} from "./session/protocol.ts";
|
|
18
65
|
import {
|
|
19
66
|
formatSessionLine,
|
|
20
67
|
listSessions,
|
|
@@ -49,6 +96,7 @@ import { kitCatalog } from "./audio/kits.ts";
|
|
|
49
96
|
import { applyEditCommand, parseEditCommand } from "./commands/edit.ts";
|
|
50
97
|
import { applyTimeCommand, parseTimeCommand } from "./commands/time.ts";
|
|
51
98
|
import {
|
|
99
|
+
barAt,
|
|
52
100
|
barStartTick,
|
|
53
101
|
bpmAtTick,
|
|
54
102
|
hasMeterChanges,
|
|
@@ -69,6 +117,10 @@ import {
|
|
|
69
117
|
rigTrackFields,
|
|
70
118
|
rigWordPatch,
|
|
71
119
|
} from "./commands/rig.ts";
|
|
120
|
+
import {
|
|
121
|
+
applyProgressionCommand,
|
|
122
|
+
parseProgressionCommand,
|
|
123
|
+
} from "./commands/progression.ts";
|
|
72
124
|
import {
|
|
73
125
|
applyGuitarCommand,
|
|
74
126
|
applyStrumCommand,
|
|
@@ -85,6 +137,11 @@ import {
|
|
|
85
137
|
parseWindCommand,
|
|
86
138
|
windListLines,
|
|
87
139
|
} from "./commands/wind.ts";
|
|
140
|
+
import {
|
|
141
|
+
applySingCommand,
|
|
142
|
+
parseSingCommand,
|
|
143
|
+
singListLines,
|
|
144
|
+
} from "./commands/sing.ts";
|
|
88
145
|
import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
|
|
89
146
|
import { applyStringCommand, parseStringCommand } from "./commands/string.ts";
|
|
90
147
|
import { instrumentPatchForWord, isModalWord } from "../core/resonators.ts";
|
|
@@ -98,6 +155,7 @@ import {
|
|
|
98
155
|
applyKeysCommand,
|
|
99
156
|
newPianoTrack,
|
|
100
157
|
parseKeysCommand,
|
|
158
|
+
parseRecordCommand,
|
|
101
159
|
} from "./commands/keys.ts";
|
|
102
160
|
import {
|
|
103
161
|
applyTuningCommand,
|
|
@@ -105,26 +163,45 @@ import {
|
|
|
105
163
|
parseTuningCommand,
|
|
106
164
|
type TuningCommand,
|
|
107
165
|
} from "./commands/tuning.ts";
|
|
166
|
+
import {
|
|
167
|
+
applyCalibrationCommand,
|
|
168
|
+
parseCalibrationCommand,
|
|
169
|
+
} from "./commands/calibration.ts";
|
|
108
170
|
import { TuningError, displayTag, resolveTuning } from "../core/tuning.ts";
|
|
109
171
|
import {
|
|
110
172
|
applyMasterCommand,
|
|
111
173
|
measurementLine,
|
|
112
174
|
parseMasterCommand,
|
|
113
175
|
} from "./commands/master.ts";
|
|
176
|
+
import { applyStyleCommand, parseStyleCommand } from "./commands/style.ts";
|
|
114
177
|
import { exportSampleRate, measureScoreOffThread } from "./audio/measure.ts";
|
|
115
178
|
import {
|
|
116
179
|
applySectionCommand,
|
|
180
|
+
loopSpan,
|
|
117
181
|
parseSectionCommand,
|
|
118
182
|
} from "./commands/arrange.ts";
|
|
119
183
|
import {
|
|
120
|
-
|
|
184
|
+
applyRangeCommand,
|
|
185
|
+
parseRangeCommand,
|
|
186
|
+
type RangeClipboard,
|
|
187
|
+
} from "./commands/range.ts";
|
|
188
|
+
import { rangeLabel } from "../core/range.ts";
|
|
189
|
+
import {
|
|
190
|
+
helpMiss,
|
|
121
191
|
helpText,
|
|
192
|
+
helpTitle,
|
|
122
193
|
helpTopicLines,
|
|
123
194
|
nearestCommand,
|
|
124
195
|
typoFix,
|
|
125
196
|
usageHint,
|
|
126
197
|
} from "./commands/help.ts";
|
|
127
|
-
import {
|
|
198
|
+
import {
|
|
199
|
+
historyReceipt,
|
|
200
|
+
historyTarget,
|
|
201
|
+
paneHistoryStep,
|
|
202
|
+
REDO_KIND,
|
|
203
|
+
UNDO_KIND,
|
|
204
|
+
} from "./commands/history.ts";
|
|
128
205
|
|
|
129
206
|
import {
|
|
130
207
|
SamplePlacementError,
|
|
@@ -139,6 +216,30 @@ import {
|
|
|
139
216
|
} from "./commands/sample.ts";
|
|
140
217
|
import { applyFitCommand, fitVoice, parseFitCommand } from "./commands/fit.ts";
|
|
141
218
|
import { applyShiftCommand, parseShiftCommand } from "./commands/shift.ts";
|
|
219
|
+
import { parseVocalCommand, runVocalCommand } from "./commands/vocal.ts";
|
|
220
|
+
import {
|
|
221
|
+
applyFormantCommand,
|
|
222
|
+
parseFormantCommand,
|
|
223
|
+
parseVowelCommand,
|
|
224
|
+
} from "./commands/formant.ts";
|
|
225
|
+
import {
|
|
226
|
+
applyLyrics,
|
|
227
|
+
parseClipCommand,
|
|
228
|
+
parseLyricsCommand,
|
|
229
|
+
runClipCommand,
|
|
230
|
+
setClipImportDeps,
|
|
231
|
+
} from "./commands/clips.ts";
|
|
232
|
+
import { pitchTraceFor } from "./commands/vocal-pitch.ts";
|
|
233
|
+
import {
|
|
234
|
+
applyVocoderCommand,
|
|
235
|
+
parseVocoderCommand,
|
|
236
|
+
vocoderListLines,
|
|
237
|
+
} from "./commands/vocoder.ts";
|
|
238
|
+
import {
|
|
239
|
+
applyAutotuneCommand,
|
|
240
|
+
autotuneListLines,
|
|
241
|
+
parseAutotuneCommand,
|
|
242
|
+
} from "./commands/autotune.ts";
|
|
142
243
|
import { parseResampleCommand, runResample } from "./commands/resample.ts";
|
|
143
244
|
import { suggestFitMode } from "./audio/dsp/onset.ts";
|
|
144
245
|
import {
|
|
@@ -148,7 +249,14 @@ import {
|
|
|
148
249
|
type SampleBank,
|
|
149
250
|
type SampleProblem,
|
|
150
251
|
} from "./audio/samples.ts";
|
|
252
|
+
import {
|
|
253
|
+
ANALYSIS_CACHE_BYTES,
|
|
254
|
+
analysisCacheStatus,
|
|
255
|
+
analysisDir,
|
|
256
|
+
pruneAnalysisCache,
|
|
257
|
+
} from "./audio/analysis.ts";
|
|
151
258
|
import { drumSnapshotFields, samplerSnapshotFields } from "../tui/drums.ts";
|
|
259
|
+
import { clipSnapshots, loadClipPeaks } from "../tui/clip-row.ts";
|
|
152
260
|
import { highwayLayers } from "../tui/layers.ts";
|
|
153
261
|
import { drumVoicePitch, isDrumInstrument } from "../core/drums.ts";
|
|
154
262
|
import {
|
|
@@ -157,6 +265,7 @@ import {
|
|
|
157
265
|
type AgentEvent,
|
|
158
266
|
type AgentHost,
|
|
159
267
|
} from "./agent/agent.ts";
|
|
268
|
+
import { routeDuringTurn } from "./agent/steer.ts";
|
|
160
269
|
import {
|
|
161
270
|
isApiSelection,
|
|
162
271
|
providerFingerprint,
|
|
@@ -175,8 +284,24 @@ import {
|
|
|
175
284
|
spendLine as formatSpendLine,
|
|
176
285
|
webHostFor,
|
|
177
286
|
} from "./agent/usage.ts";
|
|
178
|
-
import { configDir } from "./auth/credentials.ts";
|
|
179
|
-
import {
|
|
287
|
+
import { configDir, readConfig, writeConfig } from "./auth/credentials.ts";
|
|
288
|
+
import type { CommandOutcome } from "./agent/command-agent.ts";
|
|
289
|
+
import {
|
|
290
|
+
GLIDE_STEP_MS,
|
|
291
|
+
NoteScheduler,
|
|
292
|
+
finishHint,
|
|
293
|
+
gestureFor,
|
|
294
|
+
glideValues,
|
|
295
|
+
agentPathsAllowed,
|
|
296
|
+
isAgentCommand,
|
|
297
|
+
isBrokenCommand,
|
|
298
|
+
brokenCommandReceipt,
|
|
299
|
+
parseShowMe,
|
|
300
|
+
toolCaption,
|
|
301
|
+
type ShowMeLevel,
|
|
302
|
+
} from "./agent/show-me.ts";
|
|
303
|
+
import { firstRunCard, runAuthCommand, runTuiLogin } from "./auth/cli.ts";
|
|
304
|
+
import { musicalReceipt } from "./session/receipt.ts";
|
|
180
305
|
import {
|
|
181
306
|
modelPickerItems,
|
|
182
307
|
tuiAuthCommand,
|
|
@@ -184,7 +309,12 @@ import {
|
|
|
184
309
|
tuiSetModel,
|
|
185
310
|
} from "./auth/tui.ts";
|
|
186
311
|
import { TransportClock, transportMapFor } from "./audio/clock.ts";
|
|
187
|
-
import {
|
|
312
|
+
import {
|
|
313
|
+
AudioEngine,
|
|
314
|
+
detectAudioBackend,
|
|
315
|
+
type AudioBackendInfo,
|
|
316
|
+
} from "./audio/engine.ts";
|
|
317
|
+
import { audioMenuState, runAudioCommand } from "./audio/audio-command.ts";
|
|
188
318
|
import {
|
|
189
319
|
DEFAULT_GRID,
|
|
190
320
|
GRIDS,
|
|
@@ -192,10 +322,39 @@ import {
|
|
|
192
322
|
PlaySession,
|
|
193
323
|
type LiveEngine,
|
|
194
324
|
} from "./tui/play-session.ts";
|
|
195
|
-
import { EditMenu, MENU_SECTIONS, type MenuContext } from "./tui/menu.ts";
|
|
196
325
|
import {
|
|
326
|
+
EditMenu,
|
|
327
|
+
menuUsage,
|
|
328
|
+
menuSectionPath,
|
|
329
|
+
type MenuAudioDevices,
|
|
330
|
+
type MenuContext,
|
|
331
|
+
} from "./tui/menu.ts";
|
|
332
|
+
import { KNOB_PAGE_WORDS, knobPageId } from "./tui/knob-map.ts";
|
|
333
|
+
import {
|
|
334
|
+
beatOfBar,
|
|
335
|
+
cutArmed,
|
|
336
|
+
focusCommand,
|
|
337
|
+
foldBase,
|
|
338
|
+
knobNoun,
|
|
339
|
+
tapeKey,
|
|
340
|
+
tapeKnobs,
|
|
341
|
+
type TapeContext,
|
|
342
|
+
} from "./tui/tape-mode.ts";
|
|
343
|
+
import { tapeView } from "./tui/tape-view.ts";
|
|
344
|
+
import { TAPE_ZOOMS, type TapeView, type TapeZoom } from "../tui/tape.ts";
|
|
345
|
+
import type { HitTarget } from "../tui/hits.ts";
|
|
346
|
+
import { knobKey, type KnobState } from "../tui/knobs.ts";
|
|
347
|
+
import {
|
|
348
|
+
clipboardPath,
|
|
349
|
+
loadClipboard,
|
|
350
|
+
saveClipboard,
|
|
351
|
+
} from "./session/clipboard.ts";
|
|
352
|
+
import { extractRange } from "../core/range.ts";
|
|
353
|
+
import {
|
|
354
|
+
drawerAside,
|
|
197
355
|
drawerView,
|
|
198
356
|
faderChoose,
|
|
357
|
+
faderCommand,
|
|
199
358
|
faderKeyPress,
|
|
200
359
|
faderSetPosition,
|
|
201
360
|
faderStep,
|
|
@@ -217,9 +376,23 @@ import {
|
|
|
217
376
|
isStageable,
|
|
218
377
|
} from "./tui/audition.ts";
|
|
219
378
|
import { EuclidEditor, type EuclidContext } from "./tui/euclid.ts";
|
|
220
|
-
import {
|
|
379
|
+
import {
|
|
380
|
+
ARROW_DOWN,
|
|
381
|
+
ARROW_UP,
|
|
382
|
+
closesKeys,
|
|
383
|
+
HINTS,
|
|
384
|
+
KEYS,
|
|
385
|
+
keyLines,
|
|
386
|
+
type KeySection,
|
|
387
|
+
} from "../tui/grammar.ts";
|
|
221
388
|
import { renderAudition } from "./audio/audition.ts";
|
|
222
|
-
import {
|
|
389
|
+
import {
|
|
390
|
+
exportScore,
|
|
391
|
+
loopedSection,
|
|
392
|
+
playbackTime,
|
|
393
|
+
scoreBeatAt,
|
|
394
|
+
} from "./audio/arrange.ts";
|
|
395
|
+
import { loopFlash, reelGlyph } from "../tui/delight.ts";
|
|
223
396
|
import { formatForm } from "../core/sections.ts";
|
|
224
397
|
import type { ArrangeStripView } from "../tui/arrange-strip.ts";
|
|
225
398
|
import { renderScorePcm } from "./audio/wav.ts";
|
|
@@ -260,11 +433,13 @@ import {
|
|
|
260
433
|
packCredits,
|
|
261
434
|
writeCredits,
|
|
262
435
|
} from "./audio/packs.ts";
|
|
436
|
+
import { SharedLiveEngine, type LiveLink } from "./session/shared-live.ts";
|
|
263
437
|
import {
|
|
264
438
|
addNote,
|
|
265
439
|
applyScoreOperation,
|
|
266
440
|
createScore,
|
|
267
441
|
isSamplerInstrument,
|
|
442
|
+
isTrackAudible,
|
|
268
443
|
PACK_PREFIX,
|
|
269
444
|
scoreFromJSON,
|
|
270
445
|
SCORE_LIMITS,
|
|
@@ -277,22 +452,44 @@ import { decodeLoop, encodeLoop } from "../core/loop.ts";
|
|
|
277
452
|
import { scoreToMidi } from "../core/midi.ts";
|
|
278
453
|
import type { TrackScoreSnapshot } from "../tui/render.ts";
|
|
279
454
|
import { PromptModel } from "../tui/prompt.ts";
|
|
280
|
-
import { TerminalInputDecoder } from "../tui/input.ts";
|
|
281
455
|
import {
|
|
456
|
+
ESCAPE_FLUSH_MS,
|
|
457
|
+
INPUT_FLUSH,
|
|
458
|
+
PASTE_FLUSH_MS,
|
|
459
|
+
TerminalInputDecoder,
|
|
460
|
+
} from "../tui/input.ts";
|
|
461
|
+
import {
|
|
462
|
+
FrameGate,
|
|
463
|
+
IDLE_HEARTBEAT_MS,
|
|
464
|
+
MIN_FRAME_GAP_MS,
|
|
465
|
+
} from "../tui/frame-gate.ts";
|
|
466
|
+
import {
|
|
467
|
+
CARD_GLOW_MS,
|
|
282
468
|
composeFrame,
|
|
469
|
+
isTooSmall,
|
|
470
|
+
tooSmallKey,
|
|
283
471
|
TuiApp,
|
|
284
472
|
type AppView,
|
|
285
473
|
type LoudnessView,
|
|
286
474
|
type SyncState,
|
|
287
475
|
type TypesIndicator,
|
|
288
476
|
} from "../tui/app.ts";
|
|
289
|
-
import {
|
|
477
|
+
import type { DrawerView } from "../tui/drawer.ts";
|
|
478
|
+
import { fail, note, ok, toneOf, warn, type Receipt } from "../tui/activity.ts";
|
|
290
479
|
import { systemRunner } from "./auth/runner.ts";
|
|
291
480
|
import type { MediaServices } from "./media/types.ts";
|
|
292
481
|
import { encodeBuffer } from "../tui/screen.ts";
|
|
293
482
|
import { parseThemeName } from "../tui/theme.ts";
|
|
294
483
|
import { formatDiagnostic } from "../core/sdk/eval.ts";
|
|
295
484
|
import { isProject } from "./project/init.ts";
|
|
485
|
+
import {
|
|
486
|
+
parseLaunchArgs,
|
|
487
|
+
parsePaneArgs,
|
|
488
|
+
type PaneArgs,
|
|
489
|
+
parseSimpleArgv,
|
|
490
|
+
resolveTrackArg,
|
|
491
|
+
} from "./launch-args.ts";
|
|
492
|
+
import { RENDER_USAGE, runRenderCommand } from "./render.ts";
|
|
296
493
|
import { typecheckProject } from "./project/typecheck.ts";
|
|
297
494
|
import {
|
|
298
495
|
startProjectSync,
|
|
@@ -300,112 +497,100 @@ import {
|
|
|
300
497
|
type SyncHost,
|
|
301
498
|
} from "./project/sync.ts";
|
|
302
499
|
|
|
500
|
+
/** Set while `submit` runs a grammar reading, so it reads only once. */
|
|
501
|
+
let recovering = false;
|
|
502
|
+
|
|
503
|
+
/** Slash words `submit` handles itself (no parser in commandParses). */
|
|
504
|
+
const SLASH_HANDLED: ReadonlySet<string> = new Set([
|
|
505
|
+
...WINDOW_VERBS,
|
|
506
|
+
"chords",
|
|
507
|
+
"click",
|
|
508
|
+
"help",
|
|
509
|
+
]);
|
|
510
|
+
|
|
303
511
|
/** Whether a bare command parses (no side effects): for typo suggestions. */
|
|
304
512
|
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);
|
|
513
|
+
return commandParses(text, score);
|
|
334
514
|
}
|
|
335
515
|
|
|
336
516
|
const ESC = "\u001b[";
|
|
337
517
|
/** `/sessions` rows shown in the overlay. */
|
|
338
518
|
const MAX_LISTED_SESSIONS = 64;
|
|
339
519
|
|
|
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
|
-
];
|
|
520
|
+
/** Wraps a usage line at spaces before `width`, indenting continuations. */
|
|
521
|
+
function wrapUsage(text: string, width: number, indent: string): string {
|
|
522
|
+
const lines: string[] = [];
|
|
523
|
+
let line = "";
|
|
524
|
+
for (const word of text.split(" ")) {
|
|
525
|
+
const prefix = lines.length === 0 ? " " : indent;
|
|
526
|
+
if (line && prefix.length + line.length + 1 + word.length > width) {
|
|
527
|
+
lines.push(line);
|
|
528
|
+
line = word;
|
|
529
|
+
} else line = line ? `${line} ${word}` : word;
|
|
530
|
+
}
|
|
531
|
+
lines.push(line);
|
|
532
|
+
return lines.join(`\n${indent}`);
|
|
533
|
+
}
|
|
361
534
|
const HELP_TEXT = `dawg · local-first terminal music workstation
|
|
362
535
|
|
|
363
536
|
Usage:
|
|
364
537
|
dawg [--new] [--session <name|id>] [--track <name>]
|
|
365
|
-
dawg --import <file> --export <file>
|
|
538
|
+
dawg --import <file> --export <file> convert a loop file (no session)
|
|
366
539
|
dawg sessions
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
dawg init [dir] project files: song.ts, tracks/<slug>/track.ts, .dawg/sdk
|
|
540
|
+
${wrapUsage(RENDER_USAGE.replace(/^usage: /, ""), 76, " ")}
|
|
541
|
+
dawg init [dir] song.ts, tracks/<slug>/track.ts and .dawg/sdk
|
|
370
542
|
dawg check typecheck + evaluate the project; exit 1 on problems
|
|
371
|
-
dawg
|
|
543
|
+
dawg <command> --help usage: sessions render init check media model
|
|
544
|
+
dawg doctor audio backend, native sink and devices
|
|
545
|
+
dawg media doctor|download|stems|analyze|notes|sample|lyrics (--help)
|
|
372
546
|
dawg --version
|
|
373
547
|
|
|
374
|
-
|
|
548
|
+
Display options:
|
|
375
549
|
--reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
|
|
376
550
|
--theme <name> default | high-contrast | mono (NO_COLOR forces mono)
|
|
377
551
|
--no-mouse keys only; no click/wheel reporting (also DAWG_MOUSE=0)
|
|
378
552
|
|
|
379
553
|
Prompt:
|
|
380
|
-
Enter submit · Shift-Enter newline · Alt-Enter
|
|
381
|
-
Ctrl-Z undo · Ctrl-Y redo · Ctrl-O transcript · Esc
|
|
554
|
+
Enter submit · Shift-Enter newline · Alt-Enter next · Ctrl-Q now / next
|
|
555
|
+
Ctrl-Z undo · Ctrl-Y redo · Ctrl-O transcript · Esc close · Ctrl-C exit
|
|
382
556
|
Space on an empty prompt toggles playback
|
|
383
557
|
|
|
384
558
|
Commands (bare music words; app commands take a slash):
|
|
385
559
|
${helpText()}
|
|
386
560
|
|
|
387
|
-
|
|
388
|
-
dawg
|
|
389
|
-
dawg
|
|
390
|
-
dawg model [alias] pick a model, with the
|
|
561
|
+
Agent (optional; the choice is saved and reused until you log out):
|
|
562
|
+
dawg model key find existing setups and pick a provider
|
|
563
|
+
dawg model key gateway|openrouter|codex|claude
|
|
564
|
+
dawg model [alias] pick a model, with the cost per prompt
|
|
391
565
|
dawg logout [provider] · dawg auth status [--check]
|
|
392
566
|
Unrecognized requests go to the agent once a provider is configured
|
|
393
567
|
(DAWG_PROVIDER=gateway|openrouter|codex|claude|auto, DAWG_MODEL=<alias>;
|
|
394
568
|
DAWG_AI=0 disables the agent).`;
|
|
395
569
|
const args = new Set(process.argv.slice(2));
|
|
396
|
-
|
|
397
|
-
const
|
|
570
|
+
/** One parse for every launch flag; problems are reported below. */
|
|
571
|
+
const launch = parseLaunchArgs(process.argv.slice(2));
|
|
572
|
+
const launchArgs = launch.ok ? launch.args : undefined;
|
|
573
|
+
const requestedSession = launchArgs?.session;
|
|
574
|
+
/** `--track` normalized as `/track` does; matched to an existing track below. */
|
|
575
|
+
let explicitTrack = launchArgs?.track?.id;
|
|
398
576
|
/** The focused track; claimed at startup unless `--track` is given. */
|
|
399
577
|
let requestedTrack = explicitTrack ?? "main";
|
|
400
578
|
const initialInstrument = isDrumInstrument(requestedTrack) ? "kit" : "sine";
|
|
401
|
-
const importPath =
|
|
402
|
-
const exportPath =
|
|
579
|
+
const importPath = launchArgs?.importPath;
|
|
580
|
+
const exportPath = launchArgs?.exportPath;
|
|
581
|
+
// `dawg model key [provider]` is the canonical spelling of `dawg login`.
|
|
582
|
+
if (process.argv[2] === "model" && process.argv[3] === "key")
|
|
583
|
+
process.exit(await runAuthCommand(["login", ...process.argv.slice(4)]));
|
|
403
584
|
if (["login", "logout", "auth", "model"].includes(process.argv[2] ?? ""))
|
|
404
585
|
process.exit(await runAuthCommand(process.argv.slice(2)));
|
|
405
586
|
{
|
|
406
587
|
// An unknown DAWG_MODEL is an error, never a silent fallback.
|
|
407
588
|
const fromEnv = process.env.DAWG_MODEL?.trim();
|
|
408
|
-
if (
|
|
589
|
+
if (
|
|
590
|
+
fromEnv &&
|
|
591
|
+
!resolveModelChoice("gateway", fromEnv) &&
|
|
592
|
+
!resolveModelChoice("openrouter", fromEnv)
|
|
593
|
+
) {
|
|
409
594
|
process.stderr.write(
|
|
410
595
|
`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
596
|
);
|
|
@@ -413,10 +598,14 @@ if (["login", "logout", "auth", "model"].includes(process.argv[2] ?? ""))
|
|
|
413
598
|
}
|
|
414
599
|
}
|
|
415
600
|
if (process.argv[2] === "sessions") {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
601
|
+
const usage =
|
|
602
|
+
"usage: dawg sessions · lists this workspace's sessions, newest first (* marks the current one)";
|
|
603
|
+
const parsed = parseSimpleArgv(process.argv.slice(3), 0);
|
|
604
|
+
if (parsed.kind === "error") {
|
|
605
|
+
process.stderr.write(`${parsed.problem} · ${usage}\n`);
|
|
606
|
+
process.exit(2);
|
|
607
|
+
}
|
|
608
|
+
if (parsed.kind === "help") stdout.write(`${usage}\n`);
|
|
420
609
|
else await printSessions(process.cwd(), stdout);
|
|
421
610
|
process.exit(0);
|
|
422
611
|
}
|
|
@@ -429,6 +618,10 @@ if (process.argv[2] === "init" || process.argv[2] === "check") {
|
|
|
429
618
|
await command(process.argv.slice(2), process.cwd(), stdout, process.stderr),
|
|
430
619
|
);
|
|
431
620
|
}
|
|
621
|
+
if (process.argv[2] === "doctor") {
|
|
622
|
+
const { runDoctorCommand } = await import("./audio/doctor.ts");
|
|
623
|
+
process.exit(await runDoctorCommand(process.argv.slice(3), stdout));
|
|
624
|
+
}
|
|
432
625
|
if (process.argv[2] === "media") {
|
|
433
626
|
const { runMediaCommand } = await import("./media/cli.ts");
|
|
434
627
|
process.exit(
|
|
@@ -461,18 +654,37 @@ if (args.has("--help") || args.has("-h")) {
|
|
|
461
654
|
}
|
|
462
655
|
// Argument mistakes are rejected here, before `.dawg/` could be created.
|
|
463
656
|
{
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
process.stderr.write(`${problem} · dawg --help\n`);
|
|
657
|
+
if (!launch.ok) {
|
|
658
|
+
process.stderr.write(`${launch.problem} · dawg --help\n`);
|
|
467
659
|
process.exit(2);
|
|
468
660
|
}
|
|
469
661
|
}
|
|
662
|
+
// `--import X --export Y` converts one loop file to another: no session.
|
|
663
|
+
if (importPath && exportPath) {
|
|
664
|
+
try {
|
|
665
|
+
const converted = decodeLoop(await readLoopFile(importPath));
|
|
666
|
+
await writeFile(resolve(exportPath), encodeLoop(converted), "utf8");
|
|
667
|
+
} catch (error) {
|
|
668
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
669
|
+
const midi = /\.midi?$/i.test(importPath)
|
|
670
|
+
? " · MIDI files are not imported; --import takes a .track.json loop"
|
|
671
|
+
: "";
|
|
672
|
+
process.stderr.write(
|
|
673
|
+
`dawg: cannot import ${importPath} · ${reason.split("\n")[0]!.slice(0, 160)}${midi}\n`,
|
|
674
|
+
);
|
|
675
|
+
process.exit(1);
|
|
676
|
+
}
|
|
677
|
+
stdout.write(`converted ${importPath} → ${exportPath}\n`);
|
|
678
|
+
process.exit(0);
|
|
679
|
+
}
|
|
470
680
|
const demo =
|
|
471
681
|
args.has("--demo") || process.env.DAWG_DEMO === "1" || !stdin.isTTY;
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
682
|
+
// Music first: the TUI opens directly. The first session with no provider
|
|
683
|
+
// (or a saved one that stopped working) gets one card instead of a picker.
|
|
684
|
+
const launchCard =
|
|
685
|
+
!demo && process.env.DAWG_AI !== "0" && stdout.isTTY
|
|
686
|
+
? firstRunCard().catch(() => undefined)
|
|
687
|
+
: undefined;
|
|
476
688
|
|
|
477
689
|
const initial = createScore({
|
|
478
690
|
tracks: [
|
|
@@ -521,9 +733,21 @@ if (importPath) {
|
|
|
521
733
|
process.exit(1);
|
|
522
734
|
}
|
|
523
735
|
}
|
|
524
|
-
|
|
736
|
+
// A demo frame only renders: with no workspace yet (and nothing imported),
|
|
737
|
+
// it runs on a throwaway session so the cwd gets no `.dawg/`.
|
|
738
|
+
const ephemeralWorkspace =
|
|
739
|
+
demo && freshWorkspace && !importPath && selectedSession === undefined
|
|
740
|
+
? await mkdtemp(join(tmpdir(), "dawg-demo-"))
|
|
741
|
+
: undefined;
|
|
742
|
+
const session = await ensureSession(initial.toJSON(), {
|
|
743
|
+
...sessionOptions,
|
|
744
|
+
...(ephemeralWorkspace ? { workspace: ephemeralWorkspace } : {}),
|
|
745
|
+
});
|
|
746
|
+
// Who is at this window; also salts collision-free ids (core/ids.ts).
|
|
747
|
+
const actor = await currentActor();
|
|
525
748
|
// dawgd when connected, the file-lock path otherwise (see src/session/port.ts).
|
|
526
749
|
let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
|
|
750
|
+
actor,
|
|
527
751
|
paths: session.paths,
|
|
528
752
|
sessionId: session.record.sessionId,
|
|
529
753
|
label: explicitTrack ?? "window",
|
|
@@ -533,6 +757,12 @@ let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
|
|
|
533
757
|
let record: SessionRecord<ReturnType<TrackScore["toJSON"]>> =
|
|
534
758
|
port.mode === "daemon" ? await port.load() : session.record;
|
|
535
759
|
let score = scoreFromJSON(record.composition);
|
|
760
|
+
if (importPath && importedScore) score = importedScore;
|
|
761
|
+
// `--track Bass` focuses an existing `bass` (or a track named "Bass").
|
|
762
|
+
if (launchArgs?.track) {
|
|
763
|
+
explicitTrack = resolveTrackArg(score.tracks, launchArgs.track);
|
|
764
|
+
requestedTrack = explicitTrack;
|
|
765
|
+
}
|
|
536
766
|
if (importPath && importedScore) {
|
|
537
767
|
score = importedScore;
|
|
538
768
|
record = await port.append(
|
|
@@ -562,6 +792,8 @@ if (!draftTrack) await ensureFocusedTrack();
|
|
|
562
792
|
|
|
563
793
|
const clock = new TransportClock(score.tempoBpm);
|
|
564
794
|
let audio = port.player;
|
|
795
|
+
let audioInfoCache: AudioBackendInfo | undefined;
|
|
796
|
+
let audioMenuCache: { at: number; state: MenuAudioDevices } | undefined;
|
|
565
797
|
/** Session and today's spend, from the usage each response reports. */
|
|
566
798
|
const meter = new SpendMeter(configDir());
|
|
567
799
|
void meter.load().catch(() => undefined);
|
|
@@ -573,12 +805,22 @@ const tui = new TuiApp({
|
|
|
573
805
|
rows: () => stdout.rows ?? 24,
|
|
574
806
|
},
|
|
575
807
|
prompt,
|
|
576
|
-
theme:
|
|
808
|
+
theme: launchArgs?.theme ?? parseThemeName(process.env.DAWG_THEME),
|
|
577
809
|
reducedMotion:
|
|
578
810
|
args.has("--reduce-motion") || process.env.DAWG_REDUCE_MOTION === "1",
|
|
579
811
|
});
|
|
580
812
|
let syncState: SyncState = port.sync;
|
|
581
813
|
let windowCount = 1;
|
|
814
|
+
/** Pin and follow (§12.5): set by `dawg pane …`, `pin` and `follow`. */
|
|
815
|
+
const paneOptions: { pinned: boolean; follow: string | undefined } = {
|
|
816
|
+
pinned: false,
|
|
817
|
+
follow: undefined,
|
|
818
|
+
};
|
|
819
|
+
/** Every pane on this session, as the last presence push listed them. */
|
|
820
|
+
let panePresence: readonly PresenceEntry[] = [];
|
|
821
|
+
/** The last view this pane reported (JSON), so unchanged views stay quiet. */
|
|
822
|
+
let reportedView = "";
|
|
823
|
+
let sharedLive: { link: LiveLink; engine: SharedLiveEngine } | undefined;
|
|
582
824
|
/** Project file sync when `dawg.json` is in the working directory. */
|
|
583
825
|
let projectSync: ProjectSync | undefined;
|
|
584
826
|
let packStore: PackStore | undefined;
|
|
@@ -623,15 +865,65 @@ const AGENT_PREVIEW_VOICE = 0x7fff_0002;
|
|
|
623
865
|
const CHORDS_COMMAND = /^\/chords\s+(.+)$/i;
|
|
624
866
|
/** Whether the chord settings screen was open at the last menu redraw. */
|
|
625
867
|
let chordScreenWas = false;
|
|
868
|
+
/** The range clipboard (`copy bass 5-6`, then `paste at 9`); per window. */
|
|
869
|
+
let rangeClipboard: RangeClipboard | undefined;
|
|
870
|
+
/**
|
|
871
|
+
* TAPE (op1-ux §6): on, its zoom, its knob strip, the last view painted
|
|
872
|
+
* (mouse hits map cells to bars through it), a ruler drag in progress, and
|
|
873
|
+
* the pending cut (the score before and after its clear) so the first
|
|
874
|
+
* paste after it runs `move` against the score the cut came from.
|
|
875
|
+
*/
|
|
876
|
+
const tape: {
|
|
877
|
+
on: boolean;
|
|
878
|
+
zoom: TapeZoom;
|
|
879
|
+
knobs: KnobState;
|
|
880
|
+
view?: TapeView | undefined;
|
|
881
|
+
drag?: { from: number; to: number; left: number; first: number } | undefined;
|
|
882
|
+
cut?: { before: TrackScore; after?: TrackScore | undefined } | undefined;
|
|
883
|
+
/** Set by `x`: the score before it, until its `clear` lands. */
|
|
884
|
+
cutArmed?: TrackScore | undefined;
|
|
885
|
+
/** Set by the first `v` after a cut: its `move` reads `cut.before`. */
|
|
886
|
+
foldArmed?: boolean | undefined;
|
|
887
|
+
/**
|
|
888
|
+
* Where a paste's queued `jump` will put the playhead (a score beat),
|
|
889
|
+
* so a quick `v v v` tiles before that jump has run.
|
|
890
|
+
*/
|
|
891
|
+
pending?: { beat: number; until: number } | undefined;
|
|
892
|
+
} = { on: false, zoom: "beat", knobs: { selected: 0 } };
|
|
626
893
|
let stageCapture: { next?: TrackScore; committed?: TrackScore } | undefined;
|
|
627
894
|
/** Redraw soon (the audition reports renders between frames). */
|
|
628
895
|
let requestFrame: () => void = () => undefined;
|
|
629
896
|
/** Queue a prompt to run as if typed (set by the interactive loop). */
|
|
630
897
|
let runPromptLater: (command: string) => void = () => undefined;
|
|
898
|
+
/** Queue several prompts, in order, ahead of anything already queued. */
|
|
899
|
+
let runPromptsLater: (commands: readonly string[]) => void = () => undefined;
|
|
631
900
|
/** The fader drawer's focus and typing, while one is open over the menu. */
|
|
632
901
|
let fader: FaderState | undefined;
|
|
633
|
-
|
|
634
|
-
|
|
902
|
+
/** Show-me state (see the show-me section below). */
|
|
903
|
+
/** A TAPE gesture's typed command, echoed dimly in the prompt row. */
|
|
904
|
+
const gestureEcho: {
|
|
905
|
+
text?: string | undefined;
|
|
906
|
+
clear?: ReturnType<typeof setTimeout> | undefined;
|
|
907
|
+
} = {};
|
|
908
|
+
|
|
909
|
+
const showMe: {
|
|
910
|
+
level: ShowMeLevel;
|
|
911
|
+
ghost?: string | undefined;
|
|
912
|
+
caption?: string | undefined;
|
|
913
|
+
commands: string[];
|
|
914
|
+
notes: NoteScheduler;
|
|
915
|
+
clear?: ReturnType<typeof setTimeout> | undefined;
|
|
916
|
+
} = {
|
|
917
|
+
level: parseShowMe(process.env.DAWG_SHOWME ?? "") ?? "on",
|
|
918
|
+
commands: [],
|
|
919
|
+
notes: new NoteScheduler(() => score.tempoBpm),
|
|
920
|
+
};
|
|
921
|
+
if (!process.env.DAWG_SHOWME)
|
|
922
|
+
void readConfig({ dir: configDir() })
|
|
923
|
+
.then((config) => {
|
|
924
|
+
if (config.showMe) showMe.level = config.showMe;
|
|
925
|
+
})
|
|
926
|
+
.catch(() => undefined);
|
|
635
927
|
/** The fader bar a left-button drag started on. */
|
|
636
928
|
let dragging: { field: number; left: number; width: number } | undefined;
|
|
637
929
|
/** The open drawer came from a command, not the menu. */
|
|
@@ -664,7 +956,8 @@ let handoff: <T>(flow: () => Promise<T>) => Promise<T> = (flow) => flow();
|
|
|
664
956
|
function spendLine(): string {
|
|
665
957
|
const width = stdout.columns ?? 80;
|
|
666
958
|
if (!providerName || providerName === "offline")
|
|
667
|
-
|
|
959
|
+
// A state, not a nag: the first session's card already named /login.
|
|
960
|
+
return width >= 40 ? formatSpendLine({ kind: "offline" }) : "";
|
|
668
961
|
const [model, provider] = providerName.split(" · ");
|
|
669
962
|
return formatSpendLine(
|
|
670
963
|
{
|
|
@@ -689,6 +982,10 @@ if (demo) {
|
|
|
689
982
|
"add C4 at 0 for 1",
|
|
690
983
|
0,
|
|
691
984
|
);
|
|
985
|
+
if (ephemeralWorkspace) {
|
|
986
|
+
await port.close().catch(() => undefined);
|
|
987
|
+
await rm(ephemeralWorkspace, { recursive: true, force: true });
|
|
988
|
+
}
|
|
692
989
|
process.exit(0);
|
|
693
990
|
}
|
|
694
991
|
|
|
@@ -699,36 +996,8 @@ if (exportPath) {
|
|
|
699
996
|
|
|
700
997
|
await runInteractive();
|
|
701
998
|
|
|
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
999
|
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;
|
|
1000
|
+
return (await import("./version.ts")).VERSION;
|
|
732
1001
|
}
|
|
733
1002
|
|
|
734
1003
|
function seedDemo(value: TrackScore, trackId: string): TrackScore {
|
|
@@ -818,21 +1087,30 @@ function snapshot(
|
|
|
818
1087
|
muted: focused?.muted,
|
|
819
1088
|
...(tag?.cents !== undefined ? { cents: tag.cents } : {}),
|
|
820
1089
|
...(tag?.name !== undefined ? { centsFrom: tag.name } : {}),
|
|
1090
|
+
...(note.lyric !== undefined ? { lyric: note.lyric } : {}),
|
|
821
1091
|
};
|
|
822
1092
|
});
|
|
1093
|
+
// 0.7 clips: the clip row; peaks load off the frame path, then redraw.
|
|
1094
|
+
const clips = clipSnapshots(value, requestedTrack);
|
|
1095
|
+
if (clips)
|
|
1096
|
+
void loadClipPeaks(process.cwd(), focused).then((changed) => {
|
|
1097
|
+
if (changed) requestFrame();
|
|
1098
|
+
});
|
|
823
1099
|
return {
|
|
824
1100
|
notes,
|
|
1101
|
+
...(clips ? { clips } : {}),
|
|
825
1102
|
trackName:
|
|
826
1103
|
value.tracks.find((track) => track.id === requestedTrack)?.name ??
|
|
827
1104
|
requestedTrack,
|
|
828
1105
|
trackId: requestedTrack,
|
|
829
1106
|
sessionId: record.sessionId,
|
|
830
|
-
revision: record
|
|
1107
|
+
revision: shownRevision(record),
|
|
831
1108
|
bpm: value.time?.tempo
|
|
832
1109
|
? bpmAtTick(value, beat * value.ticksPerBeat)
|
|
833
1110
|
: value.tempoBpm,
|
|
834
1111
|
key: value.key ?? undefined,
|
|
835
1112
|
loopBeats: loopTicksOf(value) / value.ticksPerBeat,
|
|
1113
|
+
...(value.loop ? { loopRange: value.loop } : {}),
|
|
836
1114
|
beatsPerBar: value.beatsPerBar,
|
|
837
1115
|
...(hasMeterChanges(value)
|
|
838
1116
|
? {
|
|
@@ -846,6 +1124,7 @@ function snapshot(
|
|
|
846
1124
|
currentBeat: scoreBeatAt(value, beat),
|
|
847
1125
|
playing: clock.playing,
|
|
848
1126
|
activity,
|
|
1127
|
+
...(focused?.instrument === "vocal" ? { vocal: true } : {}),
|
|
849
1128
|
...drumSnapshotFields(
|
|
850
1129
|
value.tracks.find((track) => track.id === requestedTrack)?.instrument,
|
|
851
1130
|
notes,
|
|
@@ -857,6 +1136,7 @@ function snapshot(
|
|
|
857
1136
|
...(table && table.linear && table.size !== 12
|
|
858
1137
|
? { tuningPeriod: { size: table.size, root: table.root } }
|
|
859
1138
|
: {}),
|
|
1139
|
+
pitchTrace: pitchTraceFor(requestedTrack, value),
|
|
860
1140
|
layers:
|
|
861
1141
|
tui.highwayView === "all"
|
|
862
1142
|
? highwayLayers(
|
|
@@ -898,11 +1178,19 @@ function appView(value: TrackScore, beat: number): AppView {
|
|
|
898
1178
|
providerName && providerName !== "offline" ? providerName : undefined,
|
|
899
1179
|
spend: spendLine(),
|
|
900
1180
|
agentOffline: providerName === "offline" || process.env.DAWG_AI === "0",
|
|
1181
|
+
showMe:
|
|
1182
|
+
showMe.ghost || showMe.caption
|
|
1183
|
+
? { ghost: showMe.ghost, caption: showMe.caption }
|
|
1184
|
+
: gestureEcho.text
|
|
1185
|
+
? { caption: gestureEcho.text }
|
|
1186
|
+
: undefined,
|
|
901
1187
|
sync: syncState,
|
|
902
1188
|
sessionName: record.meta.name,
|
|
903
1189
|
windows: windowCount,
|
|
1190
|
+
pane: port.pane,
|
|
904
1191
|
types: typesIndicator,
|
|
905
1192
|
play: play?.on ? play.header() : undefined,
|
|
1193
|
+
tape: tape.on && !play?.on ? currentTapeView(value, beat) : undefined,
|
|
906
1194
|
loudness: masterLoudness(value),
|
|
907
1195
|
};
|
|
908
1196
|
}
|
|
@@ -1001,17 +1289,20 @@ function receipt(result: string | Receipt, base?: Baseline): void {
|
|
|
1001
1289
|
tui.activity.pushError(message);
|
|
1002
1290
|
return;
|
|
1003
1291
|
}
|
|
1292
|
+
// A receipt that names its own undo key needs no second hint.
|
|
1004
1293
|
const hint =
|
|
1005
|
-
scoreChanged &&
|
|
1294
|
+
scoreChanged &&
|
|
1295
|
+
undoHintsShown < MAX_UNDO_HINTS &&
|
|
1296
|
+
!message.endsWith("ctrl-z undo")
|
|
1006
1297
|
? message.startsWith("undid")
|
|
1007
|
-
? "
|
|
1008
|
-
: "
|
|
1298
|
+
? "ctrl-y redo"
|
|
1299
|
+
: "ctrl-z undo"
|
|
1009
1300
|
: undefined;
|
|
1010
1301
|
if (hint) undoHintsShown += 1;
|
|
1011
1302
|
tui.activity.pushCard(message, {
|
|
1012
1303
|
tone,
|
|
1013
|
-
baseRevision: changed ? base.revision : undefined,
|
|
1014
|
-
resultRevision: changed ? record
|
|
1304
|
+
baseRevision: changed ? shownRevision(record, base.revision) : undefined,
|
|
1305
|
+
resultRevision: changed ? shownRevision(record) : undefined,
|
|
1015
1306
|
hint,
|
|
1016
1307
|
trackId: requestedTrack,
|
|
1017
1308
|
});
|
|
@@ -1023,6 +1314,10 @@ function describeError(command: string, error: unknown): string {
|
|
|
1023
1314
|
const message = error instanceof Error ? error.message : String(error);
|
|
1024
1315
|
if (detail?.code === "ENOENT" && typeof detail.path === "string")
|
|
1025
1316
|
return `no such file · ${relative(process.cwd(), detail.path)}`;
|
|
1317
|
+
// `tempo 900` → `tempo 900 · tempo takes 20…300 BPM · tempo 128`: no raw
|
|
1318
|
+
// core keys (`tempoBpm`) on a card.
|
|
1319
|
+
const friendly = friendlyCoreError(truncateForCard(command), message);
|
|
1320
|
+
if (friendly) return friendly;
|
|
1026
1321
|
const verb = command.trim().split(/\s+/)[0]?.replace(/^\//, "") || "command";
|
|
1027
1322
|
return `${verb} failed · ${message}`;
|
|
1028
1323
|
}
|
|
@@ -1049,25 +1344,108 @@ function mouseOn(): string {
|
|
|
1049
1344
|
return mouseEnabled() ? MOUSE_ON : "";
|
|
1050
1345
|
}
|
|
1051
1346
|
|
|
1347
|
+
/**
|
|
1348
|
+
* Mouse off, plain colors, plain paste, cursor shown, main screen. A
|
|
1349
|
+
* function, not a const: `await runInteractive()` runs above this line.
|
|
1350
|
+
*/
|
|
1351
|
+
function terminalRestore(): string {
|
|
1352
|
+
return `${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/** `dawg: <stack>` for a crash printed after leaving the alternate screen. */
|
|
1356
|
+
function crashText(error: unknown): string {
|
|
1357
|
+
const text =
|
|
1358
|
+
error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
1359
|
+
return `dawg: ${text}\n`;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1052
1362
|
async function runInteractive(): Promise<void> {
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1363
|
+
// Every way out (quit, a failing teardown step, a crash, a kill, a stray
|
|
1364
|
+
// process.exit) restores the whole terminal, once, synchronously: the
|
|
1365
|
+
// shell must not stay on the alternate screen with paste brackets on.
|
|
1366
|
+
let terminalRestored = false;
|
|
1367
|
+
const restoreTerminal = () => {
|
|
1368
|
+
if (terminalRestored) return;
|
|
1369
|
+
terminalRestored = true;
|
|
1370
|
+
try {
|
|
1371
|
+
stdin.setRawMode?.(false);
|
|
1372
|
+
} catch {
|
|
1373
|
+
// stdin may already be closed.
|
|
1374
|
+
}
|
|
1375
|
+
try {
|
|
1376
|
+
writeSync(1, terminalRestore());
|
|
1377
|
+
} catch {
|
|
1378
|
+
// The terminal may already be gone (SIGHUP).
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
process.once("exit", restoreTerminal);
|
|
1382
|
+
// A kill or a closed terminal: restore it, then exit as killed.
|
|
1059
1383
|
for (const [signal, code] of [
|
|
1060
1384
|
["SIGTERM", 143],
|
|
1061
1385
|
["SIGHUP", 129],
|
|
1062
1386
|
] as const)
|
|
1063
1387
|
process.once(signal, () => {
|
|
1388
|
+
restoreTerminal();
|
|
1389
|
+
process.exit(code);
|
|
1390
|
+
});
|
|
1391
|
+
// An exception nothing caught leaves the editor in an unknown state: leave
|
|
1392
|
+
// the alternate screen first, so the stack lands in the shell, then exit.
|
|
1393
|
+
const onUncaught = (error: unknown) => {
|
|
1394
|
+
restoreTerminal();
|
|
1395
|
+
try {
|
|
1396
|
+
writeSync(2, crashText(error));
|
|
1397
|
+
} catch {
|
|
1398
|
+
// Nowhere left to report it.
|
|
1399
|
+
}
|
|
1400
|
+
process.exit(1);
|
|
1401
|
+
};
|
|
1402
|
+
// A rejected fire-and-forget promise is one failed action, not a broken
|
|
1403
|
+
// editor: report it on the activity strip and repaint over anything the
|
|
1404
|
+
// runtime wrote.
|
|
1405
|
+
const onRejection = (error: unknown) => {
|
|
1406
|
+
if (terminalRestored || screenSuspended) {
|
|
1064
1407
|
try {
|
|
1065
|
-
writeSync(
|
|
1408
|
+
writeSync(2, crashText(error));
|
|
1066
1409
|
} catch {
|
|
1067
|
-
//
|
|
1410
|
+
// Nowhere left to report it.
|
|
1068
1411
|
}
|
|
1069
|
-
|
|
1070
|
-
}
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
tui.activity.pushError(
|
|
1415
|
+
`failed · ${error instanceof Error ? error.message : String(error)}`,
|
|
1416
|
+
);
|
|
1417
|
+
tui.invalidate();
|
|
1418
|
+
requestFrame();
|
|
1419
|
+
};
|
|
1420
|
+
// Runtime warnings and console output would land on top of the frame; the
|
|
1421
|
+
// writer never repaints rows it did not change. Route them to the
|
|
1422
|
+
// transcript instead, and repaint.
|
|
1423
|
+
const onWarning = (warning: Error) => {
|
|
1424
|
+
if (screenSuspended || terminalRestored) return;
|
|
1425
|
+
tui.activity.pushNote(`warning · ${warning.message}`, "error");
|
|
1426
|
+
tui.invalidate();
|
|
1427
|
+
requestFrame();
|
|
1428
|
+
};
|
|
1429
|
+
const consoleError = console.error;
|
|
1430
|
+
const consoleWarn = console.warn;
|
|
1431
|
+
const consoleToTranscript =
|
|
1432
|
+
(original: (...data: unknown[]) => void) =>
|
|
1433
|
+
(...data: unknown[]) => {
|
|
1434
|
+
if (screenSuspended || terminalRestored) return original(...data);
|
|
1435
|
+
tui.activity.pushNote(
|
|
1436
|
+
data
|
|
1437
|
+
.map((item) => (item instanceof Error ? item.message : String(item)))
|
|
1438
|
+
.join(" "),
|
|
1439
|
+
"error",
|
|
1440
|
+
);
|
|
1441
|
+
tui.invalidate();
|
|
1442
|
+
requestFrame();
|
|
1443
|
+
};
|
|
1444
|
+
process.on("uncaughtException", onUncaught);
|
|
1445
|
+
process.on("unhandledRejection", onRejection);
|
|
1446
|
+
process.on("warning", onWarning);
|
|
1447
|
+
console.error = consoleToTranscript(consoleError);
|
|
1448
|
+
console.warn = consoleToTranscript(consoleWarn);
|
|
1071
1449
|
stdin.setRawMode?.(true);
|
|
1072
1450
|
stdin.resume();
|
|
1073
1451
|
// Alternate screen, hidden cursor, bracketed paste.
|
|
@@ -1078,7 +1456,8 @@ async function runInteractive(): Promise<void> {
|
|
|
1078
1456
|
const runPrompt = async (text: string): Promise<void> => {
|
|
1079
1457
|
const ui = tui.command(text);
|
|
1080
1458
|
if (ui !== undefined) {
|
|
1081
|
-
tui.activity.pushCard(ui, { tone: "info" });
|
|
1459
|
+
if (typeof ui === "string") tui.activity.pushCard(ui, { tone: "info" });
|
|
1460
|
+
else tui.activity.pushCard(ui.text, { tone: toneOf(ui) });
|
|
1082
1461
|
return;
|
|
1083
1462
|
}
|
|
1084
1463
|
tui.activity.pushRequest(text);
|
|
@@ -1102,6 +1481,16 @@ async function runInteractive(): Promise<void> {
|
|
|
1102
1481
|
}
|
|
1103
1482
|
};
|
|
1104
1483
|
let agentReported = false;
|
|
1484
|
+
/** A typed command during an agent turn; leaves the turn's receipt alone. */
|
|
1485
|
+
const runLocalBesideAgent = async (text: string): Promise<void> => {
|
|
1486
|
+
tui.activity.pushRequest(text);
|
|
1487
|
+
const base = baseline();
|
|
1488
|
+
try {
|
|
1489
|
+
receipt(await submit(text), base);
|
|
1490
|
+
} catch (error) {
|
|
1491
|
+
tui.activity.pushError(describeError(text, error));
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1105
1494
|
const drainQueue = async (): Promise<void> => {
|
|
1106
1495
|
if (processingQueue) return;
|
|
1107
1496
|
processingQueue = true;
|
|
@@ -1122,26 +1511,97 @@ async function runInteractive(): Promise<void> {
|
|
|
1122
1511
|
tui.activity.setQueueDepth(queuedPrompts.length);
|
|
1123
1512
|
}
|
|
1124
1513
|
};
|
|
1514
|
+
// Builds a frame only when something can have changed (tui/frame-gate.ts):
|
|
1515
|
+
// an idle editor no longer rebuilds the whole view 30 times a second.
|
|
1516
|
+
const frameGate = new FrameGate(IDLE_HEARTBEAT_MS, MIN_FRAME_GAP_MS);
|
|
1517
|
+
// `DAWG_FRAME_LOG=<file>`: one `WxH ms bytes` line per frame (the size
|
|
1518
|
+
// matrix in test/sizes-lib.ts reads frame times from it).
|
|
1519
|
+
const frameLog = process.env.DAWG_FRAME_LOG;
|
|
1520
|
+
const unwatchActivity = tui.activity.subscribe(() => frameGate.markDirty());
|
|
1521
|
+
const animating = (): boolean => {
|
|
1522
|
+
if (clock.playing || play?.on || auditionLoop?.looping) return true;
|
|
1523
|
+
const activity = tui.activity;
|
|
1524
|
+
if (activity.spinner || activity.streaming) return true;
|
|
1525
|
+
if (tui.delight.animating(Date.now())) return true;
|
|
1526
|
+
const latest = activity.latest;
|
|
1527
|
+
return (
|
|
1528
|
+
!tui.ui.reducedMotion &&
|
|
1529
|
+
latest !== undefined &&
|
|
1530
|
+
Date.now() - latest.atMs < CARD_GLOW_MS
|
|
1531
|
+
);
|
|
1532
|
+
};
|
|
1125
1533
|
const tick = (force = false) => {
|
|
1126
1534
|
if (screenSuspended) return;
|
|
1535
|
+
reportView();
|
|
1536
|
+
if (
|
|
1537
|
+
!frameGate.shouldBuild({
|
|
1538
|
+
nowMs: Date.now(),
|
|
1539
|
+
force,
|
|
1540
|
+
animating: animating(),
|
|
1541
|
+
keys: [
|
|
1542
|
+
score,
|
|
1543
|
+
record.revision,
|
|
1544
|
+
record.sessionId,
|
|
1545
|
+
record.meta.name,
|
|
1546
|
+
syncState,
|
|
1547
|
+
windowCount,
|
|
1548
|
+
panePresence,
|
|
1549
|
+
typesIndicator,
|
|
1550
|
+
menu.open,
|
|
1551
|
+
euclid.open,
|
|
1552
|
+
tui.ui.overlay,
|
|
1553
|
+
],
|
|
1554
|
+
})
|
|
1555
|
+
)
|
|
1556
|
+
return;
|
|
1127
1557
|
followCommitted();
|
|
1558
|
+
tui.delight.song(record.sessionId, record.meta.heardLoop === true);
|
|
1128
1559
|
play?.tick();
|
|
1129
1560
|
// Values in the menu follow the score as edits land.
|
|
1130
1561
|
if (menu.open) refreshMenu();
|
|
1131
1562
|
if (euclid.open) refreshEuclid();
|
|
1132
1563
|
refreshAuditionPicker();
|
|
1133
|
-
|
|
1564
|
+
// The gate already paces frames; the app's own throttle would drop an
|
|
1565
|
+
// approved frame that lands just after a forced one, and the gate would
|
|
1566
|
+
// not ask again until the heartbeat.
|
|
1567
|
+
const out = tui.render(appView(score, clock.beatAt()), { force: true });
|
|
1568
|
+
if (frameLog)
|
|
1569
|
+
appendFileSync(
|
|
1570
|
+
frameLog,
|
|
1571
|
+
`${stdout.columns ?? 0}x${stdout.rows ?? 0} ${tui.lastRenderMs.toFixed(3)} ${out.length}\n`,
|
|
1572
|
+
);
|
|
1134
1573
|
};
|
|
1135
1574
|
requestFrame = () => tick(true);
|
|
1575
|
+
// The first wrap is remembered per song in .dawg metadata, never the score.
|
|
1576
|
+
tui.onFirstLoop = () => {
|
|
1577
|
+
void port
|
|
1578
|
+
.updateMeta({ heardLoop: true })
|
|
1579
|
+
.then((result) => adoptMeta(result.meta))
|
|
1580
|
+
.catch(() => undefined);
|
|
1581
|
+
};
|
|
1136
1582
|
runPromptLater = (command) => {
|
|
1137
1583
|
queuedPrompts.unshift(command);
|
|
1138
1584
|
void drainQueue();
|
|
1139
1585
|
};
|
|
1586
|
+
runPromptsLater = (commands) => {
|
|
1587
|
+
queuedPrompts.unshift(...commands);
|
|
1588
|
+
void drainQueue();
|
|
1589
|
+
};
|
|
1140
1590
|
reportAgentActivity = () => {
|
|
1141
1591
|
tui.activity.applyAgentEvent({ type: "start", model: providerName });
|
|
1142
1592
|
};
|
|
1143
1593
|
agentEventSink = (event) => {
|
|
1144
1594
|
if (event.type === "usage") return;
|
|
1595
|
+
if (event.type === "command-typing") {
|
|
1596
|
+
showMeTyping(event.text);
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
if (event.type === "command") return;
|
|
1600
|
+
if (event.type === "tool-start") {
|
|
1601
|
+
const caption = toolCaption(event.name);
|
|
1602
|
+
if (caption) showMeCaption(caption);
|
|
1603
|
+
}
|
|
1604
|
+
if (event.type === "done" || event.type === "error") showMeFinish();
|
|
1145
1605
|
tui.activity.applyAgentEvent(event);
|
|
1146
1606
|
if (event.type === "done" || event.type === "error") agentReported = true;
|
|
1147
1607
|
};
|
|
@@ -1152,13 +1612,18 @@ async function runInteractive(): Promise<void> {
|
|
|
1152
1612
|
tick(true);
|
|
1153
1613
|
};
|
|
1154
1614
|
stdout.on("resize", onResize);
|
|
1615
|
+
const ownWrites = new OwnWrites();
|
|
1616
|
+
let presenceClients: readonly PresenceEntry[] = [];
|
|
1155
1617
|
let applying: Promise<void> = Promise.resolve();
|
|
1156
1618
|
const applyLatest = (latest: typeof record): Promise<void> =>
|
|
1157
1619
|
(applying = applying.then(() => applyRecord(latest)));
|
|
1158
1620
|
const applyRecord = async (latest: typeof record): Promise<void> => {
|
|
1159
1621
|
try {
|
|
1622
|
+
// Queued before a /fork or /resume swapped the session: stale.
|
|
1623
|
+
if (latest.sessionId !== record.sessionId) return;
|
|
1160
1624
|
if (latest.revision > record.revision) {
|
|
1161
1625
|
const previousRevision = record.revision;
|
|
1626
|
+
const before = score;
|
|
1162
1627
|
record = latest;
|
|
1163
1628
|
if (stageCapture)
|
|
1164
1629
|
stageCapture.committed = scoreFromJSON(record.composition);
|
|
@@ -1195,11 +1660,50 @@ async function runInteractive(): Promise<void> {
|
|
|
1195
1660
|
else if (payload.action === "toggle") await setTransport("toggle");
|
|
1196
1661
|
}
|
|
1197
1662
|
projectSync?.scoreChanged(score);
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1663
|
+
// Another window's play/pause is followed, not announced, and this
|
|
1664
|
+
// window's own writes (which can echo back before the append
|
|
1665
|
+
// resolves) are never "synced".
|
|
1666
|
+
const from = shownRevision(record, previousRevision);
|
|
1667
|
+
const to = shownRevision(record);
|
|
1668
|
+
await ownWrites.settled();
|
|
1669
|
+
const foreignList = foreignEvents(
|
|
1670
|
+
latest,
|
|
1671
|
+
previousRevision,
|
|
1672
|
+
ownWrites,
|
|
1673
|
+
).filter((event) => event.kind !== "transport");
|
|
1674
|
+
const foreign = foreignList.length > 0;
|
|
1675
|
+
// dawgd stamps each event's author: another pane's edit is its
|
|
1676
|
+
// letter and what it touched, only when it touches this pane's
|
|
1677
|
+
// track (§12.7); no "synced from another window" card.
|
|
1678
|
+
const letters = paneLetters(
|
|
1679
|
+
foreignList,
|
|
1680
|
+
presenceClients,
|
|
1681
|
+
port.clientId,
|
|
1682
|
+
);
|
|
1683
|
+
if (to !== from && foreign && letters.length > 0) {
|
|
1684
|
+
const edited = editedTrack(before, scoreFromJSON(latest.composition));
|
|
1685
|
+
if (edited === undefined || edited === requestedTrack)
|
|
1686
|
+
tui.activity.pushCard(
|
|
1687
|
+
`${letters.join(" ")} ✓ ${
|
|
1688
|
+
edited
|
|
1689
|
+
? (score.tracks.find((track) => track.id === edited)?.name ??
|
|
1690
|
+
edited)
|
|
1691
|
+
: "song"
|
|
1692
|
+
}`,
|
|
1693
|
+
{ tone: "info", baseRevision: from, resultRevision: to },
|
|
1694
|
+
);
|
|
1695
|
+
} else if (to !== from && foreign)
|
|
1696
|
+
tui.activity.pushCard(
|
|
1697
|
+
`synced · ${otherWindowName(presenceClients, port.clientId, {
|
|
1698
|
+
editedTrackId: editedTrack(
|
|
1699
|
+
before,
|
|
1700
|
+
scoreFromJSON(latest.composition),
|
|
1701
|
+
),
|
|
1702
|
+
trackName: (id) =>
|
|
1703
|
+
score.tracks.find((track) => track.id === id)?.name ?? id,
|
|
1704
|
+
})}`,
|
|
1705
|
+
{ tone: "info", baseRevision: from, resultRevision: to },
|
|
1706
|
+
);
|
|
1203
1707
|
}
|
|
1204
1708
|
} catch {
|
|
1205
1709
|
// An invalid composition is skipped; the next update retries.
|
|
@@ -1210,8 +1714,12 @@ async function runInteractive(): Promise<void> {
|
|
|
1210
1714
|
void applyLatest(update.record);
|
|
1211
1715
|
adoptMeta(update.record.meta);
|
|
1212
1716
|
} else if (update.type === "meta") adoptMeta(update.meta);
|
|
1213
|
-
else if (update.type === "presence")
|
|
1214
|
-
|
|
1717
|
+
else if (update.type === "presence") {
|
|
1718
|
+
presenceClients = update.clients;
|
|
1719
|
+
followPresence(panePresence, update.clients);
|
|
1720
|
+
panePresence = update.clients;
|
|
1721
|
+
windowCount = update.clients.length;
|
|
1722
|
+
} else if (update.type === "sync") syncState = update.sync;
|
|
1215
1723
|
else if (update.type === "transport") {
|
|
1216
1724
|
// Every window renders the same hit line from dawgd's timestamp.
|
|
1217
1725
|
const { playing, beat, bpm, atMs } = update.transport;
|
|
@@ -1225,11 +1733,24 @@ async function runInteractive(): Promise<void> {
|
|
|
1225
1733
|
});
|
|
1226
1734
|
}
|
|
1227
1735
|
};
|
|
1228
|
-
|
|
1736
|
+
// Each subscription answers only while its port is the live one: /fork and
|
|
1737
|
+
// /resume swap the port, and a load the old port started before the swap
|
|
1738
|
+
// could otherwise land afterwards and pull the old session's name and
|
|
1739
|
+
// score into the new one.
|
|
1740
|
+
const subscribeLive = (): (() => void) => {
|
|
1741
|
+
const bound = port;
|
|
1742
|
+
ownWrites.attach(bound);
|
|
1743
|
+
return bound.subscribe((update) => {
|
|
1744
|
+
if (bound === port) onUpdate(update);
|
|
1745
|
+
});
|
|
1746
|
+
};
|
|
1747
|
+
let unsubscribe = subscribeLive();
|
|
1229
1748
|
const refreshPresence = () =>
|
|
1230
1749
|
void port
|
|
1231
1750
|
.presence()
|
|
1232
1751
|
.then((clients) => {
|
|
1752
|
+
presenceClients = clients;
|
|
1753
|
+
panePresence = clients;
|
|
1233
1754
|
windowCount = Math.max(1, clients.length);
|
|
1234
1755
|
})
|
|
1235
1756
|
.catch(() => undefined);
|
|
@@ -1241,12 +1762,14 @@ async function runInteractive(): Promise<void> {
|
|
|
1241
1762
|
rebindPort = () => {
|
|
1242
1763
|
unsubscribe();
|
|
1243
1764
|
syncState = port.sync;
|
|
1244
|
-
unsubscribe =
|
|
1765
|
+
unsubscribe = subscribeLive();
|
|
1245
1766
|
refreshPresence();
|
|
1767
|
+
reportedView = "";
|
|
1246
1768
|
};
|
|
1247
1769
|
if (freshWorkspace)
|
|
1248
1770
|
tui.activity.pushCard("created .dawg/ · add it to .gitignore", {
|
|
1249
1771
|
tone: "info",
|
|
1772
|
+
once: true,
|
|
1250
1773
|
});
|
|
1251
1774
|
if (attachNotice)
|
|
1252
1775
|
tui.activity.pushCard(attachNotice, {
|
|
@@ -1256,11 +1779,15 @@ async function runInteractive(): Promise<void> {
|
|
|
1256
1779
|
});
|
|
1257
1780
|
if (port.status !== "file session")
|
|
1258
1781
|
tui.activity.pushCard(port.status, { tone: "info" });
|
|
1782
|
+
void launchCard?.then((card) => {
|
|
1783
|
+
if (card) tui.activity.pushCard(card.text, { tone: card.tone, once: true });
|
|
1784
|
+
});
|
|
1259
1785
|
if (await isProject(process.cwd()))
|
|
1260
1786
|
projectSync = startProjectSync(syncHost());
|
|
1261
1787
|
void currentProvider().then(() => tick(true));
|
|
1262
1788
|
tickUi = () => tick(true);
|
|
1263
1789
|
reportSampleProblems(score);
|
|
1790
|
+
if (launchArgs?.pane) void openPane(launchArgs.pane).then(() => tick(true));
|
|
1264
1791
|
// Input arrives through a detachable listener (not `for await`), so /login
|
|
1265
1792
|
// can hand the terminal to an interactive shell flow and take it back.
|
|
1266
1793
|
const inbox: string[] = [];
|
|
@@ -1277,8 +1804,28 @@ async function runInteractive(): Promise<void> {
|
|
|
1277
1804
|
stdin.on("end", onEnd);
|
|
1278
1805
|
async function* chunks(): AsyncGenerator<string> {
|
|
1279
1806
|
for (;;) {
|
|
1280
|
-
while (inbox.length === 0)
|
|
1281
|
-
|
|
1807
|
+
while (inbox.length === 0) {
|
|
1808
|
+
// A partial escape (Alt+[, a cut-off paste) is flushed after a short
|
|
1809
|
+
// idle so it never holds back the keys behind it.
|
|
1810
|
+
const held = inputDecoder.pending();
|
|
1811
|
+
const idle =
|
|
1812
|
+
held === "paste"
|
|
1813
|
+
? PASTE_FLUSH_MS
|
|
1814
|
+
: held === "escape"
|
|
1815
|
+
? ESCAPE_FLUSH_MS
|
|
1816
|
+
: undefined;
|
|
1817
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
1818
|
+
const timedOut = await new Promise<boolean>((resolve) => {
|
|
1819
|
+
wake = () => resolve(false);
|
|
1820
|
+
if (idle !== undefined)
|
|
1821
|
+
timeout = setTimeout(() => resolve(true), idle);
|
|
1822
|
+
});
|
|
1823
|
+
clearTimeout(timeout);
|
|
1824
|
+
if (timedOut && inbox.length === 0) {
|
|
1825
|
+
wake = undefined;
|
|
1826
|
+
yield INPUT_FLUSH;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1282
1829
|
wake = undefined;
|
|
1283
1830
|
const next = inbox.shift()!;
|
|
1284
1831
|
if (next === "\u0000eof") return;
|
|
@@ -1293,7 +1840,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1293
1840
|
stdin.pause();
|
|
1294
1841
|
stdin.setRawMode?.(false);
|
|
1295
1842
|
// Leave the alternate screen; restore the cursor and plain paste.
|
|
1296
|
-
stdout.write(
|
|
1843
|
+
stdout.write(terminalRestore());
|
|
1297
1844
|
try {
|
|
1298
1845
|
return await flow();
|
|
1299
1846
|
} finally {
|
|
@@ -1312,12 +1859,31 @@ async function runInteractive(): Promise<void> {
|
|
|
1312
1859
|
let exiting = false;
|
|
1313
1860
|
for await (const text of chunks()) {
|
|
1314
1861
|
// A read that is exactly ESC is the Esc key, not the start of a sequence.
|
|
1315
|
-
const values =
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1862
|
+
const values =
|
|
1863
|
+
text === INPUT_FLUSH
|
|
1864
|
+
? inputDecoder.flush()
|
|
1865
|
+
: [
|
|
1866
|
+
...inputDecoder.push(text),
|
|
1867
|
+
...(text === "\u001b" ? inputDecoder.flush() : []),
|
|
1868
|
+
];
|
|
1319
1869
|
while (values.length) {
|
|
1320
1870
|
const value = values.shift()!;
|
|
1871
|
+
// Below the minimum size only quit and play work; the hidden UI keeps
|
|
1872
|
+
// its state for when the terminal grows back (tui/app.ts).
|
|
1873
|
+
if (
|
|
1874
|
+
isTooSmall({ width: stdout.columns ?? 80, height: stdout.rows ?? 24 })
|
|
1875
|
+
) {
|
|
1876
|
+
const key = tooSmallKey(value);
|
|
1877
|
+
if (key === "quit") {
|
|
1878
|
+
exiting = true;
|
|
1879
|
+
break;
|
|
1880
|
+
}
|
|
1881
|
+
if (key === "play")
|
|
1882
|
+
void toggleTransport()
|
|
1883
|
+
.catch((error: unknown) => transportFailed(error))
|
|
1884
|
+
.finally(() => tick(true));
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1321
1887
|
// A mouse report acts on what the last frame painted under it; some
|
|
1322
1888
|
// become keys (a list row, a wheel notch) and run through below.
|
|
1323
1889
|
if (typeof value === "string" && isMouseSequence(value)) {
|
|
@@ -1328,6 +1894,14 @@ async function runInteractive(): Promise<void> {
|
|
|
1328
1894
|
}
|
|
1329
1895
|
// The fader drawer owns every key while it is up.
|
|
1330
1896
|
if (typeof value === "string" && fader && menu.open) {
|
|
1897
|
+
// Tab flips the knob front page and every param (design §8.6).
|
|
1898
|
+
if (value === "\t" && menu.knobPage && fader.typing === undefined) {
|
|
1899
|
+
const label = menu.pageKnobs(menuContext(), fader.label);
|
|
1900
|
+
if (label) fader.label = label;
|
|
1901
|
+
refreshMenu();
|
|
1902
|
+
tick(true);
|
|
1903
|
+
continue;
|
|
1904
|
+
}
|
|
1331
1905
|
const result = faderKeyPress(
|
|
1332
1906
|
fader,
|
|
1333
1907
|
menu.faderFields(menuContext()),
|
|
@@ -1341,9 +1915,9 @@ async function runInteractive(): Promise<void> {
|
|
|
1341
1915
|
continue;
|
|
1342
1916
|
}
|
|
1343
1917
|
}
|
|
1344
|
-
// The `?` panel closes on
|
|
1918
|
+
// The `?` panel closes on esc or ?; other keys wait (Ctrl-C quits).
|
|
1345
1919
|
if (tui.ui.keys && typeof value === "string" && value !== "\u0003") {
|
|
1346
|
-
tui.closeKeys();
|
|
1920
|
+
if (closesKeys(value)) tui.closeKeys();
|
|
1347
1921
|
tick(true);
|
|
1348
1922
|
continue;
|
|
1349
1923
|
}
|
|
@@ -1403,7 +1977,10 @@ async function runInteractive(): Promise<void> {
|
|
|
1403
1977
|
}
|
|
1404
1978
|
} else if (result.type === "audition")
|
|
1405
1979
|
auditionKeyPressed(result.key);
|
|
1406
|
-
else if (result.type === "fader")
|
|
1980
|
+
else if (result.type === "fader")
|
|
1981
|
+
openFader(
|
|
1982
|
+
menu.openKnobs(menuContext(), result.label) ?? result.label,
|
|
1983
|
+
);
|
|
1407
1984
|
else if (result.type === "revert") revertStaged();
|
|
1408
1985
|
else if (result.type === "hover")
|
|
1409
1986
|
hoverItem(result.command, result.key);
|
|
@@ -1452,6 +2029,20 @@ async function runInteractive(): Promise<void> {
|
|
|
1452
2029
|
tick(true);
|
|
1453
2030
|
continue;
|
|
1454
2031
|
}
|
|
2032
|
+
if (typeof value === "string" && tape.on && tapeInput(value)) {
|
|
2033
|
+
tick(true);
|
|
2034
|
+
continue;
|
|
2035
|
+
}
|
|
2036
|
+
// Ctrl-T opens TAPE (op1-ux §6); again (or esc) goes home.
|
|
2037
|
+
if (value === "\u0014" && tui.ui.overlay === undefined) {
|
|
2038
|
+
if (tape.on) receipt(exitTape());
|
|
2039
|
+
else
|
|
2040
|
+
void enterTape()
|
|
2041
|
+
.then((outcome) => receipt(outcome))
|
|
2042
|
+
.finally(() => tick(true));
|
|
2043
|
+
tick(true);
|
|
2044
|
+
continue;
|
|
2045
|
+
}
|
|
1455
2046
|
// Ctrl-P enters play mode. A bare `p` would steal the first letter of
|
|
1456
2047
|
// `pan`, `pattern`, `play` and every prose request starting with p.
|
|
1457
2048
|
if (value === "\u0010" && tui.ui.overlay === undefined) {
|
|
@@ -1474,12 +2065,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1474
2065
|
// Never await a daemon round trip here: the key loop must stay
|
|
1475
2066
|
// live for Esc, quit and redraws while the toggle is in flight.
|
|
1476
2067
|
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
|
-
})
|
|
2068
|
+
.catch((error: unknown) => transportFailed(error))
|
|
1483
2069
|
.finally(() => tick(true));
|
|
1484
2070
|
} else {
|
|
1485
2071
|
const input = tui.input(value);
|
|
@@ -1508,7 +2094,9 @@ async function runInteractive(): Promise<void> {
|
|
|
1508
2094
|
if (auditionLoop?.looping && isStageable(input.value))
|
|
1509
2095
|
hoverItem(input.value, `picker:${input.picker}`);
|
|
1510
2096
|
else if (input.picker === "pattern")
|
|
1511
|
-
previewPattern(
|
|
2097
|
+
previewPattern(
|
|
2098
|
+
input.value.replace(/^\/(?:pattern|groove)\s+/, ""),
|
|
2099
|
+
);
|
|
1512
2100
|
} else if (input.type === "pick-audition") {
|
|
1513
2101
|
pickerAuditionKey(input.key);
|
|
1514
2102
|
} else if (input.type === "pick-cancel") {
|
|
@@ -1560,6 +2148,16 @@ async function runInteractive(): Promise<void> {
|
|
|
1560
2148
|
else if (action?.kind === "cancel" && agentTurn) {
|
|
1561
2149
|
agentTurn.controller.abort();
|
|
1562
2150
|
tui.activity.setSpinner("cancelling");
|
|
2151
|
+
} else if (
|
|
2152
|
+
action?.kind === "submit" &&
|
|
2153
|
+
action.value &&
|
|
2154
|
+
agentTurn &&
|
|
2155
|
+
routeDuringTurn(action.value, parsesLocally) === "local"
|
|
2156
|
+
) {
|
|
2157
|
+
// A typed command never waits on the agent: it commits its own
|
|
2158
|
+
// revision now, and the agent's next call re-reads the score.
|
|
2159
|
+
const value = action.value;
|
|
2160
|
+
void runLocalBesideAgent(value).finally(() => tick(true));
|
|
1563
2161
|
} else if (action?.kind === "submit" && action.value && agentTurn) {
|
|
1564
2162
|
// Enter during a turn steers it; Alt-Enter still queues a follow-up.
|
|
1565
2163
|
agentTurn.steering.push(action.value);
|
|
@@ -1590,17 +2188,36 @@ async function runInteractive(): Promise<void> {
|
|
|
1590
2188
|
stdin.off("end", onEnd);
|
|
1591
2189
|
clearInterval(timer);
|
|
1592
2190
|
clearInterval(presenceTimer);
|
|
1593
|
-
await projectSync?.stop();
|
|
1594
|
-
namer.dispose();
|
|
1595
2191
|
stdout.off("resize", onResize);
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
audio.stop();
|
|
1600
|
-
await port.close();
|
|
1601
|
-
stdin.setRawMode?.(false);
|
|
2192
|
+
// The terminal first: a teardown step that throws (a read-only .dawg,
|
|
2193
|
+
// a dead engine) must not leave the shell on the alternate screen.
|
|
2194
|
+
restoreTerminal();
|
|
1602
2195
|
stdin.pause();
|
|
1603
|
-
|
|
2196
|
+
console.error = consoleError;
|
|
2197
|
+
console.warn = consoleWarn;
|
|
2198
|
+
process.off("warning", onWarning);
|
|
2199
|
+
unwatchActivity();
|
|
2200
|
+
// Each step on its own: one failure does not skip the rest.
|
|
2201
|
+
const failures: unknown[] = [];
|
|
2202
|
+
const steps: Array<() => unknown> = [
|
|
2203
|
+
() => projectSync?.stop(),
|
|
2204
|
+
() => namer.dispose(),
|
|
2205
|
+
() => unsubscribe(),
|
|
2206
|
+
() => play?.exit(),
|
|
2207
|
+
() => monitorEngine?.dispose(),
|
|
2208
|
+
() => audio.stop(),
|
|
2209
|
+
() => port.close(),
|
|
2210
|
+
];
|
|
2211
|
+
for (const step of steps) {
|
|
2212
|
+
try {
|
|
2213
|
+
await step();
|
|
2214
|
+
} catch (error) {
|
|
2215
|
+
failures.push(error);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
process.off("unhandledRejection", onRejection);
|
|
2219
|
+
process.off("uncaughtException", onUncaught);
|
|
2220
|
+
if (failures.length > 0) throw failures[0];
|
|
1604
2221
|
}
|
|
1605
2222
|
}
|
|
1606
2223
|
|
|
@@ -1615,23 +2232,23 @@ function unknownCommand(command: string): string {
|
|
|
1615
2232
|
|
|
1616
2233
|
async function submit(prompt: string): Promise<string | Receipt> {
|
|
1617
2234
|
const command = prompt.trim();
|
|
2235
|
+
// Canonical names for window commands: `model key` (login), `models`,
|
|
2236
|
+
// `voice` (the Voice topic).
|
|
2237
|
+
const canonical = canonicalWindowForm(command);
|
|
2238
|
+
if (canonical) return submit(canonical);
|
|
1618
2239
|
const helpCommand = command.match(/^\/?(?:help|\?)(?:\s+(\S+))?$/i);
|
|
1619
2240
|
if (helpCommand) {
|
|
1620
2241
|
const topic = helpCommand[1];
|
|
2242
|
+
// The panel's inner width (tui/app.ts paintText): rows clip there with
|
|
2243
|
+
// an ellipsis, the same as every other panel.
|
|
2244
|
+
const columns = stdout.columns ?? 80;
|
|
1621
2245
|
const lines = helpTopicLines(
|
|
1622
2246
|
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",
|
|
2247
|
+
Math.max(10, columns - (columns >= 60 ? 8 : 4)),
|
|
1634
2248
|
);
|
|
2249
|
+
if (!lines) return fail(helpMiss(topic!));
|
|
2250
|
+
tui.openText(helpTitle(topic), lines);
|
|
2251
|
+
return ok(topic ? helpTitle(topic) : "help · help all for every command");
|
|
1635
2252
|
}
|
|
1636
2253
|
if (/^\/?tracks$/i.test(command)) {
|
|
1637
2254
|
const problems = await sampleProblems(score);
|
|
@@ -1648,7 +2265,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1648
2265
|
return {
|
|
1649
2266
|
label: track.id,
|
|
1650
2267
|
value: `/track ${track.id}`,
|
|
1651
|
-
detail: `${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}`,
|
|
2268
|
+
detail: `${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}${paneMarks(track.id)}`,
|
|
1652
2269
|
current: track.id === requestedTrack,
|
|
1653
2270
|
};
|
|
1654
2271
|
});
|
|
@@ -1663,7 +2280,39 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1663
2280
|
items.findIndex((item) => item.current),
|
|
1664
2281
|
),
|
|
1665
2282
|
});
|
|
1666
|
-
return
|
|
2283
|
+
return note(`${items.length} track${items.length === 1 ? "" : "s"}`);
|
|
2284
|
+
}
|
|
2285
|
+
if (/^\/?(?:instruments|instrument\s+(?:list|ls|presets))$/i.test(command)) {
|
|
2286
|
+
// `instrument list`: every word `instrument <name>` takes, by family.
|
|
2287
|
+
const columns = stdout.columns ?? 80;
|
|
2288
|
+
const lines = instrumentListLines(
|
|
2289
|
+
Math.max(10, columns - (columns >= 60 ? 8 : 4)),
|
|
2290
|
+
);
|
|
2291
|
+
tui.openText("instruments", lines);
|
|
2292
|
+
return note("instruments · instrument <name> on the focused track");
|
|
2293
|
+
}
|
|
2294
|
+
if (/^\/?notes(?:\s+(?:list|ls))?$/i.test(command)) {
|
|
2295
|
+
// `notes`: the focused track's note ids, the names `remove <id>` takes.
|
|
2296
|
+
const notes = score.notes
|
|
2297
|
+
.filter((entry) => entry.trackId === requestedTrack)
|
|
2298
|
+
.slice()
|
|
2299
|
+
.sort((a, b) => a.startTick - b.startTick);
|
|
2300
|
+
const lines = notes.map(
|
|
2301
|
+
(entry) =>
|
|
2302
|
+
`${entry.id} · ${midiNoteName(entry.pitch)} · beat ${+(entry.startTick / score.ticksPerBeat).toFixed(2)}`,
|
|
2303
|
+
);
|
|
2304
|
+
if (lines.length === 0) return note(`notes · ${requestedTrack} has none`);
|
|
2305
|
+
tui.openText(`notes · ${requestedTrack}`, lines);
|
|
2306
|
+
return note(
|
|
2307
|
+
`${lines.length} note${lines.length === 1 ? "" : "s"} · ${requestedTrack} · remove <id>`,
|
|
2308
|
+
);
|
|
2309
|
+
}
|
|
2310
|
+
const tapeCommand = command.match(/^\/tape(?:\s+(on|off))?$/i);
|
|
2311
|
+
if (tapeCommand) {
|
|
2312
|
+
const wanted = tapeCommand[1]?.toLowerCase();
|
|
2313
|
+
if (wanted === "off" || (wanted === undefined && tape.on))
|
|
2314
|
+
return exitTape();
|
|
2315
|
+
return enterTape();
|
|
1667
2316
|
}
|
|
1668
2317
|
const playCommand = command.match(
|
|
1669
2318
|
/^\/play(?:\s+(on|off|degrees|in-key|chromatic))?$/i,
|
|
@@ -1696,6 +2345,41 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1696
2345
|
);
|
|
1697
2346
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1698
2347
|
}
|
|
2348
|
+
const audioCommand = command.match(/^\/audio(?:\s+(.*))?$/i);
|
|
2349
|
+
if (audioCommand) {
|
|
2350
|
+
const result = await runAudioCommand(audioCommand[1] ?? "", {
|
|
2351
|
+
info: audioInfo(),
|
|
2352
|
+
setOutput: (name) => {
|
|
2353
|
+
if (audio instanceof AudioEngine) audio.setDevice(name);
|
|
2354
|
+
monitorEngine?.setDevice(name);
|
|
2355
|
+
},
|
|
2356
|
+
background: (task) => void task,
|
|
2357
|
+
});
|
|
2358
|
+
audioMenuCache = undefined;
|
|
2359
|
+
if (result.ok && result.lines && /^\/audio(\s+test)?\s*$/i.test(command))
|
|
2360
|
+
tui.openText(/test/i.test(command) ? "audio test" : "audio", [
|
|
2361
|
+
...result.lines,
|
|
2362
|
+
]);
|
|
2363
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2364
|
+
}
|
|
2365
|
+
const showMeCommand = command.match(/^\/show-?me(?:\s+(\S+))?$/i);
|
|
2366
|
+
if (showMeCommand) {
|
|
2367
|
+
if (!showMeCommand[1])
|
|
2368
|
+
return ok(`show me · ${showMe.level} · /showme on|quiet|off`);
|
|
2369
|
+
const level = parseShowMe(showMeCommand[1]);
|
|
2370
|
+
if (!level) return fail("usage · /showme on|quiet|off");
|
|
2371
|
+
showMe.level = level;
|
|
2372
|
+
void writeConfig({ dir: configDir() }, { showMe: level }).catch(
|
|
2373
|
+
() => undefined,
|
|
2374
|
+
);
|
|
2375
|
+
return ok(
|
|
2376
|
+
level === "off"
|
|
2377
|
+
? "show me off · the agent edits with tools"
|
|
2378
|
+
: level === "quiet"
|
|
2379
|
+
? "show me quiet · the agent types commands, no captions"
|
|
2380
|
+
: "show me on · the agent types commands, slides faders, plays keys",
|
|
2381
|
+
);
|
|
2382
|
+
}
|
|
1699
2383
|
const countIn = command.match(/^\/count-?in\s+([0-2])$/i);
|
|
1700
2384
|
if (countIn) return ok(playSession().setCountIn(Number(countIn[1])));
|
|
1701
2385
|
const euclidCommand = command.match(/^\/euclid(?:\s+(\S+))?$/i);
|
|
@@ -1710,21 +2394,75 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1710
2394
|
}
|
|
1711
2395
|
const tryCommand = command.match(/^\/try(?:\s+(.+))?$/i);
|
|
1712
2396
|
if (tryCommand) return tryPrompt(tryCommand[1]?.trim() ?? "");
|
|
1713
|
-
// A bare
|
|
1714
|
-
// opens the fader drawer on it, with its
|
|
1715
|
-
|
|
1716
|
-
|
|
2397
|
+
// A bare scalar (`volume`, `pan`, `fx reverb mix`, and the song's
|
|
2398
|
+
// `tempo`, `bars` and `meter`) opens the fader drawer on it, with its
|
|
2399
|
+
// related params stacked below.
|
|
2400
|
+
if (
|
|
2401
|
+
/^\/?(?:volume|pan|tempo|bpm|bars|meter|fx\s+\S+(?:\s+\S+)?)$/i.test(
|
|
2402
|
+
command,
|
|
2403
|
+
)
|
|
2404
|
+
) {
|
|
2405
|
+
const context = menuContext();
|
|
2406
|
+
const opened =
|
|
2407
|
+
menu.showFader(context, command) ?? songFader(context, command);
|
|
2408
|
+
// Bare `volume` (and every scalar) opens on its knob page, when its
|
|
2409
|
+
// level has one and the param is a knob: the four knobs up front.
|
|
2410
|
+
const label = opened && (menu.openKnobs(context, opened) ?? opened);
|
|
1717
2411
|
if (label) {
|
|
2412
|
+
const field = menu
|
|
2413
|
+
.faderFields(context)
|
|
2414
|
+
.find((candidate) => candidate.label === label);
|
|
2415
|
+
// The receipt names the value it opened on: `tempo · 120 BPM · …`.
|
|
2416
|
+
const value =
|
|
2417
|
+
field?.kind === "number"
|
|
2418
|
+
? field.value !== undefined
|
|
2419
|
+
? field.format(field.value)
|
|
2420
|
+
: field.off
|
|
2421
|
+
: undefined;
|
|
1718
2422
|
openFader(label, true);
|
|
1719
2423
|
refreshMenu();
|
|
1720
|
-
return ok(
|
|
2424
|
+
return ok(
|
|
2425
|
+
`${label}${value ? ` · ${value}` : ""} · ${
|
|
2426
|
+
menu.knobPage === "knobs"
|
|
2427
|
+
? "↑↓ knob · ←→ turn · tab all"
|
|
2428
|
+
: "←→ adjust · enter keep · esc revert"
|
|
2429
|
+
}`,
|
|
2430
|
+
);
|
|
1721
2431
|
}
|
|
1722
2432
|
}
|
|
2433
|
+
// `mix`: the drawer's mixer page, every track's level as a fader.
|
|
2434
|
+
// `knobs [sound|mix|master|tempo|fx <effect>]`: a page's four knobs.
|
|
2435
|
+
const knobsCommand = command.match(/^\/?(?:(mix)|knobs(?:\s+(.+))?)$/i);
|
|
2436
|
+
if (knobsCommand) {
|
|
2437
|
+
const context = menuContext();
|
|
2438
|
+
const track = context.score.tracks.find((t) => t.id === context.trackId);
|
|
2439
|
+
const label = knobsCommand[1]
|
|
2440
|
+
? openMixer(context)
|
|
2441
|
+
: openKnobPage(context, knobsCommand[2], track);
|
|
2442
|
+
if (!label)
|
|
2443
|
+
return fail(
|
|
2444
|
+
knobsCommand[1]
|
|
2445
|
+
? "mix · no tracks yet · track <name>"
|
|
2446
|
+
: `knobs ${knobsCommand[2] ?? ""} · no such page · knobs ${KNOB_PAGE_WORDS.join("|")}`,
|
|
2447
|
+
);
|
|
2448
|
+
openFader(label, true);
|
|
2449
|
+
refreshMenu();
|
|
2450
|
+
return ok(
|
|
2451
|
+
knobsCommand[1]
|
|
2452
|
+
? "mix · ↑↓ track · ←→ level · tab this track · enter keep · esc revert"
|
|
2453
|
+
: `knobs · ${menu.knobPageId} · ↑↓ knob · ←→ turn · tab all params`,
|
|
2454
|
+
);
|
|
2455
|
+
}
|
|
1723
2456
|
const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
|
|
1724
2457
|
if (menuCommand) {
|
|
1725
2458
|
const section = menuCommand[1]?.toLowerCase();
|
|
1726
|
-
|
|
1727
|
-
|
|
2459
|
+
// §4: the keys topic has no menu page; it opens the ? panel.
|
|
2460
|
+
if (section && (section === "keys" || TOPIC_ALIASES[section] === "keys")) {
|
|
2461
|
+
tui.showKeys("keys", keyLines(KEYS.prompt));
|
|
2462
|
+
return ok("keys");
|
|
2463
|
+
}
|
|
2464
|
+
if (section && !menuSectionPath(menuContext(), section))
|
|
2465
|
+
return fail(menuUsage(menuContext(), section));
|
|
1728
2466
|
openMenu(section);
|
|
1729
2467
|
return ok("menu");
|
|
1730
2468
|
}
|
|
@@ -1735,12 +2473,18 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1735
2473
|
? ok(message)
|
|
1736
2474
|
: fail("usage: /grid 1/4|1/8|1/8T|1/16|1/16T|1/32");
|
|
1737
2475
|
}
|
|
2476
|
+
const paneReceipt = paneCommand(command);
|
|
2477
|
+
if (paneReceipt) return paneReceipt;
|
|
1738
2478
|
if (/^\/status$/i.test(command))
|
|
1739
2479
|
return ok(
|
|
1740
2480
|
`status · ${record.meta.name} · rev ${record.revision} · ${compositionDigest(record.composition)} · ${port.mode === "daemon" ? "shared via dawgd" : "saved locally · no daemon"}`,
|
|
1741
2481
|
);
|
|
1742
|
-
|
|
1743
|
-
if (
|
|
2482
|
+
const history = /^\/?(undo|redo)(?:\s+(all))?$/i.exec(command);
|
|
2483
|
+
if (history)
|
|
2484
|
+
return stepHistory(
|
|
2485
|
+
history[1]!.toLowerCase() as "undo" | "redo",
|
|
2486
|
+
history[2] ? "all" : "pane",
|
|
2487
|
+
);
|
|
1744
2488
|
const trackCommand = command.match(
|
|
1745
2489
|
/^\/?(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/i,
|
|
1746
2490
|
);
|
|
@@ -1754,6 +2498,73 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1754
2498
|
if (requestedTrack !== grainTrack[1]!.toLowerCase()) return focused;
|
|
1755
2499
|
return submit(grainTrack[2]!);
|
|
1756
2500
|
}
|
|
2501
|
+
// `/track rm <name>` (aliases remove, delete) and `/track move <name>
|
|
2502
|
+
// <position>`: the human surface for the removeTrack and moveTrack
|
|
2503
|
+
// operations. Removing a track also drops any vocoder src or autotune from
|
|
2504
|
+
// that named it; ^z brings everything back.
|
|
2505
|
+
const trackEdit = command.match(
|
|
2506
|
+
/^\/?track\s+(rm|remove|delete|move)\s+(.{1,64}?)\s*$/i,
|
|
2507
|
+
);
|
|
2508
|
+
if (trackEdit) {
|
|
2509
|
+
const verb = trackEdit[1]!.toLowerCase() === "move" ? "move" : "rm";
|
|
2510
|
+
let rest = trackEdit[2]!.trim();
|
|
2511
|
+
let position: number | undefined;
|
|
2512
|
+
if (verb === "move") {
|
|
2513
|
+
const tail = rest.match(/^(.+?)\s+(\d{1,3})$/);
|
|
2514
|
+
if (!tail)
|
|
2515
|
+
return fail(`usage · /track move <name> <1..${score.tracks.length}>`);
|
|
2516
|
+
rest = tail[1]!;
|
|
2517
|
+
position = Number(tail[2]);
|
|
2518
|
+
}
|
|
2519
|
+
const wanted = rest
|
|
2520
|
+
.replace(/^["']|["']$/g, "")
|
|
2521
|
+
.trim()
|
|
2522
|
+
.replace(/\s+/g, " ")
|
|
2523
|
+
.toLowerCase();
|
|
2524
|
+
const found = score.tracks.find(
|
|
2525
|
+
(track) =>
|
|
2526
|
+
track.id.toLowerCase() === wanted ||
|
|
2527
|
+
track.id.toLowerCase() === wanted.replace(/ /g, "-") ||
|
|
2528
|
+
(track.name ?? "").toLowerCase() === wanted,
|
|
2529
|
+
);
|
|
2530
|
+
if (!found) return fail(noTrack(rest));
|
|
2531
|
+
if (verb === "move") {
|
|
2532
|
+
if (position! < 1 || position! > score.tracks.length)
|
|
2533
|
+
return fail(`usage · /track move <name> <1..${score.tracks.length}>`);
|
|
2534
|
+
const next = applyScoreOperation(score, {
|
|
2535
|
+
type: "moveTrack",
|
|
2536
|
+
trackId: found.id,
|
|
2537
|
+
index: position! - 1,
|
|
2538
|
+
});
|
|
2539
|
+
await commitScore(next, "track.move", { trackId: found.id });
|
|
2540
|
+
await projectSync?.flushScore();
|
|
2541
|
+
return ok(`moved ${found.id} to position ${position}`);
|
|
2542
|
+
}
|
|
2543
|
+
if (score.tracks.length <= 1)
|
|
2544
|
+
return fail("the last track stays · /clear empties it");
|
|
2545
|
+
const next = applyScoreOperation(score, {
|
|
2546
|
+
type: "removeTrack",
|
|
2547
|
+
trackId: found.id,
|
|
2548
|
+
});
|
|
2549
|
+
const dropped = score.tracks
|
|
2550
|
+
.filter((track) => track.id !== found.id)
|
|
2551
|
+
.filter((track) => {
|
|
2552
|
+
const after = next.tracks.find((t) => t.id === track.id);
|
|
2553
|
+
return (
|
|
2554
|
+
(track.vocoder?.src !== undefined &&
|
|
2555
|
+
after?.vocoder?.src === undefined) ||
|
|
2556
|
+
(track.autotune?.from !== undefined &&
|
|
2557
|
+
after?.autotune?.from === undefined)
|
|
2558
|
+
);
|
|
2559
|
+
})
|
|
2560
|
+
.map((track) => track.id);
|
|
2561
|
+
await commitScore(next, "track.remove", { trackId: found.id });
|
|
2562
|
+
await projectSync?.flushScore();
|
|
2563
|
+
if (found.id === requestedTrack) await focusTrack(next.tracks[0]!.id);
|
|
2564
|
+
return ok(
|
|
2565
|
+
`removed ${found.id}${dropped.length ? ` · dropped references on ${dropped.join(", ")}` : ""} · ctrl-z undoes`,
|
|
2566
|
+
);
|
|
2567
|
+
}
|
|
1757
2568
|
// `/track piano b`: a name with spaces focuses the track of that name, or
|
|
1758
2569
|
// creates `piano-b` named "piano b".
|
|
1759
2570
|
const namedTrack = command.match(/^\/track\s+([a-z0-9._ -]{1,64})$/i);
|
|
@@ -1778,12 +2589,90 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1778
2589
|
}
|
|
1779
2590
|
const resample = parseResampleCommand(command);
|
|
1780
2591
|
if (resample) return resampleCommand(resample);
|
|
2592
|
+
// 0.7 clips: `/clip` edits and `/lyrics` on the focused track.
|
|
2593
|
+
const clipCommand = parseClipCommand(command);
|
|
2594
|
+
if (clipCommand) {
|
|
2595
|
+
if ("error" in clipCommand) return fail(clipCommand.error);
|
|
2596
|
+
const result = await runClipCommand(clipCommand, {
|
|
2597
|
+
score,
|
|
2598
|
+
trackId: requestedTrack,
|
|
2599
|
+
cwd: process.cwd(),
|
|
2600
|
+
});
|
|
2601
|
+
if (!result.ok) return fail(result.message);
|
|
2602
|
+
if (result.next && result.kind) {
|
|
2603
|
+
await commitScore(result.next, result.kind, {
|
|
2604
|
+
trackId: requestedTrack,
|
|
2605
|
+
...result.payload,
|
|
2606
|
+
});
|
|
2607
|
+
await projectSync?.flushScore();
|
|
2608
|
+
}
|
|
2609
|
+
return ok(result.message);
|
|
2610
|
+
}
|
|
2611
|
+
const lyricsCommand = parseLyricsCommand(command);
|
|
2612
|
+
if (lyricsCommand) {
|
|
2613
|
+
const result = applyLyrics(score, requestedTrack, lyricsCommand);
|
|
2614
|
+
if (!result.ok) return fail(result.message);
|
|
2615
|
+
if (result.next && result.kind) {
|
|
2616
|
+
await commitScore(result.next, result.kind, {
|
|
2617
|
+
trackId: requestedTrack,
|
|
2618
|
+
...result.payload,
|
|
2619
|
+
});
|
|
2620
|
+
await projectSync?.flushScore();
|
|
2621
|
+
}
|
|
2622
|
+
return ok(result.message);
|
|
2623
|
+
}
|
|
2624
|
+
// 0.7 autotune: `/autotune …` (and `/tune <preset>` pointing here).
|
|
2625
|
+
// `/vocal autotune …` takes the same path (presets panel, draft, commit).
|
|
2626
|
+
const autotune = parseAutotuneCommand(
|
|
2627
|
+
command.replace(/^\/?vocal\s+(?=autotune\b)/i, ""),
|
|
2628
|
+
);
|
|
2629
|
+
if (autotune) {
|
|
2630
|
+
if (autotune.type === "autotune-list")
|
|
2631
|
+
tui.openText("autotune presets", autotuneListLines());
|
|
2632
|
+
if (autotune.type !== "autotune-usage" && autotune.type !== "autotune-list")
|
|
2633
|
+
await materializeDraft();
|
|
2634
|
+
const result = applyAutotuneCommand(score, requestedTrack, autotune);
|
|
2635
|
+
if (result.next && result.kind)
|
|
2636
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2637
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2638
|
+
}
|
|
2639
|
+
// 0.7 Voice: `/vocal <verb>`; lanes register verbs in VOCAL_VERBS.
|
|
2640
|
+
setClipImportDeps({
|
|
2641
|
+
media: {
|
|
2642
|
+
...mediaServices(),
|
|
2643
|
+
signal: new AbortController().signal,
|
|
2644
|
+
progress: () => undefined,
|
|
2645
|
+
},
|
|
2646
|
+
});
|
|
2647
|
+
const vocal = parseVocalCommand(command);
|
|
2648
|
+
if (vocal) {
|
|
2649
|
+
if (vocal.kind === "verb") await materializeDraft();
|
|
2650
|
+
const result = await runVocalCommand(vocal, {
|
|
2651
|
+
score,
|
|
2652
|
+
trackId: requestedTrack,
|
|
2653
|
+
cwd: process.cwd(),
|
|
2654
|
+
});
|
|
2655
|
+
if (!result.ok) return fail(result.message);
|
|
2656
|
+
if (result.next && result.kind) {
|
|
2657
|
+
await commitScore(result.next, result.kind, {
|
|
2658
|
+
trackId: requestedTrack,
|
|
2659
|
+
...result.payload,
|
|
2660
|
+
});
|
|
2661
|
+
await projectSync?.flushScore();
|
|
2662
|
+
}
|
|
2663
|
+
// A verb that made or chose another track (a vocoder carrier): focus it.
|
|
2664
|
+
if (result.trackId && result.trackId !== requestedTrack) {
|
|
2665
|
+
await port.focus(result.trackId);
|
|
2666
|
+
requestedTrack = result.trackId;
|
|
2667
|
+
}
|
|
2668
|
+
return ok(result.message);
|
|
2669
|
+
}
|
|
1781
2670
|
const pack = parsePackCommand(command);
|
|
1782
2671
|
if (pack) return packCommand(pack);
|
|
1783
2672
|
const pattern = parsePatternCommand(command);
|
|
1784
2673
|
if (pattern) return patternCommand(pattern);
|
|
1785
2674
|
const kit = parseKitCommand(command);
|
|
1786
|
-
if (kit) return kitCommand(kit,
|
|
2675
|
+
if (kit) return kitCommand(kit, /^\/?kits?\s*$/i.test(command.trim()));
|
|
1787
2676
|
const wavetable = parseWavetableCommand(command);
|
|
1788
2677
|
if (wavetable) return wavetableCommand(wavetable);
|
|
1789
2678
|
const sessionReply = await sessionCommand(command);
|
|
@@ -1821,11 +2710,24 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1821
2710
|
await commitScore(result.next, result.kind, result.payload);
|
|
1822
2711
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1823
2712
|
}
|
|
2713
|
+
const progression = parseProgressionCommand(command);
|
|
2714
|
+
if (progression) {
|
|
2715
|
+
if (progression.type === "progression") await materializeDraft();
|
|
2716
|
+
const result = applyProgressionCommand(
|
|
2717
|
+
score,
|
|
2718
|
+
requestedTrack,
|
|
2719
|
+
progression,
|
|
2720
|
+
() => newId("n"),
|
|
2721
|
+
);
|
|
2722
|
+
if (result.next && result.kind)
|
|
2723
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2724
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2725
|
+
}
|
|
1824
2726
|
const strum = parseStrumCommand(command);
|
|
1825
2727
|
if (strum) {
|
|
1826
2728
|
if (strum.type === "strum") await materializeDraft();
|
|
1827
2729
|
const result = applyStrumCommand(score, requestedTrack, strum, () =>
|
|
1828
|
-
|
|
2730
|
+
newId("n"),
|
|
1829
2731
|
);
|
|
1830
2732
|
if (result.next && result.kind)
|
|
1831
2733
|
await commitScore(result.next, result.kind, result.payload);
|
|
@@ -1836,6 +2738,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1836
2738
|
if (rig.type !== "rig-show" && rig.type !== "rig-hint")
|
|
1837
2739
|
await materializeDraft();
|
|
1838
2740
|
const result = applyRigCommand(score, requestedTrack, rig);
|
|
2741
|
+
if (result.next && result.kind)
|
|
2742
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2743
|
+
return readOrDone(result);
|
|
2744
|
+
}
|
|
2745
|
+
// 0.7 `/formant` and `/vowel`: short forms of `fx formant|vowel`.
|
|
2746
|
+
const formant = parseFormantCommand(command) ?? parseVowelCommand(command);
|
|
2747
|
+
if (formant) {
|
|
2748
|
+
if (formant.type === "fx") await materializeDraft();
|
|
2749
|
+
const result = applyFormantCommand(score, requestedTrack, formant);
|
|
1839
2750
|
if (result.next && result.kind)
|
|
1840
2751
|
await commitScore(result.next, result.kind, result.payload);
|
|
1841
2752
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
@@ -1857,6 +2768,8 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1857
2768
|
const result = applyFxCommand(score, requestedTrack, fx, pinnedIr);
|
|
1858
2769
|
if (result.next && result.kind)
|
|
1859
2770
|
await commitScore(result.next, result.kind, result.payload);
|
|
2771
|
+
// Bare `fx` reads the chain (`•`); it changes nothing.
|
|
2772
|
+
if (result.ok && fx.type === "fx-list") return note(result.message);
|
|
1860
2773
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1861
2774
|
}
|
|
1862
2775
|
const unknownFx = unknownFxMessage(command);
|
|
@@ -1872,6 +2785,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1872
2785
|
}
|
|
1873
2786
|
const tuning = parseTuningCommand(command);
|
|
1874
2787
|
if (tuning) return tuningCommand(tuning);
|
|
2788
|
+
const calibration = parseCalibrationCommand(command);
|
|
2789
|
+
if (calibration) {
|
|
2790
|
+
const result = applyCalibrationCommand(score, calibration);
|
|
2791
|
+
if (result.next && result.kind)
|
|
2792
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2793
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2794
|
+
}
|
|
2795
|
+
const recordCommand = parseRecordCommand(command);
|
|
2796
|
+
if (recordCommand) return keysRecord(recordCommand);
|
|
1875
2797
|
const keysCommand = parseKeysCommand(command);
|
|
1876
2798
|
if (keysCommand) {
|
|
1877
2799
|
const reads =
|
|
@@ -1893,7 +2815,26 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1893
2815
|
await commitScore(result.next, result.kind, result.payload);
|
|
1894
2816
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1895
2817
|
}
|
|
1896
|
-
const
|
|
2818
|
+
const vocoderCommand = parseVocoderCommand(command);
|
|
2819
|
+
if (vocoderCommand) {
|
|
2820
|
+
if (vocoderCommand.type === "vocoder-list")
|
|
2821
|
+
tui.openText("vocoder presets", vocoderListLines());
|
|
2822
|
+
if (
|
|
2823
|
+
vocoderCommand.type !== "vocoder-list" &&
|
|
2824
|
+
vocoderCommand.type !== "vocoder-usage"
|
|
2825
|
+
)
|
|
2826
|
+
await materializeDraft();
|
|
2827
|
+
const result = applyVocoderCommand(score, requestedTrack, vocoderCommand);
|
|
2828
|
+
if (result.next && result.kind)
|
|
2829
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2830
|
+
// `/vocoder` on a vocal makes a carrier track: focus it.
|
|
2831
|
+
if (result.trackId && result.trackId !== requestedTrack) {
|
|
2832
|
+
await port.focus(result.trackId);
|
|
2833
|
+
requestedTrack = result.trackId;
|
|
2834
|
+
}
|
|
2835
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2836
|
+
}
|
|
2837
|
+
const windCommand = parseWindCommand(command);
|
|
1897
2838
|
if (windCommand) {
|
|
1898
2839
|
if (windCommand.type === "wind-list")
|
|
1899
2840
|
tui.openText("wind presets", windListLines());
|
|
@@ -1904,13 +2845,34 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1904
2845
|
await commitScore(result.next, result.kind, result.payload);
|
|
1905
2846
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1906
2847
|
}
|
|
2848
|
+
const singCommand = parseSingCommand(command);
|
|
2849
|
+
if (singCommand) {
|
|
2850
|
+
if (singCommand.type === "sing-list")
|
|
2851
|
+
tui.openText("sing presets", singListLines());
|
|
2852
|
+
if (singCommand.type !== "sing-show" && singCommand.type !== "sing-list")
|
|
2853
|
+
await materializeDraft();
|
|
2854
|
+
const result = applySingCommand(score, requestedTrack, singCommand);
|
|
2855
|
+
if (result.next && result.kind)
|
|
2856
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2857
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2858
|
+
}
|
|
1907
2859
|
const synth = parseSynthCommand(command);
|
|
1908
2860
|
if (synth) {
|
|
1909
2861
|
if (synth.type !== "synth-list") await materializeDraft();
|
|
1910
2862
|
const result = applySynthCommand(score, requestedTrack, synth);
|
|
1911
2863
|
if (result.next && result.kind)
|
|
1912
2864
|
await commitScore(result.next, result.kind, result.payload);
|
|
1913
|
-
|
|
2865
|
+
// Bare `synth` (design §8.6) also opens the sound page's four knobs.
|
|
2866
|
+
if (synth.type === "synth-list" && result.ok && !stageCapture) {
|
|
2867
|
+
const context = menuContext();
|
|
2868
|
+
const track = context.score.tracks.find((t) => t.id === context.trackId);
|
|
2869
|
+
const label = openKnobPage(context, "sound", track);
|
|
2870
|
+
if (label) {
|
|
2871
|
+
openFader(label, true);
|
|
2872
|
+
refreshMenu();
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
return readOrDone(result);
|
|
1914
2876
|
}
|
|
1915
2877
|
const stringCommand = parseStringCommand(command);
|
|
1916
2878
|
if (stringCommand) {
|
|
@@ -1922,7 +2884,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1922
2884
|
const result = applyStringCommand(score, requestedTrack, stringCommand);
|
|
1923
2885
|
if (result.next && result.kind)
|
|
1924
2886
|
await commitScore(result.next, result.kind, result.payload);
|
|
1925
|
-
return
|
|
2887
|
+
return readOrDone(result);
|
|
1926
2888
|
}
|
|
1927
2889
|
const grainHint = grainSrcHint(command);
|
|
1928
2890
|
if (grainHint) return fail(grainHint);
|
|
@@ -1935,6 +2897,50 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1935
2897
|
await commitScore(result.next, result.kind, result.payload);
|
|
1936
2898
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1937
2899
|
}
|
|
2900
|
+
const styleCommand = parseStyleCommand(
|
|
2901
|
+
/^\/?styles?\s+presets$/i.test(command) ? "style list" : command,
|
|
2902
|
+
);
|
|
2903
|
+
if (styleCommand) {
|
|
2904
|
+
const writes =
|
|
2905
|
+
styleCommand.type === "style-apply" ||
|
|
2906
|
+
styleCommand.type === "style-again";
|
|
2907
|
+
// Bare `style` opens Arrange › style; `style list|ls|presets` lists.
|
|
2908
|
+
if (
|
|
2909
|
+
styleCommand.type === "style-families" &&
|
|
2910
|
+
/^\/?styles?$/i.test(command)
|
|
2911
|
+
) {
|
|
2912
|
+
openMenu("style");
|
|
2913
|
+
return ok(
|
|
2914
|
+
"style · ↑/↓ browse · enter makes the song · style list prints it",
|
|
2915
|
+
);
|
|
2916
|
+
}
|
|
2917
|
+
if (writes) await materializeDraft();
|
|
2918
|
+
const result = applyStyleCommand(score, styleCommand);
|
|
2919
|
+
if (result.next && result.kind)
|
|
2920
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2921
|
+
if (result.log) tui.activity.pushNote(result.log);
|
|
2922
|
+
if (!result.ok) return fail(result.message);
|
|
2923
|
+
// A multi-line listing opens as text; the card keeps its first line
|
|
2924
|
+
// so lines never run together on the one-line strip.
|
|
2925
|
+
if (!writes && result.message.includes("\n")) {
|
|
2926
|
+
const [head = "styles", ...rest] = result.message.split("\n");
|
|
2927
|
+
tui.openText(head, rest);
|
|
2928
|
+
return note(head);
|
|
2929
|
+
}
|
|
2930
|
+
// A new song: focus its first melodic track, so play and the menu
|
|
2931
|
+
// land on something tonal instead of a track the style replaced.
|
|
2932
|
+
const melodic = writes
|
|
2933
|
+
? result.next?.tracks.find((track) => !isDrumInstrument(track.instrument))
|
|
2934
|
+
: undefined;
|
|
2935
|
+
if (melodic && melodic.id !== requestedTrack) {
|
|
2936
|
+
await port.focus(melodic.id);
|
|
2937
|
+
requestedTrack = melodic.id;
|
|
2938
|
+
draftTrack = false;
|
|
2939
|
+
}
|
|
2940
|
+
return ok(
|
|
2941
|
+
melodic ? `${result.message} · focused ${melodic.id}` : result.message,
|
|
2942
|
+
);
|
|
2943
|
+
}
|
|
1938
2944
|
const masterCommand = parseMasterCommand(command);
|
|
1939
2945
|
if (masterCommand) {
|
|
1940
2946
|
if (masterCommand.type === "master-measure")
|
|
@@ -1947,7 +2953,82 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1947
2953
|
const result = applyMasterCommand(score, masterCommand);
|
|
1948
2954
|
if (result.next && result.kind)
|
|
1949
2955
|
await commitScore(result.next, result.kind, result.payload);
|
|
1950
|
-
return
|
|
2956
|
+
return readOrDone(result);
|
|
2957
|
+
}
|
|
2958
|
+
// Range commands (op1-ux §6.4): copy/move/clear/paste/reverse bars,
|
|
2959
|
+
// bars insert|remove, loop next|prev, jump.
|
|
2960
|
+
const rangeCommand = parseRangeCommand(command, score);
|
|
2961
|
+
if (rangeCommand) {
|
|
2962
|
+
const reads =
|
|
2963
|
+
rangeCommand.type === "range-usage" ||
|
|
2964
|
+
rangeCommand.type === "jump-bar" ||
|
|
2965
|
+
rangeCommand.type === "jump-section" ||
|
|
2966
|
+
(rangeCommand.type === "range-copy" && rangeCommand.to === undefined);
|
|
2967
|
+
if (!reads) await materializeDraft();
|
|
2968
|
+
// TAPE's first paste after a cut (§6.2): the `move` reads the bars from
|
|
2969
|
+
// the score before the cut's clear, so cut and paste land as one move.
|
|
2970
|
+
const folded =
|
|
2971
|
+
rangeCommand.type === "range-move" &&
|
|
2972
|
+
tape.foldArmed &&
|
|
2973
|
+
tape.cut &&
|
|
2974
|
+
cutArmed(tape.cut.after, score)
|
|
2975
|
+
? foldBase(tape.cut.before, score)
|
|
2976
|
+
: undefined;
|
|
2977
|
+
tape.foldArmed = undefined;
|
|
2978
|
+
const cutBase =
|
|
2979
|
+
rangeCommand.type === "range-clear" ? tape.cutArmed : undefined;
|
|
2980
|
+
if (rangeCommand.type === "range-clear") tape.cutArmed = undefined;
|
|
2981
|
+
const result = applyRangeCommand(
|
|
2982
|
+
folded ?? score,
|
|
2983
|
+
{
|
|
2984
|
+
trackId: requestedTrack,
|
|
2985
|
+
playheadBar: playheadBar(),
|
|
2986
|
+
...(rangeClipboard ? { clipboard: rangeClipboard } : {}),
|
|
2987
|
+
},
|
|
2988
|
+
rangeCommand,
|
|
2989
|
+
);
|
|
2990
|
+
if (result.delegate) return submit(result.delegate);
|
|
2991
|
+
if (result.clipboard) {
|
|
2992
|
+
rangeClipboard = result.clipboard;
|
|
2993
|
+
tape.cut = undefined;
|
|
2994
|
+
void saveClipboard(paneClipboardPath(), rangeClipboard).catch(
|
|
2995
|
+
() => undefined,
|
|
2996
|
+
);
|
|
2997
|
+
}
|
|
2998
|
+
if (result.next && result.kind)
|
|
2999
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
3000
|
+
if (cutBase && result.next) tape.cut = { before: cutBase, after: score };
|
|
3001
|
+
else if (result.next && result.kind) tape.cut = undefined;
|
|
3002
|
+
if (result.seekBeat !== undefined) await seekTransport(result.seekBeat);
|
|
3003
|
+
return readOrDone(result);
|
|
3004
|
+
}
|
|
3005
|
+
// `loop <a>-<b> | <section> | off`: what playback loops. Bars set the
|
|
3006
|
+
// loop range (score.loop); a name loops that section.
|
|
3007
|
+
const loop = parseLoopCommand(command);
|
|
3008
|
+
if (loop) {
|
|
3009
|
+
if (loop.type === "loop-show")
|
|
3010
|
+
return note(
|
|
3011
|
+
score.loop
|
|
3012
|
+
? `loop · bars ${rangeLabel(score.loop)} · loop off plays the song`
|
|
3013
|
+
: score.loopSection
|
|
3014
|
+
? `loop · ${score.loopSection} · loop off plays the song`
|
|
3015
|
+
: `loop · the song · ${LOOP_USAGE}`,
|
|
3016
|
+
);
|
|
3017
|
+
if (loop.type === "loop-off") {
|
|
3018
|
+
if (score.loop) {
|
|
3019
|
+
await materializeDraft();
|
|
3020
|
+
await commitScore(score.withLoop(null), "score.loop", { loop: null });
|
|
3021
|
+
return ok("loop · off · playing the song");
|
|
3022
|
+
}
|
|
3023
|
+
return submit("section loop off");
|
|
3024
|
+
}
|
|
3025
|
+
if (loop.type === "loop-section")
|
|
3026
|
+
return submit(`section loop ${loop.name}`);
|
|
3027
|
+
const result = loopSpan(score, loop.from, loop.to);
|
|
3028
|
+
if (!result.ok) return fail(result.message);
|
|
3029
|
+
await materializeDraft();
|
|
3030
|
+
await commitScore(result.next, "score.loop", { loop: result.next.loop });
|
|
3031
|
+
return ok(result.message);
|
|
1951
3032
|
}
|
|
1952
3033
|
const arrange = parseSectionCommand(command, score);
|
|
1953
3034
|
if (arrange) {
|
|
@@ -1961,32 +3042,33 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1961
3042
|
if (result.next && result.kind)
|
|
1962
3043
|
await commitScore(result.next, result.kind, result.payload);
|
|
1963
3044
|
if (result.seekBeat !== undefined) await seekTransport(result.seekBeat);
|
|
1964
|
-
return
|
|
3045
|
+
return readOrDone(result);
|
|
1965
3046
|
}
|
|
1966
3047
|
const music = parseMusicCommand(command);
|
|
1967
3048
|
if (music) {
|
|
1968
3049
|
await materializeDraft();
|
|
1969
3050
|
const result = applyMusicCommand(score, requestedTrack, music, () =>
|
|
1970
|
-
|
|
3051
|
+
newId("n"),
|
|
1971
3052
|
);
|
|
1972
3053
|
if (result.next && result.kind)
|
|
1973
3054
|
await commitScore(result.next, result.kind, result.payload);
|
|
1974
3055
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1975
3056
|
}
|
|
1976
|
-
const exportCommand = command
|
|
3057
|
+
const exportCommand = parseExportCommand(command);
|
|
1977
3058
|
if (exportCommand) {
|
|
1978
|
-
const path = resolve(exportCommand
|
|
1979
|
-
if (
|
|
3059
|
+
const path = resolve(exportCommand.path);
|
|
3060
|
+
if (exportCommand.format === "mid") {
|
|
1980
3061
|
await writeFile(path, scoreToMidi(exportScore(score)));
|
|
1981
|
-
return `exported midi · ${exportCommand
|
|
3062
|
+
return `exported midi · ${exportCommand.path}`;
|
|
1982
3063
|
}
|
|
1983
|
-
// Audio
|
|
1984
|
-
if (
|
|
1985
|
-
return
|
|
1986
|
-
|
|
1987
|
-
|
|
3064
|
+
// Audio goes through the `dawg render` path, the same bytes as the CLI.
|
|
3065
|
+
if (exportCommand.format === "wav")
|
|
3066
|
+
return exportWav(exportCommand.path, exportCommand.stems);
|
|
3067
|
+
// Other audio names are not formats dawg writes.
|
|
3068
|
+
if (/\.(aiff?|flac|mp3|ogg|m4a)$/i.test(path))
|
|
3069
|
+
return fail(`export · ${exportCommand.path} · ${EXPORT_USAGE}`);
|
|
1988
3070
|
await writeFile(path, encodeLoop(score), "utf8");
|
|
1989
|
-
return `exported · ${exportCommand
|
|
3071
|
+
return `exported · ${exportCommand.path}`;
|
|
1990
3072
|
}
|
|
1991
3073
|
const importCommand = command.match(/^\/?import\s+([^\s]+)$/i);
|
|
1992
3074
|
if (importCommand) {
|
|
@@ -2021,14 +3103,14 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2021
3103
|
tui.activity.setSpinner(undefined);
|
|
2022
3104
|
}
|
|
2023
3105
|
}
|
|
2024
|
-
const
|
|
3106
|
+
const result = await tuiSetModel(modelMatch[1]);
|
|
2025
3107
|
provider = undefined;
|
|
2026
3108
|
await currentProvider();
|
|
2027
|
-
return
|
|
3109
|
+
return result.ok ? result.text : fail(result.text);
|
|
2028
3110
|
}
|
|
2029
3111
|
if (/^\/login\b/i.test(prompt.trim())) {
|
|
2030
3112
|
const parsed = tuiLoginArgs(prompt.trim());
|
|
2031
|
-
if (typeof parsed === "string") return `
|
|
3113
|
+
if (typeof parsed === "string") return `model key · ${parsed}`;
|
|
2032
3114
|
await handoff(() =>
|
|
2033
3115
|
runTuiLogin(
|
|
2034
3116
|
parsed.target === "auto" ? "pick" : parsed.target,
|
|
@@ -2038,8 +3120,8 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2038
3120
|
provider = undefined;
|
|
2039
3121
|
await currentProvider();
|
|
2040
3122
|
return providerName === "offline"
|
|
2041
|
-
? "
|
|
2042
|
-
: `
|
|
3123
|
+
? "no agent key · model key adds one · direct commands still work"
|
|
3124
|
+
: `agent key · ${providerName}`;
|
|
2043
3125
|
}
|
|
2044
3126
|
if (/^\/(logout|auth)\b/i.test(prompt.trim())) {
|
|
2045
3127
|
tui.activity.setSpinner(prompt.trim().split(/\s+/)[0]!.slice(1));
|
|
@@ -2054,8 +3136,46 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2054
3136
|
tui.activity.setSpinner(undefined);
|
|
2055
3137
|
}
|
|
2056
3138
|
}
|
|
3139
|
+
// The grammar's second reading (design §3): a line no handler took runs
|
|
3140
|
+
// in its other spellings — `/x` ≡ `x`, aliases, `rm|remove|delete`,
|
|
3141
|
+
// `list|presets|ls` — so only lines that failed before get one.
|
|
3142
|
+
// A line the core prompt parser takes is already understood.
|
|
3143
|
+
if (!recovering && !parsePrompt(prompt)) {
|
|
3144
|
+
const retry = recover(
|
|
3145
|
+
command,
|
|
3146
|
+
(candidate) =>
|
|
3147
|
+
parseExact(candidate, score) !== undefined ||
|
|
3148
|
+
// Window verbs get only the slash toggle, never a listing word.
|
|
3149
|
+
(candidate.startsWith("/") &&
|
|
3150
|
+
SLASH_HANDLED.has(verbOf(candidate)) &&
|
|
3151
|
+
candidate.split(/\s+/).length === command.trim().split(/\s+/).length),
|
|
3152
|
+
);
|
|
3153
|
+
if (retry !== undefined) {
|
|
3154
|
+
recovering = true;
|
|
3155
|
+
try {
|
|
3156
|
+
return await submit(retry);
|
|
3157
|
+
} finally {
|
|
3158
|
+
recovering = false;
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
// Bare `rename`, `fork`, `resume`, `login` take free text: a hint only.
|
|
3163
|
+
const freeText = FREE_TEXT_HINTS[command.toLowerCase()];
|
|
3164
|
+
if (freeText !== undefined) return note(freeText);
|
|
3165
|
+
const elsewhere = elsewhereHint(command);
|
|
3166
|
+
if (elsewhere !== undefined) return note(elsewhere);
|
|
2057
3167
|
// `/model` belongs to its own handler above; every other slash word that
|
|
2058
3168
|
// reached here is unknown or misused, and never a question for the agent.
|
|
3169
|
+
// `/instrument aah` is `instrument aah`; `instrument sing choir` is
|
|
3170
|
+
// `instrument choir`.
|
|
3171
|
+
const singWord = command.match(/^\/?instrument\s+sing\s+([a-z]+)$/i);
|
|
3172
|
+
if (singWord && isSingWord(singWord[1]!.toLowerCase()))
|
|
3173
|
+
return submit(`instrument ${singWord[1]!.toLowerCase()}`);
|
|
3174
|
+
if (/^\/instrument\s/i.test(command) && parsePrompt(command.slice(1)))
|
|
3175
|
+
return submit(command.slice(1));
|
|
3176
|
+
// A synth or effect number out of range names its range.
|
|
3177
|
+
const range = paramRangeError(command);
|
|
3178
|
+
if (range) return fail(range);
|
|
2059
3179
|
if (command.startsWith("/") && !/^\/model\b/i.test(command)) {
|
|
2060
3180
|
const hint = usageHint(command);
|
|
2061
3181
|
return fail(
|
|
@@ -2070,6 +3190,10 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2070
3190
|
// A one-letter slip on a command whose arguments parse stays local.
|
|
2071
3191
|
const fix = typoFix(command, parsesLocally);
|
|
2072
3192
|
if (fix) return fail(`${truncateForCard(command)} · did you mean ${fix}?`);
|
|
3193
|
+
// A grammar verb (not everyday English) never reaches the agent.
|
|
3194
|
+
const verb = verbOf(command);
|
|
3195
|
+
if (knownVerbs().has(verb) && !EVERYDAY_VERBS.has(verb))
|
|
3196
|
+
return fail(usageCard(truncateForCard(command), `/help ${verb}`));
|
|
2073
3197
|
if (process.env.DAWG_AI === "0")
|
|
2074
3198
|
return fail(`unrecognized · ${truncateForCard(command)} · /help`);
|
|
2075
3199
|
await materializeDraft();
|
|
@@ -2099,6 +3223,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2099
3223
|
return parsed.action;
|
|
2100
3224
|
}
|
|
2101
3225
|
if (parsed.type === "set-tempo") {
|
|
3226
|
+
const range = RANGES.tempo!;
|
|
3227
|
+
if (parsed.tempoBpm < range.min || parsed.tempoBpm > range.max)
|
|
3228
|
+
return fail(usageError(truncateForCard(command), range));
|
|
2102
3229
|
const next = score.withTempo(parsed.tempoBpm);
|
|
2103
3230
|
await commitScore(next, "score.tempo", { tempoBpm: parsed.tempoBpm });
|
|
2104
3231
|
clock.follow(next);
|
|
@@ -2126,15 +3253,10 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2126
3253
|
if (parsed.type === "track") {
|
|
2127
3254
|
// An unknown word would store and play a plain sine: say so instead.
|
|
2128
3255
|
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
|
-
}
|
|
3256
|
+
if (word !== undefined && isUnknownInstrument(word))
|
|
3257
|
+
return fail(unknownInstrumentMessage(word));
|
|
2136
3258
|
// `instrument jangle`: a guitar alias also loads its rig.
|
|
2137
|
-
const
|
|
3259
|
+
const rigged = parsed.word
|
|
2138
3260
|
? {
|
|
2139
3261
|
...parsed.patch,
|
|
2140
3262
|
...rigWordPatch(
|
|
@@ -2143,6 +3265,27 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2143
3265
|
),
|
|
2144
3266
|
}
|
|
2145
3267
|
: parsed.patch;
|
|
3268
|
+
// `instrument vocal` (0.7): the vocal chain fills unset effects.
|
|
3269
|
+
const chain = isGuideInstrument(parsed.patch.instrument)
|
|
3270
|
+
? vocalChainPatch(score.tracks.find((t) => t.id === requestedTrack))
|
|
3271
|
+
: {};
|
|
3272
|
+
const patch = { ...chain, ...rigged };
|
|
3273
|
+
// `volume drums 0.5` names its track; it must exist.
|
|
3274
|
+
if (parsed.trackId !== undefined) {
|
|
3275
|
+
const target = score.tracks.find(
|
|
3276
|
+
(candidate) =>
|
|
3277
|
+
candidate.id === parsed.trackId ||
|
|
3278
|
+
candidate.name.toLowerCase() === parsed.trackId,
|
|
3279
|
+
);
|
|
3280
|
+
if (!target) return fail(`no track ${parsed.trackId}`);
|
|
3281
|
+
const next = applyScoreOperation(score, {
|
|
3282
|
+
type: "updateTrack",
|
|
3283
|
+
trackId: target.id,
|
|
3284
|
+
patch,
|
|
3285
|
+
});
|
|
3286
|
+
await commitScore(next, "score.track", { trackId: target.id, patch });
|
|
3287
|
+
return `track · ${target.id}`;
|
|
3288
|
+
}
|
|
2146
3289
|
const next = applyScoreOperation(score, {
|
|
2147
3290
|
type: "updateTrack",
|
|
2148
3291
|
trackId: requestedTrack,
|
|
@@ -2163,6 +3306,13 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2163
3306
|
? plainSineAdvice(word)
|
|
2164
3307
|
: undefined;
|
|
2165
3308
|
if (sine) return `track · ${requestedTrack} · ${sine}`;
|
|
3309
|
+
const added = [
|
|
3310
|
+
chain.filter ? "hpf 90 Hz" : "",
|
|
3311
|
+
chain.fx?.compressor ? "compressor 3:1" : "",
|
|
3312
|
+
chain.reverb ? "plate 0.14" : "",
|
|
3313
|
+
].filter(Boolean);
|
|
3314
|
+
if (added.length)
|
|
3315
|
+
return `track · ${requestedTrack} · vocal · added ${added.join(", ")} · fx … off to remove`;
|
|
2166
3316
|
return `track · ${requestedTrack}`;
|
|
2167
3317
|
}
|
|
2168
3318
|
if (parsed.type === "automation") {
|
|
@@ -2207,6 +3357,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2207
3357
|
return `cleared · ${requestedTrack}`;
|
|
2208
3358
|
}
|
|
2209
3359
|
if (parsed.type === "remove-note") {
|
|
3360
|
+
// A receipt never claims a removal that did not happen.
|
|
3361
|
+
if (!score.notes.some((note) => note.id === parsed.noteId))
|
|
3362
|
+
return fail(noNote(parsed.noteId));
|
|
2210
3363
|
const next = applyScoreOperation(score, {
|
|
2211
3364
|
type: "removeNote",
|
|
2212
3365
|
noteId: parsed.noteId,
|
|
@@ -2254,7 +3407,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2254
3407
|
const latest = attempt === 0 ? record : await port.load();
|
|
2255
3408
|
const latestScore = scoreFromJSON(latest.composition);
|
|
2256
3409
|
const note = {
|
|
2257
|
-
id:
|
|
3410
|
+
id: newId(requestedTrack),
|
|
2258
3411
|
trackId: requestedTrack,
|
|
2259
3412
|
startTick: Math.round(parsed.start * latestScore.ticksPerBeat),
|
|
2260
3413
|
durationTicks: Math.max(
|
|
@@ -2291,17 +3444,26 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2291
3444
|
* not in the score yet. A track another live window has focused stays
|
|
2292
3445
|
* theirs; focus goes through the port so presence is right everywhere.
|
|
2293
3446
|
*/
|
|
2294
|
-
async function focusTrack(
|
|
3447
|
+
async function focusTrack(
|
|
3448
|
+
trackId: string,
|
|
3449
|
+
options: { follow?: boolean } = {},
|
|
3450
|
+
): Promise<Receipt> {
|
|
2295
3451
|
if (trackId === requestedTrack && !draftTrack)
|
|
2296
3452
|
return warn(`already on ${trackId}`);
|
|
3453
|
+
if (paneOptions.pinned)
|
|
3454
|
+
return warn(`pinned to ${requestedTrack} · unpin to change`);
|
|
3455
|
+
// Typing a track yourself stops following (§12.5).
|
|
3456
|
+
if (!options.follow && paneOptions.follow) paneOptions.follow = undefined;
|
|
3457
|
+
// Focus is not exclusive (§12.5): a track another pane shows is a soft
|
|
3458
|
+
// note on the receipt, never a refusal.
|
|
2297
3459
|
const clients = await port.presence().catch(() => []);
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
3460
|
+
const sharing = clients.find(
|
|
3461
|
+
(client) =>
|
|
3462
|
+
client.clientId !== port.clientId && client.focusedTrackId === trackId,
|
|
3463
|
+
);
|
|
3464
|
+
const shared = sharing
|
|
3465
|
+
? ` · ${sharing.pane ? `pane ${sharing.pane}` : "another pane"} is on ${trackId} too`
|
|
3466
|
+
: "";
|
|
2305
3467
|
const existing = score.tracks.find((track) => track.id === trackId);
|
|
2306
3468
|
const exists = existing !== undefined;
|
|
2307
3469
|
// `track cloud`, `track hold-2`: a new granular track with that preset.
|
|
@@ -2324,6 +3486,10 @@ async function focusTrack(trackId: string): Promise<Receipt> {
|
|
|
2324
3486
|
...newPianoTrack(trackId),
|
|
2325
3487
|
// `track vibes`: a 0.6 modal word names the track and its preset.
|
|
2326
3488
|
...(isModalWord(trackId) ? instrumentPatchForWord(trackId) : {}),
|
|
3489
|
+
// `track vocal` (0.7): a clip track with the vocal chain.
|
|
3490
|
+
...(isGuideInstrument(trackId)
|
|
3491
|
+
? { instrument: "vocal", ...vocalChainPatch(undefined) }
|
|
3492
|
+
: {}),
|
|
2327
3493
|
},
|
|
2328
3494
|
});
|
|
2329
3495
|
await commitScore(next, "track.create", { trackId });
|
|
@@ -2336,11 +3502,11 @@ async function focusTrack(trackId: string): Promise<Receipt> {
|
|
|
2336
3502
|
`track · ${trackId} is a ${existing.instrument} track · grain ${grainPreset} makes it granular`,
|
|
2337
3503
|
);
|
|
2338
3504
|
return ok(
|
|
2339
|
-
exists
|
|
3505
|
+
(exists
|
|
2340
3506
|
? `track · ${trackId}`
|
|
2341
3507
|
: grainPreset
|
|
2342
3508
|
? `track created · ${trackId} · granular ${grainPreset} · nothing to download`
|
|
2343
|
-
: `track created · ${trackId}
|
|
3509
|
+
: `track created · ${trackId}`) + shared,
|
|
2344
3510
|
);
|
|
2345
3511
|
}
|
|
2346
3512
|
|
|
@@ -2624,11 +3790,19 @@ async function packCommand(command: PackCommand): Promise<Receipt> {
|
|
|
2624
3790
|
const store = packs();
|
|
2625
3791
|
const a = await store.pruneCache(to(store.maxFileCacheBytes));
|
|
2626
3792
|
const b = await library.pruneCache(to(library.maxCacheBytes));
|
|
2627
|
-
|
|
3793
|
+
const c = await pruneAnalysisCache(
|
|
3794
|
+
analysisDir(process.cwd()),
|
|
3795
|
+
to(ANALYSIS_CACHE_BYTES),
|
|
3796
|
+
);
|
|
3797
|
+
freed = {
|
|
3798
|
+
files: a.removed + b.removed + c.removed,
|
|
3799
|
+
bytes: a.freed + b.freed + c.freed,
|
|
3800
|
+
};
|
|
2628
3801
|
}
|
|
2629
3802
|
const lines = cacheLines({
|
|
2630
3803
|
packs: await packs().cacheStatus(),
|
|
2631
3804
|
assets: await library.cacheStatus(),
|
|
3805
|
+
analysis: await analysisCacheStatus(process.cwd()),
|
|
2632
3806
|
...(freed ? { freed } : {}),
|
|
2633
3807
|
});
|
|
2634
3808
|
tui.openText("cache", lines);
|
|
@@ -2703,27 +3877,38 @@ async function packCommand(command: PackCommand): Promise<Receipt> {
|
|
|
2703
3877
|
}
|
|
2704
3878
|
}
|
|
2705
3879
|
|
|
3880
|
+
/**
|
|
3881
|
+
* A command result as a receipt: a refusal is ✗, a change is ✓, and a read
|
|
3882
|
+
* that changed nothing (a listing or a show) is •.
|
|
3883
|
+
*/
|
|
3884
|
+
function readOrDone(
|
|
3885
|
+
result: Readonly<{ ok: boolean; message: string; next?: unknown }>,
|
|
3886
|
+
): Receipt {
|
|
3887
|
+
if (!result.ok) return fail(result.message);
|
|
3888
|
+
return result.next ? ok(result.message) : note(result.message);
|
|
3889
|
+
}
|
|
3890
|
+
|
|
2706
3891
|
async function patternCommand(command: PatternCommand): Promise<Receipt> {
|
|
2707
3892
|
if (command.kind === "list") {
|
|
2708
|
-
tui.openText("
|
|
2709
|
-
return
|
|
3893
|
+
tui.openText("grooves", DRUM_PATTERNS.map(patternLine));
|
|
3894
|
+
return note(`grooves · ${DRUM_PATTERNS.length} · groove <name>`);
|
|
2710
3895
|
}
|
|
2711
3896
|
if (command.kind === "browse") {
|
|
2712
3897
|
tui.openPicker({
|
|
2713
3898
|
id: "pattern",
|
|
2714
|
-
title: "
|
|
3899
|
+
title: "grooves",
|
|
2715
3900
|
hint: HINTS.audition,
|
|
2716
3901
|
audition: true,
|
|
2717
3902
|
items: DRUM_PATTERNS.map((entry) => ({
|
|
2718
3903
|
label: `${entry.label.padEnd(22)} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
|
|
2719
|
-
value: `/
|
|
3904
|
+
value: `/groove ${entry.name}`,
|
|
2720
3905
|
})),
|
|
2721
3906
|
filterable: true,
|
|
2722
3907
|
index: 0,
|
|
2723
3908
|
});
|
|
2724
3909
|
patternPreview = undefined;
|
|
2725
3910
|
previewPattern(DRUM_PATTERNS[0]?.name);
|
|
2726
|
-
return
|
|
3911
|
+
return note("grooves · ↑/↓ preview · Enter applies on the focused track");
|
|
2727
3912
|
}
|
|
2728
3913
|
await materializeDraft();
|
|
2729
3914
|
const result = applyDrumPattern(
|
|
@@ -2784,7 +3969,7 @@ function openKitPicker(): Receipt {
|
|
|
2784
3969
|
}));
|
|
2785
3970
|
tui.openPicker({
|
|
2786
3971
|
id: "kit",
|
|
2787
|
-
title: "
|
|
3972
|
+
title: "kits",
|
|
2788
3973
|
hint: HINTS.audition,
|
|
2789
3974
|
audition: true,
|
|
2790
3975
|
items,
|
|
@@ -2816,7 +4001,7 @@ async function kitCommand(command: KitCommand, bare = false): Promise<Receipt> {
|
|
|
2816
4001
|
.map((entry) => `${entry.name} · synth · ${entry.detail}`);
|
|
2817
4002
|
lines.push(...kitListLines(await packs().bankAliases(ALIASED_PACK)));
|
|
2818
4003
|
tui.openText("kits", lines);
|
|
2819
|
-
return
|
|
4004
|
+
return note("kits · /kit <name or nickname> on the focused drum track");
|
|
2820
4005
|
}
|
|
2821
4006
|
await materializeDraft();
|
|
2822
4007
|
const trackId = kitTarget(score, requestedTrack);
|
|
@@ -2927,12 +4112,10 @@ function adoptMeta(meta: typeof record.meta): void {
|
|
|
2927
4112
|
record = { ...record, meta };
|
|
2928
4113
|
if (meta.name !== announcedName) {
|
|
2929
4114
|
announcedName = meta.name;
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
{ tone: "info" },
|
|
2935
|
-
);
|
|
4115
|
+
// The auto-name joins the receipt that earned it, as a quiet suffix.
|
|
4116
|
+
if (meta.nameSource === "auto")
|
|
4117
|
+
tui.activity.attachNote(`named “${meta.name}” · /rename`);
|
|
4118
|
+
else tui.activity.pushCard(`session · ${meta.name}`, { tone: "info" });
|
|
2936
4119
|
}
|
|
2937
4120
|
}
|
|
2938
4121
|
|
|
@@ -3046,6 +4229,7 @@ async function switchSession(sessionId: string): Promise<void> {
|
|
|
3046
4229
|
setCurrent: true,
|
|
3047
4230
|
});
|
|
3048
4231
|
port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
|
|
4232
|
+
actor,
|
|
3049
4233
|
paths: next.paths,
|
|
3050
4234
|
sessionId,
|
|
3051
4235
|
label: "window",
|
|
@@ -3072,13 +4256,269 @@ function liveEngine(): LiveEngine | undefined {
|
|
|
3072
4256
|
return previewEngine();
|
|
3073
4257
|
}
|
|
3074
4258
|
|
|
4259
|
+
/**
|
|
4260
|
+
* The engine play mode sounds through: dawgd's shared engine when the
|
|
4261
|
+
* daemon offers one (every pane's notes and one click mix there, §12.3),
|
|
4262
|
+
* else this window's own monitor. Never both.
|
|
4263
|
+
*/
|
|
4264
|
+
function playEngine(): LiveEngine | undefined {
|
|
4265
|
+
const link = port.liveLink();
|
|
4266
|
+
if (!link) return previewEngine();
|
|
4267
|
+
if (sharedLive?.link !== link)
|
|
4268
|
+
sharedLive = { link, engine: new SharedLiveEngine(link) };
|
|
4269
|
+
return sharedLive.engine;
|
|
4270
|
+
}
|
|
4271
|
+
|
|
4272
|
+
/**
|
|
4273
|
+
* What this pane shows, for the other panes' presence markers (§12.7):
|
|
4274
|
+
* sent only when it changes, and only to a daemon that has panes.
|
|
4275
|
+
*/
|
|
4276
|
+
function paneView(): PaneView {
|
|
4277
|
+
const session = play;
|
|
4278
|
+
const view: PaneView = {
|
|
4279
|
+
screen: session?.on
|
|
4280
|
+
? "play"
|
|
4281
|
+
: tape.on
|
|
4282
|
+
? "tape"
|
|
4283
|
+
: fader
|
|
4284
|
+
? "sound"
|
|
4285
|
+
: menu.open
|
|
4286
|
+
? "menu"
|
|
4287
|
+
: "home",
|
|
4288
|
+
};
|
|
4289
|
+
if (fader) view.param = fader.label.slice(0, 64);
|
|
4290
|
+
if (session?.on) view.playing = true;
|
|
4291
|
+
if (session?.recording)
|
|
4292
|
+
view.recording = session.replace ? "replace" : "overdub";
|
|
4293
|
+
if (paneOptions.pinned) view.pinned = true;
|
|
4294
|
+
if (paneOptions.follow) view.follow = paneOptions.follow;
|
|
4295
|
+
return view;
|
|
4296
|
+
}
|
|
4297
|
+
function reportView(): void {
|
|
4298
|
+
const view = paneView();
|
|
4299
|
+
const key = JSON.stringify(view);
|
|
4300
|
+
if (key === reportedView) return;
|
|
4301
|
+
reportedView = key;
|
|
4302
|
+
port.setView(view);
|
|
4303
|
+
}
|
|
4304
|
+
|
|
4305
|
+
/**
|
|
4306
|
+
* `dawg pane <screen> …` at launch: focus its track, then open the screen
|
|
4307
|
+
* through the same commands a person would type, so the pane starts where
|
|
4308
|
+
* the shell line said.
|
|
4309
|
+
*/
|
|
4310
|
+
async function openPane(pane: PaneArgs): Promise<void> {
|
|
4311
|
+
paneOptions.pinned = pane.pin;
|
|
4312
|
+
paneOptions.follow = pane.follow;
|
|
4313
|
+
let result: string | Receipt | undefined;
|
|
4314
|
+
if (pane.screen === "play") result = await enterPlay();
|
|
4315
|
+
else if (pane.screen === "tape") result = await enterTape();
|
|
4316
|
+
else if (pane.screen === "sound")
|
|
4317
|
+
result = await submit(pane.param ?? "volume");
|
|
4318
|
+
else if (pane.screen === "menu")
|
|
4319
|
+
result = await submit(`/menu${pane.param ? ` ${pane.param}` : ""}`);
|
|
4320
|
+
if (result !== undefined) receipt(result);
|
|
4321
|
+
}
|
|
4322
|
+
|
|
4323
|
+
/**
|
|
4324
|
+
* Other panes on `trackId`, for a track row's gutter (§12.7): their letters,
|
|
4325
|
+
* a red-dot `●` after a pane that is recording. Empty for a solo window.
|
|
4326
|
+
*/
|
|
4327
|
+
function paneMarks(trackId: string): string {
|
|
4328
|
+
const marks = panePresence
|
|
4329
|
+
.filter(
|
|
4330
|
+
(entry) =>
|
|
4331
|
+
entry.clientId !== port.clientId &&
|
|
4332
|
+
entry.pane &&
|
|
4333
|
+
entry.focusedTrackId === trackId,
|
|
4334
|
+
)
|
|
4335
|
+
.map((entry) => `${entry.pane}${entry.recording ? "●" : ""}`);
|
|
4336
|
+
return marks.length ? ` ${marks.join(" ")}` : "";
|
|
4337
|
+
}
|
|
4338
|
+
|
|
4339
|
+
/**
|
|
4340
|
+
* Another pane recording on `trackId` (§12.4): `pane B`, else undefined.
|
|
4341
|
+
* Play refuses to replace a pass while one is, naming it.
|
|
4342
|
+
*/
|
|
4343
|
+
function recordingPane(trackId: string): string | undefined {
|
|
4344
|
+
const other = panePresence.find(
|
|
4345
|
+
(entry) =>
|
|
4346
|
+
entry.clientId !== port.clientId &&
|
|
4347
|
+
entry.recording !== undefined &&
|
|
4348
|
+
entry.focusedTrackId === trackId,
|
|
4349
|
+
);
|
|
4350
|
+
if (!other) return undefined;
|
|
4351
|
+
return other.pane ? `pane ${other.pane}` : "another pane";
|
|
4352
|
+
}
|
|
4353
|
+
|
|
4354
|
+
/** A drawer row another pane has open on this track shows its letter. */
|
|
4355
|
+
function withPaneMarks(view: DrawerView): DrawerView {
|
|
4356
|
+
const others = panePresence.filter(
|
|
4357
|
+
(entry) =>
|
|
4358
|
+
entry.clientId !== port.clientId &&
|
|
4359
|
+
entry.pane &&
|
|
4360
|
+
entry.param &&
|
|
4361
|
+
entry.screen === "sound" &&
|
|
4362
|
+
entry.focusedTrackId === requestedTrack,
|
|
4363
|
+
);
|
|
4364
|
+
if (others.length === 0) return view;
|
|
4365
|
+
return {
|
|
4366
|
+
...view,
|
|
4367
|
+
fields: view.fields.map((field) => {
|
|
4368
|
+
const peers = others
|
|
4369
|
+
.filter((entry) => entry.param === field.label)
|
|
4370
|
+
.map((entry) => entry.pane)
|
|
4371
|
+
.join(" ");
|
|
4372
|
+
return peers ? { ...field, peers } : field;
|
|
4373
|
+
}),
|
|
4374
|
+
};
|
|
4375
|
+
}
|
|
4376
|
+
|
|
4377
|
+
/** One pane's line for `pane`: letter, screen, track, parameter, state. */
|
|
4378
|
+
function paneLine(entry: PresenceEntry): string {
|
|
4379
|
+
const bits = [
|
|
4380
|
+
`${entry.pane ?? "·"} ${entry.screen ?? "home"}`,
|
|
4381
|
+
entry.focusedTrackId ?? "no track",
|
|
4382
|
+
];
|
|
4383
|
+
if (entry.param) bits.push(entry.param);
|
|
4384
|
+
if (entry.recording) bits.push(`● ${entry.recording}`);
|
|
4385
|
+
if (entry.pinned) bits.push("pinned");
|
|
4386
|
+
if (entry.follow) bits.push(`follows ${entry.follow}`);
|
|
4387
|
+
if (entry.clientId === port.clientId) bits.push("this pane");
|
|
4388
|
+
return bits.join(" · ");
|
|
4389
|
+
}
|
|
4390
|
+
|
|
4391
|
+
/**
|
|
4392
|
+
* `pane` lists panes; `pane <screen> [track]…` prints the shell line that
|
|
4393
|
+
* opens one (dawg can't open terminal windows portably), with the tmux
|
|
4394
|
+
* split line inside tmux, and copies it via OSC 52. `pin`, `unpin`,
|
|
4395
|
+
* `follow [letter]`, `unfollow`. Undefined: not a pane command.
|
|
4396
|
+
*/
|
|
4397
|
+
function paneCommand(command: string): Receipt | undefined {
|
|
4398
|
+
const text = command.trim();
|
|
4399
|
+
if (/^\/?panes?$/i.test(text)) {
|
|
4400
|
+
if (port.mode !== "daemon")
|
|
4401
|
+
return ok("solo pane · no daemon · DAWG_DAEMON=0 is set");
|
|
4402
|
+
const entries = [...panePresence].sort((a, b) =>
|
|
4403
|
+
(a.pane ?? "~").localeCompare(b.pane ?? "~"),
|
|
4404
|
+
);
|
|
4405
|
+
if (entries.length === 0) return ok("1 pane · this one");
|
|
4406
|
+
return ok(`${entries.length} panes │ ${entries.map(paneLine).join(" │ ")}`);
|
|
4407
|
+
}
|
|
4408
|
+
const open = text.match(/^\/?pane\s+(.+)$/i);
|
|
4409
|
+
if (open) {
|
|
4410
|
+
const parsed = parsePaneArgs(open[1]!.split(/\s+/));
|
|
4411
|
+
if (!parsed.ok) return fail(parsed.problem);
|
|
4412
|
+
const line = `dawg pane ${open[1]!.trim().toLowerCase()}`;
|
|
4413
|
+
copyToClipboard(line);
|
|
4414
|
+
return ok(
|
|
4415
|
+
process.env.TMUX
|
|
4416
|
+
? `run · tmux split-window ${line} · copied`
|
|
4417
|
+
: `run in another terminal · ${line} · copied`,
|
|
4418
|
+
);
|
|
4419
|
+
}
|
|
4420
|
+
if (/^\/?pin$/i.test(text)) {
|
|
4421
|
+
paneOptions.pinned = true;
|
|
4422
|
+
paneOptions.follow = undefined;
|
|
4423
|
+
return ok(`pinned to ${requestedTrack} · unpin releases`);
|
|
4424
|
+
}
|
|
4425
|
+
if (/^\/?unpin$/i.test(text)) {
|
|
4426
|
+
if (!paneOptions.pinned) return warn("not pinned");
|
|
4427
|
+
paneOptions.pinned = false;
|
|
4428
|
+
return ok("unpinned");
|
|
4429
|
+
}
|
|
4430
|
+
const follow = text.match(/^\/?follow(?:\s+([a-z]))?$/i);
|
|
4431
|
+
if (follow) {
|
|
4432
|
+
if (port.mode !== "daemon") return warn("follow needs dawgd · solo pane");
|
|
4433
|
+
paneOptions.pinned = false;
|
|
4434
|
+
paneOptions.follow = follow[1]?.toUpperCase() ?? "*";
|
|
4435
|
+
followPresence([], panePresence);
|
|
4436
|
+
return ok(
|
|
4437
|
+
paneOptions.follow === "*"
|
|
4438
|
+
? "following the latest pane's track"
|
|
4439
|
+
: `following pane ${paneOptions.follow}`,
|
|
4440
|
+
);
|
|
4441
|
+
}
|
|
4442
|
+
if (/^\/?unfollow$/i.test(text)) {
|
|
4443
|
+
if (!paneOptions.follow) return warn("not following");
|
|
4444
|
+
paneOptions.follow = undefined;
|
|
4445
|
+
return ok("unfollowed");
|
|
4446
|
+
}
|
|
4447
|
+
return undefined;
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4450
|
+
/** OSC 52: the terminal copies `text` where it supports it; else a no-op. */
|
|
4451
|
+
function copyToClipboard(text: string): void {
|
|
4452
|
+
if (!stdout.isTTY) return;
|
|
4453
|
+
stdout.write(`\u001b]52;c;${Buffer.from(text).toString("base64")}\u0007`);
|
|
4454
|
+
}
|
|
4455
|
+
|
|
4456
|
+
/**
|
|
4457
|
+
* A following pane takes the track its target focuses: one letter, or for
|
|
4458
|
+
* `*` the other pane whose focus changed most recently. Pinned panes never
|
|
4459
|
+
* move on someone else's focus.
|
|
4460
|
+
*/
|
|
4461
|
+
function followPresence(
|
|
4462
|
+
before: readonly PresenceEntry[],
|
|
4463
|
+
after: readonly PresenceEntry[],
|
|
4464
|
+
): void {
|
|
4465
|
+
const target = paneOptions.follow;
|
|
4466
|
+
if (!target || paneOptions.pinned) return;
|
|
4467
|
+
const others = after.filter((entry) => entry.clientId !== port.clientId);
|
|
4468
|
+
let next: string | null | undefined;
|
|
4469
|
+
if (target !== "*")
|
|
4470
|
+
next = others.find((entry) => entry.pane === target)?.focusedTrackId;
|
|
4471
|
+
else {
|
|
4472
|
+
const moved = others.filter(
|
|
4473
|
+
(entry) =>
|
|
4474
|
+
before.find((old) => old.clientId === entry.clientId)
|
|
4475
|
+
?.focusedTrackId !== entry.focusedTrackId,
|
|
4476
|
+
);
|
|
4477
|
+
next = (moved.at(-1) ?? (before.length === 0 ? others.at(-1) : undefined))
|
|
4478
|
+
?.focusedTrackId;
|
|
4479
|
+
}
|
|
4480
|
+
if (!next || next === requestedTrack) return;
|
|
4481
|
+
void focusTrack(next, { follow: true }).then((result) => {
|
|
4482
|
+
if (result.ok) {
|
|
4483
|
+
receipt(
|
|
4484
|
+
ok(
|
|
4485
|
+
`follows ${target === "*" ? "latest pane" : `pane ${target}`} · ${next}`,
|
|
4486
|
+
),
|
|
4487
|
+
);
|
|
4488
|
+
tickUi();
|
|
4489
|
+
}
|
|
4490
|
+
});
|
|
4491
|
+
}
|
|
4492
|
+
|
|
3075
4493
|
/** The engine this window hears itself through (its own in daemon mode). */
|
|
3076
4494
|
function previewEngine(): AudioEngine {
|
|
3077
4495
|
if (audio instanceof AudioEngine) return audio;
|
|
3078
|
-
monitorEngine ??= new AudioEngine({
|
|
4496
|
+
monitorEngine ??= new AudioEngine({
|
|
4497
|
+
projectRoot: process.cwd(),
|
|
4498
|
+
onStatus: (status) => {
|
|
4499
|
+
if (status.state === "device")
|
|
4500
|
+
tui.activity.pushCard(`audio · ${status.message}`, { tone: "warning" });
|
|
4501
|
+
},
|
|
4502
|
+
});
|
|
3079
4503
|
return monitorEngine;
|
|
3080
4504
|
}
|
|
3081
4505
|
|
|
4506
|
+
/** This machine's player, without starting an engine just to ask. */
|
|
4507
|
+
function audioInfo(): AudioBackendInfo {
|
|
4508
|
+
if (audio instanceof AudioEngine) return audio.info;
|
|
4509
|
+
if (monitorEngine) return monitorEngine.info;
|
|
4510
|
+
audioInfoCache ??= detectAudioBackend();
|
|
4511
|
+
return audioInfoCache;
|
|
4512
|
+
}
|
|
4513
|
+
|
|
4514
|
+
/** Project › audio's rows; device lists are re-read at most every 3 s. */
|
|
4515
|
+
function audioMenu(): MenuAudioDevices {
|
|
4516
|
+
const now = performance.now();
|
|
4517
|
+
if (!audioMenuCache || now - audioMenuCache.at > 3000)
|
|
4518
|
+
audioMenuCache = { at: now, state: audioMenuState(audioInfo()) };
|
|
4519
|
+
return audioMenuCache.state;
|
|
4520
|
+
}
|
|
4521
|
+
|
|
3082
4522
|
/** The play session for the focused track (re-made when focus moves). */
|
|
3083
4523
|
function menuContext(): MenuContext {
|
|
3084
4524
|
const session = play;
|
|
@@ -3096,11 +4536,14 @@ function menuContext(): MenuContext {
|
|
|
3096
4536
|
committedChords: chordSettings,
|
|
3097
4537
|
},
|
|
3098
4538
|
trackId: requestedTrack,
|
|
4539
|
+
sessionName: record.meta.name,
|
|
3099
4540
|
playing: clock.playing,
|
|
3100
4541
|
grid: session?.grid ?? DEFAULT_GRID,
|
|
3101
4542
|
grids: GRIDS.map((grid) => grid.label),
|
|
3102
4543
|
clickOn: session?.clickOn ?? false,
|
|
3103
4544
|
countInBars: session?.countInBars ?? 1,
|
|
4545
|
+
showMe: showMe.level,
|
|
4546
|
+
audio: audioMenu(),
|
|
3104
4547
|
chords: stagedChordSettings() ?? session?.chords.settings ?? chordSettings,
|
|
3105
4548
|
projectRoot: process.cwd(),
|
|
3106
4549
|
};
|
|
@@ -3125,6 +4568,7 @@ function keysScreen(): readonly KeySection[] | undefined {
|
|
|
3125
4568
|
if (prompt.value.length > 0) return undefined;
|
|
3126
4569
|
if (play?.on)
|
|
3127
4570
|
return play.chords.on ? [...KEYS.play, ...KEYS.chords] : KEYS.play;
|
|
4571
|
+
if (tape.on) return KEYS.tape;
|
|
3128
4572
|
return KEYS.prompt;
|
|
3129
4573
|
}
|
|
3130
4574
|
|
|
@@ -3390,7 +4834,7 @@ async function startAuditionLoop(): Promise<void> {
|
|
|
3390
4834
|
loop.focus(requestedTrack);
|
|
3391
4835
|
// One sound at a time: the song stops while the loop plays.
|
|
3392
4836
|
if (clock.playing) await setTransport("pause");
|
|
3393
|
-
previewEngine().setLeadMs(
|
|
4837
|
+
previewEngine().setLeadMs(previewEngine().playLeadMs);
|
|
3394
4838
|
loop.start();
|
|
3395
4839
|
}
|
|
3396
4840
|
|
|
@@ -3606,12 +5050,23 @@ function refreshMenu(): void {
|
|
|
3606
5050
|
if (fader) {
|
|
3607
5051
|
const fields = menu.faderFields(context);
|
|
3608
5052
|
if (fields.length === 0) closeFader();
|
|
3609
|
-
else
|
|
3610
|
-
|
|
5053
|
+
else {
|
|
5054
|
+
const focusedField = fields[focusIndex(fader, fields)];
|
|
5055
|
+
const drawer = drawerView(fader, fields, menu.faderCommitted(context), {
|
|
3611
5056
|
title: menu.crumbs,
|
|
3612
5057
|
dirty: context.audition?.dirty ?? false,
|
|
3613
5058
|
status: context.audition?.status,
|
|
5059
|
+
atOnce:
|
|
5060
|
+
focusedField !== undefined &&
|
|
5061
|
+
!stageableNow(faderCommand(focusedField)),
|
|
5062
|
+
knobs: menu.knobPage,
|
|
3614
5063
|
});
|
|
5064
|
+
tui.drawer = withPaneMarks(
|
|
5065
|
+
menu.knobPage === "knobs"
|
|
5066
|
+
? { ...drawer, aside: drawerAside(menu.pageFields(context), fields) }
|
|
5067
|
+
: drawer,
|
|
5068
|
+
);
|
|
5069
|
+
}
|
|
3615
5070
|
}
|
|
3616
5071
|
const view = menu.view(context);
|
|
3617
5072
|
tui.openPicker({
|
|
@@ -3629,6 +5084,117 @@ function refreshMenu(): void {
|
|
|
3629
5084
|
// ── the fader drawer ─────────────────────────────────────────────────
|
|
3630
5085
|
|
|
3631
5086
|
/** Enter on a number row (or a bare `volume`, `fx filter`): the drawer. */
|
|
5087
|
+
/**
|
|
5088
|
+
* `export song.wav [stems]`: render the song as `dawg render song.wav` would
|
|
5089
|
+
* (the offline renderer, deterministic), then with `stems` one WAV per
|
|
5090
|
+
* audible track beside it (`song-bass.wav`), each that track soloed.
|
|
5091
|
+
*/
|
|
5092
|
+
async function exportWav(path: string, stems: boolean): Promise<Receipt> {
|
|
5093
|
+
const directory = await mkdtemp(join(tmpdir(), "dawg-export-"));
|
|
5094
|
+
const errors: string[] = [];
|
|
5095
|
+
const sink = { write: (text: string) => errors.push(text.trim()) };
|
|
5096
|
+
const quiet = { write: () => undefined };
|
|
5097
|
+
const render = async (song: TrackScore, target: string): Promise<boolean> => {
|
|
5098
|
+
const file = join(directory, "song.track.json");
|
|
5099
|
+
await writeFile(file, encodeLoop(song), "utf8");
|
|
5100
|
+
const code = await runRenderCommand(
|
|
5101
|
+
["render", target, "--import", file],
|
|
5102
|
+
process.cwd(),
|
|
5103
|
+
quiet,
|
|
5104
|
+
sink,
|
|
5105
|
+
);
|
|
5106
|
+
return code === 0;
|
|
5107
|
+
};
|
|
5108
|
+
tui.activity.setSpinner(`export ${path}`);
|
|
5109
|
+
try {
|
|
5110
|
+
if (!(await render(score, path)))
|
|
5111
|
+
return fail(errors.at(-1) ?? `export failed · ${path}`);
|
|
5112
|
+
if (!stems) return ok(`exported · ${path}`);
|
|
5113
|
+
const written: string[] = [];
|
|
5114
|
+
const base = path.replace(/\.wav$/i, "");
|
|
5115
|
+
for (const track of score.tracks) {
|
|
5116
|
+
if (!isTrackAudible(score, track.id)) continue;
|
|
5117
|
+
let solo = score;
|
|
5118
|
+
for (const other of score.tracks)
|
|
5119
|
+
solo = applyScoreOperation(solo, {
|
|
5120
|
+
type: "updateTrack",
|
|
5121
|
+
trackId: other.id,
|
|
5122
|
+
patch: { solo: other.id === track.id },
|
|
5123
|
+
});
|
|
5124
|
+
const target = `${base}-${track.id}.wav`;
|
|
5125
|
+
if (!(await render(solo, target)))
|
|
5126
|
+
return fail(errors.at(-1) ?? `export failed · ${target}`);
|
|
5127
|
+
written.push(target);
|
|
5128
|
+
}
|
|
5129
|
+
return ok(stemsReceipt(path, written));
|
|
5130
|
+
} finally {
|
|
5131
|
+
tui.activity.setSpinner(undefined);
|
|
5132
|
+
await rm(directory, { recursive: true, force: true });
|
|
5133
|
+
}
|
|
5134
|
+
}
|
|
5135
|
+
|
|
5136
|
+
/** `mix`: Mix › mixer in the drawer, on the focused track's level. */
|
|
5137
|
+
function openMixer(context: MenuContext): string | undefined {
|
|
5138
|
+
if (context.score.tracks.length === 0) return undefined;
|
|
5139
|
+
menu.show(context, "mix");
|
|
5140
|
+
if (!menu.enter(context, "mixer")) {
|
|
5141
|
+
menu.close();
|
|
5142
|
+
return undefined;
|
|
5143
|
+
}
|
|
5144
|
+
const first = menu.openKnobs(context);
|
|
5145
|
+
const fields = menu.faderFields(context);
|
|
5146
|
+
const here = fields.find((field) => field.label.includes("›"));
|
|
5147
|
+
if (!first || fields.length === 0) {
|
|
5148
|
+
menu.close();
|
|
5149
|
+
return undefined;
|
|
5150
|
+
}
|
|
5151
|
+
return here?.label ?? first;
|
|
5152
|
+
}
|
|
5153
|
+
|
|
5154
|
+
/** `knobs <page>`: open that page's level and its four knobs. */
|
|
5155
|
+
function openKnobPage(
|
|
5156
|
+
context: MenuContext,
|
|
5157
|
+
word: string | undefined,
|
|
5158
|
+
track: TrackScore["tracks"][number] | undefined,
|
|
5159
|
+
): string | undefined {
|
|
5160
|
+
const page = knobPageId(word, track);
|
|
5161
|
+
if (!page) return undefined;
|
|
5162
|
+
if (page.startsWith("fx:")) {
|
|
5163
|
+
if (!menu.showFader(context, `fx ${page.slice(3)}`)) return undefined;
|
|
5164
|
+
} else {
|
|
5165
|
+
const section = page.startsWith("sound:")
|
|
5166
|
+
? "sound"
|
|
5167
|
+
: page === "tempo"
|
|
5168
|
+
? "project"
|
|
5169
|
+
: page;
|
|
5170
|
+
menu.show(context, section);
|
|
5171
|
+
}
|
|
5172
|
+
const label = menu.openKnobs(context);
|
|
5173
|
+
if (!label || menu.knobPageId !== page) {
|
|
5174
|
+
menu.close();
|
|
5175
|
+
return undefined;
|
|
5176
|
+
}
|
|
5177
|
+
return label;
|
|
5178
|
+
}
|
|
5179
|
+
|
|
5180
|
+
/** `tempo`, `bars`, `meter`: open Project and name its fader, if it has one. */
|
|
5181
|
+
function songFader(context: MenuContext, command: string): string | undefined {
|
|
5182
|
+
// Bare song scalars and the Project fader each opens.
|
|
5183
|
+
const faders: Readonly<Record<string, string>> = {
|
|
5184
|
+
tempo: "tempo",
|
|
5185
|
+
bpm: "tempo",
|
|
5186
|
+
bars: "loop length",
|
|
5187
|
+
meter: "beats per bar",
|
|
5188
|
+
};
|
|
5189
|
+
const label = faders[command.replace(/^\//, "").toLowerCase()];
|
|
5190
|
+
if (!label) return undefined;
|
|
5191
|
+
menu.show(context, "project");
|
|
5192
|
+
if (menu.faderFields(context).some((field) => field.label === label))
|
|
5193
|
+
return label;
|
|
5194
|
+
menu.close();
|
|
5195
|
+
return undefined;
|
|
5196
|
+
}
|
|
5197
|
+
|
|
3632
5198
|
/**
|
|
3633
5199
|
* Open the drawer on `label`. `standalone` drawers came from a command or a
|
|
3634
5200
|
* click, not from inside the menu, so closing one closes the menu too.
|
|
@@ -3708,6 +5274,10 @@ function mouseInput(event: MouseEvent): string[] {
|
|
|
3708
5274
|
const frame = tui.frame;
|
|
3709
5275
|
if (!frame) return [];
|
|
3710
5276
|
const target = frame.hits.at(event.x, event.y)?.target;
|
|
5277
|
+
if (tape.on && !play?.on && !tui.ui.overlay && !menu.open) {
|
|
5278
|
+
const handled = tapeMouse(event, target);
|
|
5279
|
+
if (handled) return handled;
|
|
5280
|
+
}
|
|
3711
5281
|
if (event.kind === "wheel") {
|
|
3712
5282
|
const fields = fader && menu.open ? menu.faderFields(menuContext()) : [];
|
|
3713
5283
|
if (
|
|
@@ -3731,7 +5301,7 @@ function mouseInput(event: MouseEvent): string[] {
|
|
|
3731
5301
|
return [];
|
|
3732
5302
|
}
|
|
3733
5303
|
if (fader) return [];
|
|
3734
|
-
if (tui.ui.overlay) return [event.delta < 0 ?
|
|
5304
|
+
if (tui.ui.overlay) return [event.delta < 0 ? ARROW_UP : ARROW_DOWN];
|
|
3735
5305
|
return [];
|
|
3736
5306
|
}
|
|
3737
5307
|
// A drag keeps moving the fader it started on, even past the bar's ends.
|
|
@@ -3821,7 +5391,9 @@ function mouseInput(event: MouseEvent): string[] {
|
|
|
3821
5391
|
}
|
|
3822
5392
|
case "transport":
|
|
3823
5393
|
if (tui.ui.overlay) return [];
|
|
3824
|
-
void toggleTransport()
|
|
5394
|
+
void toggleTransport()
|
|
5395
|
+
.catch((error: unknown) => transportFailed(error))
|
|
5396
|
+
.finally(() => requestFrame());
|
|
3825
5397
|
return [];
|
|
3826
5398
|
case "tracks":
|
|
3827
5399
|
if (tui.ui.overlay) return [];
|
|
@@ -3852,22 +5424,335 @@ function playSession(): PlaySession {
|
|
|
3852
5424
|
}
|
|
3853
5425
|
|
|
3854
5426
|
async function enterPlay(): Promise<Receipt> {
|
|
3855
|
-
if (play?.on) return ok(`play · ${play.track} · esc leaves`);
|
|
5427
|
+
if (play?.on) return ok(`play mode · ${play.track} · esc leaves`);
|
|
3856
5428
|
await materializeDraft();
|
|
3857
5429
|
const session = playSession();
|
|
3858
5430
|
await session.enter();
|
|
3859
5431
|
if (hasSamplerTracks(score)) void sampleProblems(score);
|
|
3860
5432
|
return ok(
|
|
3861
|
-
`play · ${session.track} · ${session.keyboard.range} · ? keys · esc leaves`,
|
|
5433
|
+
`play mode · ${session.track} · ${session.layout.drums ? "kit" : session.keyboard.range} · ? keys · esc leaves`,
|
|
3862
5434
|
);
|
|
3863
5435
|
}
|
|
3864
5436
|
|
|
5437
|
+
/**
|
|
5438
|
+
* `keys record [replace|off]` (op1-ux §4.7, r / R on TAPE): opens PLAY on
|
|
5439
|
+
* the focused track and arms recording. With a loop set, every pass over
|
|
5440
|
+
* it commits once, so one undo step; esc goes back to TAPE.
|
|
5441
|
+
*/
|
|
5442
|
+
async function keysRecord(
|
|
5443
|
+
mode: "overdub" | "replace" | "off",
|
|
5444
|
+
): Promise<Receipt> {
|
|
5445
|
+
if (mode === "off" && !play?.on) return ok("record off");
|
|
5446
|
+
if (!play?.on) {
|
|
5447
|
+
const entered = await enterPlay();
|
|
5448
|
+
if (!play?.on) return entered;
|
|
5449
|
+
}
|
|
5450
|
+
const result = playSession().recordCommand(mode);
|
|
5451
|
+
reportView();
|
|
5452
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
5453
|
+
}
|
|
5454
|
+
|
|
3865
5455
|
async function exitPlay(): Promise<Receipt> {
|
|
3866
5456
|
if (!play?.on) return ok("play mode is off");
|
|
3867
5457
|
await play.exit();
|
|
3868
5458
|
return ok("play off");
|
|
3869
5459
|
}
|
|
3870
5460
|
|
|
5461
|
+
// ── TAPE (op1-ux §6) ─────────────────────────────────────────────────
|
|
5462
|
+
|
|
5463
|
+
/** This pane's clipboard file in `.dawg` (§6.3); undefined in a demo. */
|
|
5464
|
+
function paneClipboardPath(): string | undefined {
|
|
5465
|
+
if (demo || !record.sessionId) return undefined;
|
|
5466
|
+
return clipboardPath(process.cwd(), record.sessionId, port.pane);
|
|
5467
|
+
}
|
|
5468
|
+
|
|
5469
|
+
/** The paste's target beat until its `jump` lands (or a moment passes). */
|
|
5470
|
+
function pendingTapeBeat(value: TrackScore): number | undefined {
|
|
5471
|
+
const pending = tape.pending;
|
|
5472
|
+
if (!pending) return undefined;
|
|
5473
|
+
const here = scoreBeatAt(value, clock.beatAt());
|
|
5474
|
+
if (Date.now() > pending.until || clock.playing || here >= pending.beat) {
|
|
5475
|
+
tape.pending = undefined;
|
|
5476
|
+
return undefined;
|
|
5477
|
+
}
|
|
5478
|
+
return pending.beat;
|
|
5479
|
+
}
|
|
5480
|
+
|
|
5481
|
+
/** What TAPE's reducer and view read: the score, focus, playhead, clipboard. */
|
|
5482
|
+
function tapeContext(
|
|
5483
|
+
value: TrackScore = score,
|
|
5484
|
+
beat = clock.beatAt(),
|
|
5485
|
+
): TapeContext {
|
|
5486
|
+
const board = rangeClipboard;
|
|
5487
|
+
return {
|
|
5488
|
+
score: value,
|
|
5489
|
+
trackId: requestedTrack,
|
|
5490
|
+
beat: pendingTapeBeat(value) ?? scoreBeatAt(value, beat),
|
|
5491
|
+
clipboard: board ? { source: board.source, range: board.range } : undefined,
|
|
5492
|
+
cut: cutArmed(tape.cut?.after, value),
|
|
5493
|
+
};
|
|
5494
|
+
}
|
|
5495
|
+
|
|
5496
|
+
function currentTapeView(value: TrackScore, beat: number): TapeView {
|
|
5497
|
+
const view = tapeView({
|
|
5498
|
+
...tapeContext(value, beat),
|
|
5499
|
+
// A pending paste's jump has not landed: its bar's first pass shows.
|
|
5500
|
+
transportBeat: pendingTapeBeat(value) === undefined ? beat : undefined,
|
|
5501
|
+
zoom: tape.zoom,
|
|
5502
|
+
selected: tape.knobs.selected,
|
|
5503
|
+
marks: paneMarks,
|
|
5504
|
+
});
|
|
5505
|
+
// §9.1 and §9.3: reels turn on the transport beat; the loop brackets
|
|
5506
|
+
// flash as each pass closes. Both are still with `/motion off`.
|
|
5507
|
+
const reducedMotion = tui.ui.reducedMotion;
|
|
5508
|
+
const withDelight: TapeView = {
|
|
5509
|
+
...view,
|
|
5510
|
+
reel: reelGlyph({
|
|
5511
|
+
playing: clock.playing,
|
|
5512
|
+
beat,
|
|
5513
|
+
reducedMotion,
|
|
5514
|
+
unicode: tui.capabilities.unicode,
|
|
5515
|
+
}),
|
|
5516
|
+
loopFlash: loopFlash({
|
|
5517
|
+
playing: clock.playing,
|
|
5518
|
+
beat,
|
|
5519
|
+
loopBeats: loopedSection(value)
|
|
5520
|
+
? loopTicksOf(value) / value.ticksPerBeat
|
|
5521
|
+
: undefined,
|
|
5522
|
+
bpm: bpmAtTick(value, scoreBeatAt(value, beat) * value.ticksPerBeat),
|
|
5523
|
+
reducedMotion,
|
|
5524
|
+
}),
|
|
5525
|
+
};
|
|
5526
|
+
tape.view = withDelight;
|
|
5527
|
+
return withDelight;
|
|
5528
|
+
}
|
|
5529
|
+
|
|
5530
|
+
/** Whether the clipboard's source bars differ from what the score holds now. */
|
|
5531
|
+
function clipboardStale(): boolean {
|
|
5532
|
+
const board = rangeClipboard;
|
|
5533
|
+
if (!board) return false;
|
|
5534
|
+
try {
|
|
5535
|
+
const ids = board.clip.all ? undefined : [board.source];
|
|
5536
|
+
const now = extractRange(score, ids, board.range);
|
|
5537
|
+
return JSON.stringify(now) !== JSON.stringify(board.clip);
|
|
5538
|
+
} catch {
|
|
5539
|
+
return true;
|
|
5540
|
+
}
|
|
5541
|
+
}
|
|
5542
|
+
|
|
5543
|
+
/** Typed commands from a TAPE gesture: each echoes and runs as if typed. */
|
|
5544
|
+
function runTyped(commands: readonly string[]): void {
|
|
5545
|
+
echoGesture(commands);
|
|
5546
|
+
runPromptsLater(commands);
|
|
5547
|
+
}
|
|
5548
|
+
|
|
5549
|
+
/**
|
|
5550
|
+
* The prompt row echoes a gesture's typed command dimly for one beat
|
|
5551
|
+
* (op1-ux §10.1: type what you see), at least long enough to read.
|
|
5552
|
+
*/
|
|
5553
|
+
function echoGesture(commands: readonly string[]): void {
|
|
5554
|
+
if (commands.length === 0) return;
|
|
5555
|
+
gestureEcho.text = commands.join(" · ");
|
|
5556
|
+
if (gestureEcho.clear) clearTimeout(gestureEcho.clear);
|
|
5557
|
+
const beatMs = 60_000 / Math.max(1, score.tempoBpm);
|
|
5558
|
+
gestureEcho.clear = setTimeout(
|
|
5559
|
+
() => {
|
|
5560
|
+
gestureEcho.text = undefined;
|
|
5561
|
+
gestureEcho.clear = undefined;
|
|
5562
|
+
requestFrame();
|
|
5563
|
+
},
|
|
5564
|
+
Math.max(800, beatMs),
|
|
5565
|
+
);
|
|
5566
|
+
gestureEcho.clear.unref?.();
|
|
5567
|
+
requestFrame();
|
|
5568
|
+
}
|
|
5569
|
+
|
|
5570
|
+
async function enterTape(): Promise<Receipt> {
|
|
5571
|
+
if (play?.on) await play.exit();
|
|
5572
|
+
if (tape.on) return ok("tape · esc goes home");
|
|
5573
|
+
closeFader();
|
|
5574
|
+
if (menu.open) menu.close();
|
|
5575
|
+
tape.on = true;
|
|
5576
|
+
tape.knobs.selected = 0;
|
|
5577
|
+
if (!rangeClipboard) {
|
|
5578
|
+
rangeClipboard = await loadClipboard(paneClipboardPath());
|
|
5579
|
+
}
|
|
5580
|
+
return ok(
|
|
5581
|
+
`tape · ${score.tracks.length} track${score.tracks.length === 1 ? "" : "s"} · ${score.bars} bars · ? keys · esc home`,
|
|
5582
|
+
);
|
|
5583
|
+
}
|
|
5584
|
+
|
|
5585
|
+
function exitTape(): Receipt {
|
|
5586
|
+
if (!tape.on) return ok("tape is off");
|
|
5587
|
+
tape.on = false;
|
|
5588
|
+
tape.view = undefined;
|
|
5589
|
+
tape.drag = undefined;
|
|
5590
|
+
return ok("home");
|
|
5591
|
+
}
|
|
5592
|
+
|
|
5593
|
+
/**
|
|
5594
|
+
* One key on TAPE. True when TAPE consumed it; false sends it on (the
|
|
5595
|
+
* prompt once a command is typed, overlays, ctrl keys).
|
|
5596
|
+
*/
|
|
5597
|
+
function tapeInput(value: string): boolean {
|
|
5598
|
+
if (!tape.on || play?.on) return false;
|
|
5599
|
+
if (tui.ui.overlay !== undefined || menu.open) return false;
|
|
5600
|
+
if (prompt.value.length > 0) return false;
|
|
5601
|
+
if (value === "/" || value === "\u0003") return false;
|
|
5602
|
+
const base = tapeContext();
|
|
5603
|
+
// After a cut the source bars are empty on purpose: not stale.
|
|
5604
|
+
const context = { ...base, stale: !base.cut && clipboardStale() };
|
|
5605
|
+
const action = tapeKey(context, value);
|
|
5606
|
+
switch (action.type) {
|
|
5607
|
+
case "run":
|
|
5608
|
+
if (action.cut) tape.cutArmed = score;
|
|
5609
|
+
runTyped(action.commands);
|
|
5610
|
+
return true;
|
|
5611
|
+
case "paste": {
|
|
5612
|
+
if (action.fold) tape.foldArmed = true;
|
|
5613
|
+
const commands = [action.command];
|
|
5614
|
+
// The playhead moves past the paste so `v v v` tiles.
|
|
5615
|
+
if (action.end < score.bars) {
|
|
5616
|
+
commands.push(`jump ${action.end + 1}`);
|
|
5617
|
+
tape.pending = {
|
|
5618
|
+
beat: beatOfBar(score, action.end),
|
|
5619
|
+
until: Date.now() + 2000,
|
|
5620
|
+
};
|
|
5621
|
+
}
|
|
5622
|
+
runTyped(commands);
|
|
5623
|
+
return true;
|
|
5624
|
+
}
|
|
5625
|
+
case "note":
|
|
5626
|
+
tui.activity.pushCard(action.message, { tone: "info" });
|
|
5627
|
+
return true;
|
|
5628
|
+
case "zoom": {
|
|
5629
|
+
const index = TAPE_ZOOMS.indexOf(tape.zoom) + action.direction;
|
|
5630
|
+
tape.zoom =
|
|
5631
|
+
TAPE_ZOOMS[Math.max(0, Math.min(TAPE_ZOOMS.length - 1, index))]!;
|
|
5632
|
+
return true;
|
|
5633
|
+
}
|
|
5634
|
+
case "focus": {
|
|
5635
|
+
const command = focusCommand(score, action.row);
|
|
5636
|
+
if (command) runTyped([command]);
|
|
5637
|
+
return true;
|
|
5638
|
+
}
|
|
5639
|
+
case "transport":
|
|
5640
|
+
void toggleTransport().catch((error: unknown) => transportFailed(error));
|
|
5641
|
+
return true;
|
|
5642
|
+
case "keys":
|
|
5643
|
+
showKeys();
|
|
5644
|
+
return true;
|
|
5645
|
+
case "exit":
|
|
5646
|
+
receipt(exitTape());
|
|
5647
|
+
return true;
|
|
5648
|
+
case "pass":
|
|
5649
|
+
break;
|
|
5650
|
+
}
|
|
5651
|
+
const knob = knobKey(tape.knobs, tapeKnobs(context), value);
|
|
5652
|
+
if (knob.type === "run") {
|
|
5653
|
+
runTyped([knob.command]);
|
|
5654
|
+
return true;
|
|
5655
|
+
}
|
|
5656
|
+
if (knob.type === "open") {
|
|
5657
|
+
runTyped([knobNoun(context, knob.knob)]);
|
|
5658
|
+
return true;
|
|
5659
|
+
}
|
|
5660
|
+
if (knob.type === "handled") return true;
|
|
5661
|
+
// Unmapped printable keys are swallowed (as in play mode); control keys
|
|
5662
|
+
// (enter, ctrl-z, ctrl-k) keep their bindings.
|
|
5663
|
+
return value.length === 1 && value >= " " && value !== "\u007f";
|
|
5664
|
+
}
|
|
5665
|
+
|
|
5666
|
+
/**
|
|
5667
|
+
* The mouse on TAPE: a ruler click jumps there and a ruler drag sets the
|
|
5668
|
+
* loop, a row click focuses it, the clipboard chip pastes, and the wheel
|
|
5669
|
+
* turns the selected knob. Each runs its typed command. Undefined: not a
|
|
5670
|
+
* TAPE gesture, so the ordinary mouse path takes it.
|
|
5671
|
+
*/
|
|
5672
|
+
function tapeMouse(
|
|
5673
|
+
event: MouseEvent,
|
|
5674
|
+
target: HitTarget | undefined,
|
|
5675
|
+
): string[] | undefined {
|
|
5676
|
+
if (event.kind === "wheel") {
|
|
5677
|
+
if (!target || (target.kind !== "tape-ruler" && target.kind !== "tape-row"))
|
|
5678
|
+
return undefined;
|
|
5679
|
+
// The wheel walks the playhead a bar at a time (never a tempo knob).
|
|
5680
|
+
const bar = playheadBar();
|
|
5681
|
+
const next = Math.max(
|
|
5682
|
+
0,
|
|
5683
|
+
Math.min(score.bars - 1, bar + (event.delta < 0 ? -1 : 1)),
|
|
5684
|
+
);
|
|
5685
|
+
if (next !== bar) runTyped([`jump ${next + 1}`]);
|
|
5686
|
+
return [];
|
|
5687
|
+
}
|
|
5688
|
+
const drag = tape.drag;
|
|
5689
|
+
if (drag && (event.kind === "drag" || event.kind === "up")) {
|
|
5690
|
+
const view = tape.view;
|
|
5691
|
+
const first = drag.first;
|
|
5692
|
+
const bar =
|
|
5693
|
+
view && first !== undefined
|
|
5694
|
+
? tapeBarAt(event.x, drag.left, first)
|
|
5695
|
+
: undefined;
|
|
5696
|
+
if (bar !== undefined) drag.to = bar;
|
|
5697
|
+
if (event.kind === "up") {
|
|
5698
|
+
tape.drag = undefined;
|
|
5699
|
+
if (drag.to !== drag.from) {
|
|
5700
|
+
const start = Math.min(drag.from, drag.to);
|
|
5701
|
+
const end = Math.max(drag.from, drag.to);
|
|
5702
|
+
runTyped([`loop ${start + 1}-${end + 1}`]);
|
|
5703
|
+
}
|
|
5704
|
+
}
|
|
5705
|
+
return [];
|
|
5706
|
+
}
|
|
5707
|
+
if (event.kind !== "down" || event.button !== "left" || !target)
|
|
5708
|
+
return undefined;
|
|
5709
|
+
if (target.kind === "tape-ruler") {
|
|
5710
|
+
const bar = tapeBarAt(event.x, target.left, target.firstCell);
|
|
5711
|
+
if (bar === undefined) return [];
|
|
5712
|
+
tape.drag = {
|
|
5713
|
+
from: bar,
|
|
5714
|
+
to: bar,
|
|
5715
|
+
left: target.left,
|
|
5716
|
+
first: target.firstCell,
|
|
5717
|
+
};
|
|
5718
|
+
runTyped([`jump ${bar + 1}`]);
|
|
5719
|
+
return [];
|
|
5720
|
+
}
|
|
5721
|
+
if (target.kind === "tape-row") {
|
|
5722
|
+
// A drag along a row sets the loop over those bars, like the ruler.
|
|
5723
|
+
const bar = tapeBarAt(event.x, target.left, target.firstCell);
|
|
5724
|
+
if (bar !== undefined)
|
|
5725
|
+
tape.drag = {
|
|
5726
|
+
from: bar,
|
|
5727
|
+
to: bar,
|
|
5728
|
+
left: target.left,
|
|
5729
|
+
first: target.firstCell,
|
|
5730
|
+
};
|
|
5731
|
+
const command = focusCommand(score, target.row);
|
|
5732
|
+
if (command && score.tracks[target.row]?.id !== requestedTrack)
|
|
5733
|
+
runTyped([command]);
|
|
5734
|
+
return [];
|
|
5735
|
+
}
|
|
5736
|
+
if (target.kind === "tape-clipboard") {
|
|
5737
|
+
tapeInput("v");
|
|
5738
|
+
return [];
|
|
5739
|
+
}
|
|
5740
|
+
return undefined;
|
|
5741
|
+
}
|
|
5742
|
+
|
|
5743
|
+
/** The bar under a TAPE column, from the last painted view. */
|
|
5744
|
+
function tapeBarAt(
|
|
5745
|
+
x: number,
|
|
5746
|
+
left: number,
|
|
5747
|
+
firstCell: number,
|
|
5748
|
+
): number | undefined {
|
|
5749
|
+
const view = tape.view;
|
|
5750
|
+
if (!view) return undefined;
|
|
5751
|
+
const cell = x - left + firstCell;
|
|
5752
|
+
if (cell < 0 || cell >= view.cellBars.length) return undefined;
|
|
5753
|
+
return view.cellBars[cell];
|
|
5754
|
+
}
|
|
5755
|
+
|
|
3871
5756
|
/**
|
|
3872
5757
|
* One key in play mode. True when play mode consumed it; false sends it on
|
|
3873
5758
|
* to the normal bindings (overlays, a command being typed, Ctrl-C, arrows).
|
|
@@ -3888,9 +5773,7 @@ function playKey(value: string): boolean {
|
|
|
3888
5773
|
else if (result.command === "transport") {
|
|
3889
5774
|
if (!session.startWithCountIn())
|
|
3890
5775
|
void toggleTransport().catch((error: unknown) =>
|
|
3891
|
-
|
|
3892
|
-
`transport failed · ${error instanceof Error ? error.message : String(error)}`,
|
|
3893
|
-
),
|
|
5776
|
+
transportFailed(error),
|
|
3894
5777
|
);
|
|
3895
5778
|
} else if (result.command === "menu") return false;
|
|
3896
5779
|
return true;
|
|
@@ -3908,8 +5791,11 @@ function playHost() {
|
|
|
3908
5791
|
playing: () => clock.playing,
|
|
3909
5792
|
beatAt: (ms: number) => clock.beatAt(ms),
|
|
3910
5793
|
scoreBeat: (beat: number) => scoreBeatAt(score, beat),
|
|
3911
|
-
engine:
|
|
5794
|
+
engine: playEngine,
|
|
3912
5795
|
samples: () => liveSampleBank,
|
|
5796
|
+
// A take is a plain commitScore (it diffs into operations, so dawgd
|
|
5797
|
+
// rebases a pass over other panes' edits); a stale record reloads and
|
|
5798
|
+
// replays the take's operations on what landed meanwhile.
|
|
3913
5799
|
async commit(
|
|
3914
5800
|
next: TrackScore,
|
|
3915
5801
|
kind: string,
|
|
@@ -3919,15 +5805,7 @@ function playHost() {
|
|
|
3919
5805
|
let target = next;
|
|
3920
5806
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
3921
5807
|
try {
|
|
3922
|
-
|
|
3923
|
-
record,
|
|
3924
|
-
{ kind, payload },
|
|
3925
|
-
operations,
|
|
3926
|
-
target.toJSON(),
|
|
3927
|
-
);
|
|
3928
|
-
score = scoreFromJSON(record.composition);
|
|
3929
|
-
if (clock.playing) void audio.play(score);
|
|
3930
|
-
projectSync?.scoreChanged(score);
|
|
5808
|
+
await commitScore(target, kind, payload);
|
|
3931
5809
|
return;
|
|
3932
5810
|
} catch (error) {
|
|
3933
5811
|
if (!(error instanceof SessionConflictError)) throw error;
|
|
@@ -3941,6 +5819,7 @@ function playHost() {
|
|
|
3941
5819
|
}
|
|
3942
5820
|
throw new Error("session busy");
|
|
3943
5821
|
},
|
|
5822
|
+
recordingElsewhere: (trackId: string) => recordingPane(trackId),
|
|
3944
5823
|
async startTransport(beat: number): Promise<void> {
|
|
3945
5824
|
if (port.mode === "daemon") {
|
|
3946
5825
|
await port.transport("play", { beat });
|
|
@@ -3952,16 +5831,21 @@ function playHost() {
|
|
|
3952
5831
|
async stopTransport(): Promise<void> {
|
|
3953
5832
|
await setTransport("pause");
|
|
3954
5833
|
},
|
|
5834
|
+
ghost(pitch: number) {
|
|
5835
|
+
tui.delight.ghost(pitch, Date.now());
|
|
5836
|
+
requestFrame();
|
|
5837
|
+
},
|
|
3955
5838
|
card(text: string, tone: "info" | "success" | "warning" | "error") {
|
|
3956
5839
|
if (tone === "error") tui.activity.pushError(text);
|
|
3957
5840
|
else
|
|
3958
5841
|
tui.activity.pushCard(text, {
|
|
3959
5842
|
tone,
|
|
3960
5843
|
trackId: requestedTrack,
|
|
3961
|
-
resultRevision:
|
|
5844
|
+
resultRevision:
|
|
5845
|
+
tone === "success" ? shownRevision(record) : undefined,
|
|
3962
5846
|
});
|
|
3963
5847
|
},
|
|
3964
|
-
newNoteId: () =>
|
|
5848
|
+
newNoteId: () => newId("n"),
|
|
3965
5849
|
};
|
|
3966
5850
|
}
|
|
3967
5851
|
|
|
@@ -3982,15 +5866,48 @@ async function commitScore(
|
|
|
3982
5866
|
if (retimed) clock.follow(score);
|
|
3983
5867
|
return;
|
|
3984
5868
|
}
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
5869
|
+
// Every edit travels as score operations (snapshot when the diff cannot
|
|
5870
|
+
// express it), so dawgd rebases it over other panes' concurrent edits per
|
|
5871
|
+
// (track, property) and the log replays as operations (design §12.6).
|
|
5872
|
+
const operations = diffOps(score, next);
|
|
5873
|
+
if (operations) {
|
|
5874
|
+
record = await port.appendOperations(
|
|
5875
|
+
record,
|
|
5876
|
+
{ kind, payload },
|
|
5877
|
+
operations,
|
|
5878
|
+
next.toJSON(),
|
|
5879
|
+
);
|
|
5880
|
+
// A rebased edit lands on top of what other panes wrote meanwhile.
|
|
5881
|
+
score = deepEqual(record.composition, next.toJSON())
|
|
5882
|
+
? next
|
|
5883
|
+
: scoreFromJSON(record.composition);
|
|
5884
|
+
} else {
|
|
5885
|
+
record = await port.append(record, { kind, payload }, next.toJSON());
|
|
5886
|
+
score = next;
|
|
5887
|
+
}
|
|
5888
|
+
if (retimed || score !== next) clock.follow(score);
|
|
3988
5889
|
if (clock.playing) void audio.play(score);
|
|
3989
5890
|
projectSync?.scoreChanged(score);
|
|
3990
5891
|
reportSampleProblems(score);
|
|
3991
5892
|
void updateCredits(score);
|
|
3992
5893
|
}
|
|
3993
5894
|
|
|
5895
|
+
/** The ops from `previous` to `next`, or undefined when they do not diff. */
|
|
5896
|
+
function diffOps(
|
|
5897
|
+
previous: TrackScore,
|
|
5898
|
+
next: TrackScore,
|
|
5899
|
+
): readonly ScoreOperation[] | undefined {
|
|
5900
|
+
try {
|
|
5901
|
+
const operations = diffScores(previous, next);
|
|
5902
|
+
return operations.length > 0 && operations.length <= 256
|
|
5903
|
+
? operations
|
|
5904
|
+
: undefined;
|
|
5905
|
+
} catch (error) {
|
|
5906
|
+
if (error instanceof DiffError) return undefined;
|
|
5907
|
+
throw error;
|
|
5908
|
+
}
|
|
5909
|
+
}
|
|
5910
|
+
|
|
3994
5911
|
/** True when the transport clock must follow `next` (tempo, meter, loop). */
|
|
3995
5912
|
function timingChanged(previous: TrackScore, next: TrackScore): boolean {
|
|
3996
5913
|
return (
|
|
@@ -4005,6 +5922,9 @@ function timingChanged(previous: TrackScore, next: TrackScore): boolean {
|
|
|
4005
5922
|
function syncHost(): SyncHost {
|
|
4006
5923
|
return {
|
|
4007
5924
|
project: process.cwd(),
|
|
5925
|
+
// The files belong to one session at a time; another session's window
|
|
5926
|
+
// stays detached instead of reprinting over them.
|
|
5927
|
+
sessionId: () => record.sessionId,
|
|
4008
5928
|
current: () => score,
|
|
4009
5929
|
async commit(plan, summary) {
|
|
4010
5930
|
const baseRevision = record.revision;
|
|
@@ -4030,7 +5950,8 @@ function syncHost(): SyncHost {
|
|
|
4030
5950
|
tui.activity.pushCard(text, {
|
|
4031
5951
|
tone,
|
|
4032
5952
|
hint,
|
|
4033
|
-
resultRevision:
|
|
5953
|
+
resultRevision:
|
|
5954
|
+
tone === "success" ? shownRevision(record) : undefined,
|
|
4034
5955
|
});
|
|
4035
5956
|
},
|
|
4036
5957
|
types(state) {
|
|
@@ -4039,32 +5960,91 @@ function syncHost(): SyncHost {
|
|
|
4039
5960
|
};
|
|
4040
5961
|
}
|
|
4041
5962
|
|
|
4042
|
-
|
|
5963
|
+
/**
|
|
5964
|
+
* Undo and redo (design §12.6). By default a pane steps its own edits: the
|
|
5965
|
+
* newest edit this client made, inverted and rebased over whatever other
|
|
5966
|
+
* panes did since. `undo all` / `redo all` step the shared history, whoever
|
|
5967
|
+
* made the edit, and the receipt names the other pane. A lone pane (or one
|
|
5968
|
+
* that has made no edit since it opened) falls back to the shared history,
|
|
5969
|
+
* so single-pane sessions behave exactly as before.
|
|
5970
|
+
*/
|
|
5971
|
+
async function stepHistory(
|
|
5972
|
+
direction: "undo" | "redo",
|
|
5973
|
+
scope: "pane" | "all" = "pane",
|
|
5974
|
+
): Promise<Receipt> {
|
|
4043
5975
|
const latest = await port.load();
|
|
4044
5976
|
// A fork's undo continues into its parent's history past the fork point.
|
|
4045
|
-
const
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
5977
|
+
const events = await historyEvents(process.cwd(), latest);
|
|
5978
|
+
const before = scoreFromJSON(latest.composition);
|
|
5979
|
+
const kind = direction === "undo" ? UNDO_KIND : REDO_KIND;
|
|
5980
|
+
const key = direction === "undo" ? "undoneRevision" : "redoneRevision";
|
|
5981
|
+
const otherPanes = panePresence.filter(
|
|
5982
|
+
(entry) => entry.clientId !== port.clientId,
|
|
4049
5983
|
);
|
|
4050
|
-
|
|
5984
|
+
let next: TrackScore;
|
|
5985
|
+
let revision: number;
|
|
5986
|
+
let author = "";
|
|
5987
|
+
if (scope === "pane") {
|
|
5988
|
+
const step = paneHistoryStep(
|
|
5989
|
+
latest.composition,
|
|
5990
|
+
events,
|
|
5991
|
+
direction,
|
|
5992
|
+
port.clientId,
|
|
5993
|
+
);
|
|
5994
|
+
if (!step || (!step.ok && step.others.length === 0)) {
|
|
5995
|
+
if (otherPanes.length === 0) return stepHistory(direction, "all");
|
|
5996
|
+
if (!step)
|
|
5997
|
+
return warn(
|
|
5998
|
+
`nothing of this pane's to ${direction} · ${direction} all steps everyone's`,
|
|
5999
|
+
);
|
|
6000
|
+
}
|
|
6001
|
+
if (!step.ok) {
|
|
6002
|
+
if (step.reason === "session-wide")
|
|
6003
|
+
return warn(`that edit is session-wide · ${direction} all`);
|
|
6004
|
+
const who = step.others.map(paneName).join(", ");
|
|
6005
|
+
return warn(
|
|
6006
|
+
`${who} changed ${step.reason.replace(/ changed$/, "")} since · ${direction} all ${direction === "undo" ? "undoes" : "redoes"} theirs too`,
|
|
6007
|
+
);
|
|
6008
|
+
}
|
|
6009
|
+
next = step.next;
|
|
6010
|
+
revision = step.revision;
|
|
6011
|
+
} else {
|
|
6012
|
+
const target = historyTarget(latest.composition, events, direction);
|
|
6013
|
+
if (!target) return warn(`nothing to ${direction}`);
|
|
6014
|
+
next = scoreFromJSON(target.composition);
|
|
6015
|
+
revision = target.revision;
|
|
6016
|
+
const writer = events.find((event) => event.revision === revision)?.actor
|
|
6017
|
+
?.clientId;
|
|
6018
|
+
if (writer && writer !== port.clientId && otherPanes.length > 0)
|
|
6019
|
+
author = ` (${paneName(writer)})`;
|
|
6020
|
+
}
|
|
4051
6021
|
try {
|
|
4052
|
-
const
|
|
4053
|
-
|
|
6022
|
+
const event = {
|
|
6023
|
+
kind,
|
|
6024
|
+
payload: { [key]: revision, ...(scope === "pane" ? {} : { scope }) },
|
|
6025
|
+
};
|
|
6026
|
+
let operations: readonly ScoreOperation[] = [];
|
|
6027
|
+
try {
|
|
6028
|
+
operations = diffScores(before, next);
|
|
6029
|
+
} catch (error) {
|
|
6030
|
+
if (!(error instanceof DiffError)) throw error;
|
|
6031
|
+
}
|
|
6032
|
+
record = await port.appendOperations(
|
|
4054
6033
|
latest,
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
[direction === "undo" ? "undoneRevision" : "redoneRevision"]:
|
|
4059
|
-
target.revision,
|
|
4060
|
-
},
|
|
4061
|
-
},
|
|
4062
|
-
restored.toJSON(),
|
|
6034
|
+
event,
|
|
6035
|
+
operations,
|
|
6036
|
+
next.toJSON(),
|
|
4063
6037
|
);
|
|
4064
|
-
|
|
6038
|
+
// Compare with what was undone, which another window may have written.
|
|
6039
|
+
score = scoreFromJSON(record.composition);
|
|
4065
6040
|
if (clock.playing) void audio.play(score);
|
|
4066
6041
|
return ok(
|
|
4067
|
-
|
|
6042
|
+
historyReceipt(
|
|
6043
|
+
direction,
|
|
6044
|
+
before,
|
|
6045
|
+
score,
|
|
6046
|
+
shownRevision(record, revision),
|
|
6047
|
+
) + author,
|
|
4068
6048
|
);
|
|
4069
6049
|
} catch (error) {
|
|
4070
6050
|
if (error instanceof SessionConflictError)
|
|
@@ -4075,7 +6055,42 @@ async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
|
|
|
4075
6055
|
}
|
|
4076
6056
|
}
|
|
4077
6057
|
|
|
4078
|
-
/**
|
|
6058
|
+
/** `pane B` for a client in presence, `another pane` once it has left. */
|
|
6059
|
+
function paneName(clientId: string): string {
|
|
6060
|
+
const pane = panePresence.find((entry) => entry.clientId === clientId)?.pane;
|
|
6061
|
+
return pane ? `pane ${pane}` : "another pane";
|
|
6062
|
+
}
|
|
6063
|
+
|
|
6064
|
+
/** Space, the menu and a header click all report a failed toggle the same way. */
|
|
6065
|
+
function transportFailed(error: unknown): void {
|
|
6066
|
+
tui.activity.pushCard(
|
|
6067
|
+
`transport failed · ${error instanceof Error ? error.message : String(error)}`,
|
|
6068
|
+
{ tone: "error" },
|
|
6069
|
+
);
|
|
6070
|
+
}
|
|
6071
|
+
|
|
6072
|
+
/**
|
|
6073
|
+
* The revision dawg shows (header, receipts, undo): edits to the song.
|
|
6074
|
+
* Play and pause are logged so other windows follow them, but they are not
|
|
6075
|
+
* edits, so they never move the number: a revision shows as itself minus
|
|
6076
|
+
* the transport events at or before it.
|
|
6077
|
+
*/
|
|
6078
|
+
function shownRevision(
|
|
6079
|
+
value: typeof record,
|
|
6080
|
+
revision = value.revision,
|
|
6081
|
+
): number {
|
|
6082
|
+
let transports = 0;
|
|
6083
|
+
for (const event of value.events)
|
|
6084
|
+
if (event.revision <= revision && event.kind === "transport")
|
|
6085
|
+
transports += 1;
|
|
6086
|
+
return revision - transports;
|
|
6087
|
+
}
|
|
6088
|
+
|
|
6089
|
+
/**
|
|
6090
|
+
* The space-bar toggle: flip the transport, then record it for other
|
|
6091
|
+
* windows. The log entry keeps windows in step; the header's revision
|
|
6092
|
+
* (`shownRevision`) skips it, since play/pause is not an edit.
|
|
6093
|
+
*/
|
|
4079
6094
|
async function toggleTransport(): Promise<void> {
|
|
4080
6095
|
await setTransport("toggle");
|
|
4081
6096
|
try {
|
|
@@ -4126,6 +6141,12 @@ async function setTransport(
|
|
|
4126
6141
|
}
|
|
4127
6142
|
|
|
4128
6143
|
/** Move the playhead to transport `beat`, playing or not (section jump). */
|
|
6144
|
+
/** The bar under the playhead, 0-based on the score. */
|
|
6145
|
+
function playheadBar(): number {
|
|
6146
|
+
const beat = scoreBeatAt(score, clock.beatAt());
|
|
6147
|
+
return barAt(score, Math.max(0, Math.round(beat * score.ticksPerBeat))).bar;
|
|
6148
|
+
}
|
|
6149
|
+
|
|
4129
6150
|
async function seekTransport(beat: number): Promise<void> {
|
|
4130
6151
|
if (port.mode === "daemon") {
|
|
4131
6152
|
await port.transport("seek", { beat });
|
|
@@ -4145,11 +6166,11 @@ async function runAgent(text: string): Promise<string | Receipt> {
|
|
|
4145
6166
|
if (agentTurn) return warn("agent busy · Esc cancels");
|
|
4146
6167
|
const selection = await currentProvider();
|
|
4147
6168
|
if (selection.kind === "offline")
|
|
4148
|
-
return fail(
|
|
4149
|
-
`unrecognized · ${truncateForCard(text)} · ${selection.reason}`,
|
|
4150
|
-
);
|
|
6169
|
+
return fail(`${truncateForCard(text)} · ${NO_AGENT}`);
|
|
4151
6170
|
const turn = { controller: new AbortController(), steering: [] as string[] };
|
|
4152
6171
|
agentTurn = turn;
|
|
6172
|
+
// The turn ends on one musical receipt of what it changed.
|
|
6173
|
+
const before = score;
|
|
4153
6174
|
reportAgentActivity(`${providerName} · thinking…`);
|
|
4154
6175
|
// xcb admits a pending account on its first call, which takes longer.
|
|
4155
6176
|
let admitting = selection.kind === "xcb" && selection.admissionPending;
|
|
@@ -4182,6 +6203,8 @@ async function runAgent(text: string): Promise<string | Receipt> {
|
|
|
4182
6203
|
}
|
|
4183
6204
|
return;
|
|
4184
6205
|
}
|
|
6206
|
+
if (event.type === "done")
|
|
6207
|
+
tui.activity.setTurnReceipt(musicalReceipt(before, score));
|
|
4185
6208
|
agentEventSink(event);
|
|
4186
6209
|
},
|
|
4187
6210
|
});
|
|
@@ -4213,7 +6236,7 @@ function currentProvider(): Promise<ProviderSelection> {
|
|
|
4213
6236
|
provider = selectProvider().catch((): ProviderSelection => ({
|
|
4214
6237
|
kind: "offline",
|
|
4215
6238
|
choice: "auto",
|
|
4216
|
-
reason: "provider unavailable; run `dawg
|
|
6239
|
+
reason: "provider unavailable; run `dawg model key`",
|
|
4217
6240
|
}));
|
|
4218
6241
|
}
|
|
4219
6242
|
return provider.then((selection) => {
|
|
@@ -4225,9 +6248,9 @@ function currentProvider(): Promise<ProviderSelection> {
|
|
|
4225
6248
|
!invalidNoticeShown
|
|
4226
6249
|
) {
|
|
4227
6250
|
invalidNoticeShown = true;
|
|
4228
|
-
tui.activity.pushCard(`
|
|
6251
|
+
tui.activity.pushCard(`agent key stopped working · ${selection.reason}`, {
|
|
4229
6252
|
tone: "warning",
|
|
4230
|
-
hint: "/
|
|
6253
|
+
hint: "/model key",
|
|
4231
6254
|
});
|
|
4232
6255
|
}
|
|
4233
6256
|
providerStamp.offline = selection.kind === "offline";
|
|
@@ -4302,6 +6325,180 @@ function agentPreviewHost(): PreviewHost {
|
|
|
4302
6325
|
};
|
|
4303
6326
|
}
|
|
4304
6327
|
|
|
6328
|
+
// ── show-me ──────────────────────────────────────────────────────────
|
|
6329
|
+
|
|
6330
|
+
/**
|
|
6331
|
+
* Show-me (docs/show-me.md): with an API provider the agent writes prompt
|
|
6332
|
+
* commands, streamed. The line being written is ghost text in the empty
|
|
6333
|
+
* prompt bar at the model's own speed; each complete line runs through
|
|
6334
|
+
* `submit()`, the path a typed Enter takes, at once. A parameter value
|
|
6335
|
+
* glides there over ~150 ms while the loop plays (you hear it), notes and
|
|
6336
|
+
* hits sound as they arrive (in time when the stream is ahead of the tempo,
|
|
6337
|
+
* as step entry when it is behind), and the caption names the key or fader
|
|
6338
|
+
* a person would use. Nothing waits for the turn to end.
|
|
6339
|
+
*/
|
|
6340
|
+
|
|
6341
|
+
function showMeTyping(text: string): void {
|
|
6342
|
+
showMe.ghost = text || undefined;
|
|
6343
|
+
if (text && showMe.level === "on") showMe.caption = undefined;
|
|
6344
|
+
requestFrame();
|
|
6345
|
+
}
|
|
6346
|
+
|
|
6347
|
+
function showMeCaption(text: string | undefined): void {
|
|
6348
|
+
if (showMe.level !== "on") return;
|
|
6349
|
+
showMe.caption = text;
|
|
6350
|
+
if (showMe.clear) clearTimeout(showMe.clear);
|
|
6351
|
+
showMe.clear = undefined;
|
|
6352
|
+
requestFrame();
|
|
6353
|
+
}
|
|
6354
|
+
|
|
6355
|
+
/** End of a turn: the ghost goes, the caption becomes the do-it-yourself hint. */
|
|
6356
|
+
function showMeFinish(): void {
|
|
6357
|
+
showMe.ghost = undefined;
|
|
6358
|
+
showMe.notes.reset();
|
|
6359
|
+
const hint = finishHint(showMe.commands);
|
|
6360
|
+
showMe.commands = [];
|
|
6361
|
+
if (hint && showMe.level === "on") {
|
|
6362
|
+
showMeCaption(hint);
|
|
6363
|
+
showMe.clear = setTimeout(() => {
|
|
6364
|
+
showMe.caption = undefined;
|
|
6365
|
+
showMe.clear = undefined;
|
|
6366
|
+
requestFrame();
|
|
6367
|
+
}, 8_000);
|
|
6368
|
+
} else {
|
|
6369
|
+
showMe.caption = undefined;
|
|
6370
|
+
requestFrame();
|
|
6371
|
+
}
|
|
6372
|
+
}
|
|
6373
|
+
|
|
6374
|
+
/**
|
|
6375
|
+
* Slide a fader to its new value the way the drawer does: each eased step is
|
|
6376
|
+
* the field's own command, staged on a scratch score and played, so the loop
|
|
6377
|
+
* moves through the values. Only while the loop plays (otherwise nothing is
|
|
6378
|
+
* heard and the glide would only cost time); never longer than ~150 ms.
|
|
6379
|
+
*/
|
|
6380
|
+
async function glideFader(param: string, target: number): Promise<void> {
|
|
6381
|
+
if (!clock.playing || !stdout.isTTY) return;
|
|
6382
|
+
const scratch = new EditMenu();
|
|
6383
|
+
const context = { ...menuContext(), score };
|
|
6384
|
+
const label = scratch.showFader(context, param);
|
|
6385
|
+
if (!label) return;
|
|
6386
|
+
const field = scratch
|
|
6387
|
+
.faderFields(context)
|
|
6388
|
+
.find((candidate) => candidate.label === label);
|
|
6389
|
+
scratch.close();
|
|
6390
|
+
if (field?.kind !== "number") return;
|
|
6391
|
+
const from = field.value ?? field.start ?? target;
|
|
6392
|
+
const steps = glideValues(from, target).slice(0, -1);
|
|
6393
|
+
for (const value of steps) {
|
|
6394
|
+
const staged = await applyStaged(score, field.command(value));
|
|
6395
|
+
if (staged.next && clock.playing) void audio.play(staged.next);
|
|
6396
|
+
await Bun.sleep(GLIDE_STEP_MS);
|
|
6397
|
+
}
|
|
6398
|
+
}
|
|
6399
|
+
|
|
6400
|
+
/** Sound one streamed note on the audition voice (the loop is stopped). */
|
|
6401
|
+
function soundStreamedNote(
|
|
6402
|
+
value: TrackScore,
|
|
6403
|
+
trackId: string,
|
|
6404
|
+
pitch: number,
|
|
6405
|
+
startBeat: number,
|
|
6406
|
+
): void {
|
|
6407
|
+
if (clock.playing) return; // The loop itself plays it.
|
|
6408
|
+
const engine = liveEngine();
|
|
6409
|
+
if (!engine?.canMonitor) return;
|
|
6410
|
+
const tick = Math.round(startBeat * value.ticksPerBeat);
|
|
6411
|
+
const note = value.notes.find(
|
|
6412
|
+
(candidate) =>
|
|
6413
|
+
candidate.trackId === trackId &&
|
|
6414
|
+
candidate.pitch === pitch &&
|
|
6415
|
+
candidate.startTick === tick,
|
|
6416
|
+
);
|
|
6417
|
+
if (!note) return;
|
|
6418
|
+
try {
|
|
6419
|
+
const json = value.toJSON() as unknown as Record<string, unknown>;
|
|
6420
|
+
const single = scoreFromJSON({
|
|
6421
|
+
...json,
|
|
6422
|
+
notes: [{ ...note, startTick: 0 }],
|
|
6423
|
+
});
|
|
6424
|
+
const pcm = renderAudition({
|
|
6425
|
+
score: single,
|
|
6426
|
+
trackId,
|
|
6427
|
+
sampleRate: engine.sampleRate,
|
|
6428
|
+
...(liveSampleBank ? { samples: liveSampleBank } : {}),
|
|
6429
|
+
});
|
|
6430
|
+
if (!pcm) return;
|
|
6431
|
+
void engine
|
|
6432
|
+
.monitor(true)
|
|
6433
|
+
.then(() => engine.noteOn(AUDITION_VOICE, pcm))
|
|
6434
|
+
.catch(() => undefined);
|
|
6435
|
+
} catch {
|
|
6436
|
+
// A note that cannot render is still in the score.
|
|
6437
|
+
}
|
|
6438
|
+
}
|
|
6439
|
+
|
|
6440
|
+
/** The command host the show-me agent loop runs lines through. */
|
|
6441
|
+
function showMeCommandHost(): AgentHost["commands"] {
|
|
6442
|
+
return {
|
|
6443
|
+
isCommand: (line) =>
|
|
6444
|
+
isAgentCommand(line, score) || isBrokenCommand(line, score),
|
|
6445
|
+
async run(line): Promise<CommandOutcome> {
|
|
6446
|
+
if (!agentPathsAllowed(line)) {
|
|
6447
|
+
const revision = shownRevision(record);
|
|
6448
|
+
return {
|
|
6449
|
+
ok: false,
|
|
6450
|
+
message: `${line} · the agent works only inside this folder`,
|
|
6451
|
+
baseRevision: revision,
|
|
6452
|
+
resultRevision: revision,
|
|
6453
|
+
};
|
|
6454
|
+
}
|
|
6455
|
+
if (isBrokenCommand(line, score)) {
|
|
6456
|
+
// A command that does not parse is a red receipt, never prose.
|
|
6457
|
+
const base = baseline();
|
|
6458
|
+
receipt(fail(brokenCommandReceipt(line)), base);
|
|
6459
|
+
return {
|
|
6460
|
+
ok: false,
|
|
6461
|
+
message: brokenCommandReceipt(line),
|
|
6462
|
+
baseRevision: base.revision,
|
|
6463
|
+
resultRevision: base.revision,
|
|
6464
|
+
};
|
|
6465
|
+
}
|
|
6466
|
+
const gesture = gestureFor(line, { score, trackId: requestedTrack });
|
|
6467
|
+
showMeCaption(gesture.caption);
|
|
6468
|
+
showMe.commands.push(line);
|
|
6469
|
+
if (gesture.kind === "fader" && showMe.level === "on")
|
|
6470
|
+
await glideFader(gesture.param, gesture.value);
|
|
6471
|
+
const base = baseline();
|
|
6472
|
+
tui.activity.pushNote(`agent › ${line}`, "request");
|
|
6473
|
+
let result: string | Receipt;
|
|
6474
|
+
try {
|
|
6475
|
+
result = await submit(line);
|
|
6476
|
+
} catch (error) {
|
|
6477
|
+
result = fail(describeError(line, error));
|
|
6478
|
+
}
|
|
6479
|
+
receipt(result, base);
|
|
6480
|
+
if (gesture.kind === "keys" && showMe.level === "on") {
|
|
6481
|
+
const trackId = requestedTrack;
|
|
6482
|
+
const after = score;
|
|
6483
|
+
for (const note of gesture.notes) {
|
|
6484
|
+
const { atMs } = showMe.notes.schedule(note.start, performance.now());
|
|
6485
|
+
const delay = Math.max(0, atMs - performance.now());
|
|
6486
|
+
setTimeout(
|
|
6487
|
+
() => soundStreamedNote(after, trackId, note.pitch, note.start),
|
|
6488
|
+
delay,
|
|
6489
|
+
);
|
|
6490
|
+
}
|
|
6491
|
+
}
|
|
6492
|
+
return {
|
|
6493
|
+
ok: toneOf(result) !== "error",
|
|
6494
|
+
message: typeof result === "string" ? result : result.text,
|
|
6495
|
+
baseRevision: shownRevision(record, base.revision),
|
|
6496
|
+
resultRevision: shownRevision(record),
|
|
6497
|
+
};
|
|
6498
|
+
},
|
|
6499
|
+
};
|
|
6500
|
+
}
|
|
6501
|
+
|
|
4305
6502
|
function agentHost(
|
|
4306
6503
|
turn: { steering: string[] },
|
|
4307
6504
|
selection: ProviderSelection,
|
|
@@ -4376,6 +6573,9 @@ function agentHost(
|
|
|
4376
6573
|
}
|
|
4377
6574
|
},
|
|
4378
6575
|
takeSteering: () => turn.steering.splice(0),
|
|
6576
|
+
...(showMe.level !== "off" && stdout.isTTY
|
|
6577
|
+
? { commands: showMeCommandHost() }
|
|
6578
|
+
: {}),
|
|
4379
6579
|
workspace: { root: process.cwd() },
|
|
4380
6580
|
// A written project source is applied before the tool result returns,
|
|
4381
6581
|
// so the model reads the outcome and any type errors in the same step.
|