@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
package/src/tui/menu.ts CHANGED
@@ -41,6 +41,8 @@ import { malletsMenu, modalParameterNodes } from "./modal-menu.ts";
41
41
  import { windParameterNodes, windsMenu } from "./wind-menu.ts";
42
42
  import { singParameterNodes } from "./sing-menu.ts";
43
43
  import type { FaderSpec } from "./fader.ts";
44
+ import { knobFields, type KnobField } from "./knob-fields.ts";
45
+ import { soundFamily } from "./knob-map.ts";
44
46
  import {
45
47
  openingMeterCommand,
46
48
  openingUnit,
@@ -209,6 +211,8 @@ export type MenuContext = Readonly<{
209
211
  countInBars: number;
210
212
  /** How much agent turns show (`/showme`); absent hides the row. */
211
213
  showMe?: string;
214
+ /** Project › audio: the chosen devices and the lists to pick from. */
215
+ audio?: MenuAudioDevices;
212
216
  /** The session's name, for Project › session and export file names. */
213
217
  sessionName?: string;
214
218
  /** Play mode's chord settings (defaults when absent). */
@@ -223,6 +227,18 @@ export type MenuContext = Readonly<{
223
227
  audition?: MenuAudition;
224
228
  }>;
225
229
 
230
+ /** One output and one input (src/audio/devices.ts); no routing. */
231
+ export type MenuAudioDevices = Readonly<{
232
+ /** `Speakers` or `default (Speakers)`. */
233
+ output: string;
234
+ input: string;
235
+ /** Output names; empty when the backend cannot list them. */
236
+ outputs: readonly string[];
237
+ inputs: readonly string[];
238
+ /** Why device choice is unavailable (fallback backends). */
239
+ unavailable?: string;
240
+ }>;
241
+
226
242
  /** What the menu needs from the audition controller. */
227
243
  export type MenuAudition = Readonly<{
228
244
  looping: boolean;
@@ -602,6 +618,8 @@ export const SECTION_ALIASES: Readonly<Record<string, readonly string[]>> =
602
618
  help: ["project", "help"],
603
619
  guides: ["project", "help"],
604
620
  model: ["project", "agent"],
621
+ audio: ["project", "audio"],
622
+ devices: ["project", "audio"],
605
623
  showme: ["project", "agent"],
606
624
  models: ["project", "agent"],
607
625
  fx: ["effects"],
@@ -1072,6 +1090,14 @@ function mixSectionNodes(context: MenuContext): MenuNode[] {
1072
1090
  ).join(" ");
1073
1091
  return [
1074
1092
  ...trackNodes(context),
1093
+ {
1094
+ kind: "menu",
1095
+ id: "mixer",
1096
+ label: "mixer",
1097
+ detail: `${context.score.tracks.length} levels on one page`,
1098
+ help: "every track's level as a fader, the drawer's all-tracks page (mix) · tab flips to this track",
1099
+ build: mixerNodes,
1100
+ },
1075
1101
  {
1076
1102
  kind: "menu",
1077
1103
  id: "tracks",
@@ -1492,6 +1518,24 @@ function trackNodes(context: MenuContext): MenuNode[] {
1492
1518
  ];
1493
1519
  }
1494
1520
 
1521
+ /**
1522
+ * Mix › mixer: one level fader per track (design §8.5), each a typed
1523
+ * `volume <track> <0..1>`, so the page sets any track without moving the
1524
+ * focus. Labels are numbered, so two tracks with one name stay apart.
1525
+ */
1526
+ export function mixerNodes(context: MenuContext): MenuNode[] {
1527
+ return context.score.tracks.map((track, index): MenuNode => {
1528
+ const base = volumeNode(track);
1529
+ return {
1530
+ ...(base as Extract<MenuNode, { kind: "number" }>),
1531
+ label: `${index + 1}${track.id === context.trackId ? "›" : " "}${track.name}`,
1532
+ command: (value: number) => `volume ${track.id} ${num(value)}`,
1533
+ reset: `volume ${track.id} 1`,
1534
+ help: `${track.name}'s level; 1 is unity (0 dB) · x resets`,
1535
+ };
1536
+ });
1537
+ }
1538
+
1495
1539
  function instrumentNode(track: Track): MenuNode {
1496
1540
  const options: string[] = [...AVAILABLE_INSTRUMENTS, ...KEYS_FAMILIES];
1497
1541
  if (!options.includes(track.instrument)) options.push(track.instrument);
@@ -3113,6 +3157,16 @@ function transportNodes(context: MenuContext): MenuNode[] {
3113
3157
  help: "the agent's model, show-me and model key",
3114
3158
  build: agentNodes,
3115
3159
  },
3160
+ {
3161
+ kind: "menu",
3162
+ id: "audio",
3163
+ label: "audio",
3164
+ detail: context.audio
3165
+ ? `out ${context.audio.output} · in ${context.audio.input}`
3166
+ : "output · input",
3167
+ help: "pick the audio output and input (one each, saved on this machine)",
3168
+ build: audioNodes,
3169
+ },
3116
3170
  {
3117
3171
  kind: "menu",
3118
3172
  id: "help",
@@ -3124,6 +3178,71 @@ function transportNodes(context: MenuContext): MenuNode[] {
3124
3178
  ];
3125
3179
  }
3126
3180
 
3181
+ /** Project › audio: two rows, each opening a device list. */
3182
+ function audioNodes(context: MenuContext): MenuNode[] {
3183
+ const audio = context.audio;
3184
+ if (!audio || audio.unavailable)
3185
+ return [
3186
+ {
3187
+ kind: "info",
3188
+ label: "devices",
3189
+ value: "system default",
3190
+ help: audio?.unavailable ?? "device choice needs the native sink",
3191
+ },
3192
+ {
3193
+ kind: "action",
3194
+ label: "show audio",
3195
+ command: "/audio",
3196
+ help: "the current output, input and why choice is unavailable",
3197
+ },
3198
+ ];
3199
+ const list = (
3200
+ side: "out" | "in",
3201
+ names: readonly string[],
3202
+ current: string,
3203
+ ): MenuNode[] => [
3204
+ {
3205
+ kind: "action",
3206
+ label: current.startsWith("default") ? "● default" : "default",
3207
+ command: `/audio ${side} default`,
3208
+ help: "the system default device (follows the system setting)",
3209
+ },
3210
+ ...names.map((name): MenuNode => ({
3211
+ kind: "action",
3212
+ label: name === current ? `● ${name}` : name,
3213
+ command: `/audio ${side} ${name}`,
3214
+ help:
3215
+ side === "out"
3216
+ ? `play through ${name} (a soft blip confirms it)`
3217
+ : `listen on ${name} (audio test meters it)`,
3218
+ })),
3219
+ ];
3220
+ return [
3221
+ {
3222
+ kind: "menu",
3223
+ id: "output",
3224
+ label: "output",
3225
+ detail: audio.output,
3226
+ help: "where dawg plays: Enter picks, Esc goes back",
3227
+ build: () => list("out", audio.outputs, audio.output),
3228
+ },
3229
+ {
3230
+ kind: "menu",
3231
+ id: "input",
3232
+ label: "input",
3233
+ detail: audio.input,
3234
+ help: "the input to meter now and record from later",
3235
+ build: () => list("in", audio.inputs, audio.input),
3236
+ },
3237
+ {
3238
+ kind: "action",
3239
+ label: "test",
3240
+ command: "/audio test",
3241
+ help: "a short tone on the output, then a level meter for the input",
3242
+ },
3243
+ ];
3244
+ }
3245
+
3127
3246
  /** Project > Resample: one action per useful source and range. */
3128
3247
  function resampleNodes(context: MenuContext): MenuNode[] {
3129
3248
  const track = focused(context);
@@ -3439,6 +3558,11 @@ function childFrame(
3439
3558
  export class EditMenu {
3440
3559
  private stack: Frame[] = [];
3441
3560
  private filtering = false;
3561
+ /**
3562
+ * The fader drawer's front page (design §8.6): the open level's knob page
3563
+ * and whether Tab paged to every param. Undefined: plain rows.
3564
+ */
3565
+ private knobView: { page: string; all: boolean } | undefined;
3442
3566
  /** Typed value for the selected row (`entry` holds the row's label). */
3443
3567
  private entry: { label: string; buffer: string } | undefined;
3444
3568
 
@@ -3475,6 +3599,21 @@ export class EditMenu {
3475
3599
  }
3476
3600
  }
3477
3601
 
3602
+ /** Step into the open level's submenu `id`; false when it has none. */
3603
+ enter(context: MenuContext, id: string): boolean {
3604
+ const frame = this.stack.at(-1);
3605
+ if (!frame) return false;
3606
+ const nodes = frame.build(context);
3607
+ const index = nodes.findIndex(
3608
+ (node) => node.kind === "menu" && node.id === id,
3609
+ );
3610
+ const node = nodes[index];
3611
+ if (node?.kind !== "menu") return false;
3612
+ frame.index = index;
3613
+ this.stack.push(childFrame(frame, node));
3614
+ return true;
3615
+ }
3616
+
3478
3617
  /**
3479
3618
  * Open on the level a bare parameter command edits, for the fader drawer:
3480
3619
  * `volume` / `pan` (Mix), `fx filter` / `fx filter cutoff` (that effect,
@@ -3561,6 +3700,77 @@ export class EditMenu {
3561
3700
  this.stack = [];
3562
3701
  this.filtering = false;
3563
3702
  this.entry = undefined;
3703
+ this.knobView = undefined;
3704
+ }
3705
+
3706
+ /**
3707
+ * The knob page of the open level (design §7.3), or undefined where the
3708
+ * level has no knobs: Sound (by engine), Mix and its mixer (this track),
3709
+ * the master, Project (tempo) and one effect.
3710
+ */
3711
+ knobPageHere(context: MenuContext): string | undefined {
3712
+ const ids = this.stack.slice(1).map((frame) => frame.id ?? "");
3713
+ const [top, sub] = ids;
3714
+ const track = focused(context);
3715
+ if (top === "sound" && ids.length === 1)
3716
+ return track ? `sound:${soundFamily(track)}` : undefined;
3717
+ if (
3718
+ top === "mix" &&
3719
+ (ids.length === 1 || (ids.length === 2 && sub === "mixer"))
3720
+ )
3721
+ return "mix";
3722
+ if (top === "mix" && ids.length === 2 && sub === "master") return "master";
3723
+ if (top === "project" && ids.length === 1) return "tempo";
3724
+ const last = ids.at(-1) ?? "";
3725
+ if (top === "effects" && (EFFECT_NAMES as readonly string[]).includes(last))
3726
+ return `fx:${last}`;
3727
+ return undefined;
3728
+ }
3729
+
3730
+ /**
3731
+ * Turn the drawer's knob front page on for the open level. `label` is the
3732
+ * row the drawer opens on: when it is not one of the knobs (or the level
3733
+ * is the mixer), the drawer opens on every param instead. Returns the
3734
+ * label to focus, or undefined when the level has no knob page.
3735
+ */
3736
+ openKnobs(context: MenuContext, label?: string): string | undefined {
3737
+ const page = this.knobPageHere(context);
3738
+ this.knobView = undefined;
3739
+ if (!page) return undefined;
3740
+ const frame = this.stack.at(-1)!;
3741
+ const fallback = faderSpecs(frame.build(context));
3742
+ const knobs = knobFields(context, page, fallback);
3743
+ if (knobs.length === 0) return undefined;
3744
+ const mixer = frame.id === "mixer";
3745
+ const onKnob =
3746
+ label === undefined || knobs.some((knob) => knob.label === label);
3747
+ this.knobView = { page, all: mixer || !onKnob };
3748
+ if (this.knobView.all) return label ?? fallback[0]?.label;
3749
+ return label ?? knobs[0]!.label;
3750
+ }
3751
+
3752
+ /** Which drawer page is up: the four knobs, every param, or no knobs. */
3753
+ get knobPage(): "knobs" | "all" | undefined {
3754
+ return this.knobView ? (this.knobView.all ? "all" : "knobs") : undefined;
3755
+ }
3756
+
3757
+ /** The knob page id behind the drawer (`sound:synth`, `mix`), if any. */
3758
+ get knobPageId(): string | undefined {
3759
+ return this.knobView?.page;
3760
+ }
3761
+
3762
+ /**
3763
+ * Tab in the drawer: flip between the four knobs and every param (on the
3764
+ * mixer, between all tracks and this track). Returns the label to focus:
3765
+ * `keep` when it is on the new page, else the page's first field.
3766
+ */
3767
+ pageKnobs(context: MenuContext, keep: string): string | undefined {
3768
+ if (!this.knobView) return undefined;
3769
+ this.knobView = { ...this.knobView, all: !this.knobView.all };
3770
+ const fields = this.faderFields(context);
3771
+ return fields.some((field) => field.label === keep)
3772
+ ? keep
3773
+ : fields[0]?.label;
3564
3774
  }
3565
3775
 
3566
3776
  /** The visible rows of the current level (filtered). */
@@ -3858,10 +4068,26 @@ export class EditMenu {
3858
4068
  * related params: every filter control, every reverb control), built
3859
4069
  * from `context` (the staged score while auditioning).
3860
4070
  */
3861
- faderFields(context: MenuContext): FaderSpec[] {
4071
+ faderFields(context: MenuContext): (FaderSpec | KnobField)[] {
3862
4072
  const frame = this.stack.at(-1);
3863
4073
  if (!frame) return [];
3864
- return faderSpecs(frame.build(context));
4074
+ const rows = faderSpecs(frame.build(context));
4075
+ if (!this.knobView) return rows;
4076
+ if (!this.knobView.all)
4077
+ return knobFields(context, this.knobView.page, rows);
4078
+ // The mixer's rows are all levels: each wears the orange knob.
4079
+ if (frame.id === "mixer")
4080
+ return rows.map((row) => ({ ...row, knob: 3 as const }));
4081
+ return rows;
4082
+ }
4083
+
4084
+ /**
4085
+ * Every param of the current level, whichever drawer page is up: the
4086
+ * side list a wide drawer shows beside the four knobs.
4087
+ */
4088
+ pageFields(context: MenuContext): FaderSpec[] {
4089
+ const frame = this.stack.at(-1);
4090
+ return frame ? faderSpecs(frame.build(context)) : [];
3865
4091
  }
3866
4092
 
3867
4093
  /** The same fields as committed, for the drawer's `staged ← committed`. */
@@ -29,6 +29,7 @@ import {
29
29
  type PedalState,
30
30
  } from "../../core/expression.ts";
31
31
  import type { ClickBus } from "../audio/engine.ts";
32
+ import type { RemoteClick, RemoteNote } from "../session/shared-live.ts";
32
33
  import {
33
34
  DEFAULT_CLICK_VOLUME,
34
35
  countInClicks,
@@ -55,6 +56,7 @@ import {
55
56
  type LiveNotePcm,
56
57
  } from "../audio/live.ts";
57
58
  import type { SampleBank } from "../audio/samples.ts";
59
+ import { loopedSection } from "../audio/arrange.ts";
58
60
  import { fitting, onFitReady } from "../audio/fit.ts";
59
61
  import {
60
62
  DEFAULT_STRUM,
@@ -105,6 +107,14 @@ export interface LiveEngine {
105
107
  /** Frames of voice `id` mixed so far, or undefined once it has ended. */
106
108
  voicePosition?(id: number): number | undefined;
107
109
  setClick(click: ClickBus | undefined): void;
110
+ /**
111
+ * A shared engine (dawgd's, src/session/shared-live.ts) takes note events
112
+ * instead of PCM: it renders the voice itself, so one engine mixes every
113
+ * pane. Returns the monotonic ms the note is scheduled for.
114
+ */
115
+ remoteNote?(id: number, note: RemoteNote): number;
116
+ /** A shared engine's click: on/volume and the count-in, not a closure. */
117
+ remoteClick?(click: RemoteClick): void;
108
118
  }
109
119
 
110
120
  export interface PlayHost {
@@ -131,6 +141,12 @@ export interface PlayHost {
131
141
  startTransport(beat: number): Promise<void>;
132
142
  stopTransport(): Promise<void>;
133
143
  card(text: string, tone: "info" | "success" | "warning" | "error"): void;
144
+ /**
145
+ * Another pane recording on `trackId` (§12.4): its name (`pane B`), else
146
+ * undefined. A replace pass refuses while one is, since it would erase
147
+ * the other pane's notes.
148
+ */
149
+ recordingElsewhere?(trackId: string): string | undefined;
134
150
  /** A played key that is not recorded: the screen glows its lane. */
135
151
  ghost?(pitch: number): void;
136
152
  newNoteId(): string;
@@ -264,6 +280,10 @@ export class PlaySession {
264
280
  /** Live pedal state as the playhead entered each loop bar (replace). */
265
281
  private readonly barPedal = new Map<number, PedalState>();
266
282
  private lastBar: number | undefined;
283
+ /** Loop recording: the pass (loop wrap) the playhead is in. */
284
+ private lastPass: number | undefined;
285
+ /** Passes committed this take (the header's `pass N`). */
286
+ private passCount = 0;
267
287
  private countIn: CountIn | undefined;
268
288
  private flushing: Promise<void> = Promise.resolve();
269
289
  /** Most recent key-to-sound schedule, ms (lead before device latency). */
@@ -360,7 +380,8 @@ export class PlaySession {
360
380
  this.countIn = undefined;
361
381
  const engine = this.host.engine();
362
382
  if (engine) {
363
- engine.setClick(this.clickOn ? this.clickBus() : undefined);
383
+ if (engine.remoteClick) this.sendClick(engine.remoteClick.bind(engine));
384
+ else engine.setClick(this.clickOn ? this.clickBus() : undefined);
364
385
  engine.setLeadMs(undefined);
365
386
  await engine.monitor(false);
366
387
  }
@@ -407,9 +428,34 @@ export class PlaySession {
407
428
  private applyClick(): void {
408
429
  const engine = this.host.engine();
409
430
  if (!engine) return;
431
+ if (engine.remoteClick) {
432
+ this.sendClick(engine.remoteClick.bind(engine));
433
+ return;
434
+ }
410
435
  engine.setClick(this.clickOn || this.countIn ? this.clickBus() : undefined);
411
436
  }
412
437
 
438
+ /** The click as a shared engine takes it: on, volume, count-in. */
439
+ private sendClick(send: (click: RemoteClick) => void): void {
440
+ const count = this.countIn;
441
+ send({
442
+ on: this.clickOn,
443
+ volume: this.clickVolume,
444
+ ...(count
445
+ ? {
446
+ countIn: {
447
+ startMs: count.startMs,
448
+ startBeat: count.startBeat,
449
+ beats: count.beats,
450
+ barBeats: count.barBeats,
451
+ clickBeats: count.clickBeats,
452
+ bpm: count.bpm,
453
+ },
454
+ }
455
+ : {}),
456
+ });
457
+ }
458
+
413
459
  /** `/click on|off|<volume>`. */
414
460
  public clickCommand(argument: string): string {
415
461
  const parsed = parseClickArgument(argument, {
@@ -437,6 +483,54 @@ export class PlaySession {
437
483
  return `grid · ${match.label}`;
438
484
  }
439
485
 
486
+ /**
487
+ * `keys record [replace|off]` and the r / R keys: arm recording, overdub
488
+ * (additive) or replace (each pass erases the bars it crossed). With a
489
+ * loop set, every pass over it is one commit, so one undo step (§4.7).
490
+ * Replace refuses while another pane records this track (§12.4).
491
+ */
492
+ public recordCommand(
493
+ mode: "overdub" | "replace" | "off",
494
+ ): Readonly<{ ok: boolean; message: string }> {
495
+ if (mode === "off") {
496
+ const was = this.armed;
497
+ this.armed = false;
498
+ this.replace = false;
499
+ if (was) void this.stopRecording();
500
+ this.status = "record off";
501
+ return { ok: true, message: "record off" };
502
+ }
503
+ if (mode === "replace") {
504
+ const other = this.host.recordingElsewhere?.(this.trackId);
505
+ if (other) {
506
+ const message = `${other} is recording ${this.trackName()} · overdub instead (r)`;
507
+ this.status = `✗ ${message}`;
508
+ return { ok: false, message };
509
+ }
510
+ }
511
+ this.replace = mode === "replace";
512
+ this.armed = true;
513
+ const message = `record ${this.replace ? "replace" : "overdub"} · ${this.trackName()} · ${this.passWord()}`;
514
+ this.status = this.replace
515
+ ? "replace · each pass overwrites its bars"
516
+ : "overdub";
517
+ return { ok: true, message };
518
+ }
519
+
520
+ private trackName(): string {
521
+ return this.trackData()?.name ?? this.trackId;
522
+ }
523
+
524
+ /** `loop 5–6 · one undo step a pass`, or `one undo step a bar`. */
525
+ private passWord(): string {
526
+ const loop = loopedSection(this.host.score());
527
+ if (!loop) return "one undo step a bar";
528
+ const last = loop.startBar + loop.bars;
529
+ const bars =
530
+ loop.bars === 1 ? `${loop.startBar + 1}` : `${loop.startBar + 1}–${last}`;
531
+ return `loop ${bars} · one undo step a pass`;
532
+ }
533
+
440
534
  /** Handle one decoded key; mode commands return to the caller. */
441
535
  public press(value: string): PlayKeyResult {
442
536
  const now = this.host.now();
@@ -569,15 +663,12 @@ export class PlaySession {
569
663
 
570
664
  private command(command: PlayCommand): PlayKeyResult {
571
665
  if (command === "record") {
572
- this.armed = !this.armed;
573
- if (!this.armed) void this.stopRecording();
574
- this.status = this.armed ? "record armed" : "record off";
666
+ this.recordCommand(this.armed ? "off" : "overdub");
575
667
  return { type: "handled" };
576
668
  }
577
669
  if (command === "replace") {
578
- this.replace = !this.replace;
579
- this.armed = true;
580
- this.status = this.replace ? "replace · bars are overwritten" : "overdub";
670
+ const result = this.recordCommand(this.replace ? "overdub" : "replace");
671
+ if (!result.ok) this.host.card(result.message, "error");
581
672
  return { type: "handled" };
582
673
  }
583
674
  if (command === "click") {
@@ -632,6 +723,18 @@ export class PlaySession {
632
723
  const seconds = Number.isFinite(note.releaseAtMs)
633
724
  ? (note.releaseAtMs - note.atMs) / 1000
634
725
  : MAX_LIVE_NOTE_SECONDS;
726
+ if (engine.remoteNote) {
727
+ const scheduled = engine.remoteNote(id, {
728
+ trackId: this.trackId,
729
+ pitch: note.pitch,
730
+ velocity: note.velocity / 127,
731
+ seconds,
732
+ beat: this.host.beatAt(note.atMs),
733
+ atMs: note.atMs,
734
+ });
735
+ this.lastLatencyMs = Math.max(0, scheduled - this.host.now());
736
+ return;
737
+ }
635
738
  const samples = this.host.samples?.();
636
739
  if (this.synth?.rate !== engine.sampleRate)
637
740
  this.synth = new LiveSynth(engine.sampleRate);
@@ -949,25 +1052,50 @@ export class PlaySession {
949
1052
  if (this.pending.size > 0 || this.replaceBars.size > 0)
950
1053
  void this.stopRecording();
951
1054
  this.lastBar = undefined;
1055
+ this.lastPass = undefined;
952
1056
  return;
953
1057
  }
954
1058
  const score = this.host.score();
955
1059
  const bar = transportBar(score, this.host.beatAt(now));
956
1060
  if (bar !== this.lastBar)
957
1061
  this.barPedal.set(this.loopBar(bar), this.pedalDown ? "down" : "up");
1062
+ // With a loop set, a take commits once per pass (each wrap), so one
1063
+ // pass is one undo step; without one, once per bar (§4.7).
1064
+ const loop = loopedSection(score);
1065
+ const pass = loop ? Math.floor(bar / loop.bars) : undefined;
958
1066
  if (this.lastBar !== undefined && bar !== this.lastBar) {
1067
+ // Every bar the playhead left (a slow frame can skip one).
959
1068
  if (this.replace)
960
- this.replaceBars.add(
961
- this.loopBar(
962
- Math.floor(
963
- this.toScoreBeat(this.lastBar * score.beatsPerBar) /
964
- score.beatsPerBar,
1069
+ for (
1070
+ let left = this.lastBar;
1071
+ left < bar && left < this.lastBar + 256;
1072
+ left += 1
1073
+ )
1074
+ this.replaceBars.add(
1075
+ this.loopBar(
1076
+ Math.floor(
1077
+ this.toScoreBeat(left * score.beatsPerBar) / score.beatsPerBar,
1078
+ ),
965
1079
  ),
966
- ),
967
- );
968
- this.queueFlush(bar, now, false);
1080
+ );
1081
+ if (pass === undefined) this.queueFlush(bar, now, false);
1082
+ else if (this.lastPass !== undefined && pass !== this.lastPass)
1083
+ this.queueFlush(bar, now, false, true);
969
1084
  }
970
1085
  this.lastBar = bar;
1086
+ this.lastPass = pass;
1087
+ }
1088
+
1089
+ /** `↻ 5–6 · pass 3` while loop recording; undefined otherwise. */
1090
+ public passLabel(): string | undefined {
1091
+ if (!this.recording) return undefined;
1092
+ const loop = loopedSection(this.host.score());
1093
+ if (!loop) return undefined;
1094
+ const bars =
1095
+ loop.bars === 1
1096
+ ? `${loop.startBar + 1}`
1097
+ : `${loop.startBar + 1}–${loop.startBar + loop.bars}`;
1098
+ return `↻ ${bars} · pass ${this.passCount + 1}`;
971
1099
  }
972
1100
 
973
1101
  private toScoreBeat(beat: number): number {
@@ -986,15 +1114,27 @@ export class PlaySession {
986
1114
  if (this.pedalDown && this.host.playing())
987
1115
  this.pendingPedal.push({ beat: this.host.beatAt(now), state: "up" });
988
1116
  this.pedalDown = false;
989
- this.queueFlush(Number.POSITIVE_INFINITY, now, true);
1117
+ this.queueFlush(
1118
+ Number.POSITIVE_INFINITY,
1119
+ now,
1120
+ true,
1121
+ loopedSection(this.host.score()) !== undefined,
1122
+ );
990
1123
  await this.flushing;
991
1124
  this.recordedIds.clear();
992
1125
  this.lastBar = undefined;
1126
+ this.lastPass = undefined;
1127
+ this.passCount = 0;
993
1128
  }
994
1129
 
995
- private queueFlush(currentBar: number, now: number, all: boolean): void {
1130
+ private queueFlush(
1131
+ currentBar: number,
1132
+ now: number,
1133
+ all: boolean,
1134
+ pass = false,
1135
+ ): void {
996
1136
  this.flushing = this.flushing
997
- .then(() => this.flush(currentBar, now, all))
1137
+ .then(() => this.flush(currentBar, now, all, pass))
998
1138
  .catch((error: unknown) =>
999
1139
  this.host.card(
1000
1140
  `record failed · ${error instanceof Error ? error.message : String(error)}`,
@@ -1011,6 +1151,7 @@ export class PlaySession {
1011
1151
  currentBar: number,
1012
1152
  now: number,
1013
1153
  all: boolean,
1154
+ pass = false,
1014
1155
  ): Promise<void> {
1015
1156
  const score = this.host.score();
1016
1157
  const ready: Pending[] = [];
@@ -1027,12 +1168,39 @@ export class PlaySession {
1027
1168
  this.pendingPedal = this.pendingPedal.filter(
1028
1169
  (event) => !pedal.includes(event),
1029
1170
  );
1030
- if (ready.length === 0 && erase.length === 0 && pedal.length === 0) return;
1171
+ if (pass) this.passCount += 1;
1172
+ // §12.4: a replace pass never erases while another pane records this
1173
+ // track; its notes still land, as an overdub.
1174
+ const other =
1175
+ erase.length > 0
1176
+ ? this.host.recordingElsewhere?.(this.trackId)
1177
+ : undefined;
1178
+ if (other) {
1179
+ erase.length = 0;
1180
+ this.host.card(
1181
+ `${other} is recording ${this.trackName()} · pass kept as overdub`,
1182
+ "warning",
1183
+ );
1184
+ }
1185
+ if (ready.length === 0 && erase.length === 0 && pedal.length === 0) {
1186
+ if (pass) this.recordedIds.clear();
1187
+ return;
1188
+ }
1031
1189
  const eraseStates = new Map(
1032
1190
  erase.map((bar) => [bar, this.barPedal.get(bar) ?? "up"] as const),
1033
1191
  );
1034
1192
  try {
1035
- await this.commitTake(score, ready, erase, eraseStates, pedal, now);
1193
+ await this.commitTake(
1194
+ score,
1195
+ ready,
1196
+ erase,
1197
+ eraseStates,
1198
+ pedal,
1199
+ now,
1200
+ pass ? this.passCount : undefined,
1201
+ );
1202
+ // The next pass may replace this one's notes: forget them.
1203
+ if (pass) this.recordedIds.clear();
1036
1204
  } catch (error) {
1037
1205
  // Nothing was committed: keep the take so the next flush retries it.
1038
1206
  for (const pending of ready) this.pending.set(pending.id, pending);
@@ -1049,6 +1217,7 @@ export class PlaySession {
1049
1217
  eraseStates: ReadonlyMap<number, PedalState>,
1050
1218
  pedal: readonly { beat: number; state: PedalState }[],
1051
1219
  now: number,
1220
+ pass?: number,
1052
1221
  ): Promise<void> {
1053
1222
  const operations = recordOperations(score, {
1054
1223
  trackId: this.trackId,
@@ -1103,12 +1272,17 @@ export class PlaySession {
1103
1272
  trackId: this.trackId,
1104
1273
  operations,
1105
1274
  replace: this.replace,
1275
+ ...(pass !== undefined ? { pass } : {}),
1106
1276
  });
1107
1277
  for (const pending of ready) this.pending.delete(pending.id);
1108
1278
  for (const operation of operations)
1109
1279
  if (operation.type === "addNote") this.recordedIds.add(operation.note.id);
1280
+ const head =
1281
+ pass !== undefined
1282
+ ? `pass ${pass} · +${added} note${added === 1 ? "" : "s"}`
1283
+ : `recorded ${added} note${added === 1 ? "" : "s"}`;
1110
1284
  this.host.card(
1111
- `recorded ${added} note${added === 1 ? "" : "s"}${pedal.length && !pedalNote ? ` · ${pedal.length} pedal` : ""}${removed ? ` · replaced ${removed}` : ""} · ${this.trackId}${pedalNote}`,
1285
+ `${head}${pedal.length && !pedalNote ? ` · ${pedal.length} pedal` : ""}${removed ? ` · replaced ${removed}` : ""} · ${this.trackId}${pedalNote}`,
1112
1286
  pedalNote ? "warning" : "success",
1113
1287
  );
1114
1288
  }
@@ -1172,6 +1346,7 @@ export class PlaySession {
1172
1346
  armed: this.armed,
1173
1347
  recording: this.recording,
1174
1348
  replace: this.replace,
1349
+ pass: this.passLabel(),
1175
1350
  click: this.clickOn,
1176
1351
  sustain: this.keyboard.sustain,
1177
1352
  countIn,