@hraness/dawg 0.4.1 → 0.6.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 +99 -0
- package/DAWG.md +551 -33
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +41 -12
- package/core/expression.ts +1241 -0
- package/core/fx.ts +437 -2
- package/core/granular.ts +528 -0
- package/core/instruments.ts +281 -0
- package/core/keys.ts +386 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/resonators.ts +574 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +717 -62
- package/core/sdk/eval-child.ts +40 -3
- package/core/sdk/print.ts +614 -27
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2824 -45
- package/core/sections.ts +2072 -0
- package/core/strings.ts +845 -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 +29 -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 +29 -0
- package/guides/tempo.md +27 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +9 -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/granular-tools.ts +138 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +68 -6
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +27 -4
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +574 -16
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +9 -0
- package/src/audio/arrange.ts +489 -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/dsp/bank.ts +233 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +6 -1
- 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/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/effects/space.ts +92 -2
- package/src/audio/engine.ts +100 -21
- package/src/audio/fit.ts +402 -0
- package/src/audio/granular.ts +664 -0
- package/src/audio/instrument-check.ts +133 -0
- package/src/audio/instruments.ts +111 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/engine.ts +361 -0
- package/src/audio/keys/piano.ts +432 -0
- package/src/audio/live-worker.ts +54 -0
- package/src/audio/live.ts +263 -17
- package/src/audio/loudness.ts +596 -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 +76 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +269 -34
- package/src/audio/samples.ts +30 -7
- package/src/audio/strings/body.ts +250 -0
- package/src/audio/strings/engine.ts +369 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +198 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +147 -0
- package/src/audio/wav.ts +377 -30
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +941 -0
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +31 -0
- package/src/commands/granular.ts +427 -0
- package/src/commands/help.ts +355 -27
- package/src/commands/keys.ts +353 -0
- package/src/commands/master.ts +361 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +967 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +919 -41
- package/src/project/check.ts +13 -0
- package/src/render.ts +139 -8
- 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/granular-menu.ts +278 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +967 -23
- package/src/tui/modal-menu.ts +118 -0
- 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 +459 -43
- 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,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import {
|
|
3
|
+
isUnknownInstrument,
|
|
4
|
+
plainSineAdvice,
|
|
5
|
+
} from "./audio/instrument-check.ts";
|
|
2
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { readFileSync, writeSync } from "node:fs";
|
|
3
8
|
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
4
9
|
import { join, relative, resolve } from "node:path";
|
|
5
10
|
import { stdin, stdout } from "node:process";
|
|
@@ -42,8 +47,64 @@ import {
|
|
|
42
47
|
} from "./commands/drums.ts";
|
|
43
48
|
import { kitCatalog } from "./audio/kits.ts";
|
|
44
49
|
import { applyEditCommand, parseEditCommand } from "./commands/edit.ts";
|
|
45
|
-
import {
|
|
50
|
+
import { applyTimeCommand, parseTimeCommand } from "./commands/time.ts";
|
|
51
|
+
import {
|
|
52
|
+
barStartTick,
|
|
53
|
+
bpmAtTick,
|
|
54
|
+
hasMeterChanges,
|
|
55
|
+
loopTicksOf,
|
|
56
|
+
} from "../core/tempo.ts";
|
|
57
|
+
import {
|
|
58
|
+
applyExpressionCommand,
|
|
59
|
+
parseExpressionCommand,
|
|
60
|
+
} from "./commands/expression.ts";
|
|
61
|
+
import {
|
|
62
|
+
applyFxCommand,
|
|
63
|
+
parseFxCommand,
|
|
64
|
+
unknownFxMessage,
|
|
65
|
+
} from "./commands/fx.ts";
|
|
66
|
+
import {
|
|
67
|
+
applyRigCommand,
|
|
68
|
+
parseRigCommand,
|
|
69
|
+
rigTrackFields,
|
|
70
|
+
rigWordPatch,
|
|
71
|
+
} from "./commands/rig.ts";
|
|
72
|
+
import {
|
|
73
|
+
applyModalCommand,
|
|
74
|
+
modalListLines,
|
|
75
|
+
parseModalCommand,
|
|
76
|
+
} from "./commands/modal.ts";
|
|
46
77
|
import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
|
|
78
|
+
import { applyStringCommand, parseStringCommand } from "./commands/string.ts";
|
|
79
|
+
import { instrumentPatchForWord, isModalWord } from "../core/resonators.ts";
|
|
80
|
+
import {
|
|
81
|
+
applyGranularCommand,
|
|
82
|
+
granularTrackPreset,
|
|
83
|
+
grainSrcHint,
|
|
84
|
+
parseGranularCommand,
|
|
85
|
+
} from "./commands/granular.ts";
|
|
86
|
+
import {
|
|
87
|
+
applyKeysCommand,
|
|
88
|
+
newPianoTrack,
|
|
89
|
+
parseKeysCommand,
|
|
90
|
+
} from "./commands/keys.ts";
|
|
91
|
+
import {
|
|
92
|
+
applyTuningCommand,
|
|
93
|
+
importTuningFile,
|
|
94
|
+
parseTuningCommand,
|
|
95
|
+
type TuningCommand,
|
|
96
|
+
} from "./commands/tuning.ts";
|
|
97
|
+
import { TuningError, displayTag, resolveTuning } from "../core/tuning.ts";
|
|
98
|
+
import {
|
|
99
|
+
applyMasterCommand,
|
|
100
|
+
measurementLine,
|
|
101
|
+
parseMasterCommand,
|
|
102
|
+
} from "./commands/master.ts";
|
|
103
|
+
import { exportSampleRate, measureScoreOffThread } from "./audio/measure.ts";
|
|
104
|
+
import {
|
|
105
|
+
applySectionCommand,
|
|
106
|
+
parseSectionCommand,
|
|
107
|
+
} from "./commands/arrange.ts";
|
|
47
108
|
import {
|
|
48
109
|
HELP_TOPICS,
|
|
49
110
|
helpText,
|
|
@@ -65,9 +126,12 @@ import {
|
|
|
65
126
|
samplerTarget,
|
|
66
127
|
voiceNameFrom,
|
|
67
128
|
} from "./commands/sample.ts";
|
|
129
|
+
import { applyFitCommand, fitVoice, parseFitCommand } from "./commands/fit.ts";
|
|
130
|
+
import { suggestFitMode } from "./audio/dsp/onset.ts";
|
|
68
131
|
import {
|
|
69
132
|
SampleLibrary,
|
|
70
133
|
hasSamplerTracks,
|
|
134
|
+
sampleKey,
|
|
71
135
|
type SampleBank,
|
|
72
136
|
type SampleProblem,
|
|
73
137
|
} from "./audio/samples.ts";
|
|
@@ -106,7 +170,7 @@ import {
|
|
|
106
170
|
tuiLoginArgs,
|
|
107
171
|
tuiSetModel,
|
|
108
172
|
} from "./auth/tui.ts";
|
|
109
|
-
import { TransportClock } from "./audio/clock.ts";
|
|
173
|
+
import { TransportClock, transportMapFor } from "./audio/clock.ts";
|
|
110
174
|
import { AudioEngine } from "./audio/engine.ts";
|
|
111
175
|
import {
|
|
112
176
|
DEFAULT_GRID,
|
|
@@ -116,6 +180,23 @@ import {
|
|
|
116
180
|
type LiveEngine,
|
|
117
181
|
} from "./tui/play-session.ts";
|
|
118
182
|
import { EditMenu, MENU_SECTIONS, type MenuContext } from "./tui/menu.ts";
|
|
183
|
+
import {
|
|
184
|
+
drawerView,
|
|
185
|
+
faderChoose,
|
|
186
|
+
faderKeyPress,
|
|
187
|
+
faderSetPosition,
|
|
188
|
+
faderStep,
|
|
189
|
+
focusIndex,
|
|
190
|
+
type FaderResult,
|
|
191
|
+
type FaderState,
|
|
192
|
+
} from "./tui/fader.ts";
|
|
193
|
+
import {
|
|
194
|
+
isMouseSequence,
|
|
195
|
+
MOUSE_OFF,
|
|
196
|
+
MOUSE_ON,
|
|
197
|
+
parseMouse,
|
|
198
|
+
type MouseEvent,
|
|
199
|
+
} from "../tui/keys.ts";
|
|
119
200
|
import {
|
|
120
201
|
Audition,
|
|
121
202
|
SUPERSEDED,
|
|
@@ -125,6 +206,9 @@ import {
|
|
|
125
206
|
import { EuclidEditor, type EuclidContext } from "./tui/euclid.ts";
|
|
126
207
|
import { HINTS, KEYS, keyLines, type KeySection } from "../tui/grammar.ts";
|
|
127
208
|
import { renderAudition } from "./audio/audition.ts";
|
|
209
|
+
import { exportScore, playbackTime, scoreBeatAt } from "./audio/arrange.ts";
|
|
210
|
+
import { formatForm } from "../core/sections.ts";
|
|
211
|
+
import type { ArrangeStripView } from "../tui/arrange-strip.ts";
|
|
128
212
|
import { renderScorePcm } from "./audio/wav.ts";
|
|
129
213
|
import type { PreviewHost } from "./agent/preview-tool.ts";
|
|
130
214
|
import { rhythmVoicePitch } from "../core/rhythm.ts";
|
|
@@ -167,6 +251,7 @@ import {
|
|
|
167
251
|
addNote,
|
|
168
252
|
applyScoreOperation,
|
|
169
253
|
createScore,
|
|
254
|
+
isSamplerInstrument,
|
|
170
255
|
PACK_PREFIX,
|
|
171
256
|
scoreFromJSON,
|
|
172
257
|
SCORE_LIMITS,
|
|
@@ -176,6 +261,7 @@ import {
|
|
|
176
261
|
} from "../core/score.ts";
|
|
177
262
|
import { reconcileRhythm } from "../core/rhythm.ts";
|
|
178
263
|
import { decodeLoop, encodeLoop } from "../core/loop.ts";
|
|
264
|
+
import { scoreToMidi } from "../core/midi.ts";
|
|
179
265
|
import type { TrackScoreSnapshot } from "../tui/render.ts";
|
|
180
266
|
import { PromptModel } from "../tui/prompt.ts";
|
|
181
267
|
import { TerminalInputDecoder } from "../tui/input.ts";
|
|
@@ -183,6 +269,7 @@ import {
|
|
|
183
269
|
composeFrame,
|
|
184
270
|
TuiApp,
|
|
185
271
|
type AppView,
|
|
272
|
+
type LoudnessView,
|
|
186
273
|
type SyncState,
|
|
187
274
|
type TypesIndicator,
|
|
188
275
|
} from "../tui/app.ts";
|
|
@@ -209,11 +296,22 @@ function parsesLocally(text: string): boolean {
|
|
|
209
296
|
parseRhythmCommand,
|
|
210
297
|
parseFxCommand,
|
|
211
298
|
parseSynthCommand,
|
|
299
|
+
parseStringCommand,
|
|
300
|
+
parseGranularCommand,
|
|
301
|
+
parseKeysCommand,
|
|
302
|
+
parseExpressionCommand,
|
|
303
|
+
parseMasterCommand,
|
|
304
|
+
(value: string) => parseSectionCommand(value, score),
|
|
212
305
|
parsePatternCommand,
|
|
213
306
|
parseKitCommand,
|
|
214
307
|
parsePackCommand,
|
|
215
308
|
parseSampleCommand,
|
|
309
|
+
parseFitCommand,
|
|
216
310
|
parseWavetableCommand,
|
|
311
|
+
parseTimeCommand,
|
|
312
|
+
parseTuningCommand,
|
|
313
|
+
parseRigCommand,
|
|
314
|
+
parseModalCommand,
|
|
217
315
|
].some((parse) => parse(text) !== undefined);
|
|
218
316
|
}
|
|
219
317
|
|
|
@@ -240,6 +338,7 @@ const FLAGS = [
|
|
|
240
338
|
"--version",
|
|
241
339
|
"-v",
|
|
242
340
|
"--reduce-motion",
|
|
341
|
+
"--no-mouse",
|
|
243
342
|
];
|
|
244
343
|
const HELP_TEXT = `dawg · local-first terminal music workstation
|
|
245
344
|
|
|
@@ -247,7 +346,8 @@ Usage:
|
|
|
247
346
|
dawg [--new] [--session <name|id>] [--track <name>]
|
|
248
347
|
dawg --import <file> --export <file>
|
|
249
348
|
dawg sessions
|
|
250
|
-
dawg render <out.wav> [--session <name|id>] [--import <file>]
|
|
349
|
+
dawg render <out.wav|out.mid> [--session <name|id>] [--import <file>]
|
|
350
|
+
[--normalize <lufs|streaming|club|loud|…>] [--measure]
|
|
251
351
|
dawg init [dir] project files: song.ts, tracks/<slug>/track.ts, .dawg/sdk
|
|
252
352
|
dawg check typecheck + evaluate the project; exit 1 on problems
|
|
253
353
|
dawg media doctor|download|stems|analyze|notes|sample|lyrics … (dawg media --help)
|
|
@@ -256,6 +356,7 @@ Usage:
|
|
|
256
356
|
Usage flags:
|
|
257
357
|
--reduce-motion static hit/sustain states (also DAWG_REDUCE_MOTION=1)
|
|
258
358
|
--theme <name> default | high-contrast | mono (NO_COLOR forces mono)
|
|
359
|
+
--no-mouse keys only; no click/wheel reporting (also DAWG_MOUSE=0)
|
|
259
360
|
|
|
260
361
|
Prompt:
|
|
261
362
|
Enter submit · Shift-Enter newline · Alt-Enter queue · Ctrl-Q toggle queue
|
|
@@ -357,7 +458,13 @@ if (!demo && process.env.DAWG_AI !== "0" && stdout.isTTY)
|
|
|
357
458
|
|
|
358
459
|
const initial = createScore({
|
|
359
460
|
tracks: [
|
|
360
|
-
{
|
|
461
|
+
{
|
|
462
|
+
id: requestedTrack,
|
|
463
|
+
name: requestedTrack,
|
|
464
|
+
instrument: initialInstrument,
|
|
465
|
+
// `dawg jangle`: a guitar alias starts with its voice and rig.
|
|
466
|
+
...rigTrackFields(requestedTrack),
|
|
467
|
+
},
|
|
361
468
|
],
|
|
362
469
|
});
|
|
363
470
|
const sessionOptions: { sessionId?: string; setCurrent: boolean } = {
|
|
@@ -486,6 +593,18 @@ let chordScreenWas = false;
|
|
|
486
593
|
let stageCapture: { next?: TrackScore; committed?: TrackScore } | undefined;
|
|
487
594
|
/** Redraw soon (the audition reports renders between frames). */
|
|
488
595
|
let requestFrame: () => void = () => undefined;
|
|
596
|
+
/** Queue a prompt to run as if typed (set by the interactive loop). */
|
|
597
|
+
let runPromptLater: (command: string) => void = () => undefined;
|
|
598
|
+
/** The fader drawer's focus and typing, while one is open over the menu. */
|
|
599
|
+
let fader: FaderState | undefined;
|
|
600
|
+
const KEY_UP = "\u001b[A";
|
|
601
|
+
const KEY_DOWN = "\u001b[B";
|
|
602
|
+
/** The fader bar a left-button drag started on. */
|
|
603
|
+
let dragging: { field: number; left: number; width: number } | undefined;
|
|
604
|
+
/** The open drawer came from a command, not the menu. */
|
|
605
|
+
let faderStandalone = false;
|
|
606
|
+
/** Per-field sequence: a drag drops stale stages that would land late. */
|
|
607
|
+
const faderSeq = new Map<string, number>();
|
|
489
608
|
/** The decoded sampler voices, for play mode's live voices. */
|
|
490
609
|
let liveSampleBank: SampleBank | undefined;
|
|
491
610
|
/** The in-flight agent turn: Esc aborts it, Enter steers it. */
|
|
@@ -645,16 +764,29 @@ function snapshot(
|
|
|
645
764
|
beat: number,
|
|
646
765
|
activity?: string,
|
|
647
766
|
): TrackScoreSnapshot {
|
|
767
|
+
const focused = value.tracks.find((track) => track.id === requestedTrack);
|
|
768
|
+
const table =
|
|
769
|
+
focused && !isDrumInstrument(focused.instrument)
|
|
770
|
+
? resolveTuning(value.tuning, focused.tuning, value.key)
|
|
771
|
+
: undefined;
|
|
648
772
|
const notes = value.notes
|
|
649
773
|
.filter((note) => note.trackId === requestedTrack)
|
|
650
|
-
.map((note) =>
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
774
|
+
.map((note) => {
|
|
775
|
+
const tag =
|
|
776
|
+
focused && !isDrumInstrument(focused.instrument)
|
|
777
|
+
? displayTag(table, note.pitch, note.cents)
|
|
778
|
+
: undefined;
|
|
779
|
+
return {
|
|
780
|
+
id: note.id,
|
|
781
|
+
startBeat: note.startTick / value.ticksPerBeat,
|
|
782
|
+
durationBeats: note.durationTicks / value.ticksPerBeat,
|
|
783
|
+
pitch: note.pitch,
|
|
784
|
+
velocity: note.velocity,
|
|
785
|
+
muted: focused?.muted,
|
|
786
|
+
...(tag?.cents !== undefined ? { cents: tag.cents } : {}),
|
|
787
|
+
...(tag?.name !== undefined ? { centsFrom: tag.name } : {}),
|
|
788
|
+
};
|
|
789
|
+
});
|
|
658
790
|
return {
|
|
659
791
|
notes,
|
|
660
792
|
trackName:
|
|
@@ -663,12 +795,22 @@ function snapshot(
|
|
|
663
795
|
trackId: requestedTrack,
|
|
664
796
|
sessionId: record.sessionId,
|
|
665
797
|
revision: record.revision,
|
|
666
|
-
bpm: value.
|
|
798
|
+
bpm: value.time?.tempo
|
|
799
|
+
? bpmAtTick(value, beat * value.ticksPerBeat)
|
|
800
|
+
: value.tempoBpm,
|
|
667
801
|
key: value.key ?? undefined,
|
|
668
|
-
loopBeats: value
|
|
802
|
+
loopBeats: loopTicksOf(value) / value.ticksPerBeat,
|
|
669
803
|
beatsPerBar: value.beatsPerBar,
|
|
804
|
+
...(hasMeterChanges(value)
|
|
805
|
+
? {
|
|
806
|
+
barBeats: Array.from(
|
|
807
|
+
{ length: value.bars },
|
|
808
|
+
(_, bar) => barStartTick(value, bar) / value.ticksPerBeat,
|
|
809
|
+
),
|
|
810
|
+
}
|
|
811
|
+
: {}),
|
|
670
812
|
laneCount: 24,
|
|
671
|
-
currentBeat: beat,
|
|
813
|
+
currentBeat: scoreBeatAt(value, beat),
|
|
672
814
|
playing: clock.playing,
|
|
673
815
|
activity,
|
|
674
816
|
...drumSnapshotFields(
|
|
@@ -679,6 +821,9 @@ function snapshot(
|
|
|
679
821
|
value.tracks.find((track) => track.id === requestedTrack),
|
|
680
822
|
notes,
|
|
681
823
|
),
|
|
824
|
+
...(table && table.linear && table.size !== 12
|
|
825
|
+
? { tuningPeriod: { size: table.size, root: table.root } }
|
|
826
|
+
: {}),
|
|
682
827
|
layers:
|
|
683
828
|
tui.highwayView === "all"
|
|
684
829
|
? highwayLayers(
|
|
@@ -712,7 +857,9 @@ function renderOnce(
|
|
|
712
857
|
function appView(value: TrackScore, beat: number): AppView {
|
|
713
858
|
return {
|
|
714
859
|
score: snapshot(value, beat),
|
|
715
|
-
|
|
860
|
+
// The highway shows score time: inside the looped section or form pass.
|
|
861
|
+
beat: scoreBeatAt(value, beat),
|
|
862
|
+
arrange: arrangeStripView(value, beat),
|
|
716
863
|
// `opus-5.5 · gateway`, `sonnet · claude`; hidden when offline.
|
|
717
864
|
model:
|
|
718
865
|
providerName && providerName !== "offline" ? providerName : undefined,
|
|
@@ -723,6 +870,79 @@ function appView(value: TrackScore, beat: number): AppView {
|
|
|
723
870
|
windows: windowCount,
|
|
724
871
|
types: typesIndicator,
|
|
725
872
|
play: play?.on ? play.header() : undefined,
|
|
873
|
+
loudness: masterLoudness(value),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* The 48 kHz reading of the mastered song (what `master measure` and the
|
|
879
|
+
* export read), for the score it measured; one measurement at a time, in a
|
|
880
|
+
* worker, started when the loop plays a mastered score it has not read.
|
|
881
|
+
*/
|
|
882
|
+
let exportMeter:
|
|
883
|
+
{ score: TrackScore; view?: LoudnessView; running: boolean } | undefined;
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* The header meter: loudness of the loop the local engine is playing. With
|
|
887
|
+
* a master the engine monitors at its own rate, so the meter shows the
|
|
888
|
+
* export-rate reading once it is in and the monitor's (marked as an
|
|
889
|
+
* estimate) until then.
|
|
890
|
+
*/
|
|
891
|
+
function masterLoudness(value: TrackScore): LoudnessView | undefined {
|
|
892
|
+
const engine = audio instanceof AudioEngine ? audio : monitorEngine;
|
|
893
|
+
const report = engine?.loudness;
|
|
894
|
+
if (!report) return undefined;
|
|
895
|
+
const ceiling =
|
|
896
|
+
typeof value.master?.limiter?.ceiling === "number"
|
|
897
|
+
? value.master.limiter.ceiling
|
|
898
|
+
: value.master?.limiter
|
|
899
|
+
? -1
|
|
900
|
+
: undefined;
|
|
901
|
+
const target =
|
|
902
|
+
typeof value.master?.target === "number" ? value.master.target : undefined;
|
|
903
|
+
const monitor: LoudnessView = {
|
|
904
|
+
integrated: report.integrated,
|
|
905
|
+
truePeak: report.truePeak,
|
|
906
|
+
target,
|
|
907
|
+
ceiling,
|
|
908
|
+
};
|
|
909
|
+
if (!value.master || exportSampleRate(value) === engine.sampleRate)
|
|
910
|
+
return monitor;
|
|
911
|
+
if (exportMeter?.score === value && exportMeter.view) return exportMeter.view;
|
|
912
|
+
if (!exportMeter?.running) {
|
|
913
|
+
const measuring = { score: value, running: true } as NonNullable<
|
|
914
|
+
typeof exportMeter
|
|
915
|
+
>;
|
|
916
|
+
exportMeter = measuring;
|
|
917
|
+
void measureScoreOffThread(value, { projectRoot: process.cwd() })
|
|
918
|
+
.then((measured) => {
|
|
919
|
+
measuring.view = {
|
|
920
|
+
integrated: measured.mix.loudness.integrated,
|
|
921
|
+
truePeak: measured.mix.loudness.truePeak,
|
|
922
|
+
target,
|
|
923
|
+
ceiling,
|
|
924
|
+
};
|
|
925
|
+
})
|
|
926
|
+
.catch(() => undefined)
|
|
927
|
+
.finally(() => {
|
|
928
|
+
measuring.running = false;
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
return { ...monitor, estimate: true };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** The arrangement strip over the timeline; absent without sections. */
|
|
935
|
+
function arrangeStripView(
|
|
936
|
+
value: TrackScore,
|
|
937
|
+
beat: number,
|
|
938
|
+
): ArrangeStripView | undefined {
|
|
939
|
+
if (value.sections.length === 0) return undefined;
|
|
940
|
+
return {
|
|
941
|
+
bars: value.bars,
|
|
942
|
+
sections: value.sections,
|
|
943
|
+
loop: value.loopSection,
|
|
944
|
+
playheadBar: scoreBeatAt(value, beat) / value.beatsPerBar,
|
|
945
|
+
form: value.form.length ? formatForm(value.form) : undefined,
|
|
726
946
|
};
|
|
727
947
|
}
|
|
728
948
|
|
|
@@ -779,11 +999,46 @@ function truncateForCard(value: string): string {
|
|
|
779
999
|
return line.length > 32 ? `${line.slice(0, 31)}…` : line;
|
|
780
1000
|
}
|
|
781
1001
|
|
|
1002
|
+
/**
|
|
1003
|
+
* Mouse reporting is on unless `--no-mouse` or `DAWG_MOUSE=0` (or a dumb
|
|
1004
|
+
* terminal) says otherwise. A terminal without mouse support ignores the
|
|
1005
|
+
* modes and every key still works.
|
|
1006
|
+
*/
|
|
1007
|
+
function mouseEnabled(): boolean {
|
|
1008
|
+
return (
|
|
1009
|
+
!process.argv.includes("--no-mouse") &&
|
|
1010
|
+
process.env.DAWG_MOUSE !== "0" &&
|
|
1011
|
+
process.env.TERM !== "dumb"
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function mouseOn(): string {
|
|
1016
|
+
return mouseEnabled() ? MOUSE_ON : "";
|
|
1017
|
+
}
|
|
1018
|
+
|
|
782
1019
|
async function runInteractive(): Promise<void> {
|
|
1020
|
+
// A crash or a stray process.exit must not leave the terminal reporting
|
|
1021
|
+
// mouse events into the shell.
|
|
1022
|
+
process.once("exit", () => {
|
|
1023
|
+
if (mouseEnabled()) writeSync(1, MOUSE_OFF);
|
|
1024
|
+
});
|
|
1025
|
+
// Nor may a kill or a closed terminal: restore it, then exit as killed.
|
|
1026
|
+
for (const [signal, code] of [
|
|
1027
|
+
["SIGTERM", 143],
|
|
1028
|
+
["SIGHUP", 129],
|
|
1029
|
+
] as const)
|
|
1030
|
+
process.once(signal, () => {
|
|
1031
|
+
try {
|
|
1032
|
+
writeSync(1, `${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
1033
|
+
} catch {
|
|
1034
|
+
// The terminal may already be gone (SIGHUP).
|
|
1035
|
+
}
|
|
1036
|
+
process.exit(code);
|
|
1037
|
+
});
|
|
783
1038
|
stdin.setRawMode?.(true);
|
|
784
1039
|
stdin.resume();
|
|
785
1040
|
// Alternate screen, hidden cursor, bracketed paste.
|
|
786
|
-
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
|
|
1041
|
+
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J${mouseOn()}`);
|
|
787
1042
|
const inputDecoder = new TerminalInputDecoder();
|
|
788
1043
|
const queuedPrompts: string[] = [];
|
|
789
1044
|
let processingQueue = false;
|
|
@@ -845,6 +1100,10 @@ async function runInteractive(): Promise<void> {
|
|
|
845
1100
|
tui.render(appView(score, clock.beatAt()), { force });
|
|
846
1101
|
};
|
|
847
1102
|
requestFrame = () => tick(true);
|
|
1103
|
+
runPromptLater = (command) => {
|
|
1104
|
+
queuedPrompts.unshift(command);
|
|
1105
|
+
void drainQueue();
|
|
1106
|
+
};
|
|
848
1107
|
reportAgentActivity = () => {
|
|
849
1108
|
tui.activity.applyAgentEvent({ type: "start", model: providerName });
|
|
850
1109
|
};
|
|
@@ -871,7 +1130,7 @@ async function runInteractive(): Promise<void> {
|
|
|
871
1130
|
if (stageCapture)
|
|
872
1131
|
stageCapture.committed = scoreFromJSON(record.composition);
|
|
873
1132
|
else score = scoreFromJSON(record.composition);
|
|
874
|
-
clock.
|
|
1133
|
+
clock.follow(score);
|
|
875
1134
|
if (clock.playing) void audio.play(score);
|
|
876
1135
|
// Connected windows follow dawgd's transport frames instead.
|
|
877
1136
|
const replay =
|
|
@@ -924,6 +1183,7 @@ async function runInteractive(): Promise<void> {
|
|
|
924
1183
|
// Every window renders the same hit line from dawgd's timestamp.
|
|
925
1184
|
const { playing, beat, bpm, atMs } = update.transport;
|
|
926
1185
|
clock.setTempo(bpm);
|
|
1186
|
+
clock.setTimeMap(transportMapFor(playbackTime(score)));
|
|
927
1187
|
clock.sync(beat, playing, atMs, monotonicEpochMs());
|
|
928
1188
|
} else if (update.type === "status") {
|
|
929
1189
|
syncState = port.sync;
|
|
@@ -1000,11 +1260,11 @@ async function runInteractive(): Promise<void> {
|
|
|
1000
1260
|
stdin.pause();
|
|
1001
1261
|
stdin.setRawMode?.(false);
|
|
1002
1262
|
// Leave the alternate screen; restore the cursor and plain paste.
|
|
1003
|
-
stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
1263
|
+
stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
1004
1264
|
try {
|
|
1005
1265
|
return await flow();
|
|
1006
1266
|
} finally {
|
|
1007
|
-
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J`);
|
|
1267
|
+
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?2004h${ESC}2J${mouseOn()}`);
|
|
1008
1268
|
stdin.setRawMode?.(true);
|
|
1009
1269
|
stdin.on("data", onData);
|
|
1010
1270
|
stdin.resume();
|
|
@@ -1023,7 +1283,31 @@ async function runInteractive(): Promise<void> {
|
|
|
1023
1283
|
...inputDecoder.push(text),
|
|
1024
1284
|
...(text === "\u001b" ? inputDecoder.flush() : []),
|
|
1025
1285
|
];
|
|
1026
|
-
|
|
1286
|
+
while (values.length) {
|
|
1287
|
+
const value = values.shift()!;
|
|
1288
|
+
// A mouse report acts on what the last frame painted under it; some
|
|
1289
|
+
// become keys (a list row, a wheel notch) and run through below.
|
|
1290
|
+
if (typeof value === "string" && isMouseSequence(value)) {
|
|
1291
|
+
const event = parseMouse(value);
|
|
1292
|
+
if (event) values.unshift(...mouseInput(event));
|
|
1293
|
+
tick(true);
|
|
1294
|
+
continue;
|
|
1295
|
+
}
|
|
1296
|
+
// The fader drawer owns every key while it is up.
|
|
1297
|
+
if (typeof value === "string" && fader && menu.open) {
|
|
1298
|
+
const result = faderKeyPress(
|
|
1299
|
+
fader,
|
|
1300
|
+
menu.faderFields(menuContext()),
|
|
1301
|
+
value,
|
|
1302
|
+
faderKeyOptions(),
|
|
1303
|
+
);
|
|
1304
|
+
if (result.type !== "pass") {
|
|
1305
|
+
faderOutcome(result);
|
|
1306
|
+
refreshMenu();
|
|
1307
|
+
tick(true);
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1027
1311
|
// The `?` panel closes on any key (Ctrl-C still quits).
|
|
1028
1312
|
if (tui.ui.keys && typeof value === "string" && value !== "\u0003") {
|
|
1029
1313
|
tui.closeKeys();
|
|
@@ -1086,6 +1370,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1086
1370
|
}
|
|
1087
1371
|
} else if (result.type === "audition")
|
|
1088
1372
|
auditionKeyPressed(result.key);
|
|
1373
|
+
else if (result.type === "fader") openFader(result.label);
|
|
1089
1374
|
else if (result.type === "revert") revertStaged();
|
|
1090
1375
|
else if (result.type === "hover")
|
|
1091
1376
|
hoverItem(result.command, result.key);
|
|
@@ -1109,6 +1394,16 @@ async function runInteractive(): Promise<void> {
|
|
|
1109
1394
|
}
|
|
1110
1395
|
}
|
|
1111
1396
|
}
|
|
1397
|
+
// F1 opens the guides from anywhere the prompt has focus.
|
|
1398
|
+
if (
|
|
1399
|
+
(value === "\u001bOP" || value === "\u001b[11~") &&
|
|
1400
|
+
(tui.ui.overlay === undefined || tui.ui.overlay === "guide")
|
|
1401
|
+
) {
|
|
1402
|
+
if (tui.ui.overlay === "guide") tui.closeGuide();
|
|
1403
|
+
else tui.openGuide();
|
|
1404
|
+
tick(true);
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1112
1407
|
// Ctrl-K opens the menu on an empty prompt (in play mode too); with
|
|
1113
1408
|
// text it keeps its kill-to-end-of-line meaning.
|
|
1114
1409
|
if (
|
|
@@ -1140,7 +1435,8 @@ async function runInteractive(): Promise<void> {
|
|
|
1140
1435
|
if (
|
|
1141
1436
|
value === " " &&
|
|
1142
1437
|
prompt.value.length === 0 &&
|
|
1143
|
-
!(tui.ui.overlay === "picker" && tui.ui.picker?.audition)
|
|
1438
|
+
!(tui.ui.overlay === "picker" && tui.ui.picker?.audition) &&
|
|
1439
|
+
tui.ui.overlay !== "guide"
|
|
1144
1440
|
) {
|
|
1145
1441
|
// Never await a daemon round trip here: the key loop must stay
|
|
1146
1442
|
// live for Esc, quit and redraws while the toggle is in flight.
|
|
@@ -1271,7 +1567,7 @@ async function runInteractive(): Promise<void> {
|
|
|
1271
1567
|
await port.close();
|
|
1272
1568
|
stdin.setRawMode?.(false);
|
|
1273
1569
|
stdin.pause();
|
|
1274
|
-
stdout.write(`${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
1570
|
+
stdout.write(`${MOUSE_OFF}${ESC}0m${ESC}?2004l${ESC}?25h${ESC}?1049l`);
|
|
1275
1571
|
}
|
|
1276
1572
|
}
|
|
1277
1573
|
|
|
@@ -1306,7 +1602,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1306
1602
|
}
|
|
1307
1603
|
if (/^\/?tracks$/i.test(command)) {
|
|
1308
1604
|
const problems = await sampleProblems(score);
|
|
1309
|
-
const
|
|
1605
|
+
const items = score.tracks.map((track) => {
|
|
1310
1606
|
const voices = track.sampler
|
|
1311
1607
|
? Object.keys(track.sampler.voices).length
|
|
1312
1608
|
: 0;
|
|
@@ -1316,16 +1612,39 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1316
1612
|
const samples = track.sampler
|
|
1317
1613
|
? ` · ${voices} sample${voices === 1 ? "" : "s"}${missing ? ` · ${missing} missing` : ""}`
|
|
1318
1614
|
: "";
|
|
1319
|
-
return
|
|
1615
|
+
return {
|
|
1616
|
+
label: track.id,
|
|
1617
|
+
value: `/track ${track.id}`,
|
|
1618
|
+
detail: `${track.instrument}${samples}${track.muted ? " · muted" : ""}${track.solo ? " · solo" : ""}`,
|
|
1619
|
+
current: track.id === requestedTrack,
|
|
1620
|
+
};
|
|
1320
1621
|
});
|
|
1321
|
-
|
|
1322
|
-
|
|
1622
|
+
// A picker: Enter (or a click) focuses the track; Esc closes.
|
|
1623
|
+
tui.openPicker({
|
|
1624
|
+
id: "tracks",
|
|
1625
|
+
title: "tracks · ● focused",
|
|
1626
|
+
items,
|
|
1627
|
+
filterable: items.length > 8,
|
|
1628
|
+
index: Math.max(
|
|
1629
|
+
0,
|
|
1630
|
+
items.findIndex((item) => item.current),
|
|
1631
|
+
),
|
|
1632
|
+
});
|
|
1633
|
+
return ok(`${items.length} track${items.length === 1 ? "" : "s"}`);
|
|
1323
1634
|
}
|
|
1324
|
-
const playCommand = command.match(
|
|
1635
|
+
const playCommand = command.match(
|
|
1636
|
+
/^\/play(?:\s+(on|off|degrees|in-key|chromatic))?$/i,
|
|
1637
|
+
);
|
|
1325
1638
|
if (playCommand) {
|
|
1326
1639
|
const wanted = playCommand[1]?.toLowerCase();
|
|
1327
1640
|
if (wanted === "off" || (wanted === undefined && play?.on))
|
|
1328
1641
|
return exitPlay();
|
|
1642
|
+
if (wanted && wanted !== "on") {
|
|
1643
|
+
const message = playSession().toggleDegrees(wanted !== "chromatic");
|
|
1644
|
+
if (message.startsWith("scale degrees need")) return fail(message);
|
|
1645
|
+
const entered = await enterPlay();
|
|
1646
|
+
return entered.ok ? ok(message) : entered;
|
|
1647
|
+
}
|
|
1329
1648
|
return enterPlay();
|
|
1330
1649
|
}
|
|
1331
1650
|
const clickCommand = command.match(/^\/click(?:\s+(.+))?$/i);
|
|
@@ -1354,6 +1673,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1354
1673
|
}
|
|
1355
1674
|
const tryCommand = command.match(/^\/try(?:\s+(.+))?$/i);
|
|
1356
1675
|
if (tryCommand) return tryPrompt(tryCommand[1]?.trim() ?? "");
|
|
1676
|
+
// A bare parameter (`volume`, `pan`, `fx filter`, `fx reverb mix`)
|
|
1677
|
+
// opens the fader drawer on it, with its related params stacked below.
|
|
1678
|
+
if (/^\/?(?:volume|pan|fx\s+\S+(?:\s+\S+)?)$/i.test(command)) {
|
|
1679
|
+
const label = menu.showFader(menuContext(), command);
|
|
1680
|
+
if (label) {
|
|
1681
|
+
openFader(label, true);
|
|
1682
|
+
refreshMenu();
|
|
1683
|
+
return ok(`${label} · ←→ adjust · enter keep · esc revert`);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1357
1686
|
const menuCommand = command.match(/^\/menu(?:\s+(\S+))?$/i);
|
|
1358
1687
|
if (menuCommand) {
|
|
1359
1688
|
const section = menuCommand[1]?.toLowerCase();
|
|
@@ -1379,8 +1708,29 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1379
1708
|
/^\/?(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/i,
|
|
1380
1709
|
);
|
|
1381
1710
|
if (trackCommand) return focusTrack(trackCommand[1]!.toLowerCase());
|
|
1711
|
+
// `track cloud grain hold`: focus (or create) a track and grain it.
|
|
1712
|
+
const grainTrack = command.match(
|
|
1713
|
+
/^\/?track\s+([a-z0-9._-]{1,64})\s+(grain\s+.+)$/i,
|
|
1714
|
+
);
|
|
1715
|
+
if (grainTrack && parseGranularCommand(grainTrack[2]!)) {
|
|
1716
|
+
const focused = await focusTrack(grainTrack[1]!.toLowerCase());
|
|
1717
|
+
if (requestedTrack !== grainTrack[1]!.toLowerCase()) return focused;
|
|
1718
|
+
return submit(grainTrack[2]!);
|
|
1719
|
+
}
|
|
1720
|
+
// `/track piano b`: a name with spaces focuses the track of that name, or
|
|
1721
|
+
// creates `piano-b` named "piano b".
|
|
1722
|
+
const namedTrack = command.match(/^\/track\s+([a-z0-9._ -]{1,64})$/i);
|
|
1723
|
+
if (namedTrack) {
|
|
1724
|
+
const name = namedTrack[1]!.trim().replace(/\s+/g, " ");
|
|
1725
|
+
const found = score.tracks.find(
|
|
1726
|
+
(track) => (track.name ?? track.id).toLowerCase() === name.toLowerCase(),
|
|
1727
|
+
);
|
|
1728
|
+
return focusTrack(found?.id ?? name.toLowerCase().replace(/ /g, "-"));
|
|
1729
|
+
}
|
|
1382
1730
|
const sample = parseSampleCommand(command);
|
|
1383
1731
|
if (sample) return sampleCommand(sample);
|
|
1732
|
+
const fit = parseFitCommand(command);
|
|
1733
|
+
if (fit) return fitCommand(fit);
|
|
1384
1734
|
const pack = parsePackCommand(command);
|
|
1385
1735
|
if (pack) return packCommand(pack);
|
|
1386
1736
|
const pattern = parsePatternCommand(command);
|
|
@@ -1391,6 +1741,14 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1391
1741
|
if (wavetable) return wavetableCommand(wavetable);
|
|
1392
1742
|
const sessionReply = await sessionCommand(command);
|
|
1393
1743
|
if (sessionReply !== undefined) return sessionReply;
|
|
1744
|
+
const time = parseTimeCommand(command);
|
|
1745
|
+
if (time) {
|
|
1746
|
+
if (time.type !== "tempo-map") await materializeDraft();
|
|
1747
|
+
const result = applyTimeCommand(score, requestedTrack, time);
|
|
1748
|
+
if (result.next && result.kind)
|
|
1749
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1750
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1751
|
+
}
|
|
1394
1752
|
const edit = parseEditCommand(command);
|
|
1395
1753
|
if (edit) {
|
|
1396
1754
|
await materializeDraft();
|
|
@@ -1407,6 +1765,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1407
1765
|
await commitScore(result.next, result.kind, result.payload);
|
|
1408
1766
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1409
1767
|
}
|
|
1768
|
+
const rig = parseRigCommand(command);
|
|
1769
|
+
if (rig) {
|
|
1770
|
+
if (rig.type !== "rig-show" && rig.type !== "rig-hint")
|
|
1771
|
+
await materializeDraft();
|
|
1772
|
+
const result = applyRigCommand(score, requestedTrack, rig);
|
|
1773
|
+
if (result.next && result.kind)
|
|
1774
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1775
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1776
|
+
}
|
|
1410
1777
|
const fx = parseFxCommand(command);
|
|
1411
1778
|
if (fx) {
|
|
1412
1779
|
if (fx.type !== "fx-list") await materializeDraft();
|
|
@@ -1426,6 +1793,40 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1426
1793
|
await commitScore(result.next, result.kind, result.payload);
|
|
1427
1794
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1428
1795
|
}
|
|
1796
|
+
const unknownFx = unknownFxMessage(command);
|
|
1797
|
+
if (unknownFx) return fail(unknownFx);
|
|
1798
|
+
const expression = parseExpressionCommand(command);
|
|
1799
|
+
if (expression) {
|
|
1800
|
+
if (expression.type !== "show" && expression.type !== "invalid")
|
|
1801
|
+
await materializeDraft();
|
|
1802
|
+
const result = applyExpressionCommand(score, requestedTrack, expression);
|
|
1803
|
+
if (result.next && result.kind)
|
|
1804
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1805
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1806
|
+
}
|
|
1807
|
+
const tuning = parseTuningCommand(command);
|
|
1808
|
+
if (tuning) return tuningCommand(tuning);
|
|
1809
|
+
const keysCommand = parseKeysCommand(command);
|
|
1810
|
+
if (keysCommand) {
|
|
1811
|
+
const reads =
|
|
1812
|
+
keysCommand.type === "keys-list" || keysCommand.type === "keys-presets";
|
|
1813
|
+
if (!reads) await materializeDraft();
|
|
1814
|
+
const result = applyKeysCommand(score, requestedTrack, keysCommand);
|
|
1815
|
+
if (result.next && result.kind)
|
|
1816
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1817
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1818
|
+
}
|
|
1819
|
+
const modal = parseModalCommand(command);
|
|
1820
|
+
if (modal) {
|
|
1821
|
+
if (modal.type === "modal-list")
|
|
1822
|
+
tui.openText("modal presets", modalListLines());
|
|
1823
|
+
if (modal.type !== "modal-show" && modal.type !== "modal-list")
|
|
1824
|
+
await materializeDraft();
|
|
1825
|
+
const result = applyModalCommand(score, requestedTrack, modal);
|
|
1826
|
+
if (result.next && result.kind)
|
|
1827
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1828
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1829
|
+
}
|
|
1429
1830
|
const synth = parseSynthCommand(command);
|
|
1430
1831
|
if (synth) {
|
|
1431
1832
|
if (synth.type !== "synth-list") await materializeDraft();
|
|
@@ -1434,6 +1835,57 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1434
1835
|
await commitScore(result.next, result.kind, result.payload);
|
|
1435
1836
|
return result.ok ? ok(result.message) : fail(result.message);
|
|
1436
1837
|
}
|
|
1838
|
+
const stringCommand = parseStringCommand(command);
|
|
1839
|
+
if (stringCommand) {
|
|
1840
|
+
if (
|
|
1841
|
+
stringCommand.type !== "string-list" &&
|
|
1842
|
+
stringCommand.type !== "string-presets"
|
|
1843
|
+
)
|
|
1844
|
+
await materializeDraft();
|
|
1845
|
+
const result = applyStringCommand(score, requestedTrack, stringCommand);
|
|
1846
|
+
if (result.next && result.kind)
|
|
1847
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1848
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1849
|
+
}
|
|
1850
|
+
const grainHint = grainSrcHint(command);
|
|
1851
|
+
if (grainHint) return fail(grainHint);
|
|
1852
|
+
const grain = parseGranularCommand(command);
|
|
1853
|
+
if (grain) {
|
|
1854
|
+
if (grain.type !== "grain-list" && grain.type !== "grain-presets")
|
|
1855
|
+
await materializeDraft();
|
|
1856
|
+
const result = applyGranularCommand(score, requestedTrack, grain);
|
|
1857
|
+
if (result.next && result.kind)
|
|
1858
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1859
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1860
|
+
}
|
|
1861
|
+
const masterCommand = parseMasterCommand(command);
|
|
1862
|
+
if (masterCommand) {
|
|
1863
|
+
if (masterCommand.type === "master-measure")
|
|
1864
|
+
return ok(await measureLine(score));
|
|
1865
|
+
if (
|
|
1866
|
+
masterCommand.type !== "master-list" &&
|
|
1867
|
+
masterCommand.type !== "master-show"
|
|
1868
|
+
)
|
|
1869
|
+
await materializeDraft();
|
|
1870
|
+
const result = applyMasterCommand(score, masterCommand);
|
|
1871
|
+
if (result.next && result.kind)
|
|
1872
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1873
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1874
|
+
}
|
|
1875
|
+
const arrange = parseSectionCommand(command, score);
|
|
1876
|
+
if (arrange) {
|
|
1877
|
+
const reads =
|
|
1878
|
+
arrange.type === "section-list" ||
|
|
1879
|
+
arrange.type === "section-unknown" ||
|
|
1880
|
+
arrange.type === "form-show" ||
|
|
1881
|
+
arrange.type === "section-jump";
|
|
1882
|
+
if (!reads) await materializeDraft();
|
|
1883
|
+
const result = applySectionCommand(score, requestedTrack, arrange);
|
|
1884
|
+
if (result.next && result.kind)
|
|
1885
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
1886
|
+
if (result.seekBeat !== undefined) await seekTransport(result.seekBeat);
|
|
1887
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
1888
|
+
}
|
|
1437
1889
|
const music = parseMusicCommand(command);
|
|
1438
1890
|
if (music) {
|
|
1439
1891
|
await materializeDraft();
|
|
@@ -1447,6 +1899,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1447
1899
|
const exportCommand = command.match(/^\/?export\s+([^\s]+)$/i);
|
|
1448
1900
|
if (exportCommand) {
|
|
1449
1901
|
const path = resolve(exportCommand[1]!);
|
|
1902
|
+
if (/\.midi?$/i.test(path)) {
|
|
1903
|
+
await writeFile(path, scoreToMidi(exportScore(score)));
|
|
1904
|
+
return `exported midi · ${exportCommand[1]}`;
|
|
1905
|
+
}
|
|
1906
|
+
// Audio comes from the offline renderer, never JSON under an audio name.
|
|
1907
|
+
if (/\.(wav|aiff?|flac|mp3|ogg|m4a)$/i.test(path))
|
|
1908
|
+
return fail(
|
|
1909
|
+
`/export writes .track.json or .mid · render audio with: dawg render ${exportCommand[1]}`,
|
|
1910
|
+
);
|
|
1450
1911
|
await writeFile(path, encodeLoop(score), "utf8");
|
|
1451
1912
|
return `exported · ${exportCommand[1]}`;
|
|
1452
1913
|
}
|
|
@@ -1563,8 +2024,17 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1563
2024
|
if (parsed.type === "set-tempo") {
|
|
1564
2025
|
const next = score.withTempo(parsed.tempoBpm);
|
|
1565
2026
|
await commitScore(next, "score.tempo", { tempoBpm: parsed.tempoBpm });
|
|
1566
|
-
clock.
|
|
1567
|
-
|
|
2027
|
+
clock.follow(next);
|
|
2028
|
+
// A bare `bpm <n>` stays song tempo; on a sampler track say where the
|
|
2029
|
+
// sample's own tempo lives (`/bpm`, 0.6 fit).
|
|
2030
|
+
const focused = next.tracks.find((t) => t.id === requestedTrack);
|
|
2031
|
+
const samplerHint =
|
|
2032
|
+
/^\s*bpm\b/i.test(prompt) &&
|
|
2033
|
+
focused?.sampler &&
|
|
2034
|
+
isSamplerInstrument(focused.instrument)
|
|
2035
|
+
? ` · song tempo · the sample's own tempo is /bpm ${parsed.tempoBpm}`
|
|
2036
|
+
: "";
|
|
2037
|
+
return `tempo · ${next.tempoBpm} BPM${samplerHint}`;
|
|
1568
2038
|
}
|
|
1569
2039
|
if (parsed.type === "add-track") return focusTrack(parsed.trackId);
|
|
1570
2040
|
if (parsed.type === "set-bars" || parsed.type === "extend-bars") {
|
|
@@ -1577,15 +2047,42 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1577
2047
|
return `bars · ${bars}`;
|
|
1578
2048
|
}
|
|
1579
2049
|
if (parsed.type === "track") {
|
|
2050
|
+
// An unknown word would store and play a plain sine: say so instead.
|
|
2051
|
+
const word = parsed.patch.instrument;
|
|
2052
|
+
if (word !== undefined && isUnknownInstrument(word)) {
|
|
2053
|
+
const advice = plainSineAdvice(word) ?? "";
|
|
2054
|
+
const near = advice.match(/did you mean (\S+)\?/)?.[1];
|
|
2055
|
+
return fail(
|
|
2056
|
+
`instrument ${truncateForCard(word)} · not a dawg instrument${near ? ` · did you mean ${near}?` : ""} · /menu sounds lists them`,
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
// `instrument jangle`: a guitar alias also loads its rig.
|
|
2060
|
+
const patch = parsed.word
|
|
2061
|
+
? {
|
|
2062
|
+
...parsed.patch,
|
|
2063
|
+
...rigWordPatch(
|
|
2064
|
+
parsed.word,
|
|
2065
|
+
score.tracks.find((t) => t.id === requestedTrack)?.fx,
|
|
2066
|
+
),
|
|
2067
|
+
}
|
|
2068
|
+
: parsed.patch;
|
|
1580
2069
|
const next = applyScoreOperation(score, {
|
|
1581
2070
|
type: "updateTrack",
|
|
1582
2071
|
trackId: requestedTrack,
|
|
1583
|
-
patch
|
|
2072
|
+
patch,
|
|
1584
2073
|
});
|
|
1585
2074
|
await commitScore(next, "score.track", {
|
|
1586
2075
|
trackId: requestedTrack,
|
|
1587
|
-
patch
|
|
2076
|
+
patch,
|
|
1588
2077
|
});
|
|
2078
|
+
// Plain `marimba` keeps the legacy tone; point at the mallet engine.
|
|
2079
|
+
if (parsed.patch.instrument === "marimba" && !("modal" in parsed.patch))
|
|
2080
|
+
return `track · ${requestedTrack} · marimba (legacy tone) · modal marimba for the mallet engine`;
|
|
2081
|
+
const sine =
|
|
2082
|
+
word !== undefined && !("string" in parsed.patch)
|
|
2083
|
+
? plainSineAdvice(word)
|
|
2084
|
+
: undefined;
|
|
2085
|
+
if (sine) return `track · ${requestedTrack} · ${sine}`;
|
|
1589
2086
|
return `track · ${requestedTrack}`;
|
|
1590
2087
|
}
|
|
1591
2088
|
if (parsed.type === "automation") {
|
|
@@ -1658,6 +2155,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1658
2155
|
...(typeof parsed.patch.velocity === "number"
|
|
1659
2156
|
? { velocity: parsed.patch.velocity }
|
|
1660
2157
|
: {}),
|
|
2158
|
+
...(typeof parsed.patch.cents === "number"
|
|
2159
|
+
? { cents: parsed.patch.cents }
|
|
2160
|
+
: {}),
|
|
1661
2161
|
};
|
|
1662
2162
|
const next = applyScoreOperation(score, {
|
|
1663
2163
|
type: "updateNote",
|
|
@@ -1683,6 +2183,7 @@ async function submit(prompt: string): Promise<string | Receipt> {
|
|
|
1683
2183
|
),
|
|
1684
2184
|
pitch: parsed.pitch,
|
|
1685
2185
|
velocity: parsed.velocity,
|
|
2186
|
+
...(parsed.cents ? { cents: parsed.cents } : {}),
|
|
1686
2187
|
};
|
|
1687
2188
|
const operation: ScoreOperation = { type: "addNote", note };
|
|
1688
2189
|
const next = applyScoreOperation(latestScore, operation);
|
|
@@ -1721,14 +2222,28 @@ async function focusTrack(trackId: string): Promise<Receipt> {
|
|
|
1721
2222
|
)
|
|
1722
2223
|
)
|
|
1723
2224
|
return warn(`${trackId} is open in another window`);
|
|
1724
|
-
const
|
|
2225
|
+
const existing = score.tracks.find((track) => track.id === trackId);
|
|
2226
|
+
const exists = existing !== undefined;
|
|
2227
|
+
// `track cloud`, `track hold-2`: a new granular track with that preset.
|
|
2228
|
+
const grainPreset = granularTrackPreset(trackId);
|
|
1725
2229
|
if (!exists) {
|
|
1726
2230
|
const next = applyScoreOperation(score, {
|
|
1727
2231
|
type: "addTrack",
|
|
1728
2232
|
track: {
|
|
1729
2233
|
id: trackId,
|
|
1730
2234
|
name: trackId,
|
|
1731
|
-
instrument:
|
|
2235
|
+
instrument: grainPreset
|
|
2236
|
+
? "granular"
|
|
2237
|
+
: isDrumInstrument(trackId)
|
|
2238
|
+
? "kit"
|
|
2239
|
+
: "sine",
|
|
2240
|
+
// `track jangle`, `track gtr-metal`…: a guitar voice and its rig.
|
|
2241
|
+
...rigTrackFields(trackId),
|
|
2242
|
+
...(grainPreset ? { granular: { preset: grainPreset } } : {}),
|
|
2243
|
+
// `/track piano` (or grand, felt…) starts on the modelled piano.
|
|
2244
|
+
...newPianoTrack(trackId),
|
|
2245
|
+
// `track vibes`: a 0.6 modal word names the track and its preset.
|
|
2246
|
+
...(isModalWord(trackId) ? instrumentPatchForWord(trackId) : {}),
|
|
1732
2247
|
},
|
|
1733
2248
|
});
|
|
1734
2249
|
await commitScore(next, "track.create", { trackId });
|
|
@@ -1736,7 +2251,17 @@ async function focusTrack(trackId: string): Promise<Receipt> {
|
|
|
1736
2251
|
await port.focus(trackId);
|
|
1737
2252
|
requestedTrack = trackId;
|
|
1738
2253
|
draftTrack = false;
|
|
1739
|
-
|
|
2254
|
+
if (exists && grainPreset && existing.instrument !== "granular")
|
|
2255
|
+
return warn(
|
|
2256
|
+
`track · ${trackId} is a ${existing.instrument} track · grain ${grainPreset} makes it granular`,
|
|
2257
|
+
);
|
|
2258
|
+
return ok(
|
|
2259
|
+
exists
|
|
2260
|
+
? `track · ${trackId}`
|
|
2261
|
+
: grainPreset
|
|
2262
|
+
? `track created · ${trackId} · granular ${grainPreset} · nothing to download`
|
|
2263
|
+
: `track created · ${trackId}`,
|
|
2264
|
+
);
|
|
1740
2265
|
}
|
|
1741
2266
|
|
|
1742
2267
|
/** Decode every sampler voice (cached) and return what failed to load. */
|
|
@@ -1788,6 +2313,68 @@ function reportSampleProblems(value: TrackScore): void {
|
|
|
1788
2313
|
});
|
|
1789
2314
|
}
|
|
1790
2315
|
|
|
2316
|
+
/** `/tuning …` and `/scale …` (src/commands/tuning.ts). */
|
|
2317
|
+
async function tuningCommand(command: TuningCommand): Promise<Receipt> {
|
|
2318
|
+
const projectRoot = process.cwd();
|
|
2319
|
+
if (command.type === "tuning-set") {
|
|
2320
|
+
// Scala files outside the project are copied into tunings/ first.
|
|
2321
|
+
try {
|
|
2322
|
+
const patch = { ...command.patch };
|
|
2323
|
+
if (patch.scl)
|
|
2324
|
+
patch.scl = await importTuningFile(projectRoot, projectRoot, patch.scl);
|
|
2325
|
+
if (patch.kbm)
|
|
2326
|
+
patch.kbm = await importTuningFile(projectRoot, projectRoot, patch.kbm);
|
|
2327
|
+
command = { ...command, patch };
|
|
2328
|
+
} catch (error) {
|
|
2329
|
+
if (error instanceof TuningError)
|
|
2330
|
+
return fail(`tuning · ${error.message}`);
|
|
2331
|
+
throw error;
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
if (command.type === "tuning-set" || command.type === "tuning-off")
|
|
2335
|
+
if (command.target === "track") await materializeDraft();
|
|
2336
|
+
const read = (path: string): string | undefined => {
|
|
2337
|
+
try {
|
|
2338
|
+
return readFileSync(join(projectRoot, path), "utf8");
|
|
2339
|
+
} catch {
|
|
2340
|
+
return undefined;
|
|
2341
|
+
}
|
|
2342
|
+
};
|
|
2343
|
+
const result = applyTuningCommand(score, requestedTrack, command, read);
|
|
2344
|
+
if (result.panel) tui.openText(result.panel.title, result.panel.lines);
|
|
2345
|
+
if (result.next && result.kind)
|
|
2346
|
+
await commitScore(result.next, result.kind, result.payload);
|
|
2347
|
+
return result.ok ? ok(result.message) : fail(result.message);
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
/** `/fitmode`, `/bpm`, `/len` on the focused sampler voice (0.6). */
|
|
2351
|
+
async function fitCommand(
|
|
2352
|
+
command: NonNullable<ReturnType<typeof parseFitCommand>>,
|
|
2353
|
+
): Promise<Receipt> {
|
|
2354
|
+
let suggested: "beats" | "tones" | undefined;
|
|
2355
|
+
if (command.control === "fitmode" && command.value === undefined) {
|
|
2356
|
+
const target = fitVoice(score, requestedTrack, command.voice);
|
|
2357
|
+
if ("error" in target) return fail(target.error);
|
|
2358
|
+
const decoded = liveSampleBank?.voices.get(
|
|
2359
|
+
sampleKey(requestedTrack, target.voice),
|
|
2360
|
+
);
|
|
2361
|
+
if (!decoded)
|
|
2362
|
+
return warn(
|
|
2363
|
+
"fit · the sample is still loading · fitmode repitch|beats|tones",
|
|
2364
|
+
);
|
|
2365
|
+
suggested = suggestFitMode(decoded.mono, decoded.sampleRate);
|
|
2366
|
+
}
|
|
2367
|
+
const result = applyFitCommand(score, requestedTrack, command, suggested);
|
|
2368
|
+
if (!result.ok) return fail(result.message);
|
|
2369
|
+
await commitScore(result.next, "sample.set", { trackId: requestedTrack });
|
|
2370
|
+
await projectSync?.flushScore();
|
|
2371
|
+
return ok(
|
|
2372
|
+
suggested
|
|
2373
|
+
? `${result.message} · suggested from the sound (${suggested === "beats" ? "hits" : "held tones"})`
|
|
2374
|
+
: result.message,
|
|
2375
|
+
);
|
|
2376
|
+
}
|
|
2377
|
+
|
|
1791
2378
|
async function sampleCommand(
|
|
1792
2379
|
command: NonNullable<ReturnType<typeof parseSampleCommand>>,
|
|
1793
2380
|
): Promise<Receipt> {
|
|
@@ -2357,7 +2944,7 @@ async function switchSession(sessionId: string): Promise<void> {
|
|
|
2357
2944
|
audio = port.player;
|
|
2358
2945
|
record = port.mode === "daemon" ? await port.load() : next.record;
|
|
2359
2946
|
score = scoreFromJSON(record.composition);
|
|
2360
|
-
clock.
|
|
2947
|
+
clock.follow(score);
|
|
2361
2948
|
const attached = await attachTrack(port, score, undefined);
|
|
2362
2949
|
requestedTrack = attached.trackId;
|
|
2363
2950
|
draftTrack = attached.draft;
|
|
@@ -2412,6 +2999,8 @@ function menuContext(): MenuContext {
|
|
|
2412
2999
|
/** The screen `?` describes, or undefined while `?` is typed text. */
|
|
2413
3000
|
function keysScreen(): readonly KeySection[] | undefined {
|
|
2414
3001
|
if (euclid.open) return euclid.typing ? undefined : KEYS.euclid;
|
|
3002
|
+
if (fader && menu.open)
|
|
3003
|
+
return fader.typing !== undefined ? undefined : KEYS.fader;
|
|
2415
3004
|
if (menu.open) return menu.typing ? undefined : KEYS.menu;
|
|
2416
3005
|
const overlay = tui.ui.overlay;
|
|
2417
3006
|
if (overlay === "picker")
|
|
@@ -2422,6 +3011,7 @@ function keysScreen(): readonly KeySection[] | undefined {
|
|
|
2422
3011
|
: KEYS.list;
|
|
2423
3012
|
if (overlay === "text") return KEYS.text;
|
|
2424
3013
|
if (overlay === "log") return KEYS.log;
|
|
3014
|
+
if (overlay === "guide") return tui.guideTyping ? undefined : KEYS.guide;
|
|
2425
3015
|
if (prompt.value.length > 0) return undefined;
|
|
2426
3016
|
if (play?.on)
|
|
2427
3017
|
return play.chords.on ? [...KEYS.play, ...KEYS.chords] : KEYS.play;
|
|
@@ -2533,6 +3123,11 @@ function auditionController(): Audition {
|
|
|
2533
3123
|
clearTimer: (handle) => clearTimeout(handle as Timer),
|
|
2534
3124
|
changed: () => requestFrame(),
|
|
2535
3125
|
level: () => previewEngine().level,
|
|
3126
|
+
// The last loop the engine played is the song's when a loop starts.
|
|
3127
|
+
masterGainDb: () =>
|
|
3128
|
+
score.master?.target === undefined
|
|
3129
|
+
? undefined
|
|
3130
|
+
: previewEngine().loudness?.gainDb,
|
|
2536
3131
|
// The chord settings screen loops a progression with its settings.
|
|
2537
3132
|
phrase: (showing) => {
|
|
2538
3133
|
if (!chordScreenOpen()) return undefined;
|
|
@@ -2875,14 +3470,28 @@ function leaveAuditionScreen(): void {
|
|
|
2875
3470
|
|
|
2876
3471
|
function refreshMenu(): void {
|
|
2877
3472
|
noteChordScreen();
|
|
2878
|
-
if (!menu.open)
|
|
3473
|
+
if (!menu.open) {
|
|
3474
|
+
closeFader();
|
|
3475
|
+
return;
|
|
3476
|
+
}
|
|
2879
3477
|
if (tui.ui.overlay !== undefined && tui.ui.picker?.id !== "menu") {
|
|
2880
3478
|
// Another overlay (help, a picker) replaced the menu.
|
|
2881
3479
|
menu.close();
|
|
2882
3480
|
leaveAuditionScreen();
|
|
2883
3481
|
return;
|
|
2884
3482
|
}
|
|
2885
|
-
const
|
|
3483
|
+
const context = menuContext();
|
|
3484
|
+
if (fader) {
|
|
3485
|
+
const fields = menu.faderFields(context);
|
|
3486
|
+
if (fields.length === 0) closeFader();
|
|
3487
|
+
else
|
|
3488
|
+
tui.drawer = drawerView(fader, fields, menu.faderCommitted(context), {
|
|
3489
|
+
title: menu.crumbs,
|
|
3490
|
+
dirty: context.audition?.dirty ?? false,
|
|
3491
|
+
status: context.audition?.status,
|
|
3492
|
+
});
|
|
3493
|
+
}
|
|
3494
|
+
const view = menu.view(context);
|
|
2886
3495
|
tui.openPicker({
|
|
2887
3496
|
id: "menu",
|
|
2888
3497
|
title: view.title,
|
|
@@ -2895,6 +3504,216 @@ function refreshMenu(): void {
|
|
|
2895
3504
|
});
|
|
2896
3505
|
}
|
|
2897
3506
|
|
|
3507
|
+
// ── the fader drawer ─────────────────────────────────────────────────
|
|
3508
|
+
|
|
3509
|
+
/** Enter on a number row (or a bare `volume`, `fx filter`): the drawer. */
|
|
3510
|
+
/**
|
|
3511
|
+
* Open the drawer on `label`. `standalone` drawers came from a command or a
|
|
3512
|
+
* click, not from inside the menu, so closing one closes the menu too.
|
|
3513
|
+
*/
|
|
3514
|
+
function openFader(label: string, standalone = false): void {
|
|
3515
|
+
fader = { label };
|
|
3516
|
+
faderStandalone = standalone;
|
|
3517
|
+
const loop = auditionController();
|
|
3518
|
+
if (!loop.dirtyEdits) loop.committedNow(score);
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
function closeFader(): void {
|
|
3522
|
+
fader = undefined;
|
|
3523
|
+
tui.drawer = undefined;
|
|
3524
|
+
if (faderStandalone && menu.open) {
|
|
3525
|
+
faderStandalone = false;
|
|
3526
|
+
menu.close();
|
|
3527
|
+
tui.closePicker();
|
|
3528
|
+
stopAuditionLoop();
|
|
3529
|
+
}
|
|
3530
|
+
faderStandalone = false;
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
function faderKeyOptions(): { dirty: boolean; audition: boolean } {
|
|
3534
|
+
return { dirty: auditionLoop?.dirtyEdits ?? false, audition: true };
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
/**
|
|
3538
|
+
* Act on a drawer result. A set stages on the audition loop, filed under
|
|
3539
|
+
* its field so every nudge replaces the last: the piano roll (and the loop,
|
|
3540
|
+
* when it plays) follows at once, and Enter keeps them all as one revision.
|
|
3541
|
+
* A setting the loop cannot stage (tempo, loop length) applies directly.
|
|
3542
|
+
*/
|
|
3543
|
+
function faderOutcome(result: FaderResult): void {
|
|
3544
|
+
if (result.type === "set") {
|
|
3545
|
+
if (!stageableNow(result.command)) {
|
|
3546
|
+
runPromptLater(result.command);
|
|
3547
|
+
return;
|
|
3548
|
+
}
|
|
3549
|
+
const loop = auditionController();
|
|
3550
|
+
if (!loop.dirtyEdits) loop.committedNow(score);
|
|
3551
|
+
const seq = (faderSeq.get(result.key) ?? 0) + 1;
|
|
3552
|
+
faderSeq.set(result.key, seq);
|
|
3553
|
+
void loop
|
|
3554
|
+
.stage(result.command, {
|
|
3555
|
+
replaceKey: result.key,
|
|
3556
|
+
superseded: () => faderSeq.get(result.key) !== seq,
|
|
3557
|
+
})
|
|
3558
|
+
.then((outcome) => {
|
|
3559
|
+
if (!outcome.ok && outcome.message !== SUPERSEDED)
|
|
3560
|
+
tui.activity.pushCard(outcome.message, {
|
|
3561
|
+
tone: outcome.message.includes("failed") ? "error" : "warning",
|
|
3562
|
+
});
|
|
3563
|
+
requestFrame();
|
|
3564
|
+
});
|
|
3565
|
+
} else if (result.type === "keep") {
|
|
3566
|
+
closeFader();
|
|
3567
|
+
void keepStaged()
|
|
3568
|
+
.then((outcome) => receipt(outcome))
|
|
3569
|
+
.catch((error) => tui.activity.pushError(describeError("keep", error)))
|
|
3570
|
+
.finally(() => requestFrame());
|
|
3571
|
+
} else if (result.type === "revert") {
|
|
3572
|
+
revertStaged();
|
|
3573
|
+
closeFader();
|
|
3574
|
+
} else if (result.type === "close") closeFader();
|
|
3575
|
+
else if (result.type === "audition") auditionKeyPressed(result.key);
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
// ── the mouse ────────────────────────────────────────────────────────
|
|
3579
|
+
|
|
3580
|
+
/**
|
|
3581
|
+
* One mouse report, hit-tested against the regions the last frame painted.
|
|
3582
|
+
* Acts directly (a fader, the transport) or returns keys to run through the
|
|
3583
|
+
* ordinary key path (a list row is Enter, a wheel notch is an arrow).
|
|
3584
|
+
*/
|
|
3585
|
+
function mouseInput(event: MouseEvent): string[] {
|
|
3586
|
+
const frame = tui.frame;
|
|
3587
|
+
if (!frame) return [];
|
|
3588
|
+
const target = frame.hits.at(event.x, event.y)?.target;
|
|
3589
|
+
if (event.kind === "wheel") {
|
|
3590
|
+
const fields = fader && menu.open ? menu.faderFields(menuContext()) : [];
|
|
3591
|
+
if (
|
|
3592
|
+
fader &&
|
|
3593
|
+
target &&
|
|
3594
|
+
(target.kind === "fader-bar" ||
|
|
3595
|
+
target.kind === "fader-row" ||
|
|
3596
|
+
target.kind === "fader-step" ||
|
|
3597
|
+
target.kind === "fader-option")
|
|
3598
|
+
) {
|
|
3599
|
+
// Wheel up raises the value under the pointer.
|
|
3600
|
+
faderOutcome(
|
|
3601
|
+
faderStep(
|
|
3602
|
+
fader,
|
|
3603
|
+
fields[target.field],
|
|
3604
|
+
event.delta < 0 ? 1 : -1,
|
|
3605
|
+
event.shift ? "coarse" : "normal",
|
|
3606
|
+
),
|
|
3607
|
+
);
|
|
3608
|
+
refreshMenu();
|
|
3609
|
+
return [];
|
|
3610
|
+
}
|
|
3611
|
+
if (fader) return [];
|
|
3612
|
+
if (tui.ui.overlay) return [event.delta < 0 ? KEY_UP : KEY_DOWN];
|
|
3613
|
+
return [];
|
|
3614
|
+
}
|
|
3615
|
+
// A drag keeps moving the fader it started on, even past the bar's ends.
|
|
3616
|
+
if (event.kind === "drag" && event.button === "left" && fader && dragging) {
|
|
3617
|
+
const fields = menu.faderFields(menuContext());
|
|
3618
|
+
faderOutcome(
|
|
3619
|
+
faderSetPosition(
|
|
3620
|
+
fader,
|
|
3621
|
+
fields[dragging.field],
|
|
3622
|
+
(event.x - dragging.left) / Math.max(1, dragging.width - 1),
|
|
3623
|
+
),
|
|
3624
|
+
);
|
|
3625
|
+
refreshMenu();
|
|
3626
|
+
return [];
|
|
3627
|
+
}
|
|
3628
|
+
if (event.kind === "up") {
|
|
3629
|
+
dragging = undefined;
|
|
3630
|
+
return [];
|
|
3631
|
+
}
|
|
3632
|
+
if (event.kind !== "down" || event.button !== "left" || !target) return [];
|
|
3633
|
+
if (fader && menu.open) {
|
|
3634
|
+
const fields = menu.faderFields(menuContext());
|
|
3635
|
+
let result: FaderResult | undefined;
|
|
3636
|
+
switch (target.kind) {
|
|
3637
|
+
case "fader-step":
|
|
3638
|
+
result = faderStep(
|
|
3639
|
+
fader,
|
|
3640
|
+
fields[target.field],
|
|
3641
|
+
target.direction,
|
|
3642
|
+
event.shift ? "coarse" : "normal",
|
|
3643
|
+
);
|
|
3644
|
+
break;
|
|
3645
|
+
case "fader-bar":
|
|
3646
|
+
dragging = target;
|
|
3647
|
+
result = faderSetPosition(
|
|
3648
|
+
fader,
|
|
3649
|
+
fields[target.field],
|
|
3650
|
+
(event.x - target.left) / Math.max(1, target.width - 1),
|
|
3651
|
+
);
|
|
3652
|
+
break;
|
|
3653
|
+
case "fader-option":
|
|
3654
|
+
result = faderChoose(fader, fields[target.field], target.option);
|
|
3655
|
+
break;
|
|
3656
|
+
case "fader-row": {
|
|
3657
|
+
const field = fields[target.field];
|
|
3658
|
+
if (field) {
|
|
3659
|
+
fader.label = field.label;
|
|
3660
|
+
fader.typing = undefined;
|
|
3661
|
+
}
|
|
3662
|
+
result = { type: "handled" };
|
|
3663
|
+
break;
|
|
3664
|
+
}
|
|
3665
|
+
case "fader-keep":
|
|
3666
|
+
result = auditionLoop?.dirtyEdits
|
|
3667
|
+
? { type: "keep" }
|
|
3668
|
+
: { type: "close" };
|
|
3669
|
+
break;
|
|
3670
|
+
case "fader-revert":
|
|
3671
|
+
result = auditionLoop?.dirtyEdits
|
|
3672
|
+
? { type: "revert" }
|
|
3673
|
+
: { type: "close" };
|
|
3674
|
+
break;
|
|
3675
|
+
default:
|
|
3676
|
+
break;
|
|
3677
|
+
}
|
|
3678
|
+
if (result) {
|
|
3679
|
+
faderOutcome(result);
|
|
3680
|
+
refreshMenu();
|
|
3681
|
+
return [];
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
switch (target.kind) {
|
|
3685
|
+
case "picker-row": {
|
|
3686
|
+
const picker = tui.ui.picker;
|
|
3687
|
+
if (!picker) return [];
|
|
3688
|
+
if (picker.id === "menu" && menu.open) {
|
|
3689
|
+
// The first click selects a row; a click on the selected row opens it.
|
|
3690
|
+
const again = menu.index === target.index;
|
|
3691
|
+
menu.select(menuContext(), target.index);
|
|
3692
|
+
refreshMenu();
|
|
3693
|
+
return again ? ["\r"] : [];
|
|
3694
|
+
}
|
|
3695
|
+
const again = picker.index === target.index;
|
|
3696
|
+
picker.index = target.index;
|
|
3697
|
+
picker.filtering = false;
|
|
3698
|
+
return again ? ["\r"] : [];
|
|
3699
|
+
}
|
|
3700
|
+
case "transport":
|
|
3701
|
+
if (tui.ui.overlay) return [];
|
|
3702
|
+
void toggleTransport().finally(() => requestFrame());
|
|
3703
|
+
return [];
|
|
3704
|
+
case "tracks":
|
|
3705
|
+
if (tui.ui.overlay) return [];
|
|
3706
|
+
runPromptLater("/tracks");
|
|
3707
|
+
return [];
|
|
3708
|
+
case "model":
|
|
3709
|
+
if (tui.ui.overlay) return [];
|
|
3710
|
+
runPromptLater("/model");
|
|
3711
|
+
return [];
|
|
3712
|
+
default:
|
|
3713
|
+
return [];
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
|
|
2898
3717
|
function playSession(): PlaySession {
|
|
2899
3718
|
if (play && play.track === requestedTrack) return play;
|
|
2900
3719
|
const previous = play;
|
|
@@ -2966,6 +3785,7 @@ function playHost() {
|
|
|
2966
3785
|
now: () => performance.now(),
|
|
2967
3786
|
playing: () => clock.playing,
|
|
2968
3787
|
beatAt: (ms: number) => clock.beatAt(ms),
|
|
3788
|
+
scoreBeat: (beat: number) => scoreBeatAt(score, beat),
|
|
2969
3789
|
engine: liveEngine,
|
|
2970
3790
|
samples: () => liveSampleBank,
|
|
2971
3791
|
async commit(
|
|
@@ -3032,20 +3852,33 @@ async function commitScore(
|
|
|
3032
3852
|
// Rhythm rows regenerate after a loop resize and freeze when their lane
|
|
3033
3853
|
// is hand-edited, so rows and notes never disagree.
|
|
3034
3854
|
next = reconcileRhythm(score, next);
|
|
3855
|
+
const retimed = timingChanged(score, next);
|
|
3035
3856
|
if (stageCapture) {
|
|
3036
3857
|
// A staged edit: the audition plays it; nothing is written yet.
|
|
3037
3858
|
stageCapture.next = next;
|
|
3038
3859
|
score = next;
|
|
3860
|
+
if (retimed) clock.follow(score);
|
|
3039
3861
|
return;
|
|
3040
3862
|
}
|
|
3041
3863
|
record = await port.append(record, { kind, payload }, next.toJSON());
|
|
3042
3864
|
score = next;
|
|
3865
|
+
if (retimed) clock.follow(score);
|
|
3043
3866
|
if (clock.playing) void audio.play(score);
|
|
3044
3867
|
projectSync?.scoreChanged(score);
|
|
3045
3868
|
reportSampleProblems(score);
|
|
3046
3869
|
void updateCredits(score);
|
|
3047
3870
|
}
|
|
3048
3871
|
|
|
3872
|
+
/** True when the transport clock must follow `next` (tempo, meter, loop). */
|
|
3873
|
+
function timingChanged(previous: TrackScore, next: TrackScore): boolean {
|
|
3874
|
+
return (
|
|
3875
|
+
previous.time !== next.time ||
|
|
3876
|
+
previous.tempoBpm !== next.tempoBpm ||
|
|
3877
|
+
previous.beatsPerBar !== next.beatsPerBar ||
|
|
3878
|
+
previous.bars !== next.bars
|
|
3879
|
+
);
|
|
3880
|
+
}
|
|
3881
|
+
|
|
3049
3882
|
/** The window's side of the project file sync (see src/project/sync.ts). */
|
|
3050
3883
|
function syncHost(): SyncHost {
|
|
3051
3884
|
return {
|
|
@@ -3063,7 +3896,7 @@ function syncHost(): SyncHost {
|
|
|
3063
3896
|
plan.next.toJSON(),
|
|
3064
3897
|
);
|
|
3065
3898
|
score = scoreFromJSON(record.composition);
|
|
3066
|
-
clock.
|
|
3899
|
+
clock.follow(score);
|
|
3067
3900
|
if (clock.playing) void audio.play(score);
|
|
3068
3901
|
reportSampleProblems(score);
|
|
3069
3902
|
void baseRevision;
|
|
@@ -3170,6 +4003,17 @@ async function setTransport(
|
|
|
3170
4003
|
}
|
|
3171
4004
|
}
|
|
3172
4005
|
|
|
4006
|
+
/** Move the playhead to transport `beat`, playing or not (section jump). */
|
|
4007
|
+
async function seekTransport(beat: number): Promise<void> {
|
|
4008
|
+
if (port.mode === "daemon") {
|
|
4009
|
+
await port.transport("seek", { beat });
|
|
4010
|
+
return;
|
|
4011
|
+
}
|
|
4012
|
+
const now = Date.now();
|
|
4013
|
+
clock.sync(beat, clock.playing, now, now);
|
|
4014
|
+
if (clock.playing) await audio.play(score, clock.beatAt());
|
|
4015
|
+
}
|
|
4016
|
+
|
|
3173
4017
|
/**
|
|
3174
4018
|
* Run one streaming tool-calling turn. Each validated tool call commits its
|
|
3175
4019
|
* own revision through `agentHost`, so cancelling keeps every accepted change
|
|
@@ -3274,6 +4118,25 @@ function mediaServices(): MediaServices {
|
|
|
3274
4118
|
return { runner: systemRunner, env: process.env };
|
|
3275
4119
|
}
|
|
3276
4120
|
|
|
4121
|
+
/**
|
|
4122
|
+
* `master measure`: render the song as the loop that plays and measure
|
|
4123
|
+
* loudness, peaks and balance at the export rate. The render, master and
|
|
4124
|
+
* meter run in a worker (`measureScoreOffThread`), so the header, input and
|
|
4125
|
+
* playback keep going on a long loop.
|
|
4126
|
+
*/
|
|
4127
|
+
async function measureLine(value: TrackScore): Promise<string> {
|
|
4128
|
+
await sampleProblems(value);
|
|
4129
|
+
tui.activity.setSpinner("measuring");
|
|
4130
|
+
try {
|
|
4131
|
+
const measured = await measureScoreOffThread(value, {
|
|
4132
|
+
projectRoot: process.cwd(),
|
|
4133
|
+
});
|
|
4134
|
+
return `master measure · ${measurementLine(measured.mix, measured.master)} · ${measured.sampleRate / 1000} kHz`;
|
|
4135
|
+
} finally {
|
|
4136
|
+
tui.activity.setSpinner(undefined);
|
|
4137
|
+
}
|
|
4138
|
+
}
|
|
4139
|
+
|
|
3277
4140
|
/**
|
|
3278
4141
|
* How the agent's preview_sound renders (with this window's decoded
|
|
3279
4142
|
* samples, at the engine's rate) and plays: once, over silence, unless the
|
|
@@ -3288,6 +4151,16 @@ function agentPreviewHost(): PreviewHost {
|
|
|
3288
4151
|
...(liveSampleBank ? { samples: liveSampleBank } : {}),
|
|
3289
4152
|
});
|
|
3290
4153
|
},
|
|
4154
|
+
// Off the UI thread, at the export rate (see measureLine).
|
|
4155
|
+
measure: async (value, options) => {
|
|
4156
|
+
await sampleProblems(value);
|
|
4157
|
+
return measureScoreOffThread(value, {
|
|
4158
|
+
projectRoot: process.cwd(),
|
|
4159
|
+
...(options?.sampleRate === undefined
|
|
4160
|
+
? {}
|
|
4161
|
+
: { sampleRate: options.sampleRate }),
|
|
4162
|
+
});
|
|
4163
|
+
},
|
|
3291
4164
|
play: async (rendered) => {
|
|
3292
4165
|
if (!agentPreviewPlays || clock.playing || auditionLoop?.looping)
|
|
3293
4166
|
return false;
|
|
@@ -3356,8 +4229,13 @@ function agentHost(
|
|
|
3356
4229
|
);
|
|
3357
4230
|
throw error;
|
|
3358
4231
|
}
|
|
3359
|
-
if (
|
|
3360
|
-
|
|
4232
|
+
if (
|
|
4233
|
+
change.operations.some(
|
|
4234
|
+
(operation) =>
|
|
4235
|
+
operation.type === "setTempo" || operation.type === "setTime",
|
|
4236
|
+
)
|
|
4237
|
+
)
|
|
4238
|
+
clock.follow(score);
|
|
3361
4239
|
return { revision: record.revision };
|
|
3362
4240
|
},
|
|
3363
4241
|
async transport(action) {
|