@hraness/dawg 0.3.0 → 0.4.1
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 +138 -0
- package/DAWG.md +608 -39
- package/README.md +4 -4
- package/core/chords.ts +1724 -0
- package/core/diff.ts +13 -1
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +735 -18
- package/core/sdk/eval-child.ts +34 -22
- package/core/sdk/eval.ts +4 -1
- package/core/sdk/print.ts +304 -22
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +3226 -21
- package/core/synth.ts +1001 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -3
- package/src/agent/brief.ts +29 -4
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +17 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/tools.ts +308 -14
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +109 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +339 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +394 -0
- package/src/audio/engine.ts +112 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +481 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/sampler.ts +157 -29
- package/src/audio/samples.ts +386 -44
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +411 -295
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +26 -4
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +298 -10
- package/src/commands/music.ts +10 -8
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +162 -2
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +1222 -30
- package/src/media/cli.ts +15 -2
- package/src/media/tools.ts +106 -1
- package/src/media/wavetable.ts +202 -0
- package/src/render.ts +22 -1
- package/src/session/naming.ts +3 -1
- package/src/tui/audition.ts +521 -0
- package/src/tui/euclid.ts +516 -0
- package/src/tui/menu.ts +1305 -244
- package/src/tui/play-chords.ts +630 -0
- package/src/tui/play-session.ts +364 -16
- package/src/tui/sketch.ts +108 -0
- package/src/web/search.ts +4 -1
- package/tui/app.ts +161 -39
- package/tui/grammar.ts +286 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
package/src/main.ts
CHANGED
|
@@ -30,15 +30,37 @@ import { AutoNamer, providerNameGenerator } from "./session/naming.ts";
|
|
|
30
30
|
import { normalizeSessionName } from "./session/meta.ts";
|
|
31
31
|
import { parsePrompt } from "./agent/ops.ts";
|
|
32
32
|
import { applyMusicCommand, parseMusicCommand } from "./commands/music.ts";
|
|
33
|
+
import { applyRhythmCommand, parseRhythmCommand } from "./commands/rhythm.ts";
|
|
34
|
+
import {
|
|
35
|
+
applyDrumPattern,
|
|
36
|
+
applySynthKit,
|
|
37
|
+
DRUM_PATTERNS,
|
|
38
|
+
isSynthKitName,
|
|
39
|
+
parsePatternCommand,
|
|
40
|
+
patternLine,
|
|
41
|
+
type PatternCommand,
|
|
42
|
+
} from "./commands/drums.ts";
|
|
43
|
+
import { kitCatalog } from "./audio/kits.ts";
|
|
33
44
|
import { applyEditCommand, parseEditCommand } from "./commands/edit.ts";
|
|
34
|
-
import {
|
|
45
|
+
import { applyFxCommand, parseFxCommand } from "./commands/fx.ts";
|
|
46
|
+
import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
|
|
47
|
+
import {
|
|
48
|
+
HELP_TOPICS,
|
|
49
|
+
helpText,
|
|
50
|
+
helpTopicLines,
|
|
51
|
+
nearestCommand,
|
|
52
|
+
typoFix,
|
|
53
|
+
usageHint,
|
|
54
|
+
} from "./commands/help.ts";
|
|
35
55
|
import { historyTarget, REDO_KIND, UNDO_KIND } from "./commands/history.ts";
|
|
56
|
+
|
|
36
57
|
import {
|
|
37
58
|
SamplePlacementError,
|
|
38
59
|
addSampleVoice,
|
|
39
60
|
freeVoiceName,
|
|
40
61
|
listSampleVoices,
|
|
41
62
|
parseSampleCommand,
|
|
63
|
+
setSampleControls,
|
|
42
64
|
placeSampleFile,
|
|
43
65
|
samplerTarget,
|
|
44
66
|
voiceNameFrom,
|
|
@@ -89,19 +111,70 @@ import { AudioEngine } from "./audio/engine.ts";
|
|
|
89
111
|
import {
|
|
90
112
|
DEFAULT_GRID,
|
|
91
113
|
GRIDS,
|
|
114
|
+
PLAY_LEAD_MS,
|
|
92
115
|
PlaySession,
|
|
93
116
|
type LiveEngine,
|
|
94
117
|
} from "./tui/play-session.ts";
|
|
95
118
|
import { EditMenu, MENU_SECTIONS, type MenuContext } from "./tui/menu.ts";
|
|
119
|
+
import {
|
|
120
|
+
Audition,
|
|
121
|
+
SUPERSEDED,
|
|
122
|
+
isChordStageable,
|
|
123
|
+
isStageable,
|
|
124
|
+
} from "./tui/audition.ts";
|
|
125
|
+
import { EuclidEditor, type EuclidContext } from "./tui/euclid.ts";
|
|
126
|
+
import { HINTS, KEYS, keyLines, type KeySection } from "../tui/grammar.ts";
|
|
127
|
+
import { renderAudition } from "./audio/audition.ts";
|
|
128
|
+
import { renderScorePcm } from "./audio/wav.ts";
|
|
129
|
+
import type { PreviewHost } from "./agent/preview-tool.ts";
|
|
130
|
+
import { rhythmVoicePitch } from "../core/rhythm.ts";
|
|
131
|
+
import {
|
|
132
|
+
applyChordsCommand,
|
|
133
|
+
chordPhrase,
|
|
134
|
+
defaultChordSettings,
|
|
135
|
+
type ChordSettings,
|
|
136
|
+
} from "./tui/play-chords.ts";
|
|
137
|
+
import {
|
|
138
|
+
cacheLines,
|
|
139
|
+
kitListLines,
|
|
140
|
+
kitTarget,
|
|
141
|
+
packListLines,
|
|
142
|
+
parseKitCommand,
|
|
143
|
+
parsePackCommand,
|
|
144
|
+
useKit,
|
|
145
|
+
useSound,
|
|
146
|
+
type KitCommand,
|
|
147
|
+
type PackCommand,
|
|
148
|
+
} from "./commands/pack.ts";
|
|
149
|
+
import {
|
|
150
|
+
describeWavetable,
|
|
151
|
+
parseWavetableCommand,
|
|
152
|
+
pickWavetable,
|
|
153
|
+
wavetableListLines,
|
|
154
|
+
wavetableParamEdit,
|
|
155
|
+
type WavetableCommand,
|
|
156
|
+
} from "./commands/wavetable.ts";
|
|
157
|
+
import {
|
|
158
|
+
ALIASED_PACK,
|
|
159
|
+
DEFAULT_KITS,
|
|
160
|
+
PackError,
|
|
161
|
+
PackStore,
|
|
162
|
+
banksOf,
|
|
163
|
+
packCredits,
|
|
164
|
+
writeCredits,
|
|
165
|
+
} from "./audio/packs.ts";
|
|
96
166
|
import {
|
|
97
167
|
addNote,
|
|
98
168
|
applyScoreOperation,
|
|
99
169
|
createScore,
|
|
170
|
+
PACK_PREFIX,
|
|
100
171
|
scoreFromJSON,
|
|
101
172
|
SCORE_LIMITS,
|
|
173
|
+
type SampleRef,
|
|
102
174
|
type TrackScore,
|
|
103
175
|
type ScoreOperation,
|
|
104
176
|
} from "../core/score.ts";
|
|
177
|
+
import { reconcileRhythm } from "../core/rhythm.ts";
|
|
105
178
|
import { decodeLoop, encodeLoop } from "../core/loop.ts";
|
|
106
179
|
import type { TrackScoreSnapshot } from "../tui/render.ts";
|
|
107
180
|
import { PromptModel } from "../tui/prompt.ts";
|
|
@@ -127,6 +200,23 @@ import {
|
|
|
127
200
|
type SyncHost,
|
|
128
201
|
} from "./project/sync.ts";
|
|
129
202
|
|
|
203
|
+
/** Whether a bare command parses (no side effects): for typo suggestions. */
|
|
204
|
+
function parsesLocally(text: string): boolean {
|
|
205
|
+
return [
|
|
206
|
+
parsePrompt,
|
|
207
|
+
parseMusicCommand,
|
|
208
|
+
parseEditCommand,
|
|
209
|
+
parseRhythmCommand,
|
|
210
|
+
parseFxCommand,
|
|
211
|
+
parseSynthCommand,
|
|
212
|
+
parsePatternCommand,
|
|
213
|
+
parseKitCommand,
|
|
214
|
+
parsePackCommand,
|
|
215
|
+
parseSampleCommand,
|
|
216
|
+
parseWavetableCommand,
|
|
217
|
+
].some((parse) => parse(text) !== undefined);
|
|
218
|
+
}
|
|
219
|
+
|
|
130
220
|
const ESC = "\u001b[";
|
|
131
221
|
/** `/sessions` rows shown in the overlay. */
|
|
132
222
|
const MAX_LISTED_SESSIONS = 64;
|
|
@@ -351,6 +441,9 @@ let syncState: SyncState = port.sync;
|
|
|
351
441
|
let windowCount = 1;
|
|
352
442
|
/** Project file sync when `dawg.json` is in the working directory. */
|
|
353
443
|
let projectSync: ProjectSync | undefined;
|
|
444
|
+
let packStore: PackStore | undefined;
|
|
445
|
+
let reportedSampleProblems = "";
|
|
446
|
+
let sampleLibrary: SampleLibrary | undefined;
|
|
354
447
|
let typesIndicator: TypesIndicator | undefined;
|
|
355
448
|
let announcedName = record.meta.name;
|
|
356
449
|
let namer = makeNamer();
|
|
@@ -358,10 +451,41 @@ let namer = makeNamer();
|
|
|
358
451
|
let rebindPort: () => void = () => undefined;
|
|
359
452
|
/** Play mode's controller; kept across entries so settings persist. */
|
|
360
453
|
let play: PlaySession | undefined;
|
|
454
|
+
/** Chord-mode settings, shared by every track's play session. */
|
|
455
|
+
const chordSettings = defaultChordSettings();
|
|
361
456
|
/** The hand-editing menu (`/menu`, Ctrl-K), drawn as the picker overlay. */
|
|
362
457
|
const menu = new EditMenu();
|
|
458
|
+
/** The Euclidean rhythm editor (`/euclid`, Rhythm in `/menu`). */
|
|
459
|
+
const euclid = new EuclidEditor();
|
|
460
|
+
/** Live voice id for auditions (play-mode voices use small positive ids). */
|
|
461
|
+
const AUDITION_VOICE = 0x7fff_0001;
|
|
462
|
+
/** A voice to audition once the editor's queued command lands. */
|
|
463
|
+
let pendingAudition: string | undefined;
|
|
464
|
+
/** The pattern the /pattern picker last previewed. */
|
|
465
|
+
let patternPreview: string | undefined;
|
|
363
466
|
/** Daemon windows play no loop; play mode monitors through its own engine. */
|
|
364
467
|
let monitorEngine: AudioEngine | undefined;
|
|
468
|
+
/** The audition loop and staged edits (src/tui/audition.ts), made lazily. */
|
|
469
|
+
let auditionLoop: Audition | undefined;
|
|
470
|
+
/** Pickers that host the audition loop (Space, `a`, `c`, hover). */
|
|
471
|
+
const AUDITION_PICKERS = new Set(["kit", "pattern", "try"]);
|
|
472
|
+
const TRY_USAGE =
|
|
473
|
+
"/try <sound command> · /try fx reverb mix 0.6 · /try agent on|off";
|
|
474
|
+
/** Whether the agent's preview_sound plays its snippet in this window. */
|
|
475
|
+
let agentPreviewPlays = process.env.DAWG_AGENT_PREVIEW !== "off";
|
|
476
|
+
const AGENT_PREVIEW_VOICE = 0x7fff_0002;
|
|
477
|
+
/**
|
|
478
|
+
* Set while a staged command runs: `commitScore` hands its result here
|
|
479
|
+
* instead of appending, and a remote revision that lands meanwhile waits in
|
|
480
|
+
* `committed` (the window's `score` is the staged base until it finishes).
|
|
481
|
+
*/
|
|
482
|
+
/** A `/chords …` settings command (staged as window state, not a score edit). */
|
|
483
|
+
const CHORDS_COMMAND = /^\/chords\s+(.+)$/i;
|
|
484
|
+
/** Whether the chord settings screen was open at the last menu redraw. */
|
|
485
|
+
let chordScreenWas = false;
|
|
486
|
+
let stageCapture: { next?: TrackScore; committed?: TrackScore } | undefined;
|
|
487
|
+
/** Redraw soon (the audition reports renders between frames). */
|
|
488
|
+
let requestFrame: () => void = () => undefined;
|
|
365
489
|
/** The decoded sampler voices, for play mode's live voices. */
|
|
366
490
|
let liveSampleBank: SampleBank | undefined;
|
|
367
491
|
/** The in-flight agent turn: Esc aborts it, Enter steers it. */
|
|
@@ -698,6 +822,11 @@ async function runInteractive(): Promise<void> {
|
|
|
698
822
|
const nextPrompt = queuedPrompts.shift()!;
|
|
699
823
|
tui.activity.setQueueDepth(queuedPrompts.length);
|
|
700
824
|
await runPrompt(nextPrompt);
|
|
825
|
+
if (pendingAudition && queuedPrompts.length === 0) {
|
|
826
|
+
const voice = pendingAudition;
|
|
827
|
+
pendingAudition = undefined;
|
|
828
|
+
audition(voice);
|
|
829
|
+
}
|
|
701
830
|
tick(true);
|
|
702
831
|
}
|
|
703
832
|
} finally {
|
|
@@ -707,11 +836,15 @@ async function runInteractive(): Promise<void> {
|
|
|
707
836
|
};
|
|
708
837
|
const tick = (force = false) => {
|
|
709
838
|
if (screenSuspended) return;
|
|
839
|
+
followCommitted();
|
|
710
840
|
play?.tick();
|
|
711
841
|
// Values in the menu follow the score as edits land.
|
|
712
842
|
if (menu.open) refreshMenu();
|
|
843
|
+
if (euclid.open) refreshEuclid();
|
|
844
|
+
refreshAuditionPicker();
|
|
713
845
|
tui.render(appView(score, clock.beatAt()), { force });
|
|
714
846
|
};
|
|
847
|
+
requestFrame = () => tick(true);
|
|
715
848
|
reportAgentActivity = () => {
|
|
716
849
|
tui.activity.applyAgentEvent({ type: "start", model: providerName });
|
|
717
850
|
};
|
|
@@ -735,7 +868,9 @@ async function runInteractive(): Promise<void> {
|
|
|
735
868
|
if (latest.revision > record.revision) {
|
|
736
869
|
const previousRevision = record.revision;
|
|
737
870
|
record = latest;
|
|
738
|
-
|
|
871
|
+
if (stageCapture)
|
|
872
|
+
stageCapture.committed = scoreFromJSON(record.composition);
|
|
873
|
+
else score = scoreFromJSON(record.composition);
|
|
739
874
|
clock.setTempo(score.tempoBpm);
|
|
740
875
|
if (clock.playing) void audio.play(score);
|
|
741
876
|
// Connected windows follow dawgd's transport frames instead.
|
|
@@ -889,17 +1024,84 @@ async function runInteractive(): Promise<void> {
|
|
|
889
1024
|
...(text === "\u001b" ? inputDecoder.flush() : []),
|
|
890
1025
|
];
|
|
891
1026
|
for (const value of values) {
|
|
1027
|
+
// The `?` panel closes on any key (Ctrl-C still quits).
|
|
1028
|
+
if (tui.ui.keys && typeof value === "string" && value !== "\u0003") {
|
|
1029
|
+
tui.closeKeys();
|
|
1030
|
+
tick(true);
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
if (value === "?" && keysScreen()) {
|
|
1034
|
+
showKeys();
|
|
1035
|
+
tick(true);
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
// The rhythm editor owns every key while it is up (Esc backs out).
|
|
1039
|
+
if (typeof value === "string" && euclid.open) {
|
|
1040
|
+
if (tui.ui.overlay !== "picker" || tui.ui.picker?.id !== "euclid")
|
|
1041
|
+
euclid.close();
|
|
1042
|
+
else {
|
|
1043
|
+
const result = euclid.key(value, euclidContext());
|
|
1044
|
+
if (result.type === "close") {
|
|
1045
|
+
const back = euclid.returnTo;
|
|
1046
|
+
euclid.close();
|
|
1047
|
+
tui.closePicker();
|
|
1048
|
+
if (back === "menu") openMenu();
|
|
1049
|
+
else leaveAuditionScreen();
|
|
1050
|
+
} else if (result.type === "run") {
|
|
1051
|
+
if (!stageIfAuditioning(result.command)) {
|
|
1052
|
+
queuedPrompts.unshift(result.command);
|
|
1053
|
+
if (result.audition) pendingAudition = result.audition;
|
|
1054
|
+
void drainQueue();
|
|
1055
|
+
}
|
|
1056
|
+
} else if (result.type === "audition") audition(result.voice);
|
|
1057
|
+
else if (result.type === "loop") auditionKeyPressed(result.key);
|
|
1058
|
+
else if (result.type === "revert") revertStaged();
|
|
1059
|
+
else if (result.type === "keep")
|
|
1060
|
+
void keepStaged()
|
|
1061
|
+
.then((outcome) => receipt(outcome))
|
|
1062
|
+
.catch((error) =>
|
|
1063
|
+
tui.activity.pushError(describeError("keep", error)),
|
|
1064
|
+
)
|
|
1065
|
+
.finally(() => tick(true));
|
|
1066
|
+
if (result.type !== "pass") {
|
|
1067
|
+
refreshEuclid();
|
|
1068
|
+
tick(true);
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
892
1073
|
// The edit menu owns every key while it is up (Esc backs out).
|
|
893
1074
|
if (typeof value === "string" && menu.open) {
|
|
894
1075
|
if (tui.ui.overlay !== "picker" || tui.ui.picker?.id !== "menu")
|
|
895
1076
|
menu.close();
|
|
896
1077
|
else {
|
|
897
1078
|
const result = menu.key(value, menuContext());
|
|
898
|
-
if (result.type === "close")
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1079
|
+
if (result.type === "close") {
|
|
1080
|
+
tui.closePicker();
|
|
1081
|
+
leaveAuditionScreen();
|
|
1082
|
+
} else if (result.type === "run") {
|
|
1083
|
+
if (!stageIfAuditioning(result.command)) {
|
|
1084
|
+
queuedPrompts.unshift(result.command);
|
|
1085
|
+
void drainQueue();
|
|
1086
|
+
}
|
|
1087
|
+
} else if (result.type === "audition")
|
|
1088
|
+
auditionKeyPressed(result.key);
|
|
1089
|
+
else if (result.type === "revert") revertStaged();
|
|
1090
|
+
else if (result.type === "hover")
|
|
1091
|
+
hoverItem(result.command, result.key);
|
|
1092
|
+
else if (result.type === "unhover")
|
|
1093
|
+
void auditionLoop
|
|
1094
|
+
?.unhover(result.key)
|
|
1095
|
+
.finally(() => requestFrame());
|
|
1096
|
+
else if (result.type === "choose")
|
|
1097
|
+
chooseItem(result.command, result.key);
|
|
1098
|
+
else if (result.type === "keep")
|
|
1099
|
+
void keepStaged()
|
|
1100
|
+
.then((outcome) => receipt(outcome))
|
|
1101
|
+
.catch((error) =>
|
|
1102
|
+
tui.activity.pushError(describeError("keep", error)),
|
|
1103
|
+
)
|
|
1104
|
+
.finally(() => tick(true));
|
|
903
1105
|
if (result.type !== "pass") {
|
|
904
1106
|
refreshMenu();
|
|
905
1107
|
tick(true);
|
|
@@ -935,7 +1137,11 @@ async function runInteractive(): Promise<void> {
|
|
|
935
1137
|
// shell exit key; Ctrl-Q is reserved for prompt mode switching.
|
|
936
1138
|
// Keep the empty-prompt space shortcut for transport, while allowing
|
|
937
1139
|
// ordinary spaces once a request is being composed.
|
|
938
|
-
if (
|
|
1140
|
+
if (
|
|
1141
|
+
value === " " &&
|
|
1142
|
+
prompt.value.length === 0 &&
|
|
1143
|
+
!(tui.ui.overlay === "picker" && tui.ui.picker?.audition)
|
|
1144
|
+
) {
|
|
939
1145
|
// Never await a daemon round trip here: the key loop must stay
|
|
940
1146
|
// live for Esc, quit and redraws while the toggle is in flight.
|
|
941
1147
|
void toggleTransport()
|
|
@@ -967,6 +1173,52 @@ async function runInteractive(): Promise<void> {
|
|
|
967
1173
|
})
|
|
968
1174
|
.finally(() => tick(true));
|
|
969
1175
|
}
|
|
1176
|
+
} else if (input.type === "pick-move") {
|
|
1177
|
+
// Moving through a list auditions the row under the cursor on
|
|
1178
|
+
// the loop; with the loop off, /pattern plays one bar of it.
|
|
1179
|
+
if (auditionLoop?.looping && isStageable(input.value))
|
|
1180
|
+
hoverItem(input.value, `picker:${input.picker}`);
|
|
1181
|
+
else if (input.picker === "pattern")
|
|
1182
|
+
previewPattern(input.value.replace(/^\/pattern\s+/, ""));
|
|
1183
|
+
} else if (input.type === "pick-audition") {
|
|
1184
|
+
pickerAuditionKey(input.key);
|
|
1185
|
+
} else if (input.type === "pick-cancel") {
|
|
1186
|
+
if (AUDITION_PICKERS.has(input.picker)) leaveAuditionScreen();
|
|
1187
|
+
} else if (input.type === "pick" && input.picker === "try") {
|
|
1188
|
+
if (input.value === "keep")
|
|
1189
|
+
void keepStaged()
|
|
1190
|
+
.then((outcome) => receipt(outcome))
|
|
1191
|
+
.catch((error) =>
|
|
1192
|
+
tui.activity.pushError(describeError("keep", error)),
|
|
1193
|
+
)
|
|
1194
|
+
.finally(() => {
|
|
1195
|
+
stopAuditionLoop();
|
|
1196
|
+
tick(true);
|
|
1197
|
+
});
|
|
1198
|
+
else leaveAuditionScreen();
|
|
1199
|
+
} else if (
|
|
1200
|
+
input.type === "pick" &&
|
|
1201
|
+
AUDITION_PICKERS.has(input.picker) &&
|
|
1202
|
+
auditionLoop?.staging
|
|
1203
|
+
) {
|
|
1204
|
+
// Enter in an auditioning list keeps what you hear.
|
|
1205
|
+
const loop = auditionLoop;
|
|
1206
|
+
const key = `picker:${input.picker}`;
|
|
1207
|
+
void (
|
|
1208
|
+
isStageable(input.value)
|
|
1209
|
+
? loop.hover(input.value, key)
|
|
1210
|
+
: Promise.resolve()
|
|
1211
|
+
)
|
|
1212
|
+
.then(() => loop.settle(key))
|
|
1213
|
+
.then(() => keepStaged())
|
|
1214
|
+
.then((outcome) => receipt(outcome))
|
|
1215
|
+
.catch((error) =>
|
|
1216
|
+
tui.activity.pushError(describeError("keep", error)),
|
|
1217
|
+
)
|
|
1218
|
+
.finally(() => {
|
|
1219
|
+
stopAuditionLoop();
|
|
1220
|
+
tick(true);
|
|
1221
|
+
});
|
|
970
1222
|
} else if (input.type === "pick") {
|
|
971
1223
|
// Picker choices run as the command they stand for.
|
|
972
1224
|
queuedPrompts.unshift(
|
|
@@ -1023,11 +1275,34 @@ async function runInteractive(): Promise<void> {
|
|
|
1023
1275
|
}
|
|
1024
1276
|
}
|
|
1025
1277
|
|
|
1278
|
+
/** `unknown command /clik · did you mean /click? · /help`. */
|
|
1279
|
+
function unknownCommand(command: string): string {
|
|
1280
|
+
const word = command.split(/\s+/)[0] ?? command;
|
|
1281
|
+
const near = nearestCommand(command);
|
|
1282
|
+
return near
|
|
1283
|
+
? `unknown command ${word} · did you mean ${near}? · /help`
|
|
1284
|
+
: `unknown command ${word} · /help`;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1026
1287
|
async function submit(prompt: string): Promise<string | Receipt> {
|
|
1027
1288
|
const command = prompt.trim();
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1289
|
+
const helpCommand = command.match(/^\/?(?:help|\?)(?:\s+(\S+))?$/i);
|
|
1290
|
+
if (helpCommand) {
|
|
1291
|
+
const topic = helpCommand[1];
|
|
1292
|
+
const lines = helpTopicLines(
|
|
1293
|
+
topic,
|
|
1294
|
+
Math.max(40, (stdout.columns ?? 80) - 10),
|
|
1295
|
+
);
|
|
1296
|
+
if (!lines)
|
|
1297
|
+
return fail(
|
|
1298
|
+
`no help topic ${topic} · /help all · ${HELP_TOPICS.join(" ")}`,
|
|
1299
|
+
);
|
|
1300
|
+
tui.openText(topic ? `help · ${topic.toLowerCase()}` : "help", lines);
|
|
1301
|
+
return ok(
|
|
1302
|
+
topic
|
|
1303
|
+
? `help · ${topic.toLowerCase()}`
|
|
1304
|
+
: "help · /help all for every command",
|
|
1305
|
+
);
|
|
1031
1306
|
}
|
|
1032
1307
|
if (/^\/?tracks$/i.test(command)) {
|
|
1033
1308
|
const problems = await sampleProblems(score);
|
|
@@ -1044,9 +1319,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1044
1319
|
return `${track.id === requestedTrack ? "*" : " "} ${track.id} · ${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}`;
|
|
1045
1320
|
});
|
|
1046
1321
|
tui.openText("tracks · * focused", lines);
|
|
1047
|
-
return ok(
|
|
1048
|
-
`${lines.length} track${lines.length === 1 ? "" : "s"} · esc closes`,
|
|
1049
|
-
);
|
|
1322
|
+
return ok(`${lines.length} track${lines.length === 1 ? "" : "s"}`);
|
|
1050
1323
|
}
|
|
1051
1324
|
const playCommand = command.match(/^\/play(?:\s+(on|off))?$/i);
|
|
1052
1325
|
if (playCommand) {
|
|
@@ -1062,15 +1335,32 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1062
1335
|
? fail(message)
|
|
1063
1336
|
: ok(message);
|
|
1064
1337
|
}
|
|
1338
|
+
const chordsCommand = command.match(/^\/chords(?:\s+(.+))?$/i);
|
|
1339
|
+
if (chordsCommand) {
|
|
1340
|
+
const result = applyChordsCommand(chordSettings, chordsCommand[1] ?? "");
|
|
1341
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1342
|
+
}
|
|
1065
1343
|
const countIn = command.match(/^\/count-?in\s+([0-2])$/i);
|
|
1066
1344
|
if (countIn) return ok(playSession().setCountIn(Number(countIn[1])));
|
|
1345
|
+
const euclidCommand = command.match(/^\/euclid(?:\s+(\S+))?$/i);
|
|
1346
|
+
if (euclidCommand) {
|
|
1347
|
+
const from = menu.open ? "menu" : undefined;
|
|
1348
|
+
if (menu.open) {
|
|
1349
|
+
menu.close();
|
|
1350
|
+
tui.closePicker();
|
|
1351
|
+
}
|
|
1352
|
+
openEuclid(euclidCommand[1]?.toLowerCase(), from);
|
|
1353
|
+
return ok("rhythm editor");
|
|
1354
|
+
}
|
|
1355
|
+
const tryCommand = command.match(/^\/try(?:\s+(.+))?$/i);
|
|
1356
|
+
if (tryCommand) return tryPrompt(tryCommand[1]?.trim() ?? "");
|
|
1067
1357
|
const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
|
|
1068
1358
|
if (menuCommand) {
|
|
1069
1359
|
const section = menuCommand[1]?.toLowerCase();
|
|
1070
1360
|
if (section && !(MENU_SECTIONS as readonly string[]).includes(section))
|
|
1071
1361
|
return fail(`/menu [${MENU_SECTIONS.join("|")}]`);
|
|
1072
1362
|
openMenu(section);
|
|
1073
|
-
return ok("menu
|
|
1363
|
+
return ok("menu");
|
|
1074
1364
|
}
|
|
1075
1365
|
const gridCommand = command.match(/^\/grid\s+(\S+)$/i);
|
|
1076
1366
|
if (gridCommand) {
|
|
@@ -1091,6 +1381,14 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1091
1381
|
if (trackCommand) return focusTrack(trackCommand[1]!.toLowerCase());
|
|
1092
1382
|
const sample = parseSampleCommand(command);
|
|
1093
1383
|
if (sample) return sampleCommand(sample);
|
|
1384
|
+
const pack = parsePackCommand(command);
|
|
1385
|
+
if (pack) return packCommand(pack);
|
|
1386
|
+
const pattern = parsePatternCommand(command);
|
|
1387
|
+
if (pattern) return patternCommand(pattern);
|
|
1388
|
+
const kit = parseKitCommand(command);
|
|
1389
|
+
if (kit) return kitCommand(kit, /^\/kit\s*$/i.test(command.trim()));
|
|
1390
|
+
const wavetable = parseWavetableCommand(command);
|
|
1391
|
+
if (wavetable) return wavetableCommand(wavetable);
|
|
1094
1392
|
const sessionReply = await sessionCommand(command);
|
|
1095
1393
|
if (sessionReply !== undefined) return sessionReply;
|
|
1096
1394
|
const edit = parseEditCommand(command);
|
|
@@ -1101,6 +1399,41 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1101
1399
|
await commitScore(result.next, result.kind, result.payload);
|
|
1102
1400
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1103
1401
|
}
|
|
1402
|
+
const rhythm = parseRhythmCommand(command);
|
|
1403
|
+
if (rhythm) {
|
|
1404
|
+
await materializeDraft();
|
|
1405
|
+
const result = applyRhythmCommand(score, requestedTrack, rhythm);
|
|
1406
|
+
if (result.next && result.kind)
|
|
1407
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1408
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1409
|
+
}
|
|
1410
|
+
const fx = parseFxCommand(command);
|
|
1411
|
+
if (fx) {
|
|
1412
|
+
if (fx.type !== "fx-list") await materializeDraft();
|
|
1413
|
+
// A pack impulse is pinned (sha256 + url) once, as wavetable tables are.
|
|
1414
|
+
let pinnedIr: SampleRef | undefined;
|
|
1415
|
+
if (fx.type === "fx-ir" && fx.ir?.startsWith(PACK_PREFIX)) {
|
|
1416
|
+
try {
|
|
1417
|
+
pinnedIr = await packs().pin(fx.ir);
|
|
1418
|
+
} catch (error) {
|
|
1419
|
+
if (error instanceof PackError)
|
|
1420
|
+
return fail(`reverb ir · ${error.message}`);
|
|
1421
|
+
throw error;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
const result = applyFxCommand(score, requestedTrack, fx, pinnedIr);
|
|
1425
|
+
if (result.next && result.kind)
|
|
1426
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1427
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1428
|
+
}
|
|
1429
|
+
const synth = parseSynthCommand(command);
|
|
1430
|
+
if (synth) {
|
|
1431
|
+
if (synth.type !== "synth-list") await materializeDraft();
|
|
1432
|
+
const result = applySynthCommand(score, requestedTrack, synth);
|
|
1433
|
+
if (result.next && result.kind)
|
|
1434
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1435
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1436
|
+
}
|
|
1104
1437
|
const music = parseMusicCommand(command);
|
|
1105
1438
|
if (music) {
|
|
1106
1439
|
await materializeDraft();
|
|
@@ -1188,9 +1521,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1188
1521
|
if (command.startsWith("/") && !/^\/model\b/i.test(command)) {
|
|
1189
1522
|
const hint = usageHint(command);
|
|
1190
1523
|
return fail(
|
|
1191
|
-
hint
|
|
1192
|
-
? `${truncateForCard(command)} · ${hint}`
|
|
1193
|
-
: `unknown command ${command.split(/\s+/)[0]} · /help`,
|
|
1524
|
+
hint ? `${truncateForCard(command)} · ${hint}` : unknownCommand(command),
|
|
1194
1525
|
);
|
|
1195
1526
|
}
|
|
1196
1527
|
const parsed = parsePrompt(prompt);
|
|
@@ -1198,6 +1529,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1198
1529
|
// A known verb with bad arguments gets usage, not a model call.
|
|
1199
1530
|
const hint = usageHint(command);
|
|
1200
1531
|
if (hint) return fail(`${truncateForCard(command)} · ${hint}`);
|
|
1532
|
+
// A one-letter slip on a command whose arguments parse stays local.
|
|
1533
|
+
const fix = typoFix(command, parsesLocally);
|
|
1534
|
+
if (fix) return fail(`${truncateForCard(command)} · did you mean ${fix}?`);
|
|
1201
1535
|
if (process.env.DAWG_AI === "0")
|
|
1202
1536
|
return fail(`unrecognized · ${truncateForCard(command)} · /help`);
|
|
1203
1537
|
await materializeDraft();
|
|
@@ -1405,15 +1739,15 @@ async function focusTrack(trackId: string): Promise<Receipt> {
|
|
|
1405
1739
|
return ok(exists ? `track · ${trackId}` : `track created · ${trackId}`);
|
|
1406
1740
|
}
|
|
1407
1741
|
|
|
1408
|
-
let sampleLibrary: SampleLibrary | undefined;
|
|
1409
|
-
let reportedSampleProblems = "";
|
|
1410
|
-
|
|
1411
1742
|
/** Decode every sampler voice (cached) and return what failed to load. */
|
|
1412
1743
|
async function sampleProblems(
|
|
1413
1744
|
value: TrackScore,
|
|
1414
1745
|
): Promise<readonly SampleProblem[]> {
|
|
1415
1746
|
if (!hasSamplerTracks(value)) return [];
|
|
1416
|
-
sampleLibrary ??= new SampleLibrary({
|
|
1747
|
+
sampleLibrary ??= new SampleLibrary({
|
|
1748
|
+
projectRoot: process.cwd(),
|
|
1749
|
+
packs: packs(),
|
|
1750
|
+
});
|
|
1417
1751
|
try {
|
|
1418
1752
|
const bank = await sampleLibrary.load(value);
|
|
1419
1753
|
liveSampleBank = bank;
|
|
@@ -1465,9 +1799,22 @@ async function sampleCommand(
|
|
|
1465
1799
|
`sample · ${requestedTrack} has no samples · /sample <path> [as <voice>]`,
|
|
1466
1800
|
);
|
|
1467
1801
|
tui.openText(`samples · ${requestedTrack}`, lines);
|
|
1468
|
-
return ok(
|
|
1469
|
-
|
|
1802
|
+
return ok(`${lines.length} sample${lines.length === 1 ? "" : "s"}`);
|
|
1803
|
+
}
|
|
1804
|
+
if (command.kind === "set") {
|
|
1805
|
+
const result = setSampleControls(
|
|
1806
|
+
score,
|
|
1807
|
+
requestedTrack,
|
|
1808
|
+
command.voice,
|
|
1809
|
+
command.values,
|
|
1470
1810
|
);
|
|
1811
|
+
if (!result.ok) return fail(result.message);
|
|
1812
|
+
await commitScore(result.next, "sample.set", {
|
|
1813
|
+
trackId: requestedTrack,
|
|
1814
|
+
voice: command.voice,
|
|
1815
|
+
});
|
|
1816
|
+
await projectSync?.flushScore();
|
|
1817
|
+
return ok(result.message);
|
|
1471
1818
|
}
|
|
1472
1819
|
await materializeDraft();
|
|
1473
1820
|
const trackId = samplerTarget(score, requestedTrack);
|
|
@@ -1515,6 +1862,326 @@ async function sampleCommand(
|
|
|
1515
1862
|
);
|
|
1516
1863
|
}
|
|
1517
1864
|
|
|
1865
|
+
function packs(): PackStore {
|
|
1866
|
+
packStore ??= new PackStore();
|
|
1867
|
+
return packStore;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/** Keeps CREDITS.md in step with the CC-BY packs the score uses (quietly). */
|
|
1871
|
+
async function updateCredits(value: TrackScore): Promise<void> {
|
|
1872
|
+
const refs = value.tracks.flatMap((track) =>
|
|
1873
|
+
Object.values(track.sampler?.voices ?? {}),
|
|
1874
|
+
);
|
|
1875
|
+
if (!refs.some((ref) => ref.src.startsWith("pack:"))) return;
|
|
1876
|
+
// Only a dawg project gets CREDITS.md; a bare session directory does not.
|
|
1877
|
+
if (!projectSync) return;
|
|
1878
|
+
try {
|
|
1879
|
+
await writeCredits(process.cwd(), packCredits(refs, await packs().list()));
|
|
1880
|
+
} catch {
|
|
1881
|
+
/* credits are best effort; render metadata carries them too */
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/** Applies a pack edit as one undo step and focuses its track. */
|
|
1886
|
+
async function commitPackEdit(
|
|
1887
|
+
operations: readonly ScoreOperation[],
|
|
1888
|
+
trackId: string,
|
|
1889
|
+
kind: string,
|
|
1890
|
+
payload: Record<string, unknown>,
|
|
1891
|
+
): Promise<Receipt | undefined> {
|
|
1892
|
+
let next = score;
|
|
1893
|
+
for (const operation of operations)
|
|
1894
|
+
next = applyScoreOperation(next, operation);
|
|
1895
|
+
const problems = (await sampleProblems(next)).filter(
|
|
1896
|
+
(problem) => problem.trackId === trackId && problem.level === "error",
|
|
1897
|
+
);
|
|
1898
|
+
if (problems.length > 0) return fail(`sound · ${problems[0]!.message}`);
|
|
1899
|
+
await commitScore(next, kind, payload);
|
|
1900
|
+
if (stageCapture) return undefined;
|
|
1901
|
+
if (trackId !== requestedTrack) {
|
|
1902
|
+
await port.focus(trackId);
|
|
1903
|
+
requestedTrack = trackId;
|
|
1904
|
+
draftTrack = false;
|
|
1905
|
+
}
|
|
1906
|
+
await projectSync?.flushScore();
|
|
1907
|
+
return undefined;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
async function packCommand(command: PackCommand): Promise<Receipt> {
|
|
1911
|
+
try {
|
|
1912
|
+
if (command.kind === "usage") return warn(command.message);
|
|
1913
|
+
if (command.kind === "list") {
|
|
1914
|
+
const lines = packListLines(await packs().list());
|
|
1915
|
+
tui.openText("packs", lines);
|
|
1916
|
+
return ok(`${lines.length} packs · /pack info <name>`);
|
|
1917
|
+
}
|
|
1918
|
+
if (command.kind === "cache") {
|
|
1919
|
+
const library = (sampleLibrary ??= new SampleLibrary({
|
|
1920
|
+
projectRoot: process.cwd(),
|
|
1921
|
+
packs: packs(),
|
|
1922
|
+
}));
|
|
1923
|
+
let freed: { files: number; bytes: number } | undefined;
|
|
1924
|
+
if (command.pruneTo !== undefined) {
|
|
1925
|
+
const to = (cap: number) =>
|
|
1926
|
+
command.pruneTo === "cap" ? cap : (command.pruneTo as number);
|
|
1927
|
+
const store = packs();
|
|
1928
|
+
const a = await store.pruneCache(to(store.maxFileCacheBytes));
|
|
1929
|
+
const b = await library.pruneCache(to(library.maxCacheBytes));
|
|
1930
|
+
freed = { files: a.removed + b.removed, bytes: a.freed + b.freed };
|
|
1931
|
+
}
|
|
1932
|
+
const lines = cacheLines({
|
|
1933
|
+
packs: await packs().cacheStatus(),
|
|
1934
|
+
assets: await library.cacheStatus(),
|
|
1935
|
+
...(freed ? { freed } : {}),
|
|
1936
|
+
});
|
|
1937
|
+
tui.openText("cache", lines);
|
|
1938
|
+
return ok(lines[0]!);
|
|
1939
|
+
}
|
|
1940
|
+
if (command.kind === "add") {
|
|
1941
|
+
const added = await packs().add(
|
|
1942
|
+
command.source,
|
|
1943
|
+
command.name ? { name: command.name } : {},
|
|
1944
|
+
);
|
|
1945
|
+
return ok(
|
|
1946
|
+
`pack added · ${added.pack.name} · ${added.sounds} sounds · /pack info ${added.pack.name}`,
|
|
1947
|
+
);
|
|
1948
|
+
}
|
|
1949
|
+
if (command.kind === "info") {
|
|
1950
|
+
const info = await packs().info(command.name);
|
|
1951
|
+
if (!info)
|
|
1952
|
+
return fail(
|
|
1953
|
+
`pack · no pack named ${command.name.slice(0, 40)} · /pack list`,
|
|
1954
|
+
);
|
|
1955
|
+
const manifest = await packs().manifest(info.name);
|
|
1956
|
+
const banks = banksOf(manifest);
|
|
1957
|
+
const names = [...manifest.sounds.keys()];
|
|
1958
|
+
const lines = [
|
|
1959
|
+
`${info.name} · ${info.title}`,
|
|
1960
|
+
`license · ${info.license}${info.attribution ? ` · ${info.attribution}` : ""}`,
|
|
1961
|
+
`manifest · ${info.manifestUrl}`,
|
|
1962
|
+
...(info.homepage ? [`home · ${info.homepage}`] : []),
|
|
1963
|
+
`${names.length} sounds${banks.length ? ` · ${banks.length} kits` : ""}`,
|
|
1964
|
+
...(banks.length ? [`kits · ${banks.join(" ")}`] : []),
|
|
1965
|
+
...names.slice(0, 400).map((name) => {
|
|
1966
|
+
const sound = manifest.sounds.get(name)!;
|
|
1967
|
+
return `${name} · ${sound.kind === "zones" ? `${sound.zones.length} notes (keyed)` : `${sound.files.length} file${sound.files.length === 1 ? "" : "s"}`}`;
|
|
1968
|
+
}),
|
|
1969
|
+
];
|
|
1970
|
+
tui.openText(`pack · ${info.name}`, lines);
|
|
1971
|
+
return ok(
|
|
1972
|
+
`${info.name} · ${names.length} sounds · /pack use ${info.name}/<sound>`,
|
|
1973
|
+
);
|
|
1974
|
+
}
|
|
1975
|
+
if (command.kind === "remove") {
|
|
1976
|
+
const known = await packs().remove(command.name);
|
|
1977
|
+
return known
|
|
1978
|
+
? ok(`pack removed · ${command.name}`)
|
|
1979
|
+
: fail(
|
|
1980
|
+
`pack · no pack named ${command.name.slice(0, 40)} · /pack list`,
|
|
1981
|
+
);
|
|
1982
|
+
}
|
|
1983
|
+
await materializeDraft();
|
|
1984
|
+
const result = await useSound(
|
|
1985
|
+
packs(),
|
|
1986
|
+
score,
|
|
1987
|
+
requestedTrack,
|
|
1988
|
+
command.sound,
|
|
1989
|
+
{
|
|
1990
|
+
...(command.voice ? { voice: command.voice } : {}),
|
|
1991
|
+
},
|
|
1992
|
+
);
|
|
1993
|
+
const failed = await commitPackEdit(
|
|
1994
|
+
result.operations,
|
|
1995
|
+
result.trackId,
|
|
1996
|
+
"pack.use",
|
|
1997
|
+
{
|
|
1998
|
+
trackId: result.trackId,
|
|
1999
|
+
sound: command.sound,
|
|
2000
|
+
},
|
|
2001
|
+
);
|
|
2002
|
+
return failed ?? ok(`sound · ${result.summary}`);
|
|
2003
|
+
} catch (error) {
|
|
2004
|
+
if (error instanceof PackError) return fail(`pack · ${error.message}`);
|
|
2005
|
+
throw error;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
async function patternCommand(command: PatternCommand): Promise<Receipt> {
|
|
2010
|
+
if (command.kind === "list") {
|
|
2011
|
+
tui.openText("patterns", DRUM_PATTERNS.map(patternLine));
|
|
2012
|
+
return ok(`patterns · ${DRUM_PATTERNS.length} · /pattern <name>`);
|
|
2013
|
+
}
|
|
2014
|
+
if (command.kind === "browse") {
|
|
2015
|
+
tui.openPicker({
|
|
2016
|
+
id: "pattern",
|
|
2017
|
+
title: "drum patterns",
|
|
2018
|
+
hint: HINTS.audition,
|
|
2019
|
+
audition: true,
|
|
2020
|
+
items: DRUM_PATTERNS.map((entry) => ({
|
|
2021
|
+
label: `${entry.label.padEnd(22)} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
|
|
2022
|
+
value: `/pattern ${entry.name}`,
|
|
2023
|
+
})),
|
|
2024
|
+
filterable: true,
|
|
2025
|
+
index: 0,
|
|
2026
|
+
});
|
|
2027
|
+
patternPreview = undefined;
|
|
2028
|
+
previewPattern(DRUM_PATTERNS[0]?.name);
|
|
2029
|
+
return ok("patterns · ↑/↓ preview · Enter applies on the focused track");
|
|
2030
|
+
}
|
|
2031
|
+
await materializeDraft();
|
|
2032
|
+
const result = applyDrumPattern(
|
|
2033
|
+
score,
|
|
2034
|
+
requestedTrack,
|
|
2035
|
+
command.name,
|
|
2036
|
+
command.tempo,
|
|
2037
|
+
);
|
|
2038
|
+
if (result.next && result.kind)
|
|
2039
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2040
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
/**
|
|
2044
|
+
* Play one bar of the pattern under the picker cursor, on a scratch copy
|
|
2045
|
+
* of the focused track (or a fresh kit track when the focus is melodic).
|
|
2046
|
+
* Silent while the loop plays, like the Euclidean editor's audition.
|
|
2047
|
+
*/
|
|
2048
|
+
function previewPattern(name: string | undefined): void {
|
|
2049
|
+
if (!name || name === patternPreview) return;
|
|
2050
|
+
patternPreview = name;
|
|
2051
|
+
if (clock.playing) return;
|
|
2052
|
+
const entry = DRUM_PATTERNS.find((candidate) => candidate.name === name);
|
|
2053
|
+
if (!entry) return;
|
|
2054
|
+
let result = applyDrumPattern(score, requestedTrack, entry.name, "set");
|
|
2055
|
+
let trackId = requestedTrack;
|
|
2056
|
+
if (!result.next) {
|
|
2057
|
+
trackId = "pattern-preview";
|
|
2058
|
+
result = applyDrumPattern(score, trackId, entry.name, "set");
|
|
2059
|
+
}
|
|
2060
|
+
if (!result.next) return;
|
|
2061
|
+
const engine = liveEngine();
|
|
2062
|
+
if (!engine?.canMonitor) return;
|
|
2063
|
+
const pcm = renderAudition({
|
|
2064
|
+
score: result.next,
|
|
2065
|
+
trackId,
|
|
2066
|
+
bars: 1,
|
|
2067
|
+
sampleRate: engine.sampleRate,
|
|
2068
|
+
...(liveSampleBank ? { samples: liveSampleBank } : {}),
|
|
2069
|
+
});
|
|
2070
|
+
if (!pcm) return;
|
|
2071
|
+
void engine
|
|
2072
|
+
.monitor(true)
|
|
2073
|
+
.then(() => engine.noteOn(AUDITION_VOICE, pcm))
|
|
2074
|
+
.catch(() => undefined);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
/** `/kit` with no name: one picker with the synth kits and the sample kits. */
|
|
2078
|
+
function openKitPicker(): Receipt {
|
|
2079
|
+
const current = score.tracks.find((track) => track.id === requestedTrack);
|
|
2080
|
+
const items = kitCatalog().map((entry) => ({
|
|
2081
|
+
label: `${entry.kind === "synth" ? "synth " : "sample"} ${entry.label.padEnd(18)} ${entry.detail}`,
|
|
2082
|
+
value: entry.command,
|
|
2083
|
+
current:
|
|
2084
|
+
entry.kind === "synth" &&
|
|
2085
|
+
isDrumInstrumentTrack(current) &&
|
|
2086
|
+
(current?.kit ?? "default") === entry.name,
|
|
2087
|
+
}));
|
|
2088
|
+
tui.openPicker({
|
|
2089
|
+
id: "kit",
|
|
2090
|
+
title: "drum kits",
|
|
2091
|
+
hint: HINTS.audition,
|
|
2092
|
+
audition: true,
|
|
2093
|
+
items,
|
|
2094
|
+
filterable: true,
|
|
2095
|
+
index: Math.max(
|
|
2096
|
+
0,
|
|
2097
|
+
items.findIndex((item) => item.current),
|
|
2098
|
+
),
|
|
2099
|
+
});
|
|
2100
|
+
return ok("kits · synth kits work offline · sample kits are fetched once");
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function isDrumInstrumentTrack(track: { instrument: string } | undefined) {
|
|
2104
|
+
return track !== undefined && isDrumInstrument(track.instrument);
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
async function kitCommand(command: KitCommand, bare = false): Promise<Receipt> {
|
|
2108
|
+
if (bare) return openKitPicker();
|
|
2109
|
+
if (command.kind === "set" && isSynthKitName(command.bank)) {
|
|
2110
|
+
await materializeDraft();
|
|
2111
|
+
const result = applySynthKit(score, requestedTrack, command.bank);
|
|
2112
|
+
if (result.next && result.kind)
|
|
2113
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2114
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2115
|
+
}
|
|
2116
|
+
if (command.kind === "list") {
|
|
2117
|
+
const lines = kitCatalog()
|
|
2118
|
+
.filter((entry) => entry.kind === "synth")
|
|
2119
|
+
.map((entry) => `${entry.name} · synth · ${entry.detail}`);
|
|
2120
|
+
lines.push(...kitListLines(await packs().bankAliases(ALIASED_PACK)));
|
|
2121
|
+
tui.openText("kits", lines);
|
|
2122
|
+
return ok("kits · /kit <name or nickname> on the focused drum track");
|
|
2123
|
+
}
|
|
2124
|
+
await materializeDraft();
|
|
2125
|
+
const trackId = kitTarget(score, requestedTrack);
|
|
2126
|
+
if (!trackId)
|
|
2127
|
+
return fail(
|
|
2128
|
+
`kit · ${requestedTrack} is a melodic track · focus a drum track (/track drums) and retry`,
|
|
2129
|
+
);
|
|
2130
|
+
try {
|
|
2131
|
+
const result = await useKit(packs(), score, trackId, command.bank);
|
|
2132
|
+
const failed = await commitPackEdit(
|
|
2133
|
+
result.operations,
|
|
2134
|
+
trackId,
|
|
2135
|
+
"pack.kit",
|
|
2136
|
+
{
|
|
2137
|
+
trackId,
|
|
2138
|
+
bank: command.bank,
|
|
2139
|
+
},
|
|
2140
|
+
);
|
|
2141
|
+
return failed ?? ok(result.summary);
|
|
2142
|
+
} catch (error) {
|
|
2143
|
+
if (error instanceof PackError) return fail(`kit · ${error.message}`);
|
|
2144
|
+
throw error;
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
async function wavetableCommand(command: WavetableCommand): Promise<Receipt> {
|
|
2149
|
+
if (command.kind === "usage") return warn(command.message);
|
|
2150
|
+
if (command.kind === "show")
|
|
2151
|
+
return ok(describeWavetable(score, requestedTrack));
|
|
2152
|
+
if (command.kind === "list") {
|
|
2153
|
+
tui.openText(
|
|
2154
|
+
"wavetables",
|
|
2155
|
+
await wavetableListLines(packs(), process.cwd()),
|
|
2156
|
+
);
|
|
2157
|
+
return ok("wavetables · wt <table> sets the focused track");
|
|
2158
|
+
}
|
|
2159
|
+
await materializeDraft();
|
|
2160
|
+
const trackId = requestedTrack;
|
|
2161
|
+
try {
|
|
2162
|
+
const edit =
|
|
2163
|
+
command.kind === "table"
|
|
2164
|
+
? await pickWavetable(
|
|
2165
|
+
packs(),
|
|
2166
|
+
score,
|
|
2167
|
+
trackId,
|
|
2168
|
+
command.table,
|
|
2169
|
+
process.cwd(),
|
|
2170
|
+
)
|
|
2171
|
+
: wavetableParamEdit(score, trackId, command);
|
|
2172
|
+
const failed = await commitPackEdit(
|
|
2173
|
+
[edit.operation],
|
|
2174
|
+
trackId,
|
|
2175
|
+
"track.wavetable",
|
|
2176
|
+
{ trackId, ...command },
|
|
2177
|
+
);
|
|
2178
|
+
return failed ?? ok(edit.summary);
|
|
2179
|
+
} catch (error) {
|
|
2180
|
+
if (error instanceof PackError) return fail(`wavetable · ${error.message}`);
|
|
2181
|
+
throw error;
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
|
|
1518
2185
|
async function readLoopFile(path: string): Promise<string> {
|
|
1519
2186
|
const contents = await readFile(resolve(path));
|
|
1520
2187
|
if (contents.byteLength > 512 * 1024)
|
|
@@ -1627,7 +2294,7 @@ async function sessionCommand(
|
|
|
1627
2294
|
);
|
|
1628
2295
|
tui.openPicker({
|
|
1629
2296
|
id: "resume",
|
|
1630
|
-
title: "resume session
|
|
2297
|
+
title: "resume a session",
|
|
1631
2298
|
items: readable.map((session) => ({
|
|
1632
2299
|
label: `${session.sessionId === record.sessionId ? "* " : " "}${formatSessionLine(session, sessions)}`,
|
|
1633
2300
|
value: session.sessionId,
|
|
@@ -1644,9 +2311,7 @@ async function sessionCommand(
|
|
|
1644
2311
|
MAX_LISTED_SESSIONS,
|
|
1645
2312
|
);
|
|
1646
2313
|
tui.openText("sessions · * current · /resume <n>", lines);
|
|
1647
|
-
return ok(
|
|
1648
|
-
`${lines.length} session${lines.length === 1 ? "" : "s"} · esc closes`,
|
|
1649
|
-
);
|
|
2314
|
+
return ok(`${lines.length} session${lines.length === 1 ? "" : "s"}`);
|
|
1650
2315
|
}
|
|
1651
2316
|
if (agentTurn) return warn("agent busy · finish or Esc first");
|
|
1652
2317
|
if (verb === "fork") {
|
|
@@ -1707,6 +2372,11 @@ async function switchSession(sessionId: string): Promise<void> {
|
|
|
1707
2372
|
}
|
|
1708
2373
|
|
|
1709
2374
|
function liveEngine(): LiveEngine | undefined {
|
|
2375
|
+
return previewEngine();
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
/** The engine this window hears itself through (its own in daemon mode). */
|
|
2379
|
+
function previewEngine(): AudioEngine {
|
|
1710
2380
|
if (audio instanceof AudioEngine) return audio;
|
|
1711
2381
|
monitorEngine ??= new AudioEngine({ projectRoot: process.cwd() });
|
|
1712
2382
|
return monitorEngine;
|
|
@@ -1715,27 +2385,501 @@ function liveEngine(): LiveEngine | undefined {
|
|
|
1715
2385
|
/** The play session for the focused track (re-made when focus moves). */
|
|
1716
2386
|
function menuContext(): MenuContext {
|
|
1717
2387
|
const session = play;
|
|
2388
|
+
const loop = auditionController();
|
|
2389
|
+
loop.focus(requestedTrack);
|
|
1718
2390
|
return {
|
|
1719
|
-
score,
|
|
2391
|
+
score: loop.staging ? loop.score : score,
|
|
2392
|
+
audition: {
|
|
2393
|
+
looping: loop.looping,
|
|
2394
|
+
dirty: loop.dirtyEdits,
|
|
2395
|
+
committed: score,
|
|
2396
|
+
hint: loop.hint(),
|
|
2397
|
+
status: loop.status(),
|
|
2398
|
+
stageable: stageableNow,
|
|
2399
|
+
committedChords: chordSettings,
|
|
2400
|
+
},
|
|
1720
2401
|
trackId: requestedTrack,
|
|
1721
2402
|
playing: clock.playing,
|
|
1722
2403
|
grid: session?.grid ?? DEFAULT_GRID,
|
|
1723
2404
|
grids: GRIDS.map((grid) => grid.label),
|
|
1724
2405
|
clickOn: session?.clickOn ?? false,
|
|
1725
2406
|
countInBars: session?.countInBars ?? 1,
|
|
2407
|
+
chords: stagedChordSettings() ?? session?.chords.settings ?? chordSettings,
|
|
2408
|
+
projectRoot: process.cwd(),
|
|
1726
2409
|
};
|
|
1727
2410
|
}
|
|
1728
2411
|
|
|
2412
|
+
/** The screen `?` describes, or undefined while `?` is typed text. */
|
|
2413
|
+
function keysScreen(): readonly KeySection[] | undefined {
|
|
2414
|
+
if (euclid.open) return euclid.typing ? undefined : KEYS.euclid;
|
|
2415
|
+
if (menu.open) return menu.typing ? undefined : KEYS.menu;
|
|
2416
|
+
const overlay = tui.ui.overlay;
|
|
2417
|
+
if (overlay === "picker")
|
|
2418
|
+
return tui.pickerTyping
|
|
2419
|
+
? undefined
|
|
2420
|
+
: AUDITION_PICKERS.has(tui.ui.picker?.id ?? "")
|
|
2421
|
+
? KEYS.audition
|
|
2422
|
+
: KEYS.list;
|
|
2423
|
+
if (overlay === "text") return KEYS.text;
|
|
2424
|
+
if (overlay === "log") return KEYS.log;
|
|
2425
|
+
if (prompt.value.length > 0) return undefined;
|
|
2426
|
+
if (play?.on)
|
|
2427
|
+
return play.chords.on ? [...KEYS.play, ...KEYS.chords] : KEYS.play;
|
|
2428
|
+
return KEYS.prompt;
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
function showKeys(): void {
|
|
2432
|
+
const sections = keysScreen();
|
|
2433
|
+
if (!sections) return;
|
|
2434
|
+
const lines = keyLines(sections);
|
|
2435
|
+
// Play mode's secondary state lives here instead of the header.
|
|
2436
|
+
if (play?.on && !tui.ui.overlay && !menu.open)
|
|
2437
|
+
lines.unshift(...play.details(), "");
|
|
2438
|
+
tui.showKeys("keys", lines);
|
|
2439
|
+
}
|
|
2440
|
+
|
|
1729
2441
|
function openMenu(section?: string): void {
|
|
1730
2442
|
menu.show(menuContext(), section);
|
|
1731
2443
|
refreshMenu();
|
|
1732
2444
|
}
|
|
1733
2445
|
|
|
2446
|
+
function euclidContext(): EuclidContext {
|
|
2447
|
+
const loop = auditionController();
|
|
2448
|
+
loop.focus(requestedTrack);
|
|
2449
|
+
return {
|
|
2450
|
+
score: loop.staging ? loop.score : score,
|
|
2451
|
+
trackId: requestedTrack,
|
|
2452
|
+
audition: {
|
|
2453
|
+
looping: loop.looping,
|
|
2454
|
+
dirty: loop.dirtyEdits,
|
|
2455
|
+
committed: score,
|
|
2456
|
+
hint: loop.hint(),
|
|
2457
|
+
status: loop.status(),
|
|
2458
|
+
},
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
function openEuclid(voice?: string, origin?: string): void {
|
|
2463
|
+
euclid.show(euclidContext(), voice, origin);
|
|
2464
|
+
refreshEuclid();
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
function refreshEuclid(): void {
|
|
2468
|
+
if (!euclid.open) return;
|
|
2469
|
+
if (tui.ui.overlay !== undefined && tui.ui.picker?.id !== "euclid") {
|
|
2470
|
+
euclid.close();
|
|
2471
|
+
return;
|
|
2472
|
+
}
|
|
2473
|
+
const view = euclid.view(euclidContext());
|
|
2474
|
+
tui.openPicker({
|
|
2475
|
+
id: "euclid",
|
|
2476
|
+
title: view.title,
|
|
2477
|
+
items: view.items,
|
|
2478
|
+
index: view.index,
|
|
2479
|
+
hint: view.hint,
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
/**
|
|
2484
|
+
* Play one bar of `voice` on the focused track over silence. While the loop
|
|
2485
|
+
* is playing the edit is already audible, so nothing extra sounds.
|
|
2486
|
+
*/
|
|
2487
|
+
function audition(voice: string): void {
|
|
2488
|
+
if (clock.playing) return;
|
|
2489
|
+
const track = score.tracks.find(
|
|
2490
|
+
(candidate) => candidate.id === requestedTrack,
|
|
2491
|
+
);
|
|
2492
|
+
if (!track) return;
|
|
2493
|
+
const pitch = rhythmVoicePitch(track, voice);
|
|
2494
|
+
if (pitch === undefined) return;
|
|
2495
|
+
const engine = liveEngine();
|
|
2496
|
+
if (!engine?.canMonitor) return;
|
|
2497
|
+
const pcm = renderAudition({
|
|
2498
|
+
score,
|
|
2499
|
+
trackId: track.id,
|
|
2500
|
+
pitches: new Set([pitch]),
|
|
2501
|
+
bars: 1,
|
|
2502
|
+
sampleRate: engine.sampleRate,
|
|
2503
|
+
...(liveSampleBank ? { samples: liveSampleBank } : {}),
|
|
2504
|
+
});
|
|
2505
|
+
if (!pcm) return;
|
|
2506
|
+
void engine
|
|
2507
|
+
.monitor(true)
|
|
2508
|
+
.then(() => engine.noteOn(AUDITION_VOICE, pcm))
|
|
2509
|
+
.catch(() => undefined);
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
/**
|
|
2513
|
+
* The window's audition controller. Its loop plays through the ordinary
|
|
2514
|
+
* engine (the render worker and stem cache), so a preview sounds exactly
|
|
2515
|
+
* like the same bars of the song; staged commands run through `submit`
|
|
2516
|
+
* with `commitScore` captured, so a staged edit is made by the same code
|
|
2517
|
+
* as a committed one.
|
|
2518
|
+
*/
|
|
2519
|
+
function auditionController(): Audition {
|
|
2520
|
+
auditionLoop ??= new Audition(
|
|
2521
|
+
{
|
|
2522
|
+
apply: applyStaged,
|
|
2523
|
+
play: (preview) => previewEngine().play(preview),
|
|
2524
|
+
stop() {
|
|
2525
|
+
const engine = previewEngine();
|
|
2526
|
+
engine.stop();
|
|
2527
|
+
if (!play?.on) engine.setLeadMs(undefined);
|
|
2528
|
+
},
|
|
2529
|
+
leadMs: () => previewEngine().leadMs,
|
|
2530
|
+
now: () => performance.now(),
|
|
2531
|
+
beat: () => clock.beatAt(),
|
|
2532
|
+
setTimer: (callback, ms) => setTimeout(callback, ms),
|
|
2533
|
+
clearTimer: (handle) => clearTimeout(handle as Timer),
|
|
2534
|
+
changed: () => requestFrame(),
|
|
2535
|
+
level: () => previewEngine().level,
|
|
2536
|
+
// The chord settings screen loops a progression with its settings.
|
|
2537
|
+
phrase: (showing) => {
|
|
2538
|
+
if (!chordScreenOpen()) return undefined;
|
|
2539
|
+
const settings =
|
|
2540
|
+
showing === "A"
|
|
2541
|
+
? chordSettings
|
|
2542
|
+
: (stagedChordSettings() ?? chordSettings);
|
|
2543
|
+
return (base, track, bars) => chordPhrase(settings, base, track, bars);
|
|
2544
|
+
},
|
|
2545
|
+
},
|
|
2546
|
+
score,
|
|
2547
|
+
requestedTrack,
|
|
2548
|
+
);
|
|
2549
|
+
return auditionLoop;
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
/** True while the menu's Chords section (`/menu chords`) is open. */
|
|
2553
|
+
function chordScreenOpen(): boolean {
|
|
2554
|
+
return menu.open && menu.section === "chords";
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
/** What stages while auditioning on the screen open now. */
|
|
2558
|
+
function stageableNow(command: string): boolean {
|
|
2559
|
+
return (
|
|
2560
|
+
isStageable(command) || (chordScreenOpen() && isChordStageable(command))
|
|
2561
|
+
);
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
/**
|
|
2565
|
+
* The chord settings with the staged `/chords …` commands applied, or
|
|
2566
|
+
* undefined while none are staged. Chord settings are window state (not in
|
|
2567
|
+
* the score), so the staged ones are derived from the staged commands.
|
|
2568
|
+
*/
|
|
2569
|
+
function stagedChordSettings(): ChordSettings | undefined {
|
|
2570
|
+
const commands = auditionLoop?.dirtyEdits ? auditionLoop.commands : [];
|
|
2571
|
+
const chords = commands.filter((command) => CHORDS_COMMAND.test(command));
|
|
2572
|
+
if (chords.length === 0) return undefined;
|
|
2573
|
+
const settings = { ...chordSettings };
|
|
2574
|
+
for (const command of chords)
|
|
2575
|
+
applyChordsCommand(settings, command.match(CHORDS_COMMAND)![1]!);
|
|
2576
|
+
return settings;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/** Re-render the loop when the chord screen opens or closes. */
|
|
2580
|
+
function noteChordScreen(): void {
|
|
2581
|
+
const now = chordScreenOpen();
|
|
2582
|
+
if (now === chordScreenWas) return;
|
|
2583
|
+
chordScreenWas = now;
|
|
2584
|
+
auditionLoop?.request();
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
/**
|
|
2588
|
+
* Warm the pack cache for a staged command before it runs, so a lazy fetch
|
|
2589
|
+
* (a sample kit, a pack sound or table) happens outside the capture and the
|
|
2590
|
+
* window's score is swapped only for the instant the command takes.
|
|
2591
|
+
*/
|
|
2592
|
+
async function prefetchStaged(
|
|
2593
|
+
base: TrackScore,
|
|
2594
|
+
command: string,
|
|
2595
|
+
): Promise<void> {
|
|
2596
|
+
try {
|
|
2597
|
+
const kit = parseKitCommand(command);
|
|
2598
|
+
if (kit?.kind === "set" && !isSynthKitName(kit.bank)) {
|
|
2599
|
+
const trackId = kitTarget(base, requestedTrack);
|
|
2600
|
+
if (trackId) await useKit(packs(), base, trackId, kit.bank);
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
const pack = parsePackCommand(command);
|
|
2604
|
+
if (pack?.kind === "use") {
|
|
2605
|
+
await useSound(packs(), base, requestedTrack, pack.sound, {
|
|
2606
|
+
...(pack.voice ? { voice: pack.voice } : {}),
|
|
2607
|
+
});
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
const wavetable = parseWavetableCommand(command);
|
|
2611
|
+
if (wavetable?.kind === "table")
|
|
2612
|
+
await pickWavetable(
|
|
2613
|
+
packs(),
|
|
2614
|
+
base,
|
|
2615
|
+
requestedTrack,
|
|
2616
|
+
wavetable.table,
|
|
2617
|
+
process.cwd(),
|
|
2618
|
+
);
|
|
2619
|
+
} catch {
|
|
2620
|
+
// The command itself reports the failure.
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
/** Apply one prompt command to `base` without committing (see stageCapture). */
|
|
2625
|
+
async function applyStaged(
|
|
2626
|
+
base: TrackScore,
|
|
2627
|
+
command: string,
|
|
2628
|
+
): Promise<{ next?: TrackScore; ok: boolean; message: string }> {
|
|
2629
|
+
const chords = command.trim().match(CHORDS_COMMAND);
|
|
2630
|
+
if (chords) {
|
|
2631
|
+
// Window state: checked on a copy; kept settings apply on Enter.
|
|
2632
|
+
const result = applyChordsCommand({ ...chordSettings }, chords[1]!);
|
|
2633
|
+
return { next: base, ok: result.ok, message: result.message };
|
|
2634
|
+
}
|
|
2635
|
+
await prefetchStaged(base, command);
|
|
2636
|
+
// Staged commands run one at a time, between queued prompts.
|
|
2637
|
+
while (stageCapture) await Bun.sleep(1);
|
|
2638
|
+
const committed = score;
|
|
2639
|
+
stageCapture = {};
|
|
2640
|
+
score = base;
|
|
2641
|
+
try {
|
|
2642
|
+
const result = await submit(command);
|
|
2643
|
+
const text = typeof result === "string" ? result : result.text;
|
|
2644
|
+
const good = toneOf(result) !== "error";
|
|
2645
|
+
return { next: stageCapture.next, ok: good, message: text };
|
|
2646
|
+
} catch (error) {
|
|
2647
|
+
return { ok: false, message: describeError(command, error) };
|
|
2648
|
+
} finally {
|
|
2649
|
+
score = stageCapture?.committed ?? committed;
|
|
2650
|
+
stageCapture = undefined;
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
/** Keep the controller on the committed score (remote edits, undo, agent). */
|
|
2655
|
+
function followCommitted(): void {
|
|
2656
|
+
const loop = auditionLoop;
|
|
2657
|
+
if (!loop || stageCapture || loop.committed === score) return;
|
|
2658
|
+
if (!loop.dirtyEdits) {
|
|
2659
|
+
loop.committedNow(score);
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
void loop.rebase(score).then(({ dropped }) => {
|
|
2663
|
+
tui.activity.pushCard(
|
|
2664
|
+
dropped.length
|
|
2665
|
+
? `score changed · dropped staged ${dropped.join(", ")}`
|
|
2666
|
+
: "score changed · staged edits re-applied on top",
|
|
2667
|
+
{ tone: "warning" },
|
|
2668
|
+
);
|
|
2669
|
+
requestFrame();
|
|
2670
|
+
});
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
async function startAuditionLoop(): Promise<void> {
|
|
2674
|
+
const loop = auditionController();
|
|
2675
|
+
await materializeDraft();
|
|
2676
|
+
loop.committedNow(score);
|
|
2677
|
+
loop.focus(requestedTrack);
|
|
2678
|
+
// One sound at a time: the song stops while the loop plays.
|
|
2679
|
+
if (clock.playing) await setTransport("pause");
|
|
2680
|
+
previewEngine().setLeadMs(PLAY_LEAD_MS);
|
|
2681
|
+
loop.start();
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
function stopAuditionLoop(): void {
|
|
2685
|
+
auditionLoop?.stop();
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
/** A menu or picker audition key: Space, `a` (A/B) or `c` (context). */
|
|
2689
|
+
function auditionKeyPressed(
|
|
2690
|
+
key: "loop" | "ab" | "context",
|
|
2691
|
+
): Promise<void> | undefined {
|
|
2692
|
+
const loop = auditionController();
|
|
2693
|
+
if (key === "loop") {
|
|
2694
|
+
if (loop.looping) stopAuditionLoop();
|
|
2695
|
+
else return startAuditionLoop().finally(() => requestFrame());
|
|
2696
|
+
} else if (key === "ab") {
|
|
2697
|
+
if (!loop.dirtyEdits) {
|
|
2698
|
+
tui.activity.pushCard("A/B · nothing staged yet · ←→ to change a value", {
|
|
2699
|
+
tone: "info",
|
|
2700
|
+
});
|
|
2701
|
+
return;
|
|
2702
|
+
}
|
|
2703
|
+
loop.toggleAB();
|
|
2704
|
+
} else loop.toggleContext();
|
|
2705
|
+
return undefined;
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
/** An auditioning picker's title carries the loop status (`♪ solo · B`). */
|
|
2709
|
+
function refreshAuditionPicker(): void {
|
|
2710
|
+
const picker = tui.ui.picker as
|
|
2711
|
+
(NonNullable<typeof tui.ui.picker> & { baseTitle?: string }) | undefined;
|
|
2712
|
+
if (tui.ui.overlay !== "picker" || !picker?.audition) return;
|
|
2713
|
+
// Kept on the picker so a filter's copy of it keeps the plain title too.
|
|
2714
|
+
const base = (picker.baseTitle ??= picker.title);
|
|
2715
|
+
const status = auditionLoop?.status();
|
|
2716
|
+
const dirty = auditionLoop?.dirtyEdits ? "● " : "";
|
|
2717
|
+
picker.title = status ? `${dirty}${base} · ${status}` : base;
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
/**
|
|
2721
|
+
* `/try <command>`: hear a sound command on the audition loop before it
|
|
2722
|
+
* lands. A small picker offers keep (one undo step) or revert; Space, `a`
|
|
2723
|
+
* and `c` work as in the menu.
|
|
2724
|
+
*/
|
|
2725
|
+
async function tryPrompt(command: string): Promise<Receipt> {
|
|
2726
|
+
if (!command) return fail(TRY_USAGE);
|
|
2727
|
+
const agentToggle = command.match(/^agent\s+(on|off)$/i);
|
|
2728
|
+
if (agentToggle) {
|
|
2729
|
+
agentPreviewPlays = agentToggle[1]!.toLowerCase() === "on";
|
|
2730
|
+
return ok(
|
|
2731
|
+
`try · agent previews ${agentPreviewPlays ? "play once here" : "stay silent (numbers only)"}`,
|
|
2732
|
+
);
|
|
2733
|
+
}
|
|
2734
|
+
if (!isStageable(command))
|
|
2735
|
+
return fail(`try · only sound changes can be tried · ${TRY_USAGE}`);
|
|
2736
|
+
if (menu.open || euclid.open)
|
|
2737
|
+
return fail("try · close the open screen first");
|
|
2738
|
+
const loop = auditionController();
|
|
2739
|
+
if (!loop.looping) await startAuditionLoop();
|
|
2740
|
+
const result = await loop.stage(command);
|
|
2741
|
+
if (!result.ok) {
|
|
2742
|
+
if (!loop.dirtyEdits) stopAuditionLoop();
|
|
2743
|
+
return fail(`try · ${result.message}`);
|
|
2744
|
+
}
|
|
2745
|
+
tui.openPicker({
|
|
2746
|
+
id: "try",
|
|
2747
|
+
title: `try · ${command.slice(0, 48)}`,
|
|
2748
|
+
hint: HINTS.audition,
|
|
2749
|
+
audition: true,
|
|
2750
|
+
items: [
|
|
2751
|
+
{ label: "keep", value: "keep", detail: "commit it · one undo step" },
|
|
2752
|
+
{ label: "revert", value: "revert", detail: "drop it" },
|
|
2753
|
+
],
|
|
2754
|
+
});
|
|
2755
|
+
return ok(`trying · ${command} · a A/B · enter keep · esc revert`);
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
/** Audition a list's highlighted item in place of its previous hover. */
|
|
2759
|
+
function hoverItem(command: string, key: string): void {
|
|
2760
|
+
const loop = auditionLoop;
|
|
2761
|
+
if (!loop?.looping) return;
|
|
2762
|
+
requestFrame();
|
|
2763
|
+
void loop.hover(command, key).then((result) => {
|
|
2764
|
+
if (!result.ok && result.message !== SUPERSEDED)
|
|
2765
|
+
tui.activity.pushCard(result.message, { tone: "warning" });
|
|
2766
|
+
requestFrame();
|
|
2767
|
+
});
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
/** Enter on a list item while auditioning: it stays staged for good. */
|
|
2771
|
+
function chooseItem(command: string, key: string): void {
|
|
2772
|
+
const loop = auditionLoop;
|
|
2773
|
+
if (!loop) return;
|
|
2774
|
+
void loop
|
|
2775
|
+
.hover(command, key)
|
|
2776
|
+
.then((result) => {
|
|
2777
|
+
if (!result.ok && result.message !== SUPERSEDED)
|
|
2778
|
+
tui.activity.pushCard(result.message, { tone: "warning" });
|
|
2779
|
+
loop.settle(key);
|
|
2780
|
+
})
|
|
2781
|
+
.finally(() => requestFrame());
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
/** Space, `a` or `c` in a picker: starting the loop hovers the cursor row. */
|
|
2785
|
+
function pickerAuditionKey(key: "loop" | "ab" | "context"): void {
|
|
2786
|
+
const started = auditionKeyPressed(key);
|
|
2787
|
+
if (!started) return;
|
|
2788
|
+
const picker = tui.ui.picker;
|
|
2789
|
+
const item = picker?.items[picker.index];
|
|
2790
|
+
if (!picker || !item || !isStageable(item.value)) return;
|
|
2791
|
+
void started.then(() => {
|
|
2792
|
+
const now = tui.ui.picker;
|
|
2793
|
+
const current = now?.id === picker.id ? now.items[now.index] : undefined;
|
|
2794
|
+
if (current && isStageable(current.value))
|
|
2795
|
+
hoverItem(current.value, `picker:${picker.id}`);
|
|
2796
|
+
});
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
/** Stage a menu command while auditioning; undefined when it should run. */
|
|
2800
|
+
function stageIfAuditioning(command: string): boolean {
|
|
2801
|
+
const loop = auditionLoop;
|
|
2802
|
+
if (!loop?.staging || !stageableNow(command)) return false;
|
|
2803
|
+
void loop.stage(command).then((result) => {
|
|
2804
|
+
if (!result.ok)
|
|
2805
|
+
tui.activity.pushCard(result.message, {
|
|
2806
|
+
tone: result.message.includes("failed") ? "error" : "warning",
|
|
2807
|
+
});
|
|
2808
|
+
requestFrame();
|
|
2809
|
+
});
|
|
2810
|
+
return true;
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
/** Enter: every staged edit becomes ONE revision (one undo step). */
|
|
2814
|
+
async function keepStaged(): Promise<Receipt> {
|
|
2815
|
+
const loop = auditionLoop;
|
|
2816
|
+
await loop?.settled();
|
|
2817
|
+
const taken = loop?.take();
|
|
2818
|
+
if (!loop || !taken) return warn("nothing staged");
|
|
2819
|
+
await materializeDraft();
|
|
2820
|
+
// Chord settings are window state: they apply here, outside the revision.
|
|
2821
|
+
const chordCommands = taken.commands.filter((command) =>
|
|
2822
|
+
CHORDS_COMMAND.test(command),
|
|
2823
|
+
);
|
|
2824
|
+
for (const command of chordCommands)
|
|
2825
|
+
applyChordsCommand(chordSettings, command.match(CHORDS_COMMAND)![1]!);
|
|
2826
|
+
const scoreCommands = taken.commands.filter(
|
|
2827
|
+
(command) => !CHORDS_COMMAND.test(command),
|
|
2828
|
+
);
|
|
2829
|
+
let next = taken.score;
|
|
2830
|
+
for (let attempt = 0; scoreCommands.length && next !== score; attempt += 1) {
|
|
2831
|
+
try {
|
|
2832
|
+
await commitScore(next, "preview.commit", {
|
|
2833
|
+
trackId: loop.trackId,
|
|
2834
|
+
commands: scoreCommands,
|
|
2835
|
+
});
|
|
2836
|
+
break;
|
|
2837
|
+
} catch (error) {
|
|
2838
|
+
if (!(error instanceof SessionConflictError) || attempt > 0) throw error;
|
|
2839
|
+
// Another window committed first: replay the staged commands on it.
|
|
2840
|
+
record = await port.load();
|
|
2841
|
+
score = scoreFromJSON(record.composition);
|
|
2842
|
+
next = score;
|
|
2843
|
+
for (const command of scoreCommands) {
|
|
2844
|
+
const result = await applyStaged(next, command);
|
|
2845
|
+
if (result.next) next = result.next;
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
loop.committedNow(score);
|
|
2850
|
+
await projectSync?.flushScore();
|
|
2851
|
+
const kept = `kept ${taken.commands.length} change${taken.commands.length === 1 ? "" : "s"}`;
|
|
2852
|
+
return ok(
|
|
2853
|
+
scoreCommands.length
|
|
2854
|
+
? `${kept} · one undo step`
|
|
2855
|
+
: `${kept} · chord settings (window state, no revision)`,
|
|
2856
|
+
);
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
function revertStaged(): void {
|
|
2860
|
+
const loop = auditionLoop;
|
|
2861
|
+
if (!loop?.dirtyEdits) return;
|
|
2862
|
+
const count = loop.commands.length;
|
|
2863
|
+
loop.revert();
|
|
2864
|
+
tui.activity.pushCard(
|
|
2865
|
+
`reverted ${count} staged change${count === 1 ? "" : "s"}`,
|
|
2866
|
+
{ tone: "info" },
|
|
2867
|
+
);
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
/** Leaving an auditioning screen stops the loop and drops staged edits. */
|
|
2871
|
+
function leaveAuditionScreen(): void {
|
|
2872
|
+
if (auditionLoop?.dirtyEdits) revertStaged();
|
|
2873
|
+
stopAuditionLoop();
|
|
2874
|
+
}
|
|
2875
|
+
|
|
1734
2876
|
function refreshMenu(): void {
|
|
2877
|
+
noteChordScreen();
|
|
1735
2878
|
if (!menu.open) return;
|
|
1736
2879
|
if (tui.ui.overlay !== undefined && tui.ui.picker?.id !== "menu") {
|
|
1737
2880
|
// Another overlay (help, a picker) replaced the menu.
|
|
1738
2881
|
menu.close();
|
|
2882
|
+
leaveAuditionScreen();
|
|
1739
2883
|
return;
|
|
1740
2884
|
}
|
|
1741
2885
|
const view = menu.view(menuContext());
|
|
@@ -1747,6 +2891,7 @@ function refreshMenu(): void {
|
|
|
1747
2891
|
: [{ label: "no matches", value: "none" }],
|
|
1748
2892
|
index: view.index,
|
|
1749
2893
|
hint: view.hint,
|
|
2894
|
+
note: view.note,
|
|
1750
2895
|
});
|
|
1751
2896
|
}
|
|
1752
2897
|
|
|
@@ -1756,6 +2901,7 @@ function playSession(): PlaySession {
|
|
|
1756
2901
|
play = new PlaySession(playHost(), {
|
|
1757
2902
|
clickOn: previous?.clickOn,
|
|
1758
2903
|
clickVolume: previous?.clickVolume,
|
|
2904
|
+
chords: chordSettings,
|
|
1759
2905
|
});
|
|
1760
2906
|
if (previous) {
|
|
1761
2907
|
play.countInBars = previous.countInBars;
|
|
@@ -1771,7 +2917,7 @@ async function enterPlay(): Promise<Receipt> {
|
|
|
1771
2917
|
await session.enter();
|
|
1772
2918
|
if (hasSamplerTracks(score)) void sampleProblems(score);
|
|
1773
2919
|
return ok(
|
|
1774
|
-
`play · ${session.track} · ${session.keyboard.range} ·
|
|
2920
|
+
`play · ${session.track} · ${session.keyboard.range} · ? keys · esc leaves`,
|
|
1775
2921
|
);
|
|
1776
2922
|
}
|
|
1777
2923
|
|
|
@@ -1883,11 +3029,21 @@ async function commitScore(
|
|
|
1883
3029
|
payload: Record<string, unknown> = {},
|
|
1884
3030
|
): Promise<void> {
|
|
1885
3031
|
if (next === score) return;
|
|
3032
|
+
// Rhythm rows regenerate after a loop resize and freeze when their lane
|
|
3033
|
+
// is hand-edited, so rows and notes never disagree.
|
|
3034
|
+
next = reconcileRhythm(score, next);
|
|
3035
|
+
if (stageCapture) {
|
|
3036
|
+
// A staged edit: the audition plays it; nothing is written yet.
|
|
3037
|
+
stageCapture.next = next;
|
|
3038
|
+
score = next;
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
1886
3041
|
record = await port.append(record, { kind, payload }, next.toJSON());
|
|
1887
3042
|
score = next;
|
|
1888
3043
|
if (clock.playing) void audio.play(score);
|
|
1889
3044
|
projectSync?.scoreChanged(score);
|
|
1890
3045
|
reportSampleProblems(score);
|
|
3046
|
+
void updateCredits(score);
|
|
1891
3047
|
}
|
|
1892
3048
|
|
|
1893
3049
|
/** The window's side of the project file sync (see src/project/sync.ts). */
|
|
@@ -1992,6 +3148,8 @@ async function toggleTransport(): Promise<void> {
|
|
|
1992
3148
|
async function setTransport(
|
|
1993
3149
|
action: "play" | "pause" | "toggle",
|
|
1994
3150
|
): Promise<void> {
|
|
3151
|
+
// One sound at a time: starting the song stops an audition loop.
|
|
3152
|
+
if (action !== "pause" && auditionLoop?.looping) stopAuditionLoop();
|
|
1995
3153
|
if (port.mode === "daemon") {
|
|
1996
3154
|
// dawgd owns the only transport; its broadcast updates `clock`.
|
|
1997
3155
|
await port.transport(action);
|
|
@@ -2116,6 +3274,39 @@ function mediaServices(): MediaServices {
|
|
|
2116
3274
|
return { runner: systemRunner, env: process.env };
|
|
2117
3275
|
}
|
|
2118
3276
|
|
|
3277
|
+
/**
|
|
3278
|
+
* How the agent's preview_sound renders (with this window's decoded
|
|
3279
|
+
* samples, at the engine's rate) and plays: once, over silence, unless the
|
|
3280
|
+
* song or the audition loop is already sounding or `/try agent off`.
|
|
3281
|
+
*/
|
|
3282
|
+
function agentPreviewHost(): PreviewHost {
|
|
3283
|
+
return {
|
|
3284
|
+
render: async (value) => {
|
|
3285
|
+
await sampleProblems(value);
|
|
3286
|
+
return renderScorePcm(value, {
|
|
3287
|
+
sampleRate: previewEngine().sampleRate,
|
|
3288
|
+
...(liveSampleBank ? { samples: liveSampleBank } : {}),
|
|
3289
|
+
});
|
|
3290
|
+
},
|
|
3291
|
+
play: async (rendered) => {
|
|
3292
|
+
if (!agentPreviewPlays || clock.playing || auditionLoop?.looping)
|
|
3293
|
+
return false;
|
|
3294
|
+
const engine = liveEngine();
|
|
3295
|
+
if (!engine?.canMonitor || rendered.frames === 0) return false;
|
|
3296
|
+
try {
|
|
3297
|
+
await engine.monitor(true);
|
|
3298
|
+
engine.noteOn(AGENT_PREVIEW_VOICE, {
|
|
3299
|
+
pcm: rendered.pcm,
|
|
3300
|
+
frames: rendered.frames,
|
|
3301
|
+
});
|
|
3302
|
+
return true;
|
|
3303
|
+
} catch {
|
|
3304
|
+
return false;
|
|
3305
|
+
}
|
|
3306
|
+
},
|
|
3307
|
+
};
|
|
3308
|
+
}
|
|
3309
|
+
|
|
2119
3310
|
function agentHost(
|
|
2120
3311
|
turn: { steering: string[] },
|
|
2121
3312
|
selection: ProviderSelection,
|
|
@@ -2133,6 +3324,7 @@ function agentHost(
|
|
|
2133
3324
|
}),
|
|
2134
3325
|
}),
|
|
2135
3326
|
media: mediaServices(),
|
|
3327
|
+
preview: agentPreviewHost(),
|
|
2136
3328
|
async commit(change) {
|
|
2137
3329
|
// dawgd rebases operation intents onto newer revisions when nothing
|
|
2138
3330
|
// they touch changed; the file port keeps the strict base check.
|