@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/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
|
-
|
|
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`. */
|
package/src/tui/play-session.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
579
|
-
this.
|
|
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
|
-
|
|
961
|
-
this.
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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,
|