@hraness/dawg 0.7.0 → 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 +26 -0
- package/DAWG.md +117 -30
- package/README.md +7 -5
- package/core/diff.ts +2 -0
- package/core/ids.ts +52 -0
- package/core/loop.ts +3 -0
- package/core/range.ts +581 -0
- package/core/score.ts +123 -1
- package/core/sdk/print.ts +11 -2
- package/core/sdk/v1.ts +281 -2
- package/core/sections.ts +198 -19
- package/guides/arrange.md +9 -8
- package/guides/audio.md +31 -0
- package/guides/mix.md +3 -1
- package/guides/panes.md +32 -0
- package/guides/project.md +3 -2
- package/guides/rhythm.md +2 -1
- package/guides/tape.md +32 -0
- package/package.json +2 -1
- package/src/agent/agent.ts +4 -4
- package/src/agent/command-agent.ts +19 -10
- package/src/agent/ops.ts +15 -0
- package/src/agent/planner.ts +12 -0
- package/src/agent/range-tools.ts +216 -0
- package/src/agent/show-me.ts +1 -1
- package/src/agent/tools.ts +2 -0
- package/src/agent/xcb-agent.ts +2 -3
- package/src/audio/arrange.ts +12 -2
- package/src/audio/audio-command.ts +218 -0
- package/src/audio/devices.ts +264 -0
- package/src/audio/doctor.ts +84 -13
- package/src/audio/engine.ts +216 -28
- package/src/commands/arrange.ts +60 -25
- package/src/commands/grammar.ts +3 -1
- package/src/commands/help.ts +187 -9
- package/src/commands/history.ts +122 -11
- package/src/commands/keys.ts +17 -0
- package/src/commands/parses.ts +6 -1
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +2 -6
- package/src/commands/vocal-pitch.ts +2 -1
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +18 -0
- package/src/launch-args.ts +105 -1
- package/src/main.ts +1142 -86
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +196 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/origin.ts +20 -0
- package/src/session/port.ts +87 -10
- package/src/session/protocol.ts +360 -6
- package/src/session/rebase.ts +46 -8
- package/src/session/receipt.ts +7 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +61 -2
- package/src/tui/arrange-menu.ts +169 -1
- package/src/tui/euclid.ts +95 -14
- package/src/tui/fader.ts +55 -2
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- package/src/tui/menu.ts +228 -2
- package/src/tui/play-session.ts +196 -21
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/tui/app.ts +217 -44
- package/tui/delight.ts +49 -0
- package/tui/drawer.ts +167 -9
- package/tui/grammar.ts +34 -5
- package/tui/guide.ts +8 -1
- package/tui/highway.ts +40 -5
- package/tui/hints.ts +5 -0
- package/tui/hits.ts +7 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +12 -0
- package/tui/screen.ts +7 -4
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +19 -1
package/src/main.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { TOPIC_ALIASES } from "./lang/glossary.ts";
|
|
3
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";
|
|
4
7
|
import { isSingWord } from "../core/sing.ts";
|
|
5
8
|
import { commandParses, parseExact } from "./commands/parses.ts";
|
|
6
9
|
import { paramRangeError } from "./commands/param-range.ts";
|
|
@@ -35,7 +38,7 @@ import {
|
|
|
35
38
|
unknownInstrumentMessage,
|
|
36
39
|
} from "./audio/instrument-check.ts";
|
|
37
40
|
import { randomUUID } from "node:crypto";
|
|
38
|
-
import { readFileSync, writeSync } from "node:fs";
|
|
41
|
+
import { appendFileSync, readFileSync, writeSync } from "node:fs";
|
|
39
42
|
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
40
43
|
import { tmpdir } from "node:os";
|
|
41
44
|
import { join, relative, resolve } from "node:path";
|
|
@@ -51,8 +54,14 @@ import {
|
|
|
51
54
|
editedTrack,
|
|
52
55
|
foreignEvents,
|
|
53
56
|
otherWindowName,
|
|
57
|
+
paneLetters,
|
|
54
58
|
} from "./session/origin.ts";
|
|
55
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
compositionDigest,
|
|
61
|
+
monotonicEpochMs,
|
|
62
|
+
type PaneView,
|
|
63
|
+
type PresenceEntry,
|
|
64
|
+
} from "./session/protocol.ts";
|
|
56
65
|
import {
|
|
57
66
|
formatSessionLine,
|
|
58
67
|
listSessions,
|
|
@@ -87,6 +96,7 @@ import { kitCatalog } from "./audio/kits.ts";
|
|
|
87
96
|
import { applyEditCommand, parseEditCommand } from "./commands/edit.ts";
|
|
88
97
|
import { applyTimeCommand, parseTimeCommand } from "./commands/time.ts";
|
|
89
98
|
import {
|
|
99
|
+
barAt,
|
|
90
100
|
barStartTick,
|
|
91
101
|
bpmAtTick,
|
|
92
102
|
hasMeterChanges,
|
|
@@ -145,6 +155,7 @@ import {
|
|
|
145
155
|
applyKeysCommand,
|
|
146
156
|
newPianoTrack,
|
|
147
157
|
parseKeysCommand,
|
|
158
|
+
parseRecordCommand,
|
|
148
159
|
} from "./commands/keys.ts";
|
|
149
160
|
import {
|
|
150
161
|
applyTuningCommand,
|
|
@@ -169,6 +180,12 @@ import {
|
|
|
169
180
|
loopSpan,
|
|
170
181
|
parseSectionCommand,
|
|
171
182
|
} from "./commands/arrange.ts";
|
|
183
|
+
import {
|
|
184
|
+
applyRangeCommand,
|
|
185
|
+
parseRangeCommand,
|
|
186
|
+
type RangeClipboard,
|
|
187
|
+
} from "./commands/range.ts";
|
|
188
|
+
import { rangeLabel } from "../core/range.ts";
|
|
172
189
|
import {
|
|
173
190
|
helpMiss,
|
|
174
191
|
helpText,
|
|
@@ -181,6 +198,7 @@ import {
|
|
|
181
198
|
import {
|
|
182
199
|
historyReceipt,
|
|
183
200
|
historyTarget,
|
|
201
|
+
paneHistoryStep,
|
|
184
202
|
REDO_KIND,
|
|
185
203
|
UNDO_KIND,
|
|
186
204
|
} from "./commands/history.ts";
|
|
@@ -291,7 +309,12 @@ import {
|
|
|
291
309
|
tuiSetModel,
|
|
292
310
|
} from "./auth/tui.ts";
|
|
293
311
|
import { TransportClock, transportMapFor } from "./audio/clock.ts";
|
|
294
|
-
import {
|
|
312
|
+
import {
|
|
313
|
+
AudioEngine,
|
|
314
|
+
detectAudioBackend,
|
|
315
|
+
type AudioBackendInfo,
|
|
316
|
+
} from "./audio/engine.ts";
|
|
317
|
+
import { audioMenuState, runAudioCommand } from "./audio/audio-command.ts";
|
|
295
318
|
import {
|
|
296
319
|
DEFAULT_GRID,
|
|
297
320
|
GRIDS,
|
|
@@ -303,9 +326,32 @@ import {
|
|
|
303
326
|
EditMenu,
|
|
304
327
|
menuUsage,
|
|
305
328
|
menuSectionPath,
|
|
329
|
+
type MenuAudioDevices,
|
|
306
330
|
type MenuContext,
|
|
307
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";
|
|
308
353
|
import {
|
|
354
|
+
drawerAside,
|
|
309
355
|
drawerView,
|
|
310
356
|
faderChoose,
|
|
311
357
|
faderCommand,
|
|
@@ -340,7 +386,13 @@ import {
|
|
|
340
386
|
type KeySection,
|
|
341
387
|
} from "../tui/grammar.ts";
|
|
342
388
|
import { renderAudition } from "./audio/audition.ts";
|
|
343
|
-
import {
|
|
389
|
+
import {
|
|
390
|
+
exportScore,
|
|
391
|
+
loopedSection,
|
|
392
|
+
playbackTime,
|
|
393
|
+
scoreBeatAt,
|
|
394
|
+
} from "./audio/arrange.ts";
|
|
395
|
+
import { loopFlash, reelGlyph } from "../tui/delight.ts";
|
|
344
396
|
import { formatForm } from "../core/sections.ts";
|
|
345
397
|
import type { ArrangeStripView } from "../tui/arrange-strip.ts";
|
|
346
398
|
import { renderScorePcm } from "./audio/wav.ts";
|
|
@@ -381,6 +433,7 @@ import {
|
|
|
381
433
|
packCredits,
|
|
382
434
|
writeCredits,
|
|
383
435
|
} from "./audio/packs.ts";
|
|
436
|
+
import { SharedLiveEngine, type LiveLink } from "./session/shared-live.ts";
|
|
384
437
|
import {
|
|
385
438
|
addNote,
|
|
386
439
|
applyScoreOperation,
|
|
@@ -413,12 +466,15 @@ import {
|
|
|
413
466
|
import {
|
|
414
467
|
CARD_GLOW_MS,
|
|
415
468
|
composeFrame,
|
|
469
|
+
isTooSmall,
|
|
470
|
+
tooSmallKey,
|
|
416
471
|
TuiApp,
|
|
417
472
|
type AppView,
|
|
418
473
|
type LoudnessView,
|
|
419
474
|
type SyncState,
|
|
420
475
|
type TypesIndicator,
|
|
421
476
|
} from "../tui/app.ts";
|
|
477
|
+
import type { DrawerView } from "../tui/drawer.ts";
|
|
422
478
|
import { fail, note, ok, toneOf, warn, type Receipt } from "../tui/activity.ts";
|
|
423
479
|
import { systemRunner } from "./auth/runner.ts";
|
|
424
480
|
import type { MediaServices } from "./media/types.ts";
|
|
@@ -428,6 +484,8 @@ import { formatDiagnostic } from "../core/sdk/eval.ts";
|
|
|
428
484
|
import { isProject } from "./project/init.ts";
|
|
429
485
|
import {
|
|
430
486
|
parseLaunchArgs,
|
|
487
|
+
parsePaneArgs,
|
|
488
|
+
type PaneArgs,
|
|
431
489
|
parseSimpleArgv,
|
|
432
490
|
resolveTrackArg,
|
|
433
491
|
} from "./launch-args.ts";
|
|
@@ -685,8 +743,11 @@ const session = await ensureSession(initial.toJSON(), {
|
|
|
685
743
|
...sessionOptions,
|
|
686
744
|
...(ephemeralWorkspace ? { workspace: ephemeralWorkspace } : {}),
|
|
687
745
|
});
|
|
746
|
+
// Who is at this window; also salts collision-free ids (core/ids.ts).
|
|
747
|
+
const actor = await currentActor();
|
|
688
748
|
// dawgd when connected, the file-lock path otherwise (see src/session/port.ts).
|
|
689
749
|
let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
|
|
750
|
+
actor,
|
|
690
751
|
paths: session.paths,
|
|
691
752
|
sessionId: session.record.sessionId,
|
|
692
753
|
label: explicitTrack ?? "window",
|
|
@@ -731,6 +792,8 @@ if (!draftTrack) await ensureFocusedTrack();
|
|
|
731
792
|
|
|
732
793
|
const clock = new TransportClock(score.tempoBpm);
|
|
733
794
|
let audio = port.player;
|
|
795
|
+
let audioInfoCache: AudioBackendInfo | undefined;
|
|
796
|
+
let audioMenuCache: { at: number; state: MenuAudioDevices } | undefined;
|
|
734
797
|
/** Session and today's spend, from the usage each response reports. */
|
|
735
798
|
const meter = new SpendMeter(configDir());
|
|
736
799
|
void meter.load().catch(() => undefined);
|
|
@@ -748,6 +811,16 @@ const tui = new TuiApp({
|
|
|
748
811
|
});
|
|
749
812
|
let syncState: SyncState = port.sync;
|
|
750
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;
|
|
751
824
|
/** Project file sync when `dawg.json` is in the working directory. */
|
|
752
825
|
let projectSync: ProjectSync | undefined;
|
|
753
826
|
let packStore: PackStore | undefined;
|
|
@@ -792,14 +865,47 @@ const AGENT_PREVIEW_VOICE = 0x7fff_0002;
|
|
|
792
865
|
const CHORDS_COMMAND = /^\/chords\s+(.+)$/i;
|
|
793
866
|
/** Whether the chord settings screen was open at the last menu redraw. */
|
|
794
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 } };
|
|
795
893
|
let stageCapture: { next?: TrackScore; committed?: TrackScore } | undefined;
|
|
796
894
|
/** Redraw soon (the audition reports renders between frames). */
|
|
797
895
|
let requestFrame: () => void = () => undefined;
|
|
798
896
|
/** Queue a prompt to run as if typed (set by the interactive loop). */
|
|
799
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;
|
|
800
900
|
/** The fader drawer's focus and typing, while one is open over the menu. */
|
|
801
901
|
let fader: FaderState | undefined;
|
|
802
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
|
+
|
|
803
909
|
const showMe: {
|
|
804
910
|
level: ShowMeLevel;
|
|
805
911
|
ghost?: string | undefined;
|
|
@@ -1004,6 +1110,7 @@ function snapshot(
|
|
|
1004
1110
|
: value.tempoBpm,
|
|
1005
1111
|
key: value.key ?? undefined,
|
|
1006
1112
|
loopBeats: loopTicksOf(value) / value.ticksPerBeat,
|
|
1113
|
+
...(value.loop ? { loopRange: value.loop } : {}),
|
|
1007
1114
|
beatsPerBar: value.beatsPerBar,
|
|
1008
1115
|
...(hasMeterChanges(value)
|
|
1009
1116
|
? {
|
|
@@ -1074,12 +1181,16 @@ function appView(value: TrackScore, beat: number): AppView {
|
|
|
1074
1181
|
showMe:
|
|
1075
1182
|
showMe.ghost || showMe.caption
|
|
1076
1183
|
? { ghost: showMe.ghost, caption: showMe.caption }
|
|
1077
|
-
:
|
|
1184
|
+
: gestureEcho.text
|
|
1185
|
+
? { caption: gestureEcho.text }
|
|
1186
|
+
: undefined,
|
|
1078
1187
|
sync: syncState,
|
|
1079
1188
|
sessionName: record.meta.name,
|
|
1080
1189
|
windows: windowCount,
|
|
1190
|
+
pane: port.pane,
|
|
1081
1191
|
types: typesIndicator,
|
|
1082
1192
|
play: play?.on ? play.header() : undefined,
|
|
1193
|
+
tape: tape.on && !play?.on ? currentTapeView(value, beat) : undefined,
|
|
1083
1194
|
loudness: masterLoudness(value),
|
|
1084
1195
|
};
|
|
1085
1196
|
}
|
|
@@ -1403,6 +1514,9 @@ async function runInteractive(): Promise<void> {
|
|
|
1403
1514
|
// Builds a frame only when something can have changed (tui/frame-gate.ts):
|
|
1404
1515
|
// an idle editor no longer rebuilds the whole view 30 times a second.
|
|
1405
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;
|
|
1406
1520
|
const unwatchActivity = tui.activity.subscribe(() => frameGate.markDirty());
|
|
1407
1521
|
const animating = (): boolean => {
|
|
1408
1522
|
if (clock.playing || play?.on || auditionLoop?.looping) return true;
|
|
@@ -1418,6 +1532,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1418
1532
|
};
|
|
1419
1533
|
const tick = (force = false) => {
|
|
1420
1534
|
if (screenSuspended) return;
|
|
1535
|
+
reportView();
|
|
1421
1536
|
if (
|
|
1422
1537
|
!frameGate.shouldBuild({
|
|
1423
1538
|
nowMs: Date.now(),
|
|
@@ -1430,6 +1545,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1430
1545
|
record.meta.name,
|
|
1431
1546
|
syncState,
|
|
1432
1547
|
windowCount,
|
|
1548
|
+
panePresence,
|
|
1433
1549
|
typesIndicator,
|
|
1434
1550
|
menu.open,
|
|
1435
1551
|
euclid.open,
|
|
@@ -1448,7 +1564,12 @@ async function runInteractive(): Promise<void> {
|
|
|
1448
1564
|
// The gate already paces frames; the app's own throttle would drop an
|
|
1449
1565
|
// approved frame that lands just after a forced one, and the gate would
|
|
1450
1566
|
// not ask again until the heartbeat.
|
|
1451
|
-
tui.render(appView(score, clock.beatAt()), { force: true });
|
|
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
|
+
);
|
|
1452
1573
|
};
|
|
1453
1574
|
requestFrame = () => tick(true);
|
|
1454
1575
|
// The first wrap is remembered per song in .dawg metadata, never the score.
|
|
@@ -1462,6 +1583,10 @@ async function runInteractive(): Promise<void> {
|
|
|
1462
1583
|
queuedPrompts.unshift(command);
|
|
1463
1584
|
void drainQueue();
|
|
1464
1585
|
};
|
|
1586
|
+
runPromptsLater = (commands) => {
|
|
1587
|
+
queuedPrompts.unshift(...commands);
|
|
1588
|
+
void drainQueue();
|
|
1589
|
+
};
|
|
1465
1590
|
reportAgentActivity = () => {
|
|
1466
1591
|
tui.activity.applyAgentEvent({ type: "start", model: providerName });
|
|
1467
1592
|
};
|
|
@@ -1488,10 +1613,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1488
1613
|
};
|
|
1489
1614
|
stdout.on("resize", onResize);
|
|
1490
1615
|
const ownWrites = new OwnWrites();
|
|
1491
|
-
let presenceClients: readonly
|
|
1492
|
-
clientId: string;
|
|
1493
|
-
focusedTrackId?: string | null;
|
|
1494
|
-
}[] = [];
|
|
1616
|
+
let presenceClients: readonly PresenceEntry[] = [];
|
|
1495
1617
|
let applying: Promise<void> = Promise.resolve();
|
|
1496
1618
|
const applyLatest = (latest: typeof record): Promise<void> =>
|
|
1497
1619
|
(applying = applying.then(() => applyRecord(latest)));
|
|
@@ -1544,10 +1666,33 @@ async function runInteractive(): Promise<void> {
|
|
|
1544
1666
|
const from = shownRevision(record, previousRevision);
|
|
1545
1667
|
const to = shownRevision(record);
|
|
1546
1668
|
await ownWrites.settled();
|
|
1547
|
-
const
|
|
1548
|
-
|
|
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,
|
|
1549
1682
|
);
|
|
1550
|
-
if (to !== from && foreign)
|
|
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)
|
|
1551
1696
|
tui.activity.pushCard(
|
|
1552
1697
|
`synced · ${otherWindowName(presenceClients, port.clientId, {
|
|
1553
1698
|
editedTrackId: editedTrack(
|
|
@@ -1571,6 +1716,8 @@ async function runInteractive(): Promise<void> {
|
|
|
1571
1716
|
} else if (update.type === "meta") adoptMeta(update.meta);
|
|
1572
1717
|
else if (update.type === "presence") {
|
|
1573
1718
|
presenceClients = update.clients;
|
|
1719
|
+
followPresence(panePresence, update.clients);
|
|
1720
|
+
panePresence = update.clients;
|
|
1574
1721
|
windowCount = update.clients.length;
|
|
1575
1722
|
} else if (update.type === "sync") syncState = update.sync;
|
|
1576
1723
|
else if (update.type === "transport") {
|
|
@@ -1603,6 +1750,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1603
1750
|
.presence()
|
|
1604
1751
|
.then((clients) => {
|
|
1605
1752
|
presenceClients = clients;
|
|
1753
|
+
panePresence = clients;
|
|
1606
1754
|
windowCount = Math.max(1, clients.length);
|
|
1607
1755
|
})
|
|
1608
1756
|
.catch(() => undefined);
|
|
@@ -1616,6 +1764,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1616
1764
|
syncState = port.sync;
|
|
1617
1765
|
unsubscribe = subscribeLive();
|
|
1618
1766
|
refreshPresence();
|
|
1767
|
+
reportedView = "";
|
|
1619
1768
|
};
|
|
1620
1769
|
if (freshWorkspace)
|
|
1621
1770
|
tui.activity.pushCard("created .dawg/ · add it to .gitignore", {
|
|
@@ -1638,6 +1787,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1638
1787
|
void currentProvider().then(() => tick(true));
|
|
1639
1788
|
tickUi = () => tick(true);
|
|
1640
1789
|
reportSampleProblems(score);
|
|
1790
|
+
if (launchArgs?.pane) void openPane(launchArgs.pane).then(() => tick(true));
|
|
1641
1791
|
// Input arrives through a detachable listener (not `for await`), so /login
|
|
1642
1792
|
// can hand the terminal to an interactive shell flow and take it back.
|
|
1643
1793
|
const inbox: string[] = [];
|
|
@@ -1718,6 +1868,22 @@ async function runInteractive(): Promise<void> {
|
|
|
1718
1868
|
];
|
|
1719
1869
|
while (values.length) {
|
|
1720
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
|
+
}
|
|
1721
1887
|
// A mouse report acts on what the last frame painted under it; some
|
|
1722
1888
|
// become keys (a list row, a wheel notch) and run through below.
|
|
1723
1889
|
if (typeof value === "string" && isMouseSequence(value)) {
|
|
@@ -1728,6 +1894,14 @@ async function runInteractive(): Promise<void> {
|
|
|
1728
1894
|
}
|
|
1729
1895
|
// The fader drawer owns every key while it is up.
|
|
1730
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
|
+
}
|
|
1731
1905
|
const result = faderKeyPress(
|
|
1732
1906
|
fader,
|
|
1733
1907
|
menu.faderFields(menuContext()),
|
|
@@ -1803,7 +1977,10 @@ async function runInteractive(): Promise<void> {
|
|
|
1803
1977
|
}
|
|
1804
1978
|
} else if (result.type === "audition")
|
|
1805
1979
|
auditionKeyPressed(result.key);
|
|
1806
|
-
else if (result.type === "fader")
|
|
1980
|
+
else if (result.type === "fader")
|
|
1981
|
+
openFader(
|
|
1982
|
+
menu.openKnobs(menuContext(), result.label) ?? result.label,
|
|
1983
|
+
);
|
|
1807
1984
|
else if (result.type === "revert") revertStaged();
|
|
1808
1985
|
else if (result.type === "hover")
|
|
1809
1986
|
hoverItem(result.command, result.key);
|
|
@@ -1852,6 +2029,20 @@ async function runInteractive(): Promise<void> {
|
|
|
1852
2029
|
tick(true);
|
|
1853
2030
|
continue;
|
|
1854
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
|
+
}
|
|
1855
2046
|
// Ctrl-P enters play mode. A bare `p` would steal the first letter of
|
|
1856
2047
|
// `pan`, `pattern`, `play` and every prose request starting with p.
|
|
1857
2048
|
if (value === "\u0010" && tui.ui.overlay === undefined) {
|
|
@@ -2074,7 +2265,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2074
2265
|
return {
|
|
2075
2266
|
label: track.id,
|
|
2076
2267
|
value: `/track ${track.id}`,
|
|
2077
|
-
detail: `${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}`,
|
|
2268
|
+
detail: `${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}${paneMarks(track.id)}`,
|
|
2078
2269
|
current: track.id === requestedTrack,
|
|
2079
2270
|
};
|
|
2080
2271
|
});
|
|
@@ -2116,6 +2307,13 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2116
2307
|
`${lines.length} note${lines.length === 1 ? "" : "s"} · ${requestedTrack} · remove <id>`,
|
|
2117
2308
|
);
|
|
2118
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();
|
|
2316
|
+
}
|
|
2119
2317
|
const playCommand = command.match(
|
|
2120
2318
|
/^\/play(?:\s+(on|off|degrees|in-key|chromatic))?$/i,
|
|
2121
2319
|
);
|
|
@@ -2147,6 +2345,23 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2147
2345
|
);
|
|
2148
2346
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
2149
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
|
+
}
|
|
2150
2365
|
const showMeCommand = command.match(/^\/show-?me(?:\s+(\S+))?$/i);
|
|
2151
2366
|
if (showMeCommand) {
|
|
2152
2367
|
if (!showMeCommand[1])
|
|
@@ -2188,8 +2403,11 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2188
2403
|
)
|
|
2189
2404
|
) {
|
|
2190
2405
|
const context = menuContext();
|
|
2191
|
-
const
|
|
2406
|
+
const opened =
|
|
2192
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);
|
|
2193
2411
|
if (label) {
|
|
2194
2412
|
const field = menu
|
|
2195
2413
|
.faderFields(context)
|
|
@@ -2204,10 +2422,37 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2204
2422
|
openFader(label, true);
|
|
2205
2423
|
refreshMenu();
|
|
2206
2424
|
return ok(
|
|
2207
|
-
`${label}${value ? ` · ${value}` : ""} ·
|
|
2425
|
+
`${label}${value ? ` · ${value}` : ""} · ${
|
|
2426
|
+
menu.knobPage === "knobs"
|
|
2427
|
+
? "↑↓ knob · ←→ turn · tab all"
|
|
2428
|
+
: "←→ adjust · enter keep · esc revert"
|
|
2429
|
+
}`,
|
|
2208
2430
|
);
|
|
2209
2431
|
}
|
|
2210
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
|
+
}
|
|
2211
2456
|
const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
|
|
2212
2457
|
if (menuCommand) {
|
|
2213
2458
|
const section = menuCommand[1]?.toLowerCase();
|
|
@@ -2228,12 +2473,18 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2228
2473
|
? ok(message)
|
|
2229
2474
|
: fail("usage: /grid 1/4|1/8|1/8T|1/16|1/16T|1/32");
|
|
2230
2475
|
}
|
|
2476
|
+
const paneReceipt = paneCommand(command);
|
|
2477
|
+
if (paneReceipt) return paneReceipt;
|
|
2231
2478
|
if (/^\/status$/i.test(command))
|
|
2232
2479
|
return ok(
|
|
2233
2480
|
`status · ${record.meta.name} · rev ${record.revision} · ${compositionDigest(record.composition)} · ${port.mode === "daemon" ? "shared via dawgd" : "saved locally · no daemon"}`,
|
|
2234
2481
|
);
|
|
2235
|
-
|
|
2236
|
-
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
|
+
);
|
|
2237
2488
|
const trackCommand = command.match(
|
|
2238
2489
|
/^\/?(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/i,
|
|
2239
2490
|
);
|
|
@@ -2466,7 +2717,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2466
2717
|
score,
|
|
2467
2718
|
requestedTrack,
|
|
2468
2719
|
progression,
|
|
2469
|
-
() =>
|
|
2720
|
+
() => newId("n"),
|
|
2470
2721
|
);
|
|
2471
2722
|
if (result.next && result.kind)
|
|
2472
2723
|
await commitScore(result.next, result.kind, result.payload);
|
|
@@ -2476,7 +2727,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2476
2727
|
if (strum) {
|
|
2477
2728
|
if (strum.type === "strum") await materializeDraft();
|
|
2478
2729
|
const result = applyStrumCommand(score, requestedTrack, strum, () =>
|
|
2479
|
-
|
|
2730
|
+
newId("n"),
|
|
2480
2731
|
);
|
|
2481
2732
|
if (result.next && result.kind)
|
|
2482
2733
|
await commitScore(result.next, result.kind, result.payload);
|
|
@@ -2541,6 +2792,8 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2541
2792
|
await commitScore(result.next, result.kind, result.payload);
|
|
2542
2793
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
2543
2794
|
}
|
|
2795
|
+
const recordCommand = parseRecordCommand(command);
|
|
2796
|
+
if (recordCommand) return keysRecord(recordCommand);
|
|
2544
2797
|
const keysCommand = parseKeysCommand(command);
|
|
2545
2798
|
if (keysCommand) {
|
|
2546
2799
|
const reads =
|
|
@@ -2609,6 +2862,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2609
2862
|
const result = applySynthCommand(score, requestedTrack, synth);
|
|
2610
2863
|
if (result.next && result.kind)
|
|
2611
2864
|
await commitScore(result.next, result.kind, result.payload);
|
|
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
|
+
}
|
|
2612
2875
|
return readOrDone(result);
|
|
2613
2876
|
}
|
|
2614
2877
|
const stringCommand = parseStringCommand(command);
|
|
@@ -2692,30 +2955,79 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2692
2955
|
await commitScore(result.next, result.kind, result.payload);
|
|
2693
2956
|
return readOrDone(result);
|
|
2694
2957
|
}
|
|
2695
|
-
//
|
|
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.
|
|
2696
3007
|
const loop = parseLoopCommand(command);
|
|
2697
3008
|
if (loop) {
|
|
2698
3009
|
if (loop.type === "loop-show")
|
|
2699
3010
|
return note(
|
|
2700
|
-
score.
|
|
2701
|
-
? `loop · ${score.
|
|
2702
|
-
:
|
|
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}`,
|
|
2703
3016
|
);
|
|
2704
|
-
if (loop.type === "loop-off")
|
|
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
|
+
}
|
|
2705
3025
|
if (loop.type === "loop-section")
|
|
2706
3026
|
return submit(`section loop ${loop.name}`);
|
|
2707
|
-
const
|
|
2708
|
-
(section) =>
|
|
2709
|
-
section.startBar + 1 === loop.from &&
|
|
2710
|
-
section.startBar + section.bars === loop.to,
|
|
2711
|
-
);
|
|
2712
|
-
if (span) return submit(`section loop ${span.name}`);
|
|
2713
|
-
// No section spans the bars: mark (or move) the section named `loop`
|
|
2714
|
-
// and loop it, as one revision.
|
|
2715
|
-
await materializeDraft();
|
|
2716
|
-
const result = loopSpan(score, requestedTrack, loop.from, loop.to);
|
|
3027
|
+
const result = loopSpan(score, loop.from, loop.to);
|
|
2717
3028
|
if (!result.ok) return fail(result.message);
|
|
2718
|
-
await
|
|
3029
|
+
await materializeDraft();
|
|
3030
|
+
await commitScore(result.next, "score.loop", { loop: result.next.loop });
|
|
2719
3031
|
return ok(result.message);
|
|
2720
3032
|
}
|
|
2721
3033
|
const arrange = parseSectionCommand(command, score);
|
|
@@ -2736,7 +3048,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2736
3048
|
if (music) {
|
|
2737
3049
|
await materializeDraft();
|
|
2738
3050
|
const result = applyMusicCommand(score, requestedTrack, music, () =>
|
|
2739
|
-
|
|
3051
|
+
newId("n"),
|
|
2740
3052
|
);
|
|
2741
3053
|
if (result.next && result.kind)
|
|
2742
3054
|
await commitScore(result.next, result.kind, result.payload);
|
|
@@ -2958,6 +3270,22 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
2958
3270
|
? vocalChainPatch(score.tracks.find((t) => t.id === requestedTrack))
|
|
2959
3271
|
: {};
|
|
2960
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
|
+
}
|
|
2961
3289
|
const next = applyScoreOperation(score, {
|
|
2962
3290
|
type: "updateTrack",
|
|
2963
3291
|
trackId: requestedTrack,
|
|
@@ -3079,7 +3407,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
3079
3407
|
const latest = attempt === 0 ? record : await port.load();
|
|
3080
3408
|
const latestScore = scoreFromJSON(latest.composition);
|
|
3081
3409
|
const note = {
|
|
3082
|
-
id:
|
|
3410
|
+
id: newId(requestedTrack),
|
|
3083
3411
|
trackId: requestedTrack,
|
|
3084
3412
|
startTick: Math.round(parsed.start * latestScore.ticksPerBeat),
|
|
3085
3413
|
durationTicks: Math.max(
|
|
@@ -3116,17 +3444,26 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
3116
3444
|
* not in the score yet. A track another live window has focused stays
|
|
3117
3445
|
* theirs; focus goes through the port so presence is right everywhere.
|
|
3118
3446
|
*/
|
|
3119
|
-
async function focusTrack(
|
|
3447
|
+
async function focusTrack(
|
|
3448
|
+
trackId: string,
|
|
3449
|
+
options: { follow?: boolean } = {},
|
|
3450
|
+
): Promise<Receipt> {
|
|
3120
3451
|
if (trackId === requestedTrack && !draftTrack)
|
|
3121
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.
|
|
3122
3459
|
const clients = await port.presence().catch(() => []);
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
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
|
+
: "";
|
|
3130
3467
|
const existing = score.tracks.find((track) => track.id === trackId);
|
|
3131
3468
|
const exists = existing !== undefined;
|
|
3132
3469
|
// `track cloud`, `track hold-2`: a new granular track with that preset.
|
|
@@ -3165,11 +3502,11 @@ async function focusTrack(trackId: string): Promise<Receipt> {
|
|
|
3165
3502
|
`track · ${trackId} is a ${existing.instrument} track · grain ${grainPreset} makes it granular`,
|
|
3166
3503
|
);
|
|
3167
3504
|
return ok(
|
|
3168
|
-
exists
|
|
3505
|
+
(exists
|
|
3169
3506
|
? `track · ${trackId}`
|
|
3170
3507
|
: grainPreset
|
|
3171
3508
|
? `track created · ${trackId} · granular ${grainPreset} · nothing to download`
|
|
3172
|
-
: `track created · ${trackId}
|
|
3509
|
+
: `track created · ${trackId}`) + shared,
|
|
3173
3510
|
);
|
|
3174
3511
|
}
|
|
3175
3512
|
|
|
@@ -3892,6 +4229,7 @@ async function switchSession(sessionId: string): Promise<void> {
|
|
|
3892
4229
|
setCurrent: true,
|
|
3893
4230
|
});
|
|
3894
4231
|
port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
|
|
4232
|
+
actor,
|
|
3895
4233
|
paths: next.paths,
|
|
3896
4234
|
sessionId,
|
|
3897
4235
|
label: "window",
|
|
@@ -3918,13 +4256,269 @@ function liveEngine(): LiveEngine | undefined {
|
|
|
3918
4256
|
return previewEngine();
|
|
3919
4257
|
}
|
|
3920
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
|
+
|
|
3921
4493
|
/** The engine this window hears itself through (its own in daemon mode). */
|
|
3922
4494
|
function previewEngine(): AudioEngine {
|
|
3923
4495
|
if (audio instanceof AudioEngine) return audio;
|
|
3924
|
-
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
|
+
});
|
|
3925
4503
|
return monitorEngine;
|
|
3926
4504
|
}
|
|
3927
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
|
+
|
|
3928
4522
|
/** The play session for the focused track (re-made when focus moves). */
|
|
3929
4523
|
function menuContext(): MenuContext {
|
|
3930
4524
|
const session = play;
|
|
@@ -3949,6 +4543,7 @@ function menuContext(): MenuContext {
|
|
|
3949
4543
|
clickOn: session?.clickOn ?? false,
|
|
3950
4544
|
countInBars: session?.countInBars ?? 1,
|
|
3951
4545
|
showMe: showMe.level,
|
|
4546
|
+
audio: audioMenu(),
|
|
3952
4547
|
chords: stagedChordSettings() ?? session?.chords.settings ?? chordSettings,
|
|
3953
4548
|
projectRoot: process.cwd(),
|
|
3954
4549
|
};
|
|
@@ -3973,6 +4568,7 @@ function keysScreen(): readonly KeySection[] | undefined {
|
|
|
3973
4568
|
if (prompt.value.length > 0) return undefined;
|
|
3974
4569
|
if (play?.on)
|
|
3975
4570
|
return play.chords.on ? [...KEYS.play, ...KEYS.chords] : KEYS.play;
|
|
4571
|
+
if (tape.on) return KEYS.tape;
|
|
3976
4572
|
return KEYS.prompt;
|
|
3977
4573
|
}
|
|
3978
4574
|
|
|
@@ -4456,14 +5052,20 @@ function refreshMenu(): void {
|
|
|
4456
5052
|
if (fields.length === 0) closeFader();
|
|
4457
5053
|
else {
|
|
4458
5054
|
const focusedField = fields[focusIndex(fader, fields)];
|
|
4459
|
-
|
|
5055
|
+
const drawer = drawerView(fader, fields, menu.faderCommitted(context), {
|
|
4460
5056
|
title: menu.crumbs,
|
|
4461
5057
|
dirty: context.audition?.dirty ?? false,
|
|
4462
5058
|
status: context.audition?.status,
|
|
4463
5059
|
atOnce:
|
|
4464
5060
|
focusedField !== undefined &&
|
|
4465
5061
|
!stageableNow(faderCommand(focusedField)),
|
|
5062
|
+
knobs: menu.knobPage,
|
|
4466
5063
|
});
|
|
5064
|
+
tui.drawer = withPaneMarks(
|
|
5065
|
+
menu.knobPage === "knobs"
|
|
5066
|
+
? { ...drawer, aside: drawerAside(menu.pageFields(context), fields) }
|
|
5067
|
+
: drawer,
|
|
5068
|
+
);
|
|
4467
5069
|
}
|
|
4468
5070
|
}
|
|
4469
5071
|
const view = menu.view(context);
|
|
@@ -4531,6 +5133,50 @@ async function exportWav(path: string, stems: boolean): Promise<Receipt> {
|
|
|
4531
5133
|
}
|
|
4532
5134
|
}
|
|
4533
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
|
+
|
|
4534
5180
|
/** `tempo`, `bars`, `meter`: open Project and name its fader, if it has one. */
|
|
4535
5181
|
function songFader(context: MenuContext, command: string): string | undefined {
|
|
4536
5182
|
// Bare song scalars and the Project fader each opens.
|
|
@@ -4628,6 +5274,10 @@ function mouseInput(event: MouseEvent): string[] {
|
|
|
4628
5274
|
const frame = tui.frame;
|
|
4629
5275
|
if (!frame) return [];
|
|
4630
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
|
+
}
|
|
4631
5281
|
if (event.kind === "wheel") {
|
|
4632
5282
|
const fields = fader && menu.open ? menu.faderFields(menuContext()) : [];
|
|
4633
5283
|
if (
|
|
@@ -4784,12 +5434,325 @@ async function enterPlay(): Promise<Receipt> {
|
|
|
4784
5434
|
);
|
|
4785
5435
|
}
|
|
4786
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
|
+
|
|
4787
5455
|
async function exitPlay(): Promise<Receipt> {
|
|
4788
5456
|
if (!play?.on) return ok("play mode is off");
|
|
4789
5457
|
await play.exit();
|
|
4790
5458
|
return ok("play off");
|
|
4791
5459
|
}
|
|
4792
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
|
+
|
|
4793
5756
|
/**
|
|
4794
5757
|
* One key in play mode. True when play mode consumed it; false sends it on
|
|
4795
5758
|
* to the normal bindings (overlays, a command being typed, Ctrl-C, arrows).
|
|
@@ -4828,8 +5791,11 @@ function playHost() {
|
|
|
4828
5791
|
playing: () => clock.playing,
|
|
4829
5792
|
beatAt: (ms: number) => clock.beatAt(ms),
|
|
4830
5793
|
scoreBeat: (beat: number) => scoreBeatAt(score, beat),
|
|
4831
|
-
engine:
|
|
5794
|
+
engine: playEngine,
|
|
4832
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.
|
|
4833
5799
|
async commit(
|
|
4834
5800
|
next: TrackScore,
|
|
4835
5801
|
kind: string,
|
|
@@ -4839,15 +5805,7 @@ function playHost() {
|
|
|
4839
5805
|
let target = next;
|
|
4840
5806
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
4841
5807
|
try {
|
|
4842
|
-
|
|
4843
|
-
record,
|
|
4844
|
-
{ kind, payload },
|
|
4845
|
-
operations,
|
|
4846
|
-
target.toJSON(),
|
|
4847
|
-
);
|
|
4848
|
-
score = scoreFromJSON(record.composition);
|
|
4849
|
-
if (clock.playing) void audio.play(score);
|
|
4850
|
-
projectSync?.scoreChanged(score);
|
|
5808
|
+
await commitScore(target, kind, payload);
|
|
4851
5809
|
return;
|
|
4852
5810
|
} catch (error) {
|
|
4853
5811
|
if (!(error instanceof SessionConflictError)) throw error;
|
|
@@ -4861,6 +5819,7 @@ function playHost() {
|
|
|
4861
5819
|
}
|
|
4862
5820
|
throw new Error("session busy");
|
|
4863
5821
|
},
|
|
5822
|
+
recordingElsewhere: (trackId: string) => recordingPane(trackId),
|
|
4864
5823
|
async startTransport(beat: number): Promise<void> {
|
|
4865
5824
|
if (port.mode === "daemon") {
|
|
4866
5825
|
await port.transport("play", { beat });
|
|
@@ -4886,7 +5845,7 @@ function playHost() {
|
|
|
4886
5845
|
tone === "success" ? shownRevision(record) : undefined,
|
|
4887
5846
|
});
|
|
4888
5847
|
},
|
|
4889
|
-
newNoteId: () =>
|
|
5848
|
+
newNoteId: () => newId("n"),
|
|
4890
5849
|
};
|
|
4891
5850
|
}
|
|
4892
5851
|
|
|
@@ -4907,15 +5866,48 @@ async function commitScore(
|
|
|
4907
5866
|
if (retimed) clock.follow(score);
|
|
4908
5867
|
return;
|
|
4909
5868
|
}
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
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);
|
|
4913
5889
|
if (clock.playing) void audio.play(score);
|
|
4914
5890
|
projectSync?.scoreChanged(score);
|
|
4915
5891
|
reportSampleProblems(score);
|
|
4916
5892
|
void updateCredits(score);
|
|
4917
5893
|
}
|
|
4918
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
|
+
|
|
4919
5911
|
/** True when the transport clock must follow `next` (tempo, meter, loop). */
|
|
4920
5912
|
function timingChanged(previous: TrackScore, next: TrackScore): boolean {
|
|
4921
5913
|
return (
|
|
@@ -4968,39 +5960,91 @@ function syncHost(): SyncHost {
|
|
|
4968
5960
|
};
|
|
4969
5961
|
}
|
|
4970
5962
|
|
|
4971
|
-
|
|
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> {
|
|
4972
5975
|
const latest = await port.load();
|
|
4973
5976
|
// A fork's undo continues into its parent's history past the fork point.
|
|
4974
|
-
const
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
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,
|
|
4978
5983
|
);
|
|
4979
|
-
|
|
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
|
+
}
|
|
4980
6021
|
try {
|
|
4981
|
-
const
|
|
4982
|
-
|
|
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(
|
|
4983
6033
|
latest,
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
[direction === "undo" ? "undoneRevision" : "redoneRevision"]:
|
|
4988
|
-
target.revision,
|
|
4989
|
-
},
|
|
4990
|
-
},
|
|
4991
|
-
restored.toJSON(),
|
|
6034
|
+
event,
|
|
6035
|
+
operations,
|
|
6036
|
+
next.toJSON(),
|
|
4992
6037
|
);
|
|
4993
6038
|
// Compare with what was undone, which another window may have written.
|
|
4994
|
-
|
|
4995
|
-
score = restored;
|
|
6039
|
+
score = scoreFromJSON(record.composition);
|
|
4996
6040
|
if (clock.playing) void audio.play(score);
|
|
4997
6041
|
return ok(
|
|
4998
6042
|
historyReceipt(
|
|
4999
6043
|
direction,
|
|
5000
6044
|
before,
|
|
5001
|
-
|
|
5002
|
-
shownRevision(record,
|
|
5003
|
-
),
|
|
6045
|
+
score,
|
|
6046
|
+
shownRevision(record, revision),
|
|
6047
|
+
) + author,
|
|
5004
6048
|
);
|
|
5005
6049
|
} catch (error) {
|
|
5006
6050
|
if (error instanceof SessionConflictError)
|
|
@@ -5011,6 +6055,12 @@ async function stepHistory(direction: "undo" | "redo"): Promise<Receipt> {
|
|
|
5011
6055
|
}
|
|
5012
6056
|
}
|
|
5013
6057
|
|
|
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
|
+
|
|
5014
6064
|
/** Space, the menu and a header click all report a failed toggle the same way. */
|
|
5015
6065
|
function transportFailed(error: unknown): void {
|
|
5016
6066
|
tui.activity.pushCard(
|
|
@@ -5091,6 +6141,12 @@ async function setTransport(
|
|
|
5091
6141
|
}
|
|
5092
6142
|
|
|
5093
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
|
+
|
|
5094
6150
|
async function seekTransport(beat: number): Promise<void> {
|
|
5095
6151
|
if (port.mode === "daemon") {
|
|
5096
6152
|
await port.transport("seek", { beat });
|