@hraness/dawg 0.4.1 → 0.5.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 +70 -0
- package/DAWG.md +293 -31
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +27 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/space.ts +92 -2
- package/src/audio/engine.ts +90 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +74 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +301 -26
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +709 -33
- package/src/render.ts +109 -7
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +635 -14
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +414 -41
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +63 -1
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
package/src/main.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readFileSync, writeSync } from "node:fs";
|
|
3
4
|
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
4
5
|
import { join, relative, resolve } from "node:path";
|
|
5
6
|
import { stdin, stdout } from "node:process";
|
|
@@ -42,8 +43,36 @@ import {
|
|
|
42
43
|
} from "./commands/drums.ts";
|
|
43
44
|
import { kitCatalog } from "./audio/kits.ts";
|
|
44
45
|
import { applyEditCommand, parseEditCommand } from "./commands/edit.ts";
|
|
46
|
+
import { applyTimeCommand, parseTimeCommand } from "./commands/time.ts";
|
|
47
|
+
import {
|
|
48
|
+
barStartTick,
|
|
49
|
+
bpmAtTick,
|
|
50
|
+
hasMeterChanges,
|
|
51
|
+
loopTicksOf,
|
|
52
|
+
} from "../core/tempo.ts";
|
|
53
|
+
import {
|
|
54
|
+
applyExpressionCommand,
|
|
55
|
+
parseExpressionCommand,
|
|
56
|
+
} from "./commands/expression.ts";
|
|
45
57
|
import { applyFxCommand, parseFxCommand } from "./commands/fx.ts";
|
|
46
58
|
import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
|
|
59
|
+
import {
|
|
60
|
+
applyTuningCommand,
|
|
61
|
+
importTuningFile,
|
|
62
|
+
parseTuningCommand,
|
|
63
|
+
type TuningCommand,
|
|
64
|
+
} from "./commands/tuning.ts";
|
|
65
|
+
import { TuningError, displayTag, resolveTuning } from "../core/tuning.ts";
|
|
66
|
+
import {
|
|
67
|
+
applyMasterCommand,
|
|
68
|
+
measurementLine,
|
|
69
|
+
parseMasterCommand,
|
|
70
|
+
} from "./commands/master.ts";
|
|
71
|
+
import { exportSampleRate, measureScoreOffThread } from "./audio/measure.ts";
|
|
72
|
+
import {
|
|
73
|
+
applySectionCommand,
|
|
74
|
+
parseSectionCommand,
|
|
75
|
+
} from "./commands/arrange.ts";
|
|
47
76
|
import {
|
|
48
77
|
HELP_TOPICS,
|
|
49
78
|
helpText,
|
|
@@ -106,7 +135,7 @@ import {
|
|
|
106
135
|
tuiLoginArgs,
|
|
107
136
|
tuiSetModel,
|
|
108
137
|
} from "./auth/tui.ts";
|
|
109
|
-
import { TransportClock } from "./audio/clock.ts";
|
|
138
|
+
import { TransportClock, transportMapFor } from "./audio/clock.ts";
|
|
110
139
|
import { AudioEngine } from "./audio/engine.ts";
|
|
111
140
|
import {
|
|
112
141
|
DEFAULT_GRID,
|
|
@@ -116,6 +145,23 @@ import {
|
|
|
116
145
|
type LiveEngine,
|
|
117
146
|
} from "./tui/play-session.ts";
|
|
118
147
|
import { EditMenu, MENU_SECTIONS, type MenuContext } from "./tui/menu.ts";
|
|
148
|
+
import {
|
|
149
|
+
drawerView,
|
|
150
|
+
faderChoose,
|
|
151
|
+
faderKeyPress,
|
|
152
|
+
faderSetPosition,
|
|
153
|
+
faderStep,
|
|
154
|
+
focusIndex,
|
|
155
|
+
type FaderResult,
|
|
156
|
+
type FaderState,
|
|
157
|
+
} from "./tui/fader.ts";
|
|
158
|
+
import {
|
|
159
|
+
isMouseSequence,
|
|
160
|
+
MOUSE_OFF,
|
|
161
|
+
MOUSE_ON,
|
|
162
|
+
parseMouse,
|
|
163
|
+
type MouseEvent,
|
|
164
|
+
} from "../tui/keys.ts";
|
|
119
165
|
import {
|
|
120
166
|
Audition,
|
|
121
167
|
SUPERSEDED,
|
|
@@ -125,6 +171,9 @@ import {
|
|
|
125
171
|
import { EuclidEditor, type EuclidContext } from "./tui/euclid.ts";
|
|
126
172
|
import { HINTS, KEYS, keyLines, type KeySection } from "../tui/grammar.ts";
|
|
127
173
|
import { renderAudition } from "./audio/audition.ts";
|
|
174
|
+
import { exportScore, playbackTime, scoreBeatAt } from "./audio/arrange.ts";
|
|
175
|
+
import { formatForm } from "../core/sections.ts";
|
|
176
|
+
import type { ArrangeStripView } from "../tui/arrange-strip.ts";
|
|
128
177
|
import { renderScorePcm } from "./audio/wav.ts";
|
|
129
178
|
import type { PreviewHost } from "./agent/preview-tool.ts";
|
|
130
179
|
import { rhythmVoicePitch } from "../core/rhythm.ts";
|
|
@@ -176,6 +225,7 @@ import {
|
|
|
176
225
|
} from "../core/score.ts";
|
|
177
226
|
import { reconcileRhythm } from "../core/rhythm.ts";
|
|
178
227
|
import { decodeLoop, encodeLoop } from "../core/loop.ts";
|
|
228
|
+
import { scoreToMidi } from "../core/midi.ts";
|
|
179
229
|
import type { TrackScoreSnapshot } from "../tui/render.ts";
|
|
180
230
|
import { PromptModel } from "../tui/prompt.ts";
|
|
181
231
|
import { TerminalInputDecoder } from "../tui/input.ts";
|
|
@@ -183,6 +233,7 @@ import {
|
|
|
183
233
|
composeFrame,
|
|
184
234
|
TuiApp,
|
|
185
235
|
type AppView,
|
|
236
|
+
type LoudnessView,
|
|
186
237
|
type SyncState,
|
|
187
238
|
type TypesIndicator,
|
|
188
239
|
} from "../tui/app.ts";
|
|
@@ -209,11 +260,16 @@ function parsesLocally(text: string): boolean {
|
|
|
209
260
|
parseRhythmCommand,
|
|
210
261
|
parseFxCommand,
|
|
211
262
|
parseSynthCommand,
|
|
263
|
+
parseExpressionCommand,
|
|
264
|
+
parseMasterCommand,
|
|
265
|
+
(value: string) => parseSectionCommand(value, score),
|
|
212
266
|
parsePatternCommand,
|
|
213
267
|
parseKitCommand,
|
|
214
268
|
parsePackCommand,
|
|
215
269
|
parseSampleCommand,
|
|
216
270
|
parseWavetableCommand,
|
|
271
|
+
parseTimeCommand,
|
|
272
|
+
parseTuningCommand,
|
|
217
273
|
].some((parse) => parse(text) !== undefined);
|
|
218
274
|
}
|
|
219
275
|
|
|
@@ -240,6 +296,7 @@ const FLAGS = [
|
|
|
240
296
|
"--version",
|
|
241
297
|
"-v",
|
|
242
298
|
"--reduce-motion",
|
|
299
|
+
"--no-mouse",
|
|
243
300
|
];
|
|
244
301
|
const HELP_TEXT = `dawg · local-first terminal music workstation
|
|
245
302
|
|
|
@@ -247,7 +304,8 @@ Usage:
|
|
|
247
304
|
dawg [--new] [--session <name|id>] [--track <name>]
|
|
248
305
|
dawg --import <file> --export <file>
|
|
249
306
|
dawg sessions
|
|
250
|
-
dawg render <out.wav> [--session <name|id>] [--import <file>]
|
|
307
|
+
dawg render <out.wav|out.mid> [--session <name|id>] [--import <file>]
|
|
308
|
+
[--normalize <lufs|streaming|club|loud|…>] [--measure]
|
|
251
309
|
dawg init [dir] project files: song.ts, tracks/<slug>/track.ts, .dawg/sdk
|
|
252
310
|
dawg check typecheck + evaluate the project; exit 1 on problems
|
|
253
311
|
dawg media doctor|download|stems|analyze|notes|sample|lyrics … (dawg media --help)
|
|
@@ -256,6 +314,7 @@ Usage:
|
|
|
256
314
|
Usage flags:
|
|
257
315
|
--reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
|
|
258
316
|
--theme <name> default | high-contrast | mono (NO_COLOR forces mono)
|
|
317
|
+
--no-mouse keys only; no click/wheel reporting (also DAWG_MOUSE=0)
|
|
259
318
|
|
|
260
319
|
Prompt:
|
|
261
320
|
Enter submit · Shift-Enter newline · Alt-Enter queue · Ctrl-Q toggle queue
|
|
@@ -486,6 +545,18 @@ let chordScreenWas = false;
|
|
|
486
545
|
let stageCapture: { next?: TrackScore; committed?: TrackScore } | undefined;
|
|
487
546
|
/** Redraw soon (the audition reports renders between frames). */
|
|
488
547
|
let requestFrame: () => void = () => undefined;
|
|
548
|
+
/** Queue a prompt to run as if typed (set by the interactive loop). */
|
|
549
|
+
let runPromptLater: (command: string) => void = () => undefined;
|
|
550
|
+
/** The fader drawer's focus and typing, while one is open over the menu. */
|
|
551
|
+
let fader: FaderState | undefined;
|
|
552
|
+
const KEY_UP = "\u001b[A";
|
|
553
|
+
const KEY_DOWN = "\u001b[B";
|
|
554
|
+
/** The fader bar a left-button drag started on. */
|
|
555
|
+
let dragging: { field: number; left: number; width: number } | undefined;
|
|
556
|
+
/** The open drawer came from a command, not the menu. */
|
|
557
|
+
let faderStandalone = false;
|
|
558
|
+
/** Per-field sequence: a drag drops stale stages that would land late. */
|
|
559
|
+
const faderSeq = new Map<string, number>();
|
|
489
560
|
/** The decoded sampler voices, for play mode's live voices. */
|
|
490
561
|
let liveSampleBank: SampleBank | undefined;
|
|
491
562
|
/** The in-flight agent turn: Esc aborts it, Enter steers it. */
|
|
@@ -645,16 +716,29 @@ function snapshot(
|
|
|
645
716
|
beat: number,
|
|
646
717
|
activity?: string,
|
|
647
718
|
): TrackScoreSnapshot {
|
|
719
|
+
const focused = value.tracks.find((track) => track.id === requestedTrack);
|
|
720
|
+
const table =
|
|
721
|
+
focused && !isDrumInstrument(focused.instrument)
|
|
722
|
+
? resolveTuning(value.tuning, focused.tuning, value.key)
|
|
723
|
+
: undefined;
|
|
648
724
|
const notes = value.notes
|
|
649
725
|
.filter((note) => note.trackId === requestedTrack)
|
|
650
|
-
.map((note) =>
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
726
|
+
.map((note) => {
|
|
727
|
+
const tag =
|
|
728
|
+
focused && !isDrumInstrument(focused.instrument)
|
|
729
|
+
? displayTag(table, note.pitch, note.cents)
|
|
730
|
+
: undefined;
|
|
731
|
+
return {
|
|
732
|
+
id: note.id,
|
|
733
|
+
startBeat: note.startTick / value.ticksPerBeat,
|
|
734
|
+
durationBeats: note.durationTicks / value.ticksPerBeat,
|
|
735
|
+
pitch: note.pitch,
|
|
736
|
+
velocity: note.velocity,
|
|
737
|
+
muted: focused?.muted,
|
|
738
|
+
...(tag?.cents !== undefined ? { cents: tag.cents } : {}),
|
|
739
|
+
...(tag?.name !== undefined ? { centsFrom: tag.name } : {}),
|
|
740
|
+
};
|
|
741
|
+
});
|
|
658
742
|
return {
|
|
659
743
|
notes,
|
|
660
744
|
trackName:
|
|
@@ -663,12 +747,22 @@ function snapshot(
|
|
|
663
747
|
trackId: requestedTrack,
|
|
664
748
|
sessionId: record.sessionId,
|
|
665
749
|
revision: record.revision,
|
|
666
|
-
bpm: value.
|
|
750
|
+
bpm: value.time?.tempo
|
|
751
|
+
? bpmAtTick(value, beat * value.ticksPerBeat)
|
|
752
|
+
: value.tempoBpm,
|
|
667
753
|
key: value.key ?? undefined,
|
|
668
|
-
loopBeats: value
|
|
754
|
+
loopBeats: loopTicksOf(value) / value.ticksPerBeat,
|
|
669
755
|
beatsPerBar: value.beatsPerBar,
|
|
756
|
+
...(hasMeterChanges(value)
|
|
757
|
+
? {
|
|
758
|
+
barBeats: Array.from(
|
|
759
|
+
{ length: value.bars },
|
|
760
|
+
(_, bar) => barStartTick(value, bar) / value.ticksPerBeat,
|
|
761
|
+
),
|
|
762
|
+
}
|
|
763
|
+
: {}),
|
|
670
764
|
laneCount: 24,
|
|
671
|
-
currentBeat: beat,
|
|
765
|
+
currentBeat: scoreBeatAt(value, beat),
|
|
672
766
|
playing: clock.playing,
|
|
673
767
|
activity,
|
|
674
768
|
...drumSnapshotFields(
|
|
@@ -679,6 +773,9 @@ function snapshot(
|
|
|
679
773
|
value.tracks.find((track) => track.id === requestedTrack),
|
|
680
774
|
notes,
|
|
681
775
|
),
|
|
776
|
+
...(table && table.linear && table.size !== 12
|
|
777
|
+
? { tuningPeriod: { size: table.size, root: table.root } }
|
|
778
|
+
: {}),
|
|
682
779
|
layers:
|
|
683
780
|
tui.highwayView === "all"
|
|
684
781
|
? highwayLayers(
|
|
@@ -712,7 +809,9 @@ function renderOnce(
|
|
|
712
809
|
function appView(value: TrackScore, beat: number): AppView {
|
|
713
810
|
return {
|
|
714
811
|
score: snapshot(value, beat),
|
|
715
|
-
|
|
812
|
+
// The highway shows score time: inside the looped section or form pass.
|
|
813
|
+
beat: scoreBeatAt(value, beat),
|
|
814
|
+
arrange: arrangeStripView(value, beat),
|
|
716
815
|
// `opus-5.5 · gateway`, `sonnet · claude`; hidden when offline.
|
|
717
816
|
model:
|
|
718
817
|
providerName && providerName !== "offline" ? providerName : undefined,
|
|
@@ -723,6 +822,79 @@ function appView(value: TrackScore, beat: number): AppView {
|
|
|
723
822
|
windows: windowCount,
|
|
724
823
|
types: typesIndicator,
|
|
725
824
|
play: play?.on ? play.header() : undefined,
|
|
825
|
+
loudness: masterLoudness(value),
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* The 48 kHz reading of the mastered song (what `master measure` and the
|
|
831
|
+
* export read), for the score it measured; one measurement at a time, in a
|
|
832
|
+
* worker, started when the loop plays a mastered score it has not read.
|
|
833
|
+
*/
|
|
834
|
+
let exportMeter:
|
|
835
|
+
{ score: TrackScore; view?: LoudnessView; running: boolean } | undefined;
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* The header meter: loudness of the loop the local engine is playing. With
|
|
839
|
+
* a master the engine monitors at its own rate, so the meter shows the
|
|
840
|
+
* export-rate reading once it is in and the monitor's (marked as an
|
|
841
|
+
* estimate) until then.
|
|
842
|
+
*/
|
|
843
|
+
function masterLoudness(value: TrackScore): LoudnessView | undefined {
|
|
844
|
+
const engine = audio instanceof AudioEngine ? audio : monitorEngine;
|
|
845
|
+
const report = engine?.loudness;
|
|
846
|
+
if (!report) return undefined;
|
|
847
|
+
const ceiling =
|
|
848
|
+
typeof value.master?.limiter?.ceiling === "number"
|
|
849
|
+
? value.master.limiter.ceiling
|
|
850
|
+
: value.master?.limiter
|
|
851
|
+
? -1
|
|
852
|
+
: undefined;
|
|
853
|
+
const target =
|
|
854
|
+
typeof value.master?.target === "number" ? value.master.target : undefined;
|
|
855
|
+
const monitor: LoudnessView = {
|
|
856
|
+
integrated: report.integrated,
|
|
857
|
+
truePeak: report.truePeak,
|
|
858
|
+
target,
|
|
859
|
+
ceiling,
|
|
860
|
+
};
|
|
861
|
+
if (!value.master || exportSampleRate(value) === engine.sampleRate)
|
|
862
|
+
return monitor;
|
|
863
|
+
if (exportMeter?.score === value && exportMeter.view) return exportMeter.view;
|
|
864
|
+
if (!exportMeter?.running) {
|
|
865
|
+
const measuring = { score: value, running: true } as NonNullable<
|
|
866
|
+
typeof exportMeter
|
|
867
|
+
>;
|
|
868
|
+
exportMeter = measuring;
|
|
869
|
+
void measureScoreOffThread(value, { projectRoot: process.cwd() })
|
|
870
|
+
.then((measured) => {
|
|
871
|
+
measuring.view = {
|
|
872
|
+
integrated: measured.mix.loudness.integrated,
|
|
873
|
+
truePeak: measured.mix.loudness.truePeak,
|
|
874
|
+
target,
|
|
875
|
+
ceiling,
|
|
876
|
+
};
|
|
877
|
+
})
|
|
878
|
+
.catch(() => undefined)
|
|
879
|
+
.finally(() => {
|
|
880
|
+
measuring.running = false;
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
return { ...monitor, estimate: true };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** The arrangement strip over the timeline; absent without sections. */
|
|
887
|
+
function arrangeStripView(
|
|
888
|
+
value: TrackScore,
|
|
889
|
+
beat: number,
|
|
890
|
+
): ArrangeStripView | undefined {
|
|
891
|
+
if (value.sections.length === 0) return undefined;
|
|
892
|
+
return {
|
|
893
|
+
bars: value.bars,
|
|
894
|
+
sections: value.sections,
|
|
895
|
+
loop: value.loopSection,
|
|
896
|
+
playheadBar: scoreBeatAt(value, beat) / value.beatsPerBar,
|
|
897
|
+
form: value.form.length ? formatForm(value.form) : undefined,
|
|
726
898
|
};
|
|
727
899
|
}
|
|
728
900
|
|
|
@@ -779,11 +951,46 @@ function truncateForCard(value: string): string {
|
|
|
779
951
|
return line.length > 32 ? `${line.slice(0, 31)}…` : line;
|
|
780
952
|
}
|
|
781
953
|
|
|
954
|
+
/**
|
|
955
|
+
* Mouse reporting is on unless `--no-mouse` or `DAWG_MOUSE=0` (or a dumb
|
|
956
|
+
* terminal) says otherwise. A terminal without mouse support ignores the
|
|
957
|
+
* modes and every key still works.
|
|
958
|
+
*/
|
|
959
|
+
function mouseEnabled(): boolean {
|
|
960
|
+
return (
|
|
961
|
+
!process.argv.includes("--no-mouse") &&
|
|
962
|
+
process.env.DAWG_MOUSE !== "0" &&
|
|
963
|
+
process.env.TERM !== "dumb"
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function mouseOn(): string {
|
|
968
|
+
return mouseEnabled() ? MOUSE_ON : "";
|
|
969
|
+
}
|
|
970
|
+
|
|
782
971
|
async function runInteractive(): Promise<void> {
|
|
972
|
+
// A crash or a stray process.exit must not leave the terminal reporting
|
|
973
|
+
// mouse events into the shell.
|
|
974
|
+
process.once("exit", () => {
|
|
975
|
+
if (mouseEnabled()) writeSync(1, MOUSE_OFF);
|
|
976
|
+
});
|
|
977
|
+
// Nor may a kill or a closed terminal: restore it, then exit as killed.
|
|
978
|
+
for (const [signal, code] of [
|
|
979
|
+
["SIGTERM", 143],
|
|
980
|
+
["SIGHUP", 129],
|
|
981
|
+
] as const)
|
|
982
|
+
process.once(signal, () => {
|
|
983
|
+
try {
|
|
984
|
+
writeSync(1, `${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
985
|
+
} catch {
|
|
986
|
+
// The terminal may already be gone (SIGHUP).
|
|
987
|
+
}
|
|
988
|
+
process.exit(code);
|
|
989
|
+
});
|
|
783
990
|
stdin.setRawMode?.(true);
|
|
784
991
|
stdin.resume();
|
|
785
992
|
// Alternate screen, hidden cursor, bracketed paste.
|
|
786
|
-
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
|
|
993
|
+
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J${mouseOn()}`);
|
|
787
994
|
const inputDecoder = new TerminalInputDecoder();
|
|
788
995
|
const queuedPrompts: string[] = [];
|
|
789
996
|
let processingQueue = false;
|
|
@@ -845,6 +1052,10 @@ async function runInteractive(): Promise<void> {
|
|
|
845
1052
|
tui.render(appView(score, clock.beatAt()), { force });
|
|
846
1053
|
};
|
|
847
1054
|
requestFrame = () => tick(true);
|
|
1055
|
+
runPromptLater = (command) => {
|
|
1056
|
+
queuedPrompts.unshift(command);
|
|
1057
|
+
void drainQueue();
|
|
1058
|
+
};
|
|
848
1059
|
reportAgentActivity = () => {
|
|
849
1060
|
tui.activity.applyAgentEvent({ type: "start", model: providerName });
|
|
850
1061
|
};
|
|
@@ -871,7 +1082,7 @@ async function runInteractive(): Promise<void> {
|
|
|
871
1082
|
if (stageCapture)
|
|
872
1083
|
stageCapture.committed = scoreFromJSON(record.composition);
|
|
873
1084
|
else score = scoreFromJSON(record.composition);
|
|
874
|
-
clock.
|
|
1085
|
+
clock.follow(score);
|
|
875
1086
|
if (clock.playing) void audio.play(score);
|
|
876
1087
|
// Connected windows follow dawgd's transport frames instead.
|
|
877
1088
|
const replay =
|
|
@@ -924,6 +1135,7 @@ async function runInteractive(): Promise<void> {
|
|
|
924
1135
|
// Every window renders the same hit line from dawgd's timestamp.
|
|
925
1136
|
const { playing, beat, bpm, atMs } = update.transport;
|
|
926
1137
|
clock.setTempo(bpm);
|
|
1138
|
+
clock.setTimeMap(transportMapFor(playbackTime(score)));
|
|
927
1139
|
clock.sync(beat, playing, atMs, monotonicEpochMs());
|
|
928
1140
|
} else if (update.type === "status") {
|
|
929
1141
|
syncState = port.sync;
|
|
@@ -1000,11 +1212,11 @@ async function runInteractive(): Promise<void> {
|
|
|
1000
1212
|
stdin.pause();
|
|
1001
1213
|
stdin.setRawMode?.(false);
|
|
1002
1214
|
// Leave the alternate screen; restore the cursor and plain paste.
|
|
1003
|
-
stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
1215
|
+
stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
1004
1216
|
try {
|
|
1005
1217
|
return await flow();
|
|
1006
1218
|
} finally {
|
|
1007
|
-
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
|
|
1219
|
+
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J${mouseOn()}`);
|
|
1008
1220
|
stdin.setRawMode?.(true);
|
|
1009
1221
|
stdin.on("data", onData);
|
|
1010
1222
|
stdin.resume();
|
|
@@ -1023,7 +1235,31 @@ async function runInteractive(): Promise<void> {
|
|
|
1023
1235
|
...inputDecoder.push(text),
|
|
1024
1236
|
...(text === "\u001b" ? inputDecoder.flush() : []),
|
|
1025
1237
|
];
|
|
1026
|
-
|
|
1238
|
+
while (values.length) {
|
|
1239
|
+
const value = values.shift()!;
|
|
1240
|
+
// A mouse report acts on what the last frame painted under it; some
|
|
1241
|
+
// become keys (a list row, a wheel notch) and run through below.
|
|
1242
|
+
if (typeof value === "string" && isMouseSequence(value)) {
|
|
1243
|
+
const event = parseMouse(value);
|
|
1244
|
+
if (event) values.unshift(...mouseInput(event));
|
|
1245
|
+
tick(true);
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
// The fader drawer owns every key while it is up.
|
|
1249
|
+
if (typeof value === "string" && fader && menu.open) {
|
|
1250
|
+
const result = faderKeyPress(
|
|
1251
|
+
fader,
|
|
1252
|
+
menu.faderFields(menuContext()),
|
|
1253
|
+
value,
|
|
1254
|
+
faderKeyOptions(),
|
|
1255
|
+
);
|
|
1256
|
+
if (result.type !== "pass") {
|
|
1257
|
+
faderOutcome(result);
|
|
1258
|
+
refreshMenu();
|
|
1259
|
+
tick(true);
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1027
1263
|
// The `?` panel closes on any key (Ctrl-C still quits).
|
|
1028
1264
|
if (tui.ui.keys && typeof value === "string" && value !== "\u0003") {
|
|
1029
1265
|
tui.closeKeys();
|
|
@@ -1086,6 +1322,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1086
1322
|
}
|
|
1087
1323
|
} else if (result.type === "audition")
|
|
1088
1324
|
auditionKeyPressed(result.key);
|
|
1325
|
+
else if (result.type === "fader") openFader(result.label);
|
|
1089
1326
|
else if (result.type === "revert") revertStaged();
|
|
1090
1327
|
else if (result.type === "hover")
|
|
1091
1328
|
hoverItem(result.command, result.key);
|
|
@@ -1109,6 +1346,16 @@ async function runInteractive(): Promise<void> {
|
|
|
1109
1346
|
}
|
|
1110
1347
|
}
|
|
1111
1348
|
}
|
|
1349
|
+
// F1 opens the guides from anywhere the prompt has focus.
|
|
1350
|
+
if (
|
|
1351
|
+
(value === "\u001bOP" || value === "\u001b[11~") &&
|
|
1352
|
+
(tui.ui.overlay === undefined || tui.ui.overlay === "guide")
|
|
1353
|
+
) {
|
|
1354
|
+
if (tui.ui.overlay === "guide") tui.closeGuide();
|
|
1355
|
+
else tui.openGuide();
|
|
1356
|
+
tick(true);
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1112
1359
|
// Ctrl-K opens the menu on an empty prompt (in play mode too); with
|
|
1113
1360
|
// text it keeps its kill-to-end-of-line meaning.
|
|
1114
1361
|
if (
|
|
@@ -1140,7 +1387,8 @@ async function runInteractive(): Promise<void> {
|
|
|
1140
1387
|
if (
|
|
1141
1388
|
value === " " &&
|
|
1142
1389
|
prompt.value.length === 0 &&
|
|
1143
|
-
!(tui.ui.overlay === "picker" && tui.ui.picker?.audition)
|
|
1390
|
+
!(tui.ui.overlay === "picker" && tui.ui.picker?.audition) &&
|
|
1391
|
+
tui.ui.overlay !== "guide"
|
|
1144
1392
|
) {
|
|
1145
1393
|
// Never await a daemon round trip here: the key loop must stay
|
|
1146
1394
|
// live for Esc, quit and redraws while the toggle is in flight.
|
|
@@ -1271,7 +1519,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1271
1519
|
await port.close();
|
|
1272
1520
|
stdin.setRawMode?.(false);
|
|
1273
1521
|
stdin.pause();
|
|
1274
|
-
stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
1522
|
+
stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
1275
1523
|
}
|
|
1276
1524
|
}
|
|
1277
1525
|
|
|
@@ -1306,7 +1554,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1306
1554
|
}
|
|
1307
1555
|
if (/^\/?tracks$/i.test(command)) {
|
|
1308
1556
|
const problems = await sampleProblems(score);
|
|
1309
|
-
const
|
|
1557
|
+
const items = score.tracks.map((track) => {
|
|
1310
1558
|
const voices = track.sampler
|
|
1311
1559
|
? Object.keys(track.sampler.voices).length
|
|
1312
1560
|
: 0;
|
|
@@ -1316,16 +1564,39 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1316
1564
|
const samples = track.sampler
|
|
1317
1565
|
? ` · ${voices} sample${voices === 1 ? "" : "s"}${missing ? ` · ${missing} missing` : ""}`
|
|
1318
1566
|
: "";
|
|
1319
|
-
return
|
|
1567
|
+
return {
|
|
1568
|
+
label: track.id,
|
|
1569
|
+
value: `/track ${track.id}`,
|
|
1570
|
+
detail: `${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}`,
|
|
1571
|
+
current: track.id === requestedTrack,
|
|
1572
|
+
};
|
|
1573
|
+
});
|
|
1574
|
+
// A picker: Enter (or a click) focuses the track; Esc closes.
|
|
1575
|
+
tui.openPicker({
|
|
1576
|
+
id: "tracks",
|
|
1577
|
+
title: "tracks · ● focused",
|
|
1578
|
+
items,
|
|
1579
|
+
filterable: items.length > 8,
|
|
1580
|
+
index: Math.max(
|
|
1581
|
+
0,
|
|
1582
|
+
items.findIndex((item) => item.current),
|
|
1583
|
+
),
|
|
1320
1584
|
});
|
|
1321
|
-
|
|
1322
|
-
return ok(`${lines.length} track${lines.length === 1 ? "" : "s"}`);
|
|
1585
|
+
return ok(`${items.length} track${items.length === 1 ? "" : "s"}`);
|
|
1323
1586
|
}
|
|
1324
|
-
const playCommand = command.match(
|
|
1587
|
+
const playCommand = command.match(
|
|
1588
|
+
/^\/play(?:\s+(on|off|degrees|in-key|chromatic))?$/i,
|
|
1589
|
+
);
|
|
1325
1590
|
if (playCommand) {
|
|
1326
1591
|
const wanted = playCommand[1]?.toLowerCase();
|
|
1327
1592
|
if (wanted === "off" || (wanted === undefined && play?.on))
|
|
1328
1593
|
return exitPlay();
|
|
1594
|
+
if (wanted && wanted !== "on") {
|
|
1595
|
+
const message = playSession().toggleDegrees(wanted !== "chromatic");
|
|
1596
|
+
if (message.startsWith("scale degrees need")) return fail(message);
|
|
1597
|
+
const entered = await enterPlay();
|
|
1598
|
+
return entered.ok ? ok(message) : entered;
|
|
1599
|
+
}
|
|
1329
1600
|
return enterPlay();
|
|
1330
1601
|
}
|
|
1331
1602
|
const clickCommand = command.match(/^\/click(?:\s+(.+))?$/i);
|
|
@@ -1354,6 +1625,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1354
1625
|
}
|
|
1355
1626
|
const tryCommand = command.match(/^\/try(?:\s+(.+))?$/i);
|
|
1356
1627
|
if (tryCommand) return tryPrompt(tryCommand[1]?.trim() ?? "");
|
|
1628
|
+
// A bare parameter (`volume`, `pan`, `fx filter`, `fx reverb mix`)
|
|
1629
|
+
// opens the fader drawer on it, with its related params stacked below.
|
|
1630
|
+
if (/^\/?(?:volume|pan|fx\s+\S+(?:\s+\S+)?)$/i.test(command)) {
|
|
1631
|
+
const label = menu.showFader(menuContext(), command);
|
|
1632
|
+
if (label) {
|
|
1633
|
+
openFader(label, true);
|
|
1634
|
+
refreshMenu();
|
|
1635
|
+
return ok(`${label} · ←→ adjust · enter keep · esc revert`);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1357
1638
|
const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
|
|
1358
1639
|
if (menuCommand) {
|
|
1359
1640
|
const section = menuCommand[1]?.toLowerCase();
|
|
@@ -1379,6 +1660,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1379
1660
|
/^\/?(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/i,
|
|
1380
1661
|
);
|
|
1381
1662
|
if (trackCommand) return focusTrack(trackCommand[1]!.toLowerCase());
|
|
1663
|
+
// `/track piano b`: a name with spaces focuses the track of that name, or
|
|
1664
|
+
// creates `piano-b` named "piano b".
|
|
1665
|
+
const namedTrack = command.match(/^\/track\s+([a-z0-9._ -]{1,64})$/i);
|
|
1666
|
+
if (namedTrack) {
|
|
1667
|
+
const name = namedTrack[1]!.trim().replace(/\s+/g, " ");
|
|
1668
|
+
const found = score.tracks.find(
|
|
1669
|
+
(track) => (track.name ?? track.id).toLowerCase() === name.toLowerCase(),
|
|
1670
|
+
);
|
|
1671
|
+
return focusTrack(found?.id ?? name.toLowerCase().replace(/ /g, "-"));
|
|
1672
|
+
}
|
|
1382
1673
|
const sample = parseSampleCommand(command);
|
|
1383
1674
|
if (sample) return sampleCommand(sample);
|
|
1384
1675
|
const pack = parsePackCommand(command);
|
|
@@ -1391,6 +1682,14 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1391
1682
|
if (wavetable) return wavetableCommand(wavetable);
|
|
1392
1683
|
const sessionReply = await sessionCommand(command);
|
|
1393
1684
|
if (sessionReply !== undefined) return sessionReply;
|
|
1685
|
+
const time = parseTimeCommand(command);
|
|
1686
|
+
if (time) {
|
|
1687
|
+
if (time.type !== "tempo-map") await materializeDraft();
|
|
1688
|
+
const result = applyTimeCommand(score, requestedTrack, time);
|
|
1689
|
+
if (result.next && result.kind)
|
|
1690
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1691
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1692
|
+
}
|
|
1394
1693
|
const edit = parseEditCommand(command);
|
|
1395
1694
|
if (edit) {
|
|
1396
1695
|
await materializeDraft();
|
|
@@ -1426,6 +1725,17 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1426
1725
|
await commitScore(result.next, result.kind, result.payload);
|
|
1427
1726
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1428
1727
|
}
|
|
1728
|
+
const expression = parseExpressionCommand(command);
|
|
1729
|
+
if (expression) {
|
|
1730
|
+
if (expression.type !== "show" && expression.type !== "invalid")
|
|
1731
|
+
await materializeDraft();
|
|
1732
|
+
const result = applyExpressionCommand(score, requestedTrack, expression);
|
|
1733
|
+
if (result.next && result.kind)
|
|
1734
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1735
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1736
|
+
}
|
|
1737
|
+
const tuning = parseTuningCommand(command);
|
|
1738
|
+
if (tuning) return tuningCommand(tuning);
|
|
1429
1739
|
const synth = parseSynthCommand(command);
|
|
1430
1740
|
if (synth) {
|
|
1431
1741
|
if (synth.type !== "synth-list") await materializeDraft();
|
|
@@ -1434,6 +1744,34 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1434
1744
|
await commitScore(result.next, result.kind, result.payload);
|
|
1435
1745
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1436
1746
|
}
|
|
1747
|
+
const masterCommand = parseMasterCommand(command);
|
|
1748
|
+
if (masterCommand) {
|
|
1749
|
+
if (masterCommand.type === "master-measure")
|
|
1750
|
+
return ok(await measureLine(score));
|
|
1751
|
+
if (
|
|
1752
|
+
masterCommand.type !== "master-list" &&
|
|
1753
|
+
masterCommand.type !== "master-show"
|
|
1754
|
+
)
|
|
1755
|
+
await materializeDraft();
|
|
1756
|
+
const result = applyMasterCommand(score, masterCommand);
|
|
1757
|
+
if (result.next && result.kind)
|
|
1758
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1759
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1760
|
+
}
|
|
1761
|
+
const arrange = parseSectionCommand(command, score);
|
|
1762
|
+
if (arrange) {
|
|
1763
|
+
const reads =
|
|
1764
|
+
arrange.type === "section-list" ||
|
|
1765
|
+
arrange.type === "section-unknown" ||
|
|
1766
|
+
arrange.type === "form-show" ||
|
|
1767
|
+
arrange.type === "section-jump";
|
|
1768
|
+
if (!reads) await materializeDraft();
|
|
1769
|
+
const result = applySectionCommand(score, requestedTrack, arrange);
|
|
1770
|
+
if (result.next && result.kind)
|
|
1771
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1772
|
+
if (result.seekBeat !== undefined) await seekTransport(result.seekBeat);
|
|
1773
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1774
|
+
}
|
|
1437
1775
|
const music = parseMusicCommand(command);
|
|
1438
1776
|
if (music) {
|
|
1439
1777
|
await materializeDraft();
|
|
@@ -1447,6 +1785,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1447
1785
|
const exportCommand = command.match(/^\/?export\s+([^\s]+)$/i);
|
|
1448
1786
|
if (exportCommand) {
|
|
1449
1787
|
const path = resolve(exportCommand[1]!);
|
|
1788
|
+
if (/\.midi?$/i.test(path)) {
|
|
1789
|
+
await writeFile(path, scoreToMidi(exportScore(score)));
|
|
1790
|
+
return `exported midi · ${exportCommand[1]}`;
|
|
1791
|
+
}
|
|
1792
|
+
// Audio comes from the offline renderer, never JSON under an audio name.
|
|
1793
|
+
if (/\.(wav|aiff?|flac|mp3|ogg|m4a)$/i.test(path))
|
|
1794
|
+
return fail(
|
|
1795
|
+
`/export writes .track.json or .mid · render audio with: dawg render ${exportCommand[1]}`,
|
|
1796
|
+
);
|
|
1450
1797
|
await writeFile(path, encodeLoop(score), "utf8");
|
|
1451
1798
|
return `exported · ${exportCommand[1]}`;
|
|
1452
1799
|
}
|
|
@@ -1563,7 +1910,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1563
1910
|
if (parsed.type === "set-tempo") {
|
|
1564
1911
|
const next = score.withTempo(parsed.tempoBpm);
|
|
1565
1912
|
await commitScore(next, "score.tempo", { tempoBpm: parsed.tempoBpm });
|
|
1566
|
-
clock.
|
|
1913
|
+
clock.follow(next);
|
|
1567
1914
|
return `tempo · ${next.tempoBpm} BPM`;
|
|
1568
1915
|
}
|
|
1569
1916
|
if (parsed.type === "add-track") return focusTrack(parsed.trackId);
|
|
@@ -1658,6 +2005,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1658
2005
|
...(typeof parsed.patch.velocity === "number"
|
|
1659
2006
|
? { velocity: parsed.patch.velocity }
|
|
1660
2007
|
: {}),
|
|
2008
|
+
...(typeof parsed.patch.cents === "number"
|
|
2009
|
+
? { cents: parsed.patch.cents }
|
|
2010
|
+
: {}),
|
|
1661
2011
|
};
|
|
1662
2012
|
const next = applyScoreOperation(score, {
|
|
1663
2013
|
type: "updateNote",
|
|
@@ -1683,6 +2033,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1683
2033
|
),
|
|
1684
2034
|
pitch: parsed.pitch,
|
|
1685
2035
|
velocity: parsed.velocity,
|
|
2036
|
+
...(parsed.cents ? { cents: parsed.cents } : {}),
|
|
1686
2037
|
};
|
|
1687
2038
|
const operation: ScoreOperation = { type: "addNote", note };
|
|
1688
2039
|
const next = applyScoreOperation(latestScore, operation);
|
|
@@ -1788,6 +2139,40 @@ function reportSampleProblems(value: TrackScore): void {
|
|
|
1788
2139
|
});
|
|
1789
2140
|
}
|
|
1790
2141
|
|
|
2142
|
+
/** `/tuning …` and `/scale …` (src/commands/tuning.ts). */
|
|
2143
|
+
async function tuningCommand(command: TuningCommand): Promise<Receipt> {
|
|
2144
|
+
const projectRoot = process.cwd();
|
|
2145
|
+
if (command.type === "tuning-set") {
|
|
2146
|
+
// Scala files outside the project are copied into tunings/ first.
|
|
2147
|
+
try {
|
|
2148
|
+
const patch = { ...command.patch };
|
|
2149
|
+
if (patch.scl)
|
|
2150
|
+
patch.scl = await importTuningFile(projectRoot, projectRoot, patch.scl);
|
|
2151
|
+
if (patch.kbm)
|
|
2152
|
+
patch.kbm = await importTuningFile(projectRoot, projectRoot, patch.kbm);
|
|
2153
|
+
command = { ...command, patch };
|
|
2154
|
+
} catch (error) {
|
|
2155
|
+
if (error instanceof TuningError)
|
|
2156
|
+
return fail(`tuning · ${error.message}`);
|
|
2157
|
+
throw error;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
if (command.type === "tuning-set" || command.type === "tuning-off")
|
|
2161
|
+
if (command.target === "track") await materializeDraft();
|
|
2162
|
+
const read = (path: string): string | undefined => {
|
|
2163
|
+
try {
|
|
2164
|
+
return readFileSync(join(projectRoot, path), "utf8");
|
|
2165
|
+
} catch {
|
|
2166
|
+
return undefined;
|
|
2167
|
+
}
|
|
2168
|
+
};
|
|
2169
|
+
const result = applyTuningCommand(score, requestedTrack, command, read);
|
|
2170
|
+
if (result.panel) tui.openText(result.panel.title, result.panel.lines);
|
|
2171
|
+
if (result.next && result.kind)
|
|
2172
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2173
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
1791
2176
|
async function sampleCommand(
|
|
1792
2177
|
command: NonNullable<ReturnType<typeof parseSampleCommand>>,
|
|
1793
2178
|
): Promise<Receipt> {
|
|
@@ -2357,7 +2742,7 @@ async function switchSession(sessionId: string): Promise<void> {
|
|
|
2357
2742
|
audio = port.player;
|
|
2358
2743
|
record = port.mode === "daemon" ? await port.load() : next.record;
|
|
2359
2744
|
score = scoreFromJSON(record.composition);
|
|
2360
|
-
clock.
|
|
2745
|
+
clock.follow(score);
|
|
2361
2746
|
const attached = await attachTrack(port, score, undefined);
|
|
2362
2747
|
requestedTrack = attached.trackId;
|
|
2363
2748
|
draftTrack = attached.draft;
|
|
@@ -2412,6 +2797,8 @@ function menuContext(): MenuContext {
|
|
|
2412
2797
|
/** The screen `?` describes, or undefined while `?` is typed text. */
|
|
2413
2798
|
function keysScreen(): readonly KeySection[] | undefined {
|
|
2414
2799
|
if (euclid.open) return euclid.typing ? undefined : KEYS.euclid;
|
|
2800
|
+
if (fader && menu.open)
|
|
2801
|
+
return fader.typing !== undefined ? undefined : KEYS.fader;
|
|
2415
2802
|
if (menu.open) return menu.typing ? undefined : KEYS.menu;
|
|
2416
2803
|
const overlay = tui.ui.overlay;
|
|
2417
2804
|
if (overlay === "picker")
|
|
@@ -2422,6 +2809,7 @@ function keysScreen(): readonly KeySection[] | undefined {
|
|
|
2422
2809
|
: KEYS.list;
|
|
2423
2810
|
if (overlay === "text") return KEYS.text;
|
|
2424
2811
|
if (overlay === "log") return KEYS.log;
|
|
2812
|
+
if (overlay === "guide") return tui.guideTyping ? undefined : KEYS.guide;
|
|
2425
2813
|
if (prompt.value.length > 0) return undefined;
|
|
2426
2814
|
if (play?.on)
|
|
2427
2815
|
return play.chords.on ? [...KEYS.play, ...KEYS.chords] : KEYS.play;
|
|
@@ -2533,6 +2921,11 @@ function auditionController(): Audition {
|
|
|
2533
2921
|
clearTimer: (handle) => clearTimeout(handle as Timer),
|
|
2534
2922
|
changed: () => requestFrame(),
|
|
2535
2923
|
level: () => previewEngine().level,
|
|
2924
|
+
// The last loop the engine played is the song's when a loop starts.
|
|
2925
|
+
masterGainDb: () =>
|
|
2926
|
+
score.master?.target === undefined
|
|
2927
|
+
? undefined
|
|
2928
|
+
: previewEngine().loudness?.gainDb,
|
|
2536
2929
|
// The chord settings screen loops a progression with its settings.
|
|
2537
2930
|
phrase: (showing) => {
|
|
2538
2931
|
if (!chordScreenOpen()) return undefined;
|
|
@@ -2875,14 +3268,28 @@ function leaveAuditionScreen(): void {
|
|
|
2875
3268
|
|
|
2876
3269
|
function refreshMenu(): void {
|
|
2877
3270
|
noteChordScreen();
|
|
2878
|
-
if (!menu.open)
|
|
3271
|
+
if (!menu.open) {
|
|
3272
|
+
closeFader();
|
|
3273
|
+
return;
|
|
3274
|
+
}
|
|
2879
3275
|
if (tui.ui.overlay !== undefined && tui.ui.picker?.id !== "menu") {
|
|
2880
3276
|
// Another overlay (help, a picker) replaced the menu.
|
|
2881
3277
|
menu.close();
|
|
2882
3278
|
leaveAuditionScreen();
|
|
2883
3279
|
return;
|
|
2884
3280
|
}
|
|
2885
|
-
const
|
|
3281
|
+
const context = menuContext();
|
|
3282
|
+
if (fader) {
|
|
3283
|
+
const fields = menu.faderFields(context);
|
|
3284
|
+
if (fields.length === 0) closeFader();
|
|
3285
|
+
else
|
|
3286
|
+
tui.drawer = drawerView(fader, fields, menu.faderCommitted(context), {
|
|
3287
|
+
title: menu.crumbs,
|
|
3288
|
+
dirty: context.audition?.dirty ?? false,
|
|
3289
|
+
status: context.audition?.status,
|
|
3290
|
+
});
|
|
3291
|
+
}
|
|
3292
|
+
const view = menu.view(context);
|
|
2886
3293
|
tui.openPicker({
|
|
2887
3294
|
id: "menu",
|
|
2888
3295
|
title: view.title,
|
|
@@ -2895,6 +3302,216 @@ function refreshMenu(): void {
|
|
|
2895
3302
|
});
|
|
2896
3303
|
}
|
|
2897
3304
|
|
|
3305
|
+
// ── the fader drawer ─────────────────────────────────────────────────
|
|
3306
|
+
|
|
3307
|
+
/** Enter on a number row (or a bare `volume`, `fx filter`): the drawer. */
|
|
3308
|
+
/**
|
|
3309
|
+
* Open the drawer on `label`. `standalone` drawers came from a command or a
|
|
3310
|
+
* click, not from inside the menu, so closing one closes the menu too.
|
|
3311
|
+
*/
|
|
3312
|
+
function openFader(label: string, standalone = false): void {
|
|
3313
|
+
fader = { label };
|
|
3314
|
+
faderStandalone = standalone;
|
|
3315
|
+
const loop = auditionController();
|
|
3316
|
+
if (!loop.dirtyEdits) loop.committedNow(score);
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
function closeFader(): void {
|
|
3320
|
+
fader = undefined;
|
|
3321
|
+
tui.drawer = undefined;
|
|
3322
|
+
if (faderStandalone && menu.open) {
|
|
3323
|
+
faderStandalone = false;
|
|
3324
|
+
menu.close();
|
|
3325
|
+
tui.closePicker();
|
|
3326
|
+
stopAuditionLoop();
|
|
3327
|
+
}
|
|
3328
|
+
faderStandalone = false;
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
function faderKeyOptions(): { dirty: boolean; audition: boolean } {
|
|
3332
|
+
return { dirty: auditionLoop?.dirtyEdits ?? false, audition: true };
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
/**
|
|
3336
|
+
* Act on a drawer result. A set stages on the audition loop, filed under
|
|
3337
|
+
* its field so every nudge replaces the last: the piano roll (and the loop,
|
|
3338
|
+
* when it plays) follows at once, and Enter keeps them all as one revision.
|
|
3339
|
+
* A setting the loop cannot stage (tempo, loop length) applies directly.
|
|
3340
|
+
*/
|
|
3341
|
+
function faderOutcome(result: FaderResult): void {
|
|
3342
|
+
if (result.type === "set") {
|
|
3343
|
+
if (!stageableNow(result.command)) {
|
|
3344
|
+
runPromptLater(result.command);
|
|
3345
|
+
return;
|
|
3346
|
+
}
|
|
3347
|
+
const loop = auditionController();
|
|
3348
|
+
if (!loop.dirtyEdits) loop.committedNow(score);
|
|
3349
|
+
const seq = (faderSeq.get(result.key) ?? 0) + 1;
|
|
3350
|
+
faderSeq.set(result.key, seq);
|
|
3351
|
+
void loop
|
|
3352
|
+
.stage(result.command, {
|
|
3353
|
+
replaceKey: result.key,
|
|
3354
|
+
superseded: () => faderSeq.get(result.key) !== seq,
|
|
3355
|
+
})
|
|
3356
|
+
.then((outcome) => {
|
|
3357
|
+
if (!outcome.ok && outcome.message !== SUPERSEDED)
|
|
3358
|
+
tui.activity.pushCard(outcome.message, {
|
|
3359
|
+
tone: outcome.message.includes("failed") ? "error" : "warning",
|
|
3360
|
+
});
|
|
3361
|
+
requestFrame();
|
|
3362
|
+
});
|
|
3363
|
+
} else if (result.type === "keep") {
|
|
3364
|
+
closeFader();
|
|
3365
|
+
void keepStaged()
|
|
3366
|
+
.then((outcome) => receipt(outcome))
|
|
3367
|
+
.catch((error) => tui.activity.pushError(describeError("keep", error)))
|
|
3368
|
+
.finally(() => requestFrame());
|
|
3369
|
+
} else if (result.type === "revert") {
|
|
3370
|
+
revertStaged();
|
|
3371
|
+
closeFader();
|
|
3372
|
+
} else if (result.type === "close") closeFader();
|
|
3373
|
+
else if (result.type === "audition") auditionKeyPressed(result.key);
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
// ── the mouse ────────────────────────────────────────────────────────
|
|
3377
|
+
|
|
3378
|
+
/**
|
|
3379
|
+
* One mouse report, hit-tested against the regions the last frame painted.
|
|
3380
|
+
* Acts directly (a fader, the transport) or returns keys to run through the
|
|
3381
|
+
* ordinary key path (a list row is Enter, a wheel notch is an arrow).
|
|
3382
|
+
*/
|
|
3383
|
+
function mouseInput(event: MouseEvent): string[] {
|
|
3384
|
+
const frame = tui.frame;
|
|
3385
|
+
if (!frame) return [];
|
|
3386
|
+
const target = frame.hits.at(event.x, event.y)?.target;
|
|
3387
|
+
if (event.kind === "wheel") {
|
|
3388
|
+
const fields = fader && menu.open ? menu.faderFields(menuContext()) : [];
|
|
3389
|
+
if (
|
|
3390
|
+
fader &&
|
|
3391
|
+
target &&
|
|
3392
|
+
(target.kind === "fader-bar" ||
|
|
3393
|
+
target.kind === "fader-row" ||
|
|
3394
|
+
target.kind === "fader-step" ||
|
|
3395
|
+
target.kind === "fader-option")
|
|
3396
|
+
) {
|
|
3397
|
+
// Wheel up raises the value under the pointer.
|
|
3398
|
+
faderOutcome(
|
|
3399
|
+
faderStep(
|
|
3400
|
+
fader,
|
|
3401
|
+
fields[target.field],
|
|
3402
|
+
event.delta < 0 ? 1 : -1,
|
|
3403
|
+
event.shift ? "coarse" : "normal",
|
|
3404
|
+
),
|
|
3405
|
+
);
|
|
3406
|
+
refreshMenu();
|
|
3407
|
+
return [];
|
|
3408
|
+
}
|
|
3409
|
+
if (fader) return [];
|
|
3410
|
+
if (tui.ui.overlay) return [event.delta < 0 ? KEY_UP : KEY_DOWN];
|
|
3411
|
+
return [];
|
|
3412
|
+
}
|
|
3413
|
+
// A drag keeps moving the fader it started on, even past the bar's ends.
|
|
3414
|
+
if (event.kind === "drag" && event.button === "left" && fader && dragging) {
|
|
3415
|
+
const fields = menu.faderFields(menuContext());
|
|
3416
|
+
faderOutcome(
|
|
3417
|
+
faderSetPosition(
|
|
3418
|
+
fader,
|
|
3419
|
+
fields[dragging.field],
|
|
3420
|
+
(event.x - dragging.left) / Math.max(1, dragging.width - 1),
|
|
3421
|
+
),
|
|
3422
|
+
);
|
|
3423
|
+
refreshMenu();
|
|
3424
|
+
return [];
|
|
3425
|
+
}
|
|
3426
|
+
if (event.kind === "up") {
|
|
3427
|
+
dragging = undefined;
|
|
3428
|
+
return [];
|
|
3429
|
+
}
|
|
3430
|
+
if (event.kind !== "down" || event.button !== "left" || !target) return [];
|
|
3431
|
+
if (fader && menu.open) {
|
|
3432
|
+
const fields = menu.faderFields(menuContext());
|
|
3433
|
+
let result: FaderResult | undefined;
|
|
3434
|
+
switch (target.kind) {
|
|
3435
|
+
case "fader-step":
|
|
3436
|
+
result = faderStep(
|
|
3437
|
+
fader,
|
|
3438
|
+
fields[target.field],
|
|
3439
|
+
target.direction,
|
|
3440
|
+
event.shift ? "coarse" : "normal",
|
|
3441
|
+
);
|
|
3442
|
+
break;
|
|
3443
|
+
case "fader-bar":
|
|
3444
|
+
dragging = target;
|
|
3445
|
+
result = faderSetPosition(
|
|
3446
|
+
fader,
|
|
3447
|
+
fields[target.field],
|
|
3448
|
+
(event.x - target.left) / Math.max(1, target.width - 1),
|
|
3449
|
+
);
|
|
3450
|
+
break;
|
|
3451
|
+
case "fader-option":
|
|
3452
|
+
result = faderChoose(fader, fields[target.field], target.option);
|
|
3453
|
+
break;
|
|
3454
|
+
case "fader-row": {
|
|
3455
|
+
const field = fields[target.field];
|
|
3456
|
+
if (field) {
|
|
3457
|
+
fader.label = field.label;
|
|
3458
|
+
fader.typing = undefined;
|
|
3459
|
+
}
|
|
3460
|
+
result = { type: "handled" };
|
|
3461
|
+
break;
|
|
3462
|
+
}
|
|
3463
|
+
case "fader-keep":
|
|
3464
|
+
result = auditionLoop?.dirtyEdits
|
|
3465
|
+
? { type: "keep" }
|
|
3466
|
+
: { type: "close" };
|
|
3467
|
+
break;
|
|
3468
|
+
case "fader-revert":
|
|
3469
|
+
result = auditionLoop?.dirtyEdits
|
|
3470
|
+
? { type: "revert" }
|
|
3471
|
+
: { type: "close" };
|
|
3472
|
+
break;
|
|
3473
|
+
default:
|
|
3474
|
+
break;
|
|
3475
|
+
}
|
|
3476
|
+
if (result) {
|
|
3477
|
+
faderOutcome(result);
|
|
3478
|
+
refreshMenu();
|
|
3479
|
+
return [];
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
switch (target.kind) {
|
|
3483
|
+
case "picker-row": {
|
|
3484
|
+
const picker = tui.ui.picker;
|
|
3485
|
+
if (!picker) return [];
|
|
3486
|
+
if (picker.id === "menu" && menu.open) {
|
|
3487
|
+
// The first click selects a row; a click on the selected row opens it.
|
|
3488
|
+
const again = menu.index === target.index;
|
|
3489
|
+
menu.select(menuContext(), target.index);
|
|
3490
|
+
refreshMenu();
|
|
3491
|
+
return again ? ["\r"] : [];
|
|
3492
|
+
}
|
|
3493
|
+
const again = picker.index === target.index;
|
|
3494
|
+
picker.index = target.index;
|
|
3495
|
+
picker.filtering = false;
|
|
3496
|
+
return again ? ["\r"] : [];
|
|
3497
|
+
}
|
|
3498
|
+
case "transport":
|
|
3499
|
+
if (tui.ui.overlay) return [];
|
|
3500
|
+
void toggleTransport().finally(() => requestFrame());
|
|
3501
|
+
return [];
|
|
3502
|
+
case "tracks":
|
|
3503
|
+
if (tui.ui.overlay) return [];
|
|
3504
|
+
runPromptLater("/tracks");
|
|
3505
|
+
return [];
|
|
3506
|
+
case "model":
|
|
3507
|
+
if (tui.ui.overlay) return [];
|
|
3508
|
+
runPromptLater("/model");
|
|
3509
|
+
return [];
|
|
3510
|
+
default:
|
|
3511
|
+
return [];
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
|
|
2898
3515
|
function playSession(): PlaySession {
|
|
2899
3516
|
if (play && play.track === requestedTrack) return play;
|
|
2900
3517
|
const previous = play;
|
|
@@ -2966,6 +3583,7 @@ function playHost() {
|
|
|
2966
3583
|
now: () => performance.now(),
|
|
2967
3584
|
playing: () => clock.playing,
|
|
2968
3585
|
beatAt: (ms: number) => clock.beatAt(ms),
|
|
3586
|
+
scoreBeat: (beat: number) => scoreBeatAt(score, beat),
|
|
2969
3587
|
engine: liveEngine,
|
|
2970
3588
|
samples: () => liveSampleBank,
|
|
2971
3589
|
async commit(
|
|
@@ -3032,20 +3650,33 @@ async function commitScore(
|
|
|
3032
3650
|
// Rhythm rows regenerate after a loop resize and freeze when their lane
|
|
3033
3651
|
// is hand-edited, so rows and notes never disagree.
|
|
3034
3652
|
next = reconcileRhythm(score, next);
|
|
3653
|
+
const retimed = timingChanged(score, next);
|
|
3035
3654
|
if (stageCapture) {
|
|
3036
3655
|
// A staged edit: the audition plays it; nothing is written yet.
|
|
3037
3656
|
stageCapture.next = next;
|
|
3038
3657
|
score = next;
|
|
3658
|
+
if (retimed) clock.follow(score);
|
|
3039
3659
|
return;
|
|
3040
3660
|
}
|
|
3041
3661
|
record = await port.append(record, { kind, payload }, next.toJSON());
|
|
3042
3662
|
score = next;
|
|
3663
|
+
if (retimed) clock.follow(score);
|
|
3043
3664
|
if (clock.playing) void audio.play(score);
|
|
3044
3665
|
projectSync?.scoreChanged(score);
|
|
3045
3666
|
reportSampleProblems(score);
|
|
3046
3667
|
void updateCredits(score);
|
|
3047
3668
|
}
|
|
3048
3669
|
|
|
3670
|
+
/** True when the transport clock must follow `next` (tempo, meter, loop). */
|
|
3671
|
+
function timingChanged(previous: TrackScore, next: TrackScore): boolean {
|
|
3672
|
+
return (
|
|
3673
|
+
previous.time !== next.time ||
|
|
3674
|
+
previous.tempoBpm !== next.tempoBpm ||
|
|
3675
|
+
previous.beatsPerBar !== next.beatsPerBar ||
|
|
3676
|
+
previous.bars !== next.bars
|
|
3677
|
+
);
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3049
3680
|
/** The window's side of the project file sync (see src/project/sync.ts). */
|
|
3050
3681
|
function syncHost(): SyncHost {
|
|
3051
3682
|
return {
|
|
@@ -3063,7 +3694,7 @@ function syncHost(): SyncHost {
|
|
|
3063
3694
|
plan.next.toJSON(),
|
|
3064
3695
|
);
|
|
3065
3696
|
score = scoreFromJSON(record.composition);
|
|
3066
|
-
clock.
|
|
3697
|
+
clock.follow(score);
|
|
3067
3698
|
if (clock.playing) void audio.play(score);
|
|
3068
3699
|
reportSampleProblems(score);
|
|
3069
3700
|
void baseRevision;
|
|
@@ -3170,6 +3801,17 @@ async function setTransport(
|
|
|
3170
3801
|
}
|
|
3171
3802
|
}
|
|
3172
3803
|
|
|
3804
|
+
/** Move the playhead to transport `beat`, playing or not (section jump). */
|
|
3805
|
+
async function seekTransport(beat: number): Promise<void> {
|
|
3806
|
+
if (port.mode === "daemon") {
|
|
3807
|
+
await port.transport("seek", { beat });
|
|
3808
|
+
return;
|
|
3809
|
+
}
|
|
3810
|
+
const now = Date.now();
|
|
3811
|
+
clock.sync(beat, clock.playing, now, now);
|
|
3812
|
+
if (clock.playing) await audio.play(score, clock.beatAt());
|
|
3813
|
+
}
|
|
3814
|
+
|
|
3173
3815
|
/**
|
|
3174
3816
|
* Run one streaming tool-calling turn. Each validated tool call commits its
|
|
3175
3817
|
* own revision through `agentHost`, so cancelling keeps every accepted change
|
|
@@ -3274,6 +3916,25 @@ function mediaServices(): MediaServices {
|
|
|
3274
3916
|
return { runner: systemRunner, env: process.env };
|
|
3275
3917
|
}
|
|
3276
3918
|
|
|
3919
|
+
/**
|
|
3920
|
+
* `master measure`: render the song as the loop that plays and measure
|
|
3921
|
+
* loudness, peaks and balance at the export rate. The render, master and
|
|
3922
|
+
* meter run in a worker (`measureScoreOffThread`), so the header, input and
|
|
3923
|
+
* playback keep going on a long loop.
|
|
3924
|
+
*/
|
|
3925
|
+
async function measureLine(value: TrackScore): Promise<string> {
|
|
3926
|
+
await sampleProblems(value);
|
|
3927
|
+
tui.activity.setSpinner("measuring");
|
|
3928
|
+
try {
|
|
3929
|
+
const measured = await measureScoreOffThread(value, {
|
|
3930
|
+
projectRoot: process.cwd(),
|
|
3931
|
+
});
|
|
3932
|
+
return `master measure · ${measurementLine(measured.mix, measured.master)} · ${measured.sampleRate / 1000} kHz`;
|
|
3933
|
+
} finally {
|
|
3934
|
+
tui.activity.setSpinner(undefined);
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3277
3938
|
/**
|
|
3278
3939
|
* How the agent's preview_sound renders (with this window's decoded
|
|
3279
3940
|
* samples, at the engine's rate) and plays: once, over silence, unless the
|
|
@@ -3288,6 +3949,16 @@ function agentPreviewHost(): PreviewHost {
|
|
|
3288
3949
|
...(liveSampleBank ? { samples: liveSampleBank } : {}),
|
|
3289
3950
|
});
|
|
3290
3951
|
},
|
|
3952
|
+
// Off the UI thread, at the export rate (see measureLine).
|
|
3953
|
+
measure: async (value, options) => {
|
|
3954
|
+
await sampleProblems(value);
|
|
3955
|
+
return measureScoreOffThread(value, {
|
|
3956
|
+
projectRoot: process.cwd(),
|
|
3957
|
+
...(options?.sampleRate === undefined
|
|
3958
|
+
? {}
|
|
3959
|
+
: { sampleRate: options.sampleRate }),
|
|
3960
|
+
});
|
|
3961
|
+
},
|
|
3291
3962
|
play: async (rendered) => {
|
|
3292
3963
|
if (!agentPreviewPlays || clock.playing || auditionLoop?.looping)
|
|
3293
3964
|
return false;
|
|
@@ -3356,8 +4027,13 @@ function agentHost(
|
|
|
3356
4027
|
);
|
|
3357
4028
|
throw error;
|
|
3358
4029
|
}
|
|
3359
|
-
if (
|
|
3360
|
-
|
|
4030
|
+
if (
|
|
4031
|
+
change.operations.some(
|
|
4032
|
+
(operation) =>
|
|
4033
|
+
operation.type === "setTempo" || operation.type === "setTime",
|
|
4034
|
+
)
|
|
4035
|
+
)
|
|
4036
|
+
clock.follow(score);
|
|
3361
4037
|
return { revision: record.revision };
|
|
3362
4038
|
},
|
|
3363
4039
|
async transport(action) {
|