@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/DAWG.md +117 -30
  3. package/README.md +7 -5
  4. package/core/diff.ts +2 -0
  5. package/core/ids.ts +52 -0
  6. package/core/loop.ts +3 -0
  7. package/core/range.ts +581 -0
  8. package/core/score.ts +123 -1
  9. package/core/sdk/print.ts +11 -2
  10. package/core/sdk/v1.ts +281 -2
  11. package/core/sections.ts +198 -19
  12. package/guides/arrange.md +9 -8
  13. package/guides/audio.md +31 -0
  14. package/guides/mix.md +3 -1
  15. package/guides/panes.md +32 -0
  16. package/guides/project.md +3 -2
  17. package/guides/rhythm.md +2 -1
  18. package/guides/tape.md +32 -0
  19. package/package.json +2 -1
  20. package/src/agent/agent.ts +4 -4
  21. package/src/agent/command-agent.ts +19 -10
  22. package/src/agent/ops.ts +15 -0
  23. package/src/agent/planner.ts +12 -0
  24. package/src/agent/range-tools.ts +216 -0
  25. package/src/agent/show-me.ts +1 -1
  26. package/src/agent/tools.ts +2 -0
  27. package/src/agent/xcb-agent.ts +2 -3
  28. package/src/audio/arrange.ts +12 -2
  29. package/src/audio/audio-command.ts +218 -0
  30. package/src/audio/devices.ts +264 -0
  31. package/src/audio/doctor.ts +84 -13
  32. package/src/audio/engine.ts +216 -28
  33. package/src/commands/arrange.ts +60 -25
  34. package/src/commands/grammar.ts +3 -1
  35. package/src/commands/help.ts +187 -9
  36. package/src/commands/history.ts +122 -11
  37. package/src/commands/keys.ts +17 -0
  38. package/src/commands/parses.ts +6 -1
  39. package/src/commands/range.ts +763 -0
  40. package/src/commands/resample.ts +13 -10
  41. package/src/commands/sample.ts +11 -1
  42. package/src/commands/sing.ts +2 -6
  43. package/src/commands/vocal-pitch.ts +2 -1
  44. package/src/identity/actor.ts +127 -0
  45. package/src/lang/glossary.ts +18 -0
  46. package/src/launch-args.ts +105 -1
  47. package/src/main.ts +1142 -86
  48. package/src/session/client.ts +107 -3
  49. package/src/session/clipboard.ts +79 -0
  50. package/src/session/daemon.ts +196 -5
  51. package/src/session/live-host.ts +232 -0
  52. package/src/session/origin.ts +20 -0
  53. package/src/session/port.ts +87 -10
  54. package/src/session/protocol.ts +360 -6
  55. package/src/session/rebase.ts +46 -8
  56. package/src/session/receipt.ts +7 -0
  57. package/src/session/shared-live.ts +131 -0
  58. package/src/session/store.ts +61 -2
  59. package/src/tui/arrange-menu.ts +169 -1
  60. package/src/tui/euclid.ts +95 -14
  61. package/src/tui/fader.ts +55 -2
  62. package/src/tui/knob-fields.ts +107 -0
  63. package/src/tui/knob-map.ts +198 -0
  64. package/src/tui/menu.ts +228 -2
  65. package/src/tui/play-session.ts +196 -21
  66. package/src/tui/tape-mode.ts +580 -0
  67. package/src/tui/tape-view.ts +263 -0
  68. package/tui/app.ts +217 -44
  69. package/tui/delight.ts +49 -0
  70. package/tui/drawer.ts +167 -9
  71. package/tui/grammar.ts +34 -5
  72. package/tui/guide.ts +8 -1
  73. package/tui/highway.ts +40 -5
  74. package/tui/hints.ts +5 -0
  75. package/tui/hits.ts +7 -1
  76. package/tui/knobs.ts +268 -0
  77. package/tui/play-strip.ts +12 -0
  78. package/tui/screen.ts +7 -4
  79. package/tui/tape.ts +439 -0
  80. package/tui/text.ts +35 -2
  81. package/tui/theme.ts +19 -1
@@ -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
- /** Native sink output device name (default: system default). */
297
- device?: string;
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
- /** `restarting`: the player died and is being respawned; `stopped`: gave up. */
304
- state: "restarting" | "stopped";
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 spawnPlayer: (command: readonly string[]) => PlayerProcess;
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
- const device = options.device ?? process.env.DAWG_AUDIO_DEVICE?.trim();
498
- const bufferFrames =
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.spawnPlayer =
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
- this.onStatus?.({
1167
- state: "restarting",
1168
- message: `audio player exited; restarting (${this.respawns}/${MAX_RESPAWNS})`,
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(command: readonly string[]): PlayerProcess {
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",
@@ -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 (lower.length === 1 && (lower[0] === "bake" || lower[0] === "flatten"))
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 bake · form verse chorus verse",
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 · baked into ${next.bars} bars`);
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` when no section spans those bars: mark the section named
930
- * `loop` over them (moving it if it exists) and loop it, as one next score.
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; name: string; message: string }>
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
- const marked = applySectionCommand(score, trackId, {
945
- type: "section-mark",
946
- name,
947
- startBar: from - 1,
948
- bars: to - from + 1,
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: looped.next,
961
- name,
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
  }
@@ -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",