@hraness/dawg 0.4.0 → 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 +86 -0
- package/DAWG.md +300 -35
- 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/chain.ts +12 -2
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/convolution.ts +43 -5
- 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 +197 -50
- package/src/audio/engine.ts +175 -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 +87 -16
- 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 +448 -59
- 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 +826 -47
- 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 +58 -5
- package/src/tui/euclid.ts +50 -6
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +656 -16
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +95 -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 +79 -12
- 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,14 +145,44 @@ import {
|
|
|
116
145
|
type LiveEngine,
|
|
117
146
|
} from "./tui/play-session.ts";
|
|
118
147
|
import { EditMenu, MENU_SECTIONS, type MenuContext } from "./tui/menu.ts";
|
|
119
|
-
import {
|
|
120
|
-
|
|
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";
|
|
165
|
+
import {
|
|
166
|
+
Audition,
|
|
167
|
+
SUPERSEDED,
|
|
168
|
+
isChordStageable,
|
|
169
|
+
isStageable,
|
|
170
|
+
} from "./tui/audition.ts";
|
|
171
|
+
import { EuclidEditor, type EuclidContext } from "./tui/euclid.ts";
|
|
121
172
|
import { HINTS, KEYS, keyLines, type KeySection } from "../tui/grammar.ts";
|
|
122
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";
|
|
123
177
|
import { renderScorePcm } from "./audio/wav.ts";
|
|
124
178
|
import type { PreviewHost } from "./agent/preview-tool.ts";
|
|
125
179
|
import { rhythmVoicePitch } from "../core/rhythm.ts";
|
|
126
|
-
import {
|
|
180
|
+
import {
|
|
181
|
+
applyChordsCommand,
|
|
182
|
+
chordPhrase,
|
|
183
|
+
defaultChordSettings,
|
|
184
|
+
type ChordSettings,
|
|
185
|
+
} from "./tui/play-chords.ts";
|
|
127
186
|
import {
|
|
128
187
|
cacheLines,
|
|
129
188
|
kitListLines,
|
|
@@ -166,6 +225,7 @@ import {
|
|
|
166
225
|
} from "../core/score.ts";
|
|
167
226
|
import { reconcileRhythm } from "../core/rhythm.ts";
|
|
168
227
|
import { decodeLoop, encodeLoop } from "../core/loop.ts";
|
|
228
|
+
import { scoreToMidi } from "../core/midi.ts";
|
|
169
229
|
import type { TrackScoreSnapshot } from "../tui/render.ts";
|
|
170
230
|
import { PromptModel } from "../tui/prompt.ts";
|
|
171
231
|
import { TerminalInputDecoder } from "../tui/input.ts";
|
|
@@ -173,6 +233,7 @@ import {
|
|
|
173
233
|
composeFrame,
|
|
174
234
|
TuiApp,
|
|
175
235
|
type AppView,
|
|
236
|
+
type LoudnessView,
|
|
176
237
|
type SyncState,
|
|
177
238
|
type TypesIndicator,
|
|
178
239
|
} from "../tui/app.ts";
|
|
@@ -199,11 +260,16 @@ function parsesLocally(text: string): boolean {
|
|
|
199
260
|
parseRhythmCommand,
|
|
200
261
|
parseFxCommand,
|
|
201
262
|
parseSynthCommand,
|
|
263
|
+
parseExpressionCommand,
|
|
264
|
+
parseMasterCommand,
|
|
265
|
+
(value: string) => parseSectionCommand(value, score),
|
|
202
266
|
parsePatternCommand,
|
|
203
267
|
parseKitCommand,
|
|
204
268
|
parsePackCommand,
|
|
205
269
|
parseSampleCommand,
|
|
206
270
|
parseWavetableCommand,
|
|
271
|
+
parseTimeCommand,
|
|
272
|
+
parseTuningCommand,
|
|
207
273
|
].some((parse) => parse(text) !== undefined);
|
|
208
274
|
}
|
|
209
275
|
|
|
@@ -230,6 +296,7 @@ const FLAGS = [
|
|
|
230
296
|
"--version",
|
|
231
297
|
"-v",
|
|
232
298
|
"--reduce-motion",
|
|
299
|
+
"--no-mouse",
|
|
233
300
|
];
|
|
234
301
|
const HELP_TEXT = `dawg · local-first terminal music workstation
|
|
235
302
|
|
|
@@ -237,7 +304,8 @@ Usage:
|
|
|
237
304
|
dawg [--new] [--session <name|id>] [--track <name>]
|
|
238
305
|
dawg --import <file> --export <file>
|
|
239
306
|
dawg sessions
|
|
240
|
-
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]
|
|
241
309
|
dawg init [dir] project files: song.ts, tracks/<slug>/track.ts, .dawg/sdk
|
|
242
310
|
dawg check typecheck + evaluate the project; exit 1 on problems
|
|
243
311
|
dawg media doctor|download|stems|analyze|notes|sample|lyrics … (dawg media --help)
|
|
@@ -246,6 +314,7 @@ Usage:
|
|
|
246
314
|
Usage flags:
|
|
247
315
|
--reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
|
|
248
316
|
--theme <name> default | high-contrast | mono (NO_COLOR forces mono)
|
|
317
|
+
--no-mouse keys only; no click/wheel reporting (also DAWG_MOUSE=0)
|
|
249
318
|
|
|
250
319
|
Prompt:
|
|
251
320
|
Enter submit · Shift-Enter newline · Alt-Enter queue · Ctrl-Q toggle queue
|
|
@@ -469,9 +538,25 @@ const AGENT_PREVIEW_VOICE = 0x7fff_0002;
|
|
|
469
538
|
* instead of appending, and a remote revision that lands meanwhile waits in
|
|
470
539
|
* `committed` (the window's `score` is the staged base until it finishes).
|
|
471
540
|
*/
|
|
541
|
+
/** A `/chords …` settings command (staged as window state, not a score edit). */
|
|
542
|
+
const CHORDS_COMMAND = /^\/chords\s+(.+)$/i;
|
|
543
|
+
/** Whether the chord settings screen was open at the last menu redraw. */
|
|
544
|
+
let chordScreenWas = false;
|
|
472
545
|
let stageCapture: { next?: TrackScore; committed?: TrackScore } | undefined;
|
|
473
546
|
/** Redraw soon (the audition reports renders between frames). */
|
|
474
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>();
|
|
475
560
|
/** The decoded sampler voices, for play mode's live voices. */
|
|
476
561
|
let liveSampleBank: SampleBank | undefined;
|
|
477
562
|
/** The in-flight agent turn: Esc aborts it, Enter steers it. */
|
|
@@ -631,16 +716,29 @@ function snapshot(
|
|
|
631
716
|
beat: number,
|
|
632
717
|
activity?: string,
|
|
633
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;
|
|
634
724
|
const notes = value.notes
|
|
635
725
|
.filter((note) => note.trackId === requestedTrack)
|
|
636
|
-
.map((note) =>
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
+
});
|
|
644
742
|
return {
|
|
645
743
|
notes,
|
|
646
744
|
trackName:
|
|
@@ -649,12 +747,22 @@ function snapshot(
|
|
|
649
747
|
trackId: requestedTrack,
|
|
650
748
|
sessionId: record.sessionId,
|
|
651
749
|
revision: record.revision,
|
|
652
|
-
bpm: value.
|
|
750
|
+
bpm: value.time?.tempo
|
|
751
|
+
? bpmAtTick(value, beat * value.ticksPerBeat)
|
|
752
|
+
: value.tempoBpm,
|
|
653
753
|
key: value.key ?? undefined,
|
|
654
|
-
loopBeats: value
|
|
754
|
+
loopBeats: loopTicksOf(value) / value.ticksPerBeat,
|
|
655
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
|
+
: {}),
|
|
656
764
|
laneCount: 24,
|
|
657
|
-
currentBeat: beat,
|
|
765
|
+
currentBeat: scoreBeatAt(value, beat),
|
|
658
766
|
playing: clock.playing,
|
|
659
767
|
activity,
|
|
660
768
|
...drumSnapshotFields(
|
|
@@ -665,6 +773,9 @@ function snapshot(
|
|
|
665
773
|
value.tracks.find((track) => track.id === requestedTrack),
|
|
666
774
|
notes,
|
|
667
775
|
),
|
|
776
|
+
...(table && table.linear && table.size !== 12
|
|
777
|
+
? { tuningPeriod: { size: table.size, root: table.root } }
|
|
778
|
+
: {}),
|
|
668
779
|
layers:
|
|
669
780
|
tui.highwayView === "all"
|
|
670
781
|
? highwayLayers(
|
|
@@ -698,7 +809,9 @@ function renderOnce(
|
|
|
698
809
|
function appView(value: TrackScore, beat: number): AppView {
|
|
699
810
|
return {
|
|
700
811
|
score: snapshot(value, beat),
|
|
701
|
-
|
|
812
|
+
// The highway shows score time: inside the looped section or form pass.
|
|
813
|
+
beat: scoreBeatAt(value, beat),
|
|
814
|
+
arrange: arrangeStripView(value, beat),
|
|
702
815
|
// `opus-5.5 · gateway`, `sonnet · claude`; hidden when offline.
|
|
703
816
|
model:
|
|
704
817
|
providerName && providerName !== "offline" ? providerName : undefined,
|
|
@@ -709,6 +822,79 @@ function appView(value: TrackScore, beat: number): AppView {
|
|
|
709
822
|
windows: windowCount,
|
|
710
823
|
types: typesIndicator,
|
|
711
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,
|
|
712
898
|
};
|
|
713
899
|
}
|
|
714
900
|
|
|
@@ -765,11 +951,46 @@ function truncateForCard(value: string): string {
|
|
|
765
951
|
return line.length > 32 ? `${line.slice(0, 31)}…` : line;
|
|
766
952
|
}
|
|
767
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
|
+
|
|
768
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
|
+
});
|
|
769
990
|
stdin.setRawMode?.(true);
|
|
770
991
|
stdin.resume();
|
|
771
992
|
// Alternate screen, hidden cursor, bracketed paste.
|
|
772
|
-
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
|
|
993
|
+
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J${mouseOn()}`);
|
|
773
994
|
const inputDecoder = new TerminalInputDecoder();
|
|
774
995
|
const queuedPrompts: string[] = [];
|
|
775
996
|
let processingQueue = false;
|
|
@@ -831,6 +1052,10 @@ async function runInteractive(): Promise<void> {
|
|
|
831
1052
|
tui.render(appView(score, clock.beatAt()), { force });
|
|
832
1053
|
};
|
|
833
1054
|
requestFrame = () => tick(true);
|
|
1055
|
+
runPromptLater = (command) => {
|
|
1056
|
+
queuedPrompts.unshift(command);
|
|
1057
|
+
void drainQueue();
|
|
1058
|
+
};
|
|
834
1059
|
reportAgentActivity = () => {
|
|
835
1060
|
tui.activity.applyAgentEvent({ type: "start", model: providerName });
|
|
836
1061
|
};
|
|
@@ -857,7 +1082,7 @@ async function runInteractive(): Promise<void> {
|
|
|
857
1082
|
if (stageCapture)
|
|
858
1083
|
stageCapture.committed = scoreFromJSON(record.composition);
|
|
859
1084
|
else score = scoreFromJSON(record.composition);
|
|
860
|
-
clock.
|
|
1085
|
+
clock.follow(score);
|
|
861
1086
|
if (clock.playing) void audio.play(score);
|
|
862
1087
|
// Connected windows follow dawgd's transport frames instead.
|
|
863
1088
|
const replay =
|
|
@@ -910,6 +1135,7 @@ async function runInteractive(): Promise<void> {
|
|
|
910
1135
|
// Every window renders the same hit line from dawgd's timestamp.
|
|
911
1136
|
const { playing, beat, bpm, atMs } = update.transport;
|
|
912
1137
|
clock.setTempo(bpm);
|
|
1138
|
+
clock.setTimeMap(transportMapFor(playbackTime(score)));
|
|
913
1139
|
clock.sync(beat, playing, atMs, monotonicEpochMs());
|
|
914
1140
|
} else if (update.type === "status") {
|
|
915
1141
|
syncState = port.sync;
|
|
@@ -986,11 +1212,11 @@ async function runInteractive(): Promise<void> {
|
|
|
986
1212
|
stdin.pause();
|
|
987
1213
|
stdin.setRawMode?.(false);
|
|
988
1214
|
// Leave the alternate screen; restore the cursor and plain paste.
|
|
989
|
-
stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
1215
|
+
stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
990
1216
|
try {
|
|
991
1217
|
return await flow();
|
|
992
1218
|
} finally {
|
|
993
|
-
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
|
|
1219
|
+
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J${mouseOn()}`);
|
|
994
1220
|
stdin.setRawMode?.(true);
|
|
995
1221
|
stdin.on("data", onData);
|
|
996
1222
|
stdin.resume();
|
|
@@ -1009,7 +1235,31 @@ async function runInteractive(): Promise<void> {
|
|
|
1009
1235
|
...inputDecoder.push(text),
|
|
1010
1236
|
...(text === "\u001b" ? inputDecoder.flush() : []),
|
|
1011
1237
|
];
|
|
1012
|
-
|
|
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
|
+
}
|
|
1013
1263
|
// The `?` panel closes on any key (Ctrl-C still quits).
|
|
1014
1264
|
if (tui.ui.keys && typeof value === "string" && value !== "\u0003") {
|
|
1015
1265
|
tui.closeKeys();
|
|
@@ -1032,11 +1282,23 @@ async function runInteractive(): Promise<void> {
|
|
|
1032
1282
|
euclid.close();
|
|
1033
1283
|
tui.closePicker();
|
|
1034
1284
|
if (back === "menu") openMenu();
|
|
1285
|
+
else leaveAuditionScreen();
|
|
1035
1286
|
} else if (result.type === "run") {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1287
|
+
if (!stageIfAuditioning(result.command)) {
|
|
1288
|
+
queuedPrompts.unshift(result.command);
|
|
1289
|
+
if (result.audition) pendingAudition = result.audition;
|
|
1290
|
+
void drainQueue();
|
|
1291
|
+
}
|
|
1039
1292
|
} else if (result.type === "audition") audition(result.voice);
|
|
1293
|
+
else if (result.type === "loop") auditionKeyPressed(result.key);
|
|
1294
|
+
else if (result.type === "revert") revertStaged();
|
|
1295
|
+
else if (result.type === "keep")
|
|
1296
|
+
void keepStaged()
|
|
1297
|
+
.then((outcome) => receipt(outcome))
|
|
1298
|
+
.catch((error) =>
|
|
1299
|
+
tui.activity.pushError(describeError("keep", error)),
|
|
1300
|
+
)
|
|
1301
|
+
.finally(() => tick(true));
|
|
1040
1302
|
if (result.type !== "pass") {
|
|
1041
1303
|
refreshEuclid();
|
|
1042
1304
|
tick(true);
|
|
@@ -1060,6 +1322,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1060
1322
|
}
|
|
1061
1323
|
} else if (result.type === "audition")
|
|
1062
1324
|
auditionKeyPressed(result.key);
|
|
1325
|
+
else if (result.type === "fader") openFader(result.label);
|
|
1063
1326
|
else if (result.type === "revert") revertStaged();
|
|
1064
1327
|
else if (result.type === "hover")
|
|
1065
1328
|
hoverItem(result.command, result.key);
|
|
@@ -1083,6 +1346,16 @@ async function runInteractive(): Promise<void> {
|
|
|
1083
1346
|
}
|
|
1084
1347
|
}
|
|
1085
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
|
+
}
|
|
1086
1359
|
// Ctrl-K opens the menu on an empty prompt (in play mode too); with
|
|
1087
1360
|
// text it keeps its kill-to-end-of-line meaning.
|
|
1088
1361
|
if (
|
|
@@ -1114,7 +1387,8 @@ async function runInteractive(): Promise<void> {
|
|
|
1114
1387
|
if (
|
|
1115
1388
|
value === " " &&
|
|
1116
1389
|
prompt.value.length === 0 &&
|
|
1117
|
-
!(tui.ui.overlay === "picker" && tui.ui.picker?.audition)
|
|
1390
|
+
!(tui.ui.overlay === "picker" && tui.ui.picker?.audition) &&
|
|
1391
|
+
tui.ui.overlay !== "guide"
|
|
1118
1392
|
) {
|
|
1119
1393
|
// Never await a daemon round trip here: the key loop must stay
|
|
1120
1394
|
// live for Esc, quit and redraws while the toggle is in flight.
|
|
@@ -1245,7 +1519,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1245
1519
|
await port.close();
|
|
1246
1520
|
stdin.setRawMode?.(false);
|
|
1247
1521
|
stdin.pause();
|
|
1248
|
-
stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
1522
|
+
stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
1249
1523
|
}
|
|
1250
1524
|
}
|
|
1251
1525
|
|
|
@@ -1280,7 +1554,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1280
1554
|
}
|
|
1281
1555
|
if (/^\/?tracks$/i.test(command)) {
|
|
1282
1556
|
const problems = await sampleProblems(score);
|
|
1283
|
-
const
|
|
1557
|
+
const items = score.tracks.map((track) => {
|
|
1284
1558
|
const voices = track.sampler
|
|
1285
1559
|
? Object.keys(track.sampler.voices).length
|
|
1286
1560
|
: 0;
|
|
@@ -1290,16 +1564,39 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1290
1564
|
const samples = track.sampler
|
|
1291
1565
|
? ` · ${voices} sample${voices === 1 ? "" : "s"}${missing ? ` · ${missing} missing` : ""}`
|
|
1292
1566
|
: "";
|
|
1293
|
-
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
|
+
};
|
|
1294
1573
|
});
|
|
1295
|
-
|
|
1296
|
-
|
|
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
|
+
),
|
|
1584
|
+
});
|
|
1585
|
+
return ok(`${items.length} track${items.length === 1 ? "" : "s"}`);
|
|
1297
1586
|
}
|
|
1298
|
-
const playCommand = command.match(
|
|
1587
|
+
const playCommand = command.match(
|
|
1588
|
+
/^\/play(?:\s+(on|off|degrees|in-key|chromatic))?$/i,
|
|
1589
|
+
);
|
|
1299
1590
|
if (playCommand) {
|
|
1300
1591
|
const wanted = playCommand[1]?.toLowerCase();
|
|
1301
1592
|
if (wanted === "off" || (wanted === undefined && play?.on))
|
|
1302
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
|
+
}
|
|
1303
1600
|
return enterPlay();
|
|
1304
1601
|
}
|
|
1305
1602
|
const clickCommand = command.match(/^\/click(?:\s+(.+))?$/i);
|
|
@@ -1328,6 +1625,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1328
1625
|
}
|
|
1329
1626
|
const tryCommand = command.match(/^\/try(?:\s+(.+))?$/i);
|
|
1330
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
|
+
}
|
|
1331
1638
|
const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
|
|
1332
1639
|
if (menuCommand) {
|
|
1333
1640
|
const section = menuCommand[1]?.toLowerCase();
|
|
@@ -1353,6 +1660,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1353
1660
|
/^\/?(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/i,
|
|
1354
1661
|
);
|
|
1355
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
|
+
}
|
|
1356
1673
|
const sample = parseSampleCommand(command);
|
|
1357
1674
|
if (sample) return sampleCommand(sample);
|
|
1358
1675
|
const pack = parsePackCommand(command);
|
|
@@ -1365,6 +1682,14 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1365
1682
|
if (wavetable) return wavetableCommand(wavetable);
|
|
1366
1683
|
const sessionReply = await sessionCommand(command);
|
|
1367
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
|
+
}
|
|
1368
1693
|
const edit = parseEditCommand(command);
|
|
1369
1694
|
if (edit) {
|
|
1370
1695
|
await materializeDraft();
|
|
@@ -1400,6 +1725,17 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1400
1725
|
await commitScore(result.next, result.kind, result.payload);
|
|
1401
1726
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1402
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);
|
|
1403
1739
|
const synth = parseSynthCommand(command);
|
|
1404
1740
|
if (synth) {
|
|
1405
1741
|
if (synth.type !== "synth-list") await materializeDraft();
|
|
@@ -1408,6 +1744,34 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1408
1744
|
await commitScore(result.next, result.kind, result.payload);
|
|
1409
1745
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1410
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
|
+
}
|
|
1411
1775
|
const music = parseMusicCommand(command);
|
|
1412
1776
|
if (music) {
|
|
1413
1777
|
await materializeDraft();
|
|
@@ -1421,6 +1785,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1421
1785
|
const exportCommand = command.match(/^\/?export\s+([^\s]+)$/i);
|
|
1422
1786
|
if (exportCommand) {
|
|
1423
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
|
+
);
|
|
1424
1797
|
await writeFile(path, encodeLoop(score), "utf8");
|
|
1425
1798
|
return `exported · ${exportCommand[1]}`;
|
|
1426
1799
|
}
|
|
@@ -1537,7 +1910,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1537
1910
|
if (parsed.type === "set-tempo") {
|
|
1538
1911
|
const next = score.withTempo(parsed.tempoBpm);
|
|
1539
1912
|
await commitScore(next, "score.tempo", { tempoBpm: parsed.tempoBpm });
|
|
1540
|
-
clock.
|
|
1913
|
+
clock.follow(next);
|
|
1541
1914
|
return `tempo · ${next.tempoBpm} BPM`;
|
|
1542
1915
|
}
|
|
1543
1916
|
if (parsed.type === "add-track") return focusTrack(parsed.trackId);
|
|
@@ -1632,6 +2005,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1632
2005
|
...(typeof parsed.patch.velocity === "number"
|
|
1633
2006
|
? { velocity: parsed.patch.velocity }
|
|
1634
2007
|
: {}),
|
|
2008
|
+
...(typeof parsed.patch.cents === "number"
|
|
2009
|
+
? { cents: parsed.patch.cents }
|
|
2010
|
+
: {}),
|
|
1635
2011
|
};
|
|
1636
2012
|
const next = applyScoreOperation(score, {
|
|
1637
2013
|
type: "updateNote",
|
|
@@ -1657,6 +2033,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1657
2033
|
),
|
|
1658
2034
|
pitch: parsed.pitch,
|
|
1659
2035
|
velocity: parsed.velocity,
|
|
2036
|
+
...(parsed.cents ? { cents: parsed.cents } : {}),
|
|
1660
2037
|
};
|
|
1661
2038
|
const operation: ScoreOperation = { type: "addNote", note };
|
|
1662
2039
|
const next = applyScoreOperation(latestScore, operation);
|
|
@@ -1762,6 +2139,40 @@ function reportSampleProblems(value: TrackScore): void {
|
|
|
1762
2139
|
});
|
|
1763
2140
|
}
|
|
1764
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
|
+
|
|
1765
2176
|
async function sampleCommand(
|
|
1766
2177
|
command: NonNullable<ReturnType<typeof parseSampleCommand>>,
|
|
1767
2178
|
): Promise<Receipt> {
|
|
@@ -2331,7 +2742,7 @@ async function switchSession(sessionId: string): Promise<void> {
|
|
|
2331
2742
|
audio = port.player;
|
|
2332
2743
|
record = port.mode === "daemon" ? await port.load() : next.record;
|
|
2333
2744
|
score = scoreFromJSON(record.composition);
|
|
2334
|
-
clock.
|
|
2745
|
+
clock.follow(score);
|
|
2335
2746
|
const attached = await attachTrack(port, score, undefined);
|
|
2336
2747
|
requestedTrack = attached.trackId;
|
|
2337
2748
|
draftTrack = attached.draft;
|
|
@@ -2369,6 +2780,8 @@ function menuContext(): MenuContext {
|
|
|
2369
2780
|
committed: score,
|
|
2370
2781
|
hint: loop.hint(),
|
|
2371
2782
|
status: loop.status(),
|
|
2783
|
+
stageable: stageableNow,
|
|
2784
|
+
committedChords: chordSettings,
|
|
2372
2785
|
},
|
|
2373
2786
|
trackId: requestedTrack,
|
|
2374
2787
|
playing: clock.playing,
|
|
@@ -2376,7 +2789,7 @@ function menuContext(): MenuContext {
|
|
|
2376
2789
|
grids: GRIDS.map((grid) => grid.label),
|
|
2377
2790
|
clickOn: session?.clickOn ?? false,
|
|
2378
2791
|
countInBars: session?.countInBars ?? 1,
|
|
2379
|
-
chords: session?.chords.settings ?? chordSettings,
|
|
2792
|
+
chords: stagedChordSettings() ?? session?.chords.settings ?? chordSettings,
|
|
2380
2793
|
projectRoot: process.cwd(),
|
|
2381
2794
|
};
|
|
2382
2795
|
}
|
|
@@ -2384,6 +2797,8 @@ function menuContext(): MenuContext {
|
|
|
2384
2797
|
/** The screen `?` describes, or undefined while `?` is typed text. */
|
|
2385
2798
|
function keysScreen(): readonly KeySection[] | undefined {
|
|
2386
2799
|
if (euclid.open) return euclid.typing ? undefined : KEYS.euclid;
|
|
2800
|
+
if (fader && menu.open)
|
|
2801
|
+
return fader.typing !== undefined ? undefined : KEYS.fader;
|
|
2387
2802
|
if (menu.open) return menu.typing ? undefined : KEYS.menu;
|
|
2388
2803
|
const overlay = tui.ui.overlay;
|
|
2389
2804
|
if (overlay === "picker")
|
|
@@ -2394,6 +2809,7 @@ function keysScreen(): readonly KeySection[] | undefined {
|
|
|
2394
2809
|
: KEYS.list;
|
|
2395
2810
|
if (overlay === "text") return KEYS.text;
|
|
2396
2811
|
if (overlay === "log") return KEYS.log;
|
|
2812
|
+
if (overlay === "guide") return tui.guideTyping ? undefined : KEYS.guide;
|
|
2397
2813
|
if (prompt.value.length > 0) return undefined;
|
|
2398
2814
|
if (play?.on)
|
|
2399
2815
|
return play.chords.on ? [...KEYS.play, ...KEYS.chords] : KEYS.play;
|
|
@@ -2415,8 +2831,20 @@ function openMenu(section?: string): void {
|
|
|
2415
2831
|
refreshMenu();
|
|
2416
2832
|
}
|
|
2417
2833
|
|
|
2418
|
-
function euclidContext() {
|
|
2419
|
-
|
|
2834
|
+
function euclidContext(): EuclidContext {
|
|
2835
|
+
const loop = auditionController();
|
|
2836
|
+
loop.focus(requestedTrack);
|
|
2837
|
+
return {
|
|
2838
|
+
score: loop.staging ? loop.score : score,
|
|
2839
|
+
trackId: requestedTrack,
|
|
2840
|
+
audition: {
|
|
2841
|
+
looping: loop.looping,
|
|
2842
|
+
dirty: loop.dirtyEdits,
|
|
2843
|
+
committed: score,
|
|
2844
|
+
hint: loop.hint(),
|
|
2845
|
+
status: loop.status(),
|
|
2846
|
+
},
|
|
2847
|
+
};
|
|
2420
2848
|
}
|
|
2421
2849
|
|
|
2422
2850
|
function openEuclid(voice?: string, origin?: string): void {
|
|
@@ -2493,6 +2921,20 @@ function auditionController(): Audition {
|
|
|
2493
2921
|
clearTimer: (handle) => clearTimeout(handle as Timer),
|
|
2494
2922
|
changed: () => requestFrame(),
|
|
2495
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,
|
|
2929
|
+
// The chord settings screen loops a progression with its settings.
|
|
2930
|
+
phrase: (showing) => {
|
|
2931
|
+
if (!chordScreenOpen()) return undefined;
|
|
2932
|
+
const settings =
|
|
2933
|
+
showing === "A"
|
|
2934
|
+
? chordSettings
|
|
2935
|
+
: (stagedChordSettings() ?? chordSettings);
|
|
2936
|
+
return (base, track, bars) => chordPhrase(settings, base, track, bars);
|
|
2937
|
+
},
|
|
2496
2938
|
},
|
|
2497
2939
|
score,
|
|
2498
2940
|
requestedTrack,
|
|
@@ -2500,6 +2942,41 @@ function auditionController(): Audition {
|
|
|
2500
2942
|
return auditionLoop;
|
|
2501
2943
|
}
|
|
2502
2944
|
|
|
2945
|
+
/** True while the menu's Chords section (`/menu chords`) is open. */
|
|
2946
|
+
function chordScreenOpen(): boolean {
|
|
2947
|
+
return menu.open && menu.section === "chords";
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
/** What stages while auditioning on the screen open now. */
|
|
2951
|
+
function stageableNow(command: string): boolean {
|
|
2952
|
+
return (
|
|
2953
|
+
isStageable(command) || (chordScreenOpen() && isChordStageable(command))
|
|
2954
|
+
);
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
/**
|
|
2958
|
+
* The chord settings with the staged `/chords …` commands applied, or
|
|
2959
|
+
* undefined while none are staged. Chord settings are window state (not in
|
|
2960
|
+
* the score), so the staged ones are derived from the staged commands.
|
|
2961
|
+
*/
|
|
2962
|
+
function stagedChordSettings(): ChordSettings | undefined {
|
|
2963
|
+
const commands = auditionLoop?.dirtyEdits ? auditionLoop.commands : [];
|
|
2964
|
+
const chords = commands.filter((command) => CHORDS_COMMAND.test(command));
|
|
2965
|
+
if (chords.length === 0) return undefined;
|
|
2966
|
+
const settings = { ...chordSettings };
|
|
2967
|
+
for (const command of chords)
|
|
2968
|
+
applyChordsCommand(settings, command.match(CHORDS_COMMAND)![1]!);
|
|
2969
|
+
return settings;
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
/** Re-render the loop when the chord screen opens or closes. */
|
|
2973
|
+
function noteChordScreen(): void {
|
|
2974
|
+
const now = chordScreenOpen();
|
|
2975
|
+
if (now === chordScreenWas) return;
|
|
2976
|
+
chordScreenWas = now;
|
|
2977
|
+
auditionLoop?.request();
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2503
2980
|
/**
|
|
2504
2981
|
* Warm the pack cache for a staged command before it runs, so a lazy fetch
|
|
2505
2982
|
* (a sample kit, a pack sound or table) happens outside the capture and the
|
|
@@ -2542,6 +3019,12 @@ async function applyStaged(
|
|
|
2542
3019
|
base: TrackScore,
|
|
2543
3020
|
command: string,
|
|
2544
3021
|
): Promise<{ next?: TrackScore; ok: boolean; message: string }> {
|
|
3022
|
+
const chords = command.trim().match(CHORDS_COMMAND);
|
|
3023
|
+
if (chords) {
|
|
3024
|
+
// Window state: checked on a copy; kept settings apply on Enter.
|
|
3025
|
+
const result = applyChordsCommand({ ...chordSettings }, chords[1]!);
|
|
3026
|
+
return { next: base, ok: result.ok, message: result.message };
|
|
3027
|
+
}
|
|
2545
3028
|
await prefetchStaged(base, command);
|
|
2546
3029
|
// Staged commands run one at a time, between queued prompts.
|
|
2547
3030
|
while (stageCapture) await Bun.sleep(1);
|
|
@@ -2709,7 +3192,7 @@ function pickerAuditionKey(key: "loop" | "ab" | "context"): void {
|
|
|
2709
3192
|
/** Stage a menu command while auditioning; undefined when it should run. */
|
|
2710
3193
|
function stageIfAuditioning(command: string): boolean {
|
|
2711
3194
|
const loop = auditionLoop;
|
|
2712
|
-
if (!loop?.staging || !
|
|
3195
|
+
if (!loop?.staging || !stageableNow(command)) return false;
|
|
2713
3196
|
void loop.stage(command).then((result) => {
|
|
2714
3197
|
if (!result.ok)
|
|
2715
3198
|
tui.activity.pushCard(result.message, {
|
|
@@ -2727,12 +3210,21 @@ async function keepStaged(): Promise<Receipt> {
|
|
|
2727
3210
|
const taken = loop?.take();
|
|
2728
3211
|
if (!loop || !taken) return warn("nothing staged");
|
|
2729
3212
|
await materializeDraft();
|
|
3213
|
+
// Chord settings are window state: they apply here, outside the revision.
|
|
3214
|
+
const chordCommands = taken.commands.filter((command) =>
|
|
3215
|
+
CHORDS_COMMAND.test(command),
|
|
3216
|
+
);
|
|
3217
|
+
for (const command of chordCommands)
|
|
3218
|
+
applyChordsCommand(chordSettings, command.match(CHORDS_COMMAND)![1]!);
|
|
3219
|
+
const scoreCommands = taken.commands.filter(
|
|
3220
|
+
(command) => !CHORDS_COMMAND.test(command),
|
|
3221
|
+
);
|
|
2730
3222
|
let next = taken.score;
|
|
2731
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
3223
|
+
for (let attempt = 0; scoreCommands.length && next !== score; attempt += 1) {
|
|
2732
3224
|
try {
|
|
2733
3225
|
await commitScore(next, "preview.commit", {
|
|
2734
3226
|
trackId: loop.trackId,
|
|
2735
|
-
commands:
|
|
3227
|
+
commands: scoreCommands,
|
|
2736
3228
|
});
|
|
2737
3229
|
break;
|
|
2738
3230
|
} catch (error) {
|
|
@@ -2741,7 +3233,7 @@ async function keepStaged(): Promise<Receipt> {
|
|
|
2741
3233
|
record = await port.load();
|
|
2742
3234
|
score = scoreFromJSON(record.composition);
|
|
2743
3235
|
next = score;
|
|
2744
|
-
for (const command of
|
|
3236
|
+
for (const command of scoreCommands) {
|
|
2745
3237
|
const result = await applyStaged(next, command);
|
|
2746
3238
|
if (result.next) next = result.next;
|
|
2747
3239
|
}
|
|
@@ -2749,8 +3241,11 @@ async function keepStaged(): Promise<Receipt> {
|
|
|
2749
3241
|
}
|
|
2750
3242
|
loop.committedNow(score);
|
|
2751
3243
|
await projectSync?.flushScore();
|
|
3244
|
+
const kept = `kept ${taken.commands.length} change${taken.commands.length === 1 ? "" : "s"}`;
|
|
2752
3245
|
return ok(
|
|
2753
|
-
|
|
3246
|
+
scoreCommands.length
|
|
3247
|
+
? `${kept} · one undo step`
|
|
3248
|
+
: `${kept} · chord settings (window state, no revision)`,
|
|
2754
3249
|
);
|
|
2755
3250
|
}
|
|
2756
3251
|
|
|
@@ -2772,14 +3267,29 @@ function leaveAuditionScreen(): void {
|
|
|
2772
3267
|
}
|
|
2773
3268
|
|
|
2774
3269
|
function refreshMenu(): void {
|
|
2775
|
-
|
|
3270
|
+
noteChordScreen();
|
|
3271
|
+
if (!menu.open) {
|
|
3272
|
+
closeFader();
|
|
3273
|
+
return;
|
|
3274
|
+
}
|
|
2776
3275
|
if (tui.ui.overlay !== undefined && tui.ui.picker?.id !== "menu") {
|
|
2777
3276
|
// Another overlay (help, a picker) replaced the menu.
|
|
2778
3277
|
menu.close();
|
|
2779
3278
|
leaveAuditionScreen();
|
|
2780
3279
|
return;
|
|
2781
3280
|
}
|
|
2782
|
-
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);
|
|
2783
3293
|
tui.openPicker({
|
|
2784
3294
|
id: "menu",
|
|
2785
3295
|
title: view.title,
|
|
@@ -2792,6 +3302,216 @@ function refreshMenu(): void {
|
|
|
2792
3302
|
});
|
|
2793
3303
|
}
|
|
2794
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
|
+
|
|
2795
3515
|
function playSession(): PlaySession {
|
|
2796
3516
|
if (play && play.track === requestedTrack) return play;
|
|
2797
3517
|
const previous = play;
|
|
@@ -2863,6 +3583,7 @@ function playHost() {
|
|
|
2863
3583
|
now: () => performance.now(),
|
|
2864
3584
|
playing: () => clock.playing,
|
|
2865
3585
|
beatAt: (ms: number) => clock.beatAt(ms),
|
|
3586
|
+
scoreBeat: (beat: number) => scoreBeatAt(score, beat),
|
|
2866
3587
|
engine: liveEngine,
|
|
2867
3588
|
samples: () => liveSampleBank,
|
|
2868
3589
|
async commit(
|
|
@@ -2929,20 +3650,33 @@ async function commitScore(
|
|
|
2929
3650
|
// Rhythm rows regenerate after a loop resize and freeze when their lane
|
|
2930
3651
|
// is hand-edited, so rows and notes never disagree.
|
|
2931
3652
|
next = reconcileRhythm(score, next);
|
|
3653
|
+
const retimed = timingChanged(score, next);
|
|
2932
3654
|
if (stageCapture) {
|
|
2933
3655
|
// A staged edit: the audition plays it; nothing is written yet.
|
|
2934
3656
|
stageCapture.next = next;
|
|
2935
3657
|
score = next;
|
|
3658
|
+
if (retimed) clock.follow(score);
|
|
2936
3659
|
return;
|
|
2937
3660
|
}
|
|
2938
3661
|
record = await port.append(record, { kind, payload }, next.toJSON());
|
|
2939
3662
|
score = next;
|
|
3663
|
+
if (retimed) clock.follow(score);
|
|
2940
3664
|
if (clock.playing) void audio.play(score);
|
|
2941
3665
|
projectSync?.scoreChanged(score);
|
|
2942
3666
|
reportSampleProblems(score);
|
|
2943
3667
|
void updateCredits(score);
|
|
2944
3668
|
}
|
|
2945
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
|
+
|
|
2946
3680
|
/** The window's side of the project file sync (see src/project/sync.ts). */
|
|
2947
3681
|
function syncHost(): SyncHost {
|
|
2948
3682
|
return {
|
|
@@ -2960,7 +3694,7 @@ function syncHost(): SyncHost {
|
|
|
2960
3694
|
plan.next.toJSON(),
|
|
2961
3695
|
);
|
|
2962
3696
|
score = scoreFromJSON(record.composition);
|
|
2963
|
-
clock.
|
|
3697
|
+
clock.follow(score);
|
|
2964
3698
|
if (clock.playing) void audio.play(score);
|
|
2965
3699
|
reportSampleProblems(score);
|
|
2966
3700
|
void baseRevision;
|
|
@@ -3067,6 +3801,17 @@ async function setTransport(
|
|
|
3067
3801
|
}
|
|
3068
3802
|
}
|
|
3069
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
|
+
|
|
3070
3815
|
/**
|
|
3071
3816
|
* Run one streaming tool-calling turn. Each validated tool call commits its
|
|
3072
3817
|
* own revision through `agentHost`, so cancelling keeps every accepted change
|
|
@@ -3171,6 +3916,25 @@ function mediaServices(): MediaServices {
|
|
|
3171
3916
|
return { runner: systemRunner, env: process.env };
|
|
3172
3917
|
}
|
|
3173
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
|
+
|
|
3174
3938
|
/**
|
|
3175
3939
|
* How the agent's preview_sound renders (with this window's decoded
|
|
3176
3940
|
* samples, at the engine's rate) and plays: once, over silence, unless the
|
|
@@ -3185,6 +3949,16 @@ function agentPreviewHost(): PreviewHost {
|
|
|
3185
3949
|
...(liveSampleBank ? { samples: liveSampleBank } : {}),
|
|
3186
3950
|
});
|
|
3187
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
|
+
},
|
|
3188
3962
|
play: async (rendered) => {
|
|
3189
3963
|
if (!agentPreviewPlays || clock.playing || auditionLoop?.looping)
|
|
3190
3964
|
return false;
|
|
@@ -3253,8 +4027,13 @@ function agentHost(
|
|
|
3253
4027
|
);
|
|
3254
4028
|
throw error;
|
|
3255
4029
|
}
|
|
3256
|
-
if (
|
|
3257
|
-
|
|
4030
|
+
if (
|
|
4031
|
+
change.operations.some(
|
|
4032
|
+
(operation) =>
|
|
4033
|
+
operation.type === "setTempo" || operation.type === "setTime",
|
|
4034
|
+
)
|
|
4035
|
+
)
|
|
4036
|
+
clock.follow(score);
|
|
3258
4037
|
return { revision: record.revision };
|
|
3259
4038
|
},
|
|
3260
4039
|
async transport(action) {
|