@hraness/dawg 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/DAWG.md +117 -30
- package/README.md +7 -5
- package/core/diff.ts +2 -0
- package/core/ids.ts +52 -0
- package/core/loop.ts +3 -0
- package/core/range.ts +581 -0
- package/core/score.ts +123 -1
- package/core/sdk/print.ts +11 -2
- package/core/sdk/v1.ts +281 -2
- package/core/sections.ts +198 -19
- package/guides/arrange.md +9 -8
- package/guides/audio.md +31 -0
- package/guides/mix.md +3 -1
- package/guides/panes.md +32 -0
- package/guides/project.md +3 -2
- package/guides/rhythm.md +2 -1
- package/guides/tape.md +32 -0
- package/package.json +2 -1
- package/src/agent/agent.ts +4 -4
- package/src/agent/command-agent.ts +19 -10
- package/src/agent/ops.ts +15 -0
- package/src/agent/planner.ts +12 -0
- package/src/agent/range-tools.ts +216 -0
- package/src/agent/show-me.ts +1 -1
- package/src/agent/tools.ts +2 -0
- package/src/agent/xcb-agent.ts +2 -3
- package/src/audio/arrange.ts +12 -2
- package/src/audio/audio-command.ts +218 -0
- package/src/audio/devices.ts +264 -0
- package/src/audio/doctor.ts +84 -13
- package/src/audio/engine.ts +216 -28
- package/src/commands/arrange.ts +60 -25
- package/src/commands/grammar.ts +3 -1
- package/src/commands/help.ts +187 -9
- package/src/commands/history.ts +122 -11
- package/src/commands/keys.ts +17 -0
- package/src/commands/parses.ts +6 -1
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +2 -6
- package/src/commands/vocal-pitch.ts +2 -1
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +18 -0
- package/src/launch-args.ts +105 -1
- package/src/main.ts +1142 -86
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +196 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/origin.ts +20 -0
- package/src/session/port.ts +87 -10
- package/src/session/protocol.ts +360 -6
- package/src/session/rebase.ts +46 -8
- package/src/session/receipt.ts +7 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +61 -2
- package/src/tui/arrange-menu.ts +169 -1
- package/src/tui/euclid.ts +95 -14
- package/src/tui/fader.ts +55 -2
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- package/src/tui/menu.ts +228 -2
- package/src/tui/play-session.ts +196 -21
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/tui/app.ts +217 -44
- package/tui/delight.ts +49 -0
- package/tui/drawer.ts +167 -9
- package/tui/grammar.ts +34 -5
- package/tui/guide.ts +8 -1
- package/tui/highway.ts +40 -5
- package/tui/hints.ts +5 -0
- package/tui/hits.ts +7 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +12 -0
- package/tui/screen.ts +7 -4
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +19 -1
package/src/audio/engine.ts
CHANGED
|
@@ -14,6 +14,12 @@ import { measureLoudness, pcmChannels } from "./loudness.ts";
|
|
|
14
14
|
import type { MasterReport } from "./master.ts";
|
|
15
15
|
import { levelOf, type SoundLevel } from "./preview.ts";
|
|
16
16
|
import { DEFAULT_SAMPLE_RATE, RENDER_CHANNELS } from "./wav.ts";
|
|
17
|
+
import { statSync } from "node:fs";
|
|
18
|
+
import {
|
|
19
|
+
audioChoicePath,
|
|
20
|
+
readAudioChoice,
|
|
21
|
+
resolveOutputDevice,
|
|
22
|
+
} from "./devices.ts";
|
|
17
23
|
import { playbackTime } from "./arrange.ts";
|
|
18
24
|
import { transportMapFor, type TransportMap } from "./clock.ts";
|
|
19
25
|
import {
|
|
@@ -67,6 +73,15 @@ export type DetectOptions = Readonly<{
|
|
|
67
73
|
}>;
|
|
68
74
|
|
|
69
75
|
let cachedProbe: NativeProbe | undefined;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Replace the process's native probe (a test preload's fake sink, so a PTY
|
|
79
|
+
* run can list and switch devices without hardware).
|
|
80
|
+
*/
|
|
81
|
+
export function setNativeProbe(probe: NativeProbe | undefined): void {
|
|
82
|
+
cachedProbe = probe;
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
/** The real probe, once per process (dlopen is not repeated). */
|
|
71
86
|
function defaultNativeProbe(): NativeProbe {
|
|
72
87
|
cachedProbe ??= probeNativeSink();
|
|
@@ -293,20 +308,49 @@ export type AudioEngineOptions = Readonly<{
|
|
|
293
308
|
onStatus?: (status: AudioStatus) => void;
|
|
294
309
|
/** Play-mode lead (default 60 ms; 15 ms on the native sink). */
|
|
295
310
|
playLeadMs?: number;
|
|
296
|
-
/**
|
|
297
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Output device name (native sink; sox via AUDIODEV). Default:
|
|
313
|
+
* DAWG_AUDIO_DEVICE, then the saved choice (src/audio/devices.ts), then
|
|
314
|
+
* the system default. `null` forces the system default.
|
|
315
|
+
*/
|
|
316
|
+
device?: string | null;
|
|
317
|
+
/**
|
|
318
|
+
* The saved choice to follow (`<config>/audio.json`): checked about once
|
|
319
|
+
* a second while playing and before each player starts, so a choice made
|
|
320
|
+
* in another window or the daemon's client moves playback. Defaults to
|
|
321
|
+
* the real file when the backend is auto-detected (no `info`, no
|
|
322
|
+
* `spawn`, no `device`); `false` never follows one.
|
|
323
|
+
*/
|
|
324
|
+
choicePath?: string | false;
|
|
298
325
|
/** Native sink device buffer frames (default 128; 0 = device default). */
|
|
299
326
|
bufferFrames?: number;
|
|
300
327
|
}>;
|
|
301
328
|
|
|
302
329
|
export type AudioStatus = Readonly<{
|
|
303
|
-
/**
|
|
304
|
-
|
|
330
|
+
/**
|
|
331
|
+
* `restarting`: the player died and is being respawned; `stopped`: gave
|
|
332
|
+
* up; `device`: the chosen output is gone, playback moved to the default.
|
|
333
|
+
*/
|
|
334
|
+
state: "restarting" | "stopped" | "device";
|
|
305
335
|
message: string;
|
|
306
336
|
}>;
|
|
307
337
|
|
|
308
338
|
/** Respawns of a dying player before playback is declared stopped. */
|
|
309
339
|
const MAX_RESPAWNS = 3;
|
|
340
|
+
/** How often a playing engine checks the saved device choice. */
|
|
341
|
+
const CHOICE_CHECK_MS = 1000;
|
|
342
|
+
|
|
343
|
+
function fileMtime(path: string): number {
|
|
344
|
+
try {
|
|
345
|
+
return statSync(path).mtimeMs;
|
|
346
|
+
} catch {
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function cleanEnvDevice(): string | undefined {
|
|
352
|
+
return resolveOutputDevice(process.env, {});
|
|
353
|
+
}
|
|
310
354
|
/** A player that ran this long before dying resets the respawn budget. */
|
|
311
355
|
const RESPAWN_RESET_MS = 10_000;
|
|
312
356
|
/**
|
|
@@ -430,7 +474,14 @@ export class AudioEngine {
|
|
|
430
474
|
public readonly playLeadMs: number;
|
|
431
475
|
private readonly now: () => number;
|
|
432
476
|
private readonly useTimer: boolean;
|
|
433
|
-
private readonly
|
|
477
|
+
private readonly customSpawn:
|
|
478
|
+
((command: readonly string[]) => PlayerProcess) | undefined;
|
|
479
|
+
private readonly bufferFrames: number;
|
|
480
|
+
/** The output device the next player opens (undefined: system default). */
|
|
481
|
+
private outputDevice: string | undefined;
|
|
482
|
+
private readonly choicePath: string | false;
|
|
483
|
+
private choiceMtime: number;
|
|
484
|
+
private choiceCheckedMs = Number.NEGATIVE_INFINITY;
|
|
434
485
|
private readonly renderer: LoopRenderer;
|
|
435
486
|
private readonly respawnMs: number;
|
|
436
487
|
private readonly onStatus: ((status: AudioStatus) => void) | undefined;
|
|
@@ -494,21 +545,24 @@ export class AudioEngine {
|
|
|
494
545
|
(native ? NATIVE_PLAY_LEAD_MS : 60);
|
|
495
546
|
this.now = options.now ?? (() => performance.now());
|
|
496
547
|
this.useTimer = options.timer ?? true;
|
|
497
|
-
|
|
498
|
-
|
|
548
|
+
this.choicePath =
|
|
549
|
+
options.choicePath ??
|
|
550
|
+
(options.info === undefined &&
|
|
551
|
+
options.spawn === undefined &&
|
|
552
|
+
options.device === undefined
|
|
553
|
+
? audioChoicePath()
|
|
554
|
+
: false);
|
|
555
|
+
this.outputDevice =
|
|
556
|
+
options.device === null
|
|
557
|
+
? undefined
|
|
558
|
+
: ((options.device?.trim() || undefined) ??
|
|
559
|
+
(this.choicePath
|
|
560
|
+
? resolveOutputDevice(process.env, readAudioChoice(this.choicePath))
|
|
561
|
+
: cleanEnvDevice()));
|
|
562
|
+
this.choiceMtime = this.choicePath ? fileMtime(this.choicePath) : 0;
|
|
563
|
+
this.bufferFrames =
|
|
499
564
|
options.bufferFrames ?? envNumber(process.env.DAWG_AUDIO_BUFFER) ?? 128;
|
|
500
|
-
this.
|
|
501
|
-
options.spawn ??
|
|
502
|
-
(native
|
|
503
|
-
? () =>
|
|
504
|
-
openNativePlayer(native, {
|
|
505
|
-
rate: this.sampleRate,
|
|
506
|
-
channels: RENDER_CHANNELS,
|
|
507
|
-
...(device ? { device } : {}),
|
|
508
|
-
bufferFrames,
|
|
509
|
-
now: this.now,
|
|
510
|
-
})
|
|
511
|
-
: spawnStdinPlayer);
|
|
565
|
+
this.customSpawn = options.spawn;
|
|
512
566
|
this.respawnMs = options.respawnMs ?? 250;
|
|
513
567
|
this.onStatus = options.onStatus;
|
|
514
568
|
this.renderer = new LoopRenderer({
|
|
@@ -900,6 +954,7 @@ export class AudioEngine {
|
|
|
900
954
|
const child = this.child;
|
|
901
955
|
const loop = this.loop;
|
|
902
956
|
if (!child || (!loop && !this.monitoring)) return;
|
|
957
|
+
if (this.followChoice()) return;
|
|
903
958
|
let remaining: number;
|
|
904
959
|
if (child.clock) {
|
|
905
960
|
// Device-clocked: top the ring up to the lead. Frames the device
|
|
@@ -1103,6 +1158,134 @@ export class AudioEngine {
|
|
|
1103
1158
|
this.loop = next;
|
|
1104
1159
|
}
|
|
1105
1160
|
|
|
1161
|
+
/** The output device players open; undefined is the system default. */
|
|
1162
|
+
public get device(): string | undefined {
|
|
1163
|
+
return this.outputDevice;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** Whether this backend can play on a chosen output. */
|
|
1167
|
+
public get choosesDevice(): boolean {
|
|
1168
|
+
return (
|
|
1169
|
+
this.customSpawn === undefined &&
|
|
1170
|
+
(this.info.backend === "native" || this.info.backend === "sox")
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Move playback to `device` (undefined: the system default). A running
|
|
1176
|
+
* player is replaced at the frame being heard, so the song carries on.
|
|
1177
|
+
*/
|
|
1178
|
+
public setDevice(device: string | undefined): void {
|
|
1179
|
+
const next = device?.trim() || undefined;
|
|
1180
|
+
if (next === this.outputDevice) return;
|
|
1181
|
+
this.outputDevice = next;
|
|
1182
|
+
const child = this.child;
|
|
1183
|
+
if (!child || !this.choosesDevice) return;
|
|
1184
|
+
const loop = this.loop;
|
|
1185
|
+
const heard = this.heardFrame(loop);
|
|
1186
|
+
const at = this.now();
|
|
1187
|
+
const generation = this.generation;
|
|
1188
|
+
this.child = undefined;
|
|
1189
|
+
this.loop = undefined;
|
|
1190
|
+
if (this.timer) clearInterval(this.timer);
|
|
1191
|
+
this.timer = undefined;
|
|
1192
|
+
child.kill();
|
|
1193
|
+
this.starting = child.exited
|
|
1194
|
+
.catch(() => undefined)
|
|
1195
|
+
.then(() => this.lock.release())
|
|
1196
|
+
.then(async () => {
|
|
1197
|
+
if (generation !== this.generation || this.child) return;
|
|
1198
|
+
if (!loop && !this.monitoring) return;
|
|
1199
|
+
const frame = heard + ((this.now() - at) * this.sampleRate) / 1000;
|
|
1200
|
+
await this.start(loop, loop ? beatOfFrame(loop, frame) : 0);
|
|
1201
|
+
})
|
|
1202
|
+
.finally(() => {
|
|
1203
|
+
this.starting = undefined;
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Follow the saved choice when its file changed (at most once a second).
|
|
1209
|
+
* Returns true when that moved playback to another device.
|
|
1210
|
+
*/
|
|
1211
|
+
private followChoice(force = false): boolean {
|
|
1212
|
+
if (!this.choicePath) return false;
|
|
1213
|
+
const now = this.now();
|
|
1214
|
+
if (!force && now - this.choiceCheckedMs < CHOICE_CHECK_MS) return false;
|
|
1215
|
+
this.choiceCheckedMs = now;
|
|
1216
|
+
const mtime = fileMtime(this.choicePath);
|
|
1217
|
+
if (mtime === this.choiceMtime) return false;
|
|
1218
|
+
this.choiceMtime = mtime;
|
|
1219
|
+
const next = resolveOutputDevice(
|
|
1220
|
+
process.env,
|
|
1221
|
+
readAudioChoice(this.choicePath),
|
|
1222
|
+
);
|
|
1223
|
+
if (next === this.outputDevice) return false;
|
|
1224
|
+
this.setDevice(next);
|
|
1225
|
+
return true;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
private heardFrame(loop: Loop | undefined): number {
|
|
1229
|
+
return loop
|
|
1230
|
+
? (((this.cursor - this.queuedFrames()) % loop.frames) + loop.frames) %
|
|
1231
|
+
loop.frames
|
|
1232
|
+
: 0;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** Open a player on the chosen output, or the default when it is gone. */
|
|
1236
|
+
private spawnPlayer(command: readonly string[]): PlayerProcess {
|
|
1237
|
+
if (this.customSpawn) return this.customSpawn(command);
|
|
1238
|
+
this.followChoice(true);
|
|
1239
|
+
const native =
|
|
1240
|
+
this.info.backend === "native" ? this.info.native : undefined;
|
|
1241
|
+
const device = this.outputDevice;
|
|
1242
|
+
if (native) {
|
|
1243
|
+
const open = (name: string | undefined) =>
|
|
1244
|
+
openNativePlayer(native, {
|
|
1245
|
+
rate: this.sampleRate,
|
|
1246
|
+
channels: RENDER_CHANNELS,
|
|
1247
|
+
...(name ? { device: name } : {}),
|
|
1248
|
+
bufferFrames: this.bufferFrames,
|
|
1249
|
+
now: this.now,
|
|
1250
|
+
});
|
|
1251
|
+
if (!device) return open(undefined);
|
|
1252
|
+
try {
|
|
1253
|
+
return open(device);
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
const player = open(undefined);
|
|
1256
|
+
this.deviceLost(device, error);
|
|
1257
|
+
return player;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return spawnStdinPlayer(
|
|
1261
|
+
command,
|
|
1262
|
+
this.info.backend === "sox" && device ? { AUDIODEV: device } : undefined,
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/** The chosen output is gone: play on the default and say so once. */
|
|
1267
|
+
private deviceLost(device: string, _error?: unknown): void {
|
|
1268
|
+
if (this.outputDevice !== device) return;
|
|
1269
|
+
this.outputDevice = undefined;
|
|
1270
|
+
this.onStatus?.({
|
|
1271
|
+
state: "device",
|
|
1272
|
+
message: `audio output "${device}" is unavailable · playing on the system default`,
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** After a player died: is its chosen output still there? */
|
|
1277
|
+
private deviceGone(): string | undefined {
|
|
1278
|
+
const device = this.outputDevice;
|
|
1279
|
+
if (!device || !this.choosesDevice) return undefined;
|
|
1280
|
+
if (this.info.backend === "sox") return device;
|
|
1281
|
+
try {
|
|
1282
|
+
const names = this.info.native?.devices(false) ?? [];
|
|
1283
|
+
return names.some((entry) => entry.name === device) ? undefined : device;
|
|
1284
|
+
} catch {
|
|
1285
|
+
return undefined;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1106
1289
|
private async start(loop: Loop | undefined, beat: number): Promise<void> {
|
|
1107
1290
|
const generation = this.generation;
|
|
1108
1291
|
if (!(await this.lock.acquire())) return;
|
|
@@ -1142,11 +1325,9 @@ export class AudioEngine {
|
|
|
1142
1325
|
const loop = this.loop;
|
|
1143
1326
|
const ranMs = this.now() - this.startMs;
|
|
1144
1327
|
// Frame the device was playing when the stream ended.
|
|
1145
|
-
const heard = loop
|
|
1146
|
-
? (((this.cursor - this.queuedFrames()) % loop.frames) + loop.frames) %
|
|
1147
|
-
loop.frames
|
|
1148
|
-
: 0;
|
|
1328
|
+
const heard = this.heardFrame(loop);
|
|
1149
1329
|
const diedAt = this.now();
|
|
1330
|
+
const lost = this.deviceGone();
|
|
1150
1331
|
this.child = undefined;
|
|
1151
1332
|
this.loop = undefined;
|
|
1152
1333
|
if (this.timer) clearInterval(this.timer);
|
|
@@ -1163,10 +1344,13 @@ export class AudioEngine {
|
|
|
1163
1344
|
}
|
|
1164
1345
|
this.respawns += 1;
|
|
1165
1346
|
const delay = this.respawnMs * 2 ** (this.respawns - 1);
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1347
|
+
// An unplugged output: one notice, then the default device.
|
|
1348
|
+
if (lost) this.deviceLost(lost);
|
|
1349
|
+
else
|
|
1350
|
+
this.onStatus?.({
|
|
1351
|
+
state: "restarting",
|
|
1352
|
+
message: `audio player exited; restarting (${this.respawns}/${MAX_RESPAWNS})`,
|
|
1353
|
+
});
|
|
1170
1354
|
this.respawnTimer = setTimeout(() => {
|
|
1171
1355
|
this.respawnTimer = undefined;
|
|
1172
1356
|
if (generation !== this.generation || this.child || this.starting)
|
|
@@ -1181,9 +1365,13 @@ export class AudioEngine {
|
|
|
1181
1365
|
}
|
|
1182
1366
|
}
|
|
1183
1367
|
|
|
1184
|
-
function spawnStdinPlayer(
|
|
1368
|
+
function spawnStdinPlayer(
|
|
1369
|
+
command: readonly string[],
|
|
1370
|
+
env?: Readonly<Record<string, string>>,
|
|
1371
|
+
): PlayerProcess {
|
|
1185
1372
|
if (command.length === 0) throw new Error("no player command");
|
|
1186
1373
|
const child = Bun.spawn([...command], {
|
|
1374
|
+
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
1187
1375
|
stdin: "pipe",
|
|
1188
1376
|
stdout: "ignore",
|
|
1189
1377
|
stderr: "ignore",
|
package/src/commands/arrange.ts
CHANGED
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
arrangedStartBar,
|
|
38
38
|
deleteSection,
|
|
39
39
|
duplicateSection,
|
|
40
|
+
joinSection,
|
|
41
|
+
splitSection,
|
|
40
42
|
DROP_CUT_MIN,
|
|
41
43
|
FILL_STYLES,
|
|
42
44
|
findSection,
|
|
@@ -96,6 +98,8 @@ export type SectionCommand =
|
|
|
96
98
|
| { type: "section-reset"; name: string }
|
|
97
99
|
| { type: "section-loop"; name?: string }
|
|
98
100
|
| { type: "section-jump"; name: string }
|
|
101
|
+
| { type: "section-split"; name: string; atBar: number }
|
|
102
|
+
| { type: "section-join"; name: string; force: boolean }
|
|
99
103
|
| { type: "form-show" }
|
|
100
104
|
| { type: "form-set"; text: string }
|
|
101
105
|
| { type: "form-bake" }
|
|
@@ -134,6 +138,8 @@ const SUBCOMMANDS = new Set([
|
|
|
134
138
|
"loop",
|
|
135
139
|
"jump",
|
|
136
140
|
"go",
|
|
141
|
+
"split",
|
|
142
|
+
"join",
|
|
137
143
|
]);
|
|
138
144
|
|
|
139
145
|
/** `3-6` or `3..6` or `3` → 0-based start and length (1-based inclusive in). */
|
|
@@ -370,6 +376,22 @@ function parseSub(
|
|
|
370
376
|
};
|
|
371
377
|
case "vary":
|
|
372
378
|
return parseVary(lead.name, lead.rest);
|
|
379
|
+
case "split": {
|
|
380
|
+
// section split verse at 5 (1-based bar)
|
|
381
|
+
const where = tail[0] === "at" ? tail.slice(1) : tail;
|
|
382
|
+
if (where.length === 2 && where[0] === "bar") where.shift();
|
|
383
|
+
if (where.length !== 1 || !/^\d{1,4}$/u.test(where[0]!)) return undefined;
|
|
384
|
+
const bar = Number(where[0]);
|
|
385
|
+
return bar >= 1
|
|
386
|
+
? { type: "section-split", name: lead.name, atBar: bar - 1 }
|
|
387
|
+
: undefined;
|
|
388
|
+
}
|
|
389
|
+
case "join":
|
|
390
|
+
if (tail.length === 0)
|
|
391
|
+
return { type: "section-join", name: lead.name, force: false };
|
|
392
|
+
return tail.length === 1 && tail[0] === "force"
|
|
393
|
+
? { type: "section-join", name: lead.name, force: true }
|
|
394
|
+
: undefined;
|
|
373
395
|
default:
|
|
374
396
|
return undefined;
|
|
375
397
|
}
|
|
@@ -424,7 +446,10 @@ function parseFormWords(
|
|
|
424
446
|
lower: readonly string[],
|
|
425
447
|
): SectionCommand | undefined {
|
|
426
448
|
if (words.length === 0) return { type: "form-show" };
|
|
427
|
-
if (
|
|
449
|
+
if (
|
|
450
|
+
lower.length === 1 &&
|
|
451
|
+
(lower[0] === "bake" || lower[0] === "flatten" || lower[0] === "print")
|
|
452
|
+
)
|
|
428
453
|
return { type: "form-bake" };
|
|
429
454
|
if (lower.length === 1 && (lower[0] === "off" || lower[0] === "none"))
|
|
430
455
|
return { type: "form-set", text: "" };
|
|
@@ -713,6 +738,27 @@ function applyUnchecked(
|
|
|
713
738
|
`section · added ${section.name} ${barsLabel(section)}${next.bars > score.bars ? ` · song now ${next.bars} bars` : ""}`,
|
|
714
739
|
);
|
|
715
740
|
}
|
|
741
|
+
case "section-split": {
|
|
742
|
+
const next = splitSection(score, command.name, command.atBar);
|
|
743
|
+
const half = next.sections.find(
|
|
744
|
+
(candidate) => !findSection(score, candidate.name),
|
|
745
|
+
);
|
|
746
|
+
return changed(
|
|
747
|
+
score,
|
|
748
|
+
next,
|
|
749
|
+
`section · ${command.name} split at bar ${command.atBar + 1}${half ? ` · ${half.name} ${barsLabel(half)}` : ""}`,
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
case "section-join": {
|
|
753
|
+
const section = findSection(score, command.name)!;
|
|
754
|
+
const next = joinSection(score, command.name, { force: command.force });
|
|
755
|
+
const joined = findSection(next, section.name);
|
|
756
|
+
return changed(
|
|
757
|
+
score,
|
|
758
|
+
next,
|
|
759
|
+
`section · joined into ${section.name}${joined ? ` ${barsLabel(joined)}` : ""}`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
716
762
|
case "section-dup": {
|
|
717
763
|
const next = duplicateSection(score, command.name, {
|
|
718
764
|
...(command.as ? { as: command.as } : {}),
|
|
@@ -882,10 +928,10 @@ function applyUnchecked(
|
|
|
882
928
|
if (score.form.length === 0)
|
|
883
929
|
return {
|
|
884
930
|
ok: false,
|
|
885
|
-
message: "form · no form to
|
|
931
|
+
message: "form · no form to print · form verse chorus verse",
|
|
886
932
|
};
|
|
887
933
|
const next = flattenForm(score);
|
|
888
|
-
return changed(score, next, `form
|
|
934
|
+
return changed(score, next, `printed form to tape · ${next.bars} bars`);
|
|
889
935
|
}
|
|
890
936
|
case "build": {
|
|
891
937
|
const result = generateBuild(score, command.options);
|
|
@@ -926,39 +972,28 @@ function describeVary(vary: { transpose?: number; gain?: number }): string {
|
|
|
926
972
|
}
|
|
927
973
|
|
|
928
974
|
/**
|
|
929
|
-
* `loop 2-3
|
|
930
|
-
*
|
|
975
|
+
* `loop 2-3`: the loop range (score.loop, op1-ux §4), never a section. A
|
|
976
|
+
* range past the song's end is refused; a looped section stops looping.
|
|
931
977
|
*/
|
|
932
978
|
export function loopSpan(
|
|
933
979
|
score: TrackScore,
|
|
934
|
-
trackId: string,
|
|
935
980
|
from: number,
|
|
936
981
|
to: number,
|
|
937
982
|
):
|
|
938
|
-
| Readonly<{ ok: true; next: TrackScore;
|
|
983
|
+
| Readonly<{ ok: true; next: TrackScore; message: string }>
|
|
939
984
|
| Readonly<{ ok: false; message: string }> {
|
|
940
985
|
const bars = from === to ? `${from}` : `${from}-${to}`;
|
|
941
|
-
const name = "loop";
|
|
942
986
|
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to < from)
|
|
943
987
|
return { ok: false, message: `loop ${bars} · bars run low-high from 1` };
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
});
|
|
950
|
-
if (!marked.ok || !marked.next)
|
|
951
|
-
return { ok: false, message: `loop ${bars} · ${marked.message}` };
|
|
952
|
-
const looped = applySectionCommand(marked.next, trackId, {
|
|
953
|
-
type: "section-loop",
|
|
954
|
-
name,
|
|
955
|
-
});
|
|
956
|
-
if (!looped.ok || !looped.next)
|
|
957
|
-
return { ok: false, message: `loop ${bars} · ${looped.message}` };
|
|
988
|
+
if (to > score.bars)
|
|
989
|
+
return {
|
|
990
|
+
ok: false,
|
|
991
|
+
message: `loop ${bars} · the song has ${score.bars} bars · bars ${to} lengthens it`,
|
|
992
|
+
};
|
|
993
|
+
const next = score.withLoop({ startBar: from - 1, bars: to - from + 1 });
|
|
958
994
|
return {
|
|
959
995
|
ok: true,
|
|
960
|
-
next
|
|
961
|
-
|
|
962
|
-
message: `loop · bars ${from === to ? from : `${from}–${to}`} · section ${name} · loop off plays the song`,
|
|
996
|
+
next,
|
|
997
|
+
message: `loop · bars ${from === to ? from : `${from}–${to}`} · loop off plays the song`,
|
|
963
998
|
};
|
|
964
999
|
}
|
package/src/commands/grammar.ts
CHANGED
|
@@ -217,7 +217,7 @@ export type LoopCommand =
|
|
|
217
217
|
| Readonly<{ type: "loop-show" }>;
|
|
218
218
|
|
|
219
219
|
export const LOOP_USAGE =
|
|
220
|
-
"loop <a>-<b> | <section> | off · loop 1-4 · loop chorus · loop off";
|
|
220
|
+
"loop <a>-<b> | <section> | next | prev | off · loop 1-4 · loop chorus · loop next · loop off";
|
|
221
221
|
|
|
222
222
|
/**
|
|
223
223
|
* `loop 1-4` (bars, 1-based inclusive), `loop 1 4`, `loop chorus`, `loop
|
|
@@ -315,6 +315,7 @@ export const WINDOW_VERBS: ReadonlySet<string> = new Set([
|
|
|
315
315
|
"theme",
|
|
316
316
|
"motion",
|
|
317
317
|
"showme",
|
|
318
|
+
"audio",
|
|
318
319
|
"model",
|
|
319
320
|
"sessions",
|
|
320
321
|
"logout",
|
|
@@ -333,6 +334,7 @@ export const WINDOW_VERBS: ReadonlySet<string> = new Set([
|
|
|
333
334
|
"len",
|
|
334
335
|
"fitmode",
|
|
335
336
|
"euclid",
|
|
337
|
+
"tape",
|
|
336
338
|
"style",
|
|
337
339
|
"styles",
|
|
338
340
|
"calibration",
|