@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
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pane's handle on the machine's one live engine (design §12.3). It looks
|
|
3
|
+
* like the local `LiveEngine` to play mode, but notes go to dawgd as note
|
|
4
|
+
* events (`remoteNote`) and the click as a structured request
|
|
5
|
+
* (`remoteClick`): no audio crosses the socket. dawgd renders each voice
|
|
6
|
+
* from the score it holds and mixes every pane into one stream and one
|
|
7
|
+
* click.
|
|
8
|
+
*
|
|
9
|
+
* Times cross the seam on the authority's clock: local monotonic ms plus
|
|
10
|
+
* `performance.timeOrigin` plus the port's clock offset (0 on one machine).
|
|
11
|
+
*/
|
|
12
|
+
import type { ClickBus } from "../audio/engine.ts";
|
|
13
|
+
import type { LiveNotePcm } from "../audio/live.ts";
|
|
14
|
+
import type { LiveClick, LiveMessage, LiveStatus } from "./protocol.ts";
|
|
15
|
+
|
|
16
|
+
/** What the shared engine needs from the session connection. */
|
|
17
|
+
export interface LiveLink {
|
|
18
|
+
liveMonitor(on: boolean): Promise<LiveStatus>;
|
|
19
|
+
live(message: Exclude<LiveMessage, { action: "monitor" }>): void;
|
|
20
|
+
/** Authority clock minus this machine's, in ms. */
|
|
21
|
+
readonly clockOffsetMs: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A played note as play mode hands it to a shared engine. */
|
|
25
|
+
export type RemoteNote = Readonly<{
|
|
26
|
+
trackId: string;
|
|
27
|
+
pitch: number;
|
|
28
|
+
/** 0..1. */
|
|
29
|
+
velocity: number;
|
|
30
|
+
seconds: number;
|
|
31
|
+
/** Transport beat of the press (unwrapped). */
|
|
32
|
+
beat: number;
|
|
33
|
+
/** Monotonic ms of the press. */
|
|
34
|
+
atMs: number;
|
|
35
|
+
}>;
|
|
36
|
+
|
|
37
|
+
/** A structured click request (a shared engine cannot take closures). */
|
|
38
|
+
export type RemoteClick = Readonly<{
|
|
39
|
+
on: boolean;
|
|
40
|
+
volume: number;
|
|
41
|
+
countIn?: Readonly<{
|
|
42
|
+
/** Monotonic ms the count-in starts. */
|
|
43
|
+
startMs: number;
|
|
44
|
+
startBeat: number;
|
|
45
|
+
beats: number;
|
|
46
|
+
barBeats: number;
|
|
47
|
+
clickBeats: number;
|
|
48
|
+
bpm: number;
|
|
49
|
+
}>;
|
|
50
|
+
}>;
|
|
51
|
+
|
|
52
|
+
export class SharedLiveEngine {
|
|
53
|
+
public sampleRate = 48_000;
|
|
54
|
+
public canMonitor = true;
|
|
55
|
+
public leadMs = 15;
|
|
56
|
+
public playLeadMs: number | undefined;
|
|
57
|
+
public audioNote: string | undefined = "shared engine";
|
|
58
|
+
/** Notes sent (tests and the status line read it). */
|
|
59
|
+
public sent = 0;
|
|
60
|
+
|
|
61
|
+
public constructor(private readonly link: LiveLink) {}
|
|
62
|
+
|
|
63
|
+
public async monitor(on: boolean): Promise<void> {
|
|
64
|
+
try {
|
|
65
|
+
const status = await this.link.liveMonitor(on);
|
|
66
|
+
this.sampleRate = status.sampleRate;
|
|
67
|
+
this.canMonitor = status.canMonitor;
|
|
68
|
+
this.leadMs = status.leadMs;
|
|
69
|
+
this.playLeadMs = status.leadMs;
|
|
70
|
+
this.audioNote = status.note ?? "shared engine";
|
|
71
|
+
} catch {
|
|
72
|
+
// dawgd is reconnecting; the link resends monitor once it is back.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The lead is dawgd's: a pane never retimes the shared engine. */
|
|
77
|
+
public setLeadMs(_ms: number | undefined): void {}
|
|
78
|
+
|
|
79
|
+
/** Local PCM never reaches a shared engine; play mode uses `remoteNote`. */
|
|
80
|
+
public noteOn(_id: number, _note: LiveNotePcm): number {
|
|
81
|
+
return performance.now() + this.leadMs;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public remoteNote(id: number, note: RemoteNote): number {
|
|
85
|
+
this.sent += 1;
|
|
86
|
+
this.link.live({
|
|
87
|
+
v: 1,
|
|
88
|
+
type: "live",
|
|
89
|
+
action: "on",
|
|
90
|
+
voice: id,
|
|
91
|
+
trackId: note.trackId,
|
|
92
|
+
pitch: note.pitch,
|
|
93
|
+
velocity: Math.max(0, Math.min(1, note.velocity)),
|
|
94
|
+
seconds: Math.max(0, Math.min(600, note.seconds)),
|
|
95
|
+
beat: note.beat,
|
|
96
|
+
atMs: this.authority(note.atMs),
|
|
97
|
+
});
|
|
98
|
+
return performance.now() + this.leadMs;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
public noteOff(id: number): void {
|
|
102
|
+
this.link.live({
|
|
103
|
+
v: 1,
|
|
104
|
+
type: "live",
|
|
105
|
+
action: "off",
|
|
106
|
+
voice: id,
|
|
107
|
+
atMs: this.authority(performance.now()),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A pane cannot hand dawgd a click closure; see `remoteClick`. */
|
|
112
|
+
public setClick(click: ClickBus | undefined): void {
|
|
113
|
+
if (!click) this.remoteClick({ on: false, volume: 0 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
public remoteClick(click: RemoteClick): void {
|
|
117
|
+
const message: LiveClick = {
|
|
118
|
+
on: click.on,
|
|
119
|
+
volume: Math.max(0, Math.min(1, click.volume)),
|
|
120
|
+
};
|
|
121
|
+
if (click.countIn) {
|
|
122
|
+
const { startMs, ...rest } = click.countIn;
|
|
123
|
+
message.countIn = { ...rest, startAtMs: this.authority(startMs) };
|
|
124
|
+
}
|
|
125
|
+
this.link.live({ v: 1, type: "live", action: "click", ...message });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private authority(monotonicMs: number): number {
|
|
129
|
+
return performance.timeOrigin + monotonicMs + this.link.clockOffsetMs;
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/session/store.ts
CHANGED
|
@@ -39,12 +39,36 @@ export class SessionValidationError extends Error {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Who made an event. Stamped by the authority from the connection's hello,
|
|
44
|
+
* never trusted from the intent: `actorId` is the person (absent for v1
|
|
45
|
+
* clients and the file fallback), `clientId` the window, `seq` the
|
|
46
|
+
* per-(actor, client) intent number used for retry dedupe.
|
|
47
|
+
*/
|
|
48
|
+
export type EventActor = {
|
|
49
|
+
actorId?: string;
|
|
50
|
+
clientId: string;
|
|
51
|
+
seq?: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
42
54
|
export type SessionEvent = {
|
|
43
55
|
id: string;
|
|
44
56
|
revision: number;
|
|
57
|
+
/**
|
|
58
|
+
* Opaque to the store: any bounded string is kept and passed through, so a
|
|
59
|
+
* newer peer's event kinds survive an older reader.
|
|
60
|
+
*/
|
|
45
61
|
kind: string;
|
|
46
62
|
payload: unknown;
|
|
47
63
|
at: string;
|
|
64
|
+
actor?: EventActor;
|
|
65
|
+
/**
|
|
66
|
+
* The score operations this event applied: sent by an ops intent, or
|
|
67
|
+
* derived by the authority from a composition intent. Folding `ops` from
|
|
68
|
+
* the previous composition reproduces this one. Events without `ops` are
|
|
69
|
+
* snapshots (too large to diff, or not expressible as operations).
|
|
70
|
+
*/
|
|
71
|
+
ops?: unknown[];
|
|
48
72
|
/**
|
|
49
73
|
* How to recover the composition this event replaced from the one it
|
|
50
74
|
* produced (see `delta.ts`). Absent once compacted away: the store drops
|
|
@@ -204,7 +228,10 @@ export async function appendSessionEvent<T>(
|
|
|
204
228
|
// History is the store's job: a `before` composition in the payload (the
|
|
205
229
|
// pre-rewind convention) is dropped rather than persisted in full.
|
|
206
230
|
const payload = stripBefore(event.payload);
|
|
207
|
-
const payloadJson = stringifyJson(
|
|
231
|
+
const payloadJson = stringifyJson(
|
|
232
|
+
event.ops === undefined ? payload : [payload, event.ops],
|
|
233
|
+
"session event payload",
|
|
234
|
+
);
|
|
208
235
|
const payloadBytes = Buffer.byteLength(payloadJson, "utf8");
|
|
209
236
|
if (payloadBytes > MAX_EVENT_BYTES)
|
|
210
237
|
throw new Error(`session event exceeds ${MAX_EVENT_BYTES} bytes`);
|
|
@@ -231,6 +258,8 @@ export async function appendSessionEvent<T>(
|
|
|
231
258
|
revision,
|
|
232
259
|
at,
|
|
233
260
|
};
|
|
261
|
+
if (event.actor !== undefined) appended.actor = { ...event.actor };
|
|
262
|
+
if (event.ops !== undefined) appended.ops = [...event.ops];
|
|
234
263
|
const rewind = diffRewind(disk.composition, composition);
|
|
235
264
|
if (Buffer.byteLength(JSON.stringify(rewind), "utf8") <= MAX_REWIND_BYTES)
|
|
236
265
|
appended.rewind = rewind;
|
|
@@ -523,7 +552,9 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
|
|
|
523
552
|
)
|
|
524
553
|
throw new SessionValidationError("session event timestamp is invalid");
|
|
525
554
|
const payloadJson = stringifyJson(
|
|
526
|
-
candidate.
|
|
555
|
+
candidate.ops === undefined
|
|
556
|
+
? candidate.payload
|
|
557
|
+
: [candidate.payload, candidate.ops],
|
|
527
558
|
"session event payload",
|
|
528
559
|
);
|
|
529
560
|
if (Buffer.byteLength(payloadJson, "utf8") > MAX_EVENT_BYTES)
|
|
@@ -548,6 +579,13 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
|
|
|
548
579
|
at: candidate.at,
|
|
549
580
|
};
|
|
550
581
|
if (rewind !== undefined) parsed.rewind = rewind;
|
|
582
|
+
if (candidate.actor !== undefined)
|
|
583
|
+
parsed.actor = parseEventActor(candidate.actor);
|
|
584
|
+
if (candidate.ops !== undefined) {
|
|
585
|
+
if (!Array.isArray(candidate.ops))
|
|
586
|
+
throw new SessionValidationError("session event ops are invalid");
|
|
587
|
+
parsed.ops = candidate.ops;
|
|
588
|
+
}
|
|
551
589
|
return parsed;
|
|
552
590
|
});
|
|
553
591
|
let meta: SessionMeta;
|
|
@@ -601,3 +639,24 @@ function stringifyJson(value: unknown, label: string): string {
|
|
|
601
639
|
throw new SessionValidationError(`${label} must be JSON data`);
|
|
602
640
|
}
|
|
603
641
|
}
|
|
642
|
+
|
|
643
|
+
function parseEventActor(value: unknown): EventActor {
|
|
644
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
645
|
+
throw new SessionValidationError("session event actor is invalid");
|
|
646
|
+
const actor = value as Record<string, unknown>;
|
|
647
|
+
const valid =
|
|
648
|
+
typeof actor.clientId === "string" &&
|
|
649
|
+
actor.clientId.length > 0 &&
|
|
650
|
+
actor.clientId.length <= 64 &&
|
|
651
|
+
(actor.actorId === undefined ||
|
|
652
|
+
(typeof actor.actorId === "string" &&
|
|
653
|
+
/^a_[a-z2-7]{22}$/.test(actor.actorId))) &&
|
|
654
|
+
(actor.seq === undefined ||
|
|
655
|
+
(Number.isSafeInteger(actor.seq) && (actor.seq as number) >= 1));
|
|
656
|
+
if (!valid)
|
|
657
|
+
throw new SessionValidationError("session event actor is invalid");
|
|
658
|
+
const parsed: EventActor = { clientId: actor.clientId as string };
|
|
659
|
+
if (actor.actorId !== undefined) parsed.actorId = actor.actorId as string;
|
|
660
|
+
if (actor.seq !== undefined) parsed.seq = actor.seq as number;
|
|
661
|
+
return parsed;
|
|
662
|
+
}
|
package/src/tui/arrange-menu.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { Section } from "../../core/score.ts";
|
|
8
8
|
import { formatForm } from "../../core/sections.ts";
|
|
9
9
|
import { barsLabel } from "../commands/arrange.ts";
|
|
10
|
+
import { rangeLabel } from "../../core/range.ts";
|
|
10
11
|
import type { MenuContext, MenuNode } from "./menu.ts";
|
|
11
12
|
import { styleMenuDetail, styleMenuNodes } from "./style-menu.ts";
|
|
12
13
|
|
|
@@ -21,6 +22,151 @@ export function arrangeDetail(context: MenuContext): string {
|
|
|
21
22
|
return parts.join(" · ");
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/** `loop 5–6 · 16 bars`, or the song length alone. */
|
|
26
|
+
export function rangeDetail(context: MenuContext): string {
|
|
27
|
+
const score = context.score;
|
|
28
|
+
const bars = `${score.bars} bar${score.bars === 1 ? "" : "s"}`;
|
|
29
|
+
return score.loop ? `loop ${rangeLabel(score.loop)} · ${bars}` : bars;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A bar range typed as `5-6` or `5`: kept as typed, else undefined. */
|
|
33
|
+
function barsText(text: string): string | undefined {
|
|
34
|
+
const value = text.trim().replace(/\s*(?:–|\.\.)\s*/u, "-");
|
|
35
|
+
return /^\d{1,4}(?:-\d{1,4})?$/u.test(value) ? value : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Arrange › range (op1-ux §6.4): each row runs the typed range command on
|
|
40
|
+
* the focused track, so the menu, the prompt and the agent share one path.
|
|
41
|
+
*/
|
|
42
|
+
export function rangeNodes(context: MenuContext): MenuNode[] {
|
|
43
|
+
const score = context.score;
|
|
44
|
+
const track = context.trackId;
|
|
45
|
+
const nodes: MenuNode[] = [
|
|
46
|
+
{
|
|
47
|
+
kind: "entry",
|
|
48
|
+
label: "loop bars",
|
|
49
|
+
value: score.loop ? rangeLabel(score.loop) : "the song",
|
|
50
|
+
placeholder: "bars, e.g. 5-6",
|
|
51
|
+
example: "loop 5-6",
|
|
52
|
+
help: "loop these bars in playback; empty plays the song",
|
|
53
|
+
command: (text) => {
|
|
54
|
+
if (!text.trim()) return "loop off";
|
|
55
|
+
const bars = barsText(text);
|
|
56
|
+
return bars ? `loop ${bars}` : undefined;
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
if (score.loop)
|
|
61
|
+
nodes.push(
|
|
62
|
+
{
|
|
63
|
+
kind: "action",
|
|
64
|
+
label: "loop next",
|
|
65
|
+
command: "loop next",
|
|
66
|
+
help: "step the loop one length later",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
kind: "action",
|
|
70
|
+
label: "loop prev",
|
|
71
|
+
command: "loop prev",
|
|
72
|
+
help: "step the loop one length earlier",
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
nodes.push(
|
|
76
|
+
{
|
|
77
|
+
kind: "entry",
|
|
78
|
+
label: "copy bars",
|
|
79
|
+
value: "",
|
|
80
|
+
placeholder: "bars to bar, e.g. 5-6 to 7 x2",
|
|
81
|
+
example: `copy ${track} 5-6 to 7 x2`,
|
|
82
|
+
help: "copy this track's bars; x2 tiles, insert shifts later music",
|
|
83
|
+
command: (text) =>
|
|
84
|
+
text.trim() ? `copy ${track} ${text.trim()}` : undefined,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
kind: "entry",
|
|
88
|
+
label: "move bars",
|
|
89
|
+
value: "",
|
|
90
|
+
placeholder: "bars to bar, e.g. 5-6 to 9",
|
|
91
|
+
example: `move ${track} 5-6 to 9`,
|
|
92
|
+
help: "move this track's bars; the source empties",
|
|
93
|
+
command: (text) =>
|
|
94
|
+
text.trim() ? `move ${track} ${text.trim()}` : undefined,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
kind: "entry",
|
|
98
|
+
label: "clear bars",
|
|
99
|
+
value: "",
|
|
100
|
+
placeholder: "bars, e.g. 5-6",
|
|
101
|
+
example: `clear ${track} 5-6`,
|
|
102
|
+
help: "empty this track's bars; the bars stay",
|
|
103
|
+
command: (text) => {
|
|
104
|
+
const bars = barsText(text);
|
|
105
|
+
return bars ? `clear ${track} ${bars}` : undefined;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
kind: "entry",
|
|
110
|
+
label: "reverse bars",
|
|
111
|
+
value: "",
|
|
112
|
+
placeholder: "bars, e.g. 5-6",
|
|
113
|
+
example: `reverse ${track} 5-6`,
|
|
114
|
+
help: "mirror this track's bars in time",
|
|
115
|
+
command: (text) => {
|
|
116
|
+
const bars = barsText(text);
|
|
117
|
+
return bars ? `reverse ${track} ${bars}` : undefined;
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
kind: "entry",
|
|
122
|
+
label: "paste at",
|
|
123
|
+
value: "",
|
|
124
|
+
placeholder: "a bar, e.g. 9",
|
|
125
|
+
example: "paste at 9",
|
|
126
|
+
help: "lay the clipboard down (copy bars without `to` fills it)",
|
|
127
|
+
command: (text) =>
|
|
128
|
+
/^\d{1,4}$/u.test(text.trim()) ? `paste at ${text.trim()}` : undefined,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
kind: "entry",
|
|
132
|
+
label: "insert bars",
|
|
133
|
+
value: "",
|
|
134
|
+
placeholder: "count at bar, e.g. 2 at 3",
|
|
135
|
+
example: "bars insert 2 at 3",
|
|
136
|
+
help: "add empty bars; later sections, clips, automation and tempo move right",
|
|
137
|
+
command: (text) =>
|
|
138
|
+
/^\d{1,3}\s+at\s+\d{1,4}$/u.test(text.trim())
|
|
139
|
+
? `bars insert ${text.trim().replace(/\s+/gu, " ")}`
|
|
140
|
+
: undefined,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
kind: "entry",
|
|
144
|
+
label: "remove bars",
|
|
145
|
+
value: "",
|
|
146
|
+
placeholder: "bars, e.g. 5-6",
|
|
147
|
+
example: "bars remove 5-6",
|
|
148
|
+
help: "cut the bars out; later music moves left",
|
|
149
|
+
command: (text) => {
|
|
150
|
+
const bars = barsText(text);
|
|
151
|
+
return bars ? `bars remove ${bars}` : undefined;
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
kind: "entry",
|
|
156
|
+
label: "jump",
|
|
157
|
+
value: "",
|
|
158
|
+
placeholder: "bar or bar.beat, e.g. 5.3",
|
|
159
|
+
example: "jump 5.3",
|
|
160
|
+
help: "move the playhead",
|
|
161
|
+
command: (text) =>
|
|
162
|
+
/^\d{1,4}(?:\.\d{1,2})?$/u.test(text.trim())
|
|
163
|
+
? `jump ${text.trim()}`
|
|
164
|
+
: undefined,
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
return nodes;
|
|
168
|
+
}
|
|
169
|
+
|
|
24
170
|
/**
|
|
25
171
|
* Arrange's rows after tracks (§4a): sections (each section, mark bars,
|
|
26
172
|
* add section), then form and the style browser.
|
|
@@ -54,7 +200,7 @@ export function arrangeNodes(context: MenuContext): MenuNode[] {
|
|
|
54
200
|
if (score.form.length)
|
|
55
201
|
nodes.push({
|
|
56
202
|
kind: "action",
|
|
57
|
-
label: "
|
|
203
|
+
label: "print form to tape",
|
|
58
204
|
command: "form bake",
|
|
59
205
|
help: "write the form out as plain bars, then clear it",
|
|
60
206
|
});
|
|
@@ -66,6 +212,28 @@ export function arrangeNodes(context: MenuContext): MenuNode[] {
|
|
|
66
212
|
help: "play the whole song (or form) again",
|
|
67
213
|
});
|
|
68
214
|
}
|
|
215
|
+
// TAPE (op1-ux §6): every track across the bars, copy/cut/paste keys.
|
|
216
|
+
nodes.push({
|
|
217
|
+
kind: "action",
|
|
218
|
+
label: "tape",
|
|
219
|
+
command: "/tape",
|
|
220
|
+
help: "every track across the bars · c copy, x cut, v paste · ctrl-t",
|
|
221
|
+
});
|
|
222
|
+
// §4.7: record the loop in PLAY, one undo step per pass (r / R on TAPE).
|
|
223
|
+
nodes.push({
|
|
224
|
+
kind: "action",
|
|
225
|
+
label: "record the loop",
|
|
226
|
+
command: "keys record",
|
|
227
|
+
help: "play mode over the loop · each pass is one undo step · r on tape",
|
|
228
|
+
});
|
|
229
|
+
nodes.push({
|
|
230
|
+
kind: "menu",
|
|
231
|
+
id: "range",
|
|
232
|
+
label: "range",
|
|
233
|
+
detail: rangeDetail(context),
|
|
234
|
+
help: "loop bars, copy, move, clear, paste or reverse them, insert or remove bars",
|
|
235
|
+
build: rangeNodes,
|
|
236
|
+
});
|
|
69
237
|
// The style browser: a whole song from a style (src/tui/style-menu.ts).
|
|
70
238
|
nodes.push({
|
|
71
239
|
kind: "menu",
|
package/src/tui/euclid.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* runs through the normal prompt path, so every change is one revision and
|
|
10
10
|
* one undo step and the row's detail teaches the command.
|
|
11
11
|
*
|
|
12
|
-
* Keys: ↑↓
|
|
12
|
+
* Keys: ↑↓ knob (● drum ▲ pulses ■ rotate ◆ velocity) · ←→ (h l + -) turn ·
|
|
13
|
+
* tab / shift-tab (] [) every parameter ·
|
|
13
14
|
* digits type a value · enter add/edit · space loop · x off · f freeze ·
|
|
14
15
|
* esc back.
|
|
15
16
|
*
|
|
@@ -48,6 +49,8 @@ import {
|
|
|
48
49
|
import type { PickerItem } from "../../tui/app.ts";
|
|
49
50
|
import { auditionKey, type AuditionKey } from "./audition.ts";
|
|
50
51
|
import type { MenuAudition } from "./menu.ts";
|
|
52
|
+
import { KNOB_MAPS } from "./knob-map.ts";
|
|
53
|
+
import { knobGlyph, KNOBS, nextKnob, type KnobIndex } from "../../tui/knobs.ts";
|
|
51
54
|
|
|
52
55
|
export type EuclidContext = Readonly<{
|
|
53
56
|
/** The score shown: the staged one while auditioning. */
|
|
@@ -295,6 +298,28 @@ export function ringText(row: RhythmRow | undefined): string {
|
|
|
295
298
|
.join("");
|
|
296
299
|
}
|
|
297
300
|
|
|
301
|
+
/**
|
|
302
|
+
* The four knobs (KNOB_MAPS.euclid): blue `drum` picks the row, the others
|
|
303
|
+
* name EUCLID_PARAMS fields.
|
|
304
|
+
*/
|
|
305
|
+
const EUCLID_KNOBS = KNOB_MAPS.euclid!;
|
|
306
|
+
|
|
307
|
+
/** After the strip, the knob keys, then the way out; fitHint drops the knob keys first. */
|
|
308
|
+
const KNOB_KEYS = "↑↓ knob · ←→ turn · esc back · ? keys";
|
|
309
|
+
|
|
310
|
+
/** The knob that turns `param`, if one does. */
|
|
311
|
+
function knobOfParam(param: number): KnobIndex | undefined {
|
|
312
|
+
const at = EUCLID_KNOBS.indexOf(EUCLID_PARAMS[param]!.field);
|
|
313
|
+
return at < 0 ? undefined : (at as KnobIndex);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** EUCLID_PARAMS index a knob turns; undefined for the drum knob. */
|
|
317
|
+
function paramOfKnob(knob: KnobIndex): number | undefined {
|
|
318
|
+
const field = EUCLID_KNOBS[knob];
|
|
319
|
+
const at = EUCLID_PARAMS.findIndex((param) => param.field === field);
|
|
320
|
+
return at < 0 ? undefined : at;
|
|
321
|
+
}
|
|
322
|
+
|
|
298
323
|
/** Tab ] next field, shift-tab [ previous (the brackets are the old keys). */
|
|
299
324
|
const isNextField = (value: string) => KEY_TAB.has(value) || value === "]";
|
|
300
325
|
const isPrevField = (value: string) => KEY_BACKTAB.has(value) || value === "[";
|
|
@@ -303,6 +328,8 @@ export class EuclidEditor {
|
|
|
303
328
|
private visible = false;
|
|
304
329
|
private lane = 0;
|
|
305
330
|
private param = 0;
|
|
331
|
+
/** True while the blue knob (which drum) has focus, not a field. */
|
|
332
|
+
private onDrum = true;
|
|
306
333
|
private entry: string | undefined;
|
|
307
334
|
/** Where Esc returns to (`menu` when opened from `/menu`). */
|
|
308
335
|
private origin: string | undefined;
|
|
@@ -327,6 +354,10 @@ export class EuclidEditor {
|
|
|
327
354
|
this.origin = origin;
|
|
328
355
|
const lanes = this.lanes(context);
|
|
329
356
|
const at = voice ? lanes.findIndex((lane) => lane.voice === voice) : -1;
|
|
357
|
+
// Opened on a named drum, the green knob (pulses) has focus; otherwise
|
|
358
|
+
// the blue one, to pick the drum.
|
|
359
|
+
this.onDrum = at < 0;
|
|
360
|
+
if (!this.onDrum) this.param = paramOfKnob(1) ?? 0;
|
|
330
361
|
if (at >= 0) this.lane = at;
|
|
331
362
|
else {
|
|
332
363
|
// Land on the first voice that already has a row.
|
|
@@ -352,9 +383,14 @@ export class EuclidEditor {
|
|
|
352
383
|
return lanes[Math.min(this.lane, lanes.length - 1)]?.voice;
|
|
353
384
|
}
|
|
354
385
|
|
|
355
|
-
/** Parameter under the cursor. */
|
|
386
|
+
/** Parameter under the cursor (`drum` on the blue knob). */
|
|
356
387
|
get selectedParam(): string {
|
|
357
|
-
return EUCLID_PARAMS[this.param]!.field;
|
|
388
|
+
return this.onDrum ? "drum" : EUCLID_PARAMS[this.param]!.field;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The knob with focus; undefined on a field no knob turns. */
|
|
392
|
+
get selectedKnob(): KnobIndex | undefined {
|
|
393
|
+
return this.onDrum ? 0 : knobOfParam(this.param);
|
|
358
394
|
}
|
|
359
395
|
|
|
360
396
|
key(value: string, context: EuclidContext): EuclidResult {
|
|
@@ -376,7 +412,8 @@ export class EuclidEditor {
|
|
|
376
412
|
if (KEY_ENTER.has(value)) {
|
|
377
413
|
const text = this.entry.trim();
|
|
378
414
|
this.entry = undefined;
|
|
379
|
-
if (!lane || !/^-?[0-9./tT]+$/.test(text))
|
|
415
|
+
if (this.onDrum || !lane || !/^-?[0-9./tT]+$/.test(text))
|
|
416
|
+
return { type: "handled" };
|
|
380
417
|
return {
|
|
381
418
|
type: "run",
|
|
382
419
|
command: `euclid ${lane.voice} ${param.field} ${text}`,
|
|
@@ -392,21 +429,45 @@ export class EuclidEditor {
|
|
|
392
429
|
return audition?.dirty ? { type: "revert" } : { type: "close" };
|
|
393
430
|
const loopKey = audition ? auditionKey(value) : undefined;
|
|
394
431
|
if (loopKey) return { type: "loop", key: loopKey };
|
|
432
|
+
// ↑↓ pick a knob (design §8.7); from a field no knob turns, ↑ lands
|
|
433
|
+
// on orange and ↓ on blue.
|
|
395
434
|
if (KEY_UP.has(value) || KEY_DOWN.has(value)) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
435
|
+
const up = KEY_UP.has(value);
|
|
436
|
+
const from = this.selectedKnob;
|
|
437
|
+
const knob =
|
|
438
|
+
from === undefined
|
|
439
|
+
? up
|
|
440
|
+
? 3
|
|
441
|
+
: 0
|
|
442
|
+
: nextKnob(from, up ? -1 : 1, (index) =>
|
|
443
|
+
index === 0 ? true : paramOfKnob(index) !== undefined,
|
|
444
|
+
);
|
|
445
|
+
this.onDrum = knob === 0;
|
|
446
|
+
if (!this.onDrum) this.param = paramOfKnob(knob) ?? this.param;
|
|
400
447
|
return { type: "handled" };
|
|
401
448
|
}
|
|
449
|
+
// Tab walks every field (the full list behind the knobs).
|
|
402
450
|
if (isNextField(value) || isPrevField(value)) {
|
|
403
451
|
const direction = isNextField(value) ? 1 : -1;
|
|
404
|
-
this.
|
|
405
|
-
|
|
452
|
+
if (this.onDrum) {
|
|
453
|
+
this.onDrum = false;
|
|
454
|
+
this.param = direction > 0 ? 0 : EUCLID_PARAMS.length - 1;
|
|
455
|
+
} else
|
|
456
|
+
this.param =
|
|
457
|
+
(this.param + direction + EUCLID_PARAMS.length) %
|
|
458
|
+
EUCLID_PARAMS.length;
|
|
406
459
|
return { type: "handled" };
|
|
407
460
|
}
|
|
408
461
|
if (!lane) return { type: "handled" };
|
|
409
462
|
if (value === " ") return { type: "audition", voice: lane.voice };
|
|
463
|
+
if (this.onDrum && (KEY_LEFT.has(value) || KEY_RIGHT.has(value))) {
|
|
464
|
+
// The blue knob turns which drum row has focus.
|
|
465
|
+
if (lanes.length)
|
|
466
|
+
this.lane =
|
|
467
|
+
(this.lane + (KEY_RIGHT.has(value) ? 1 : -1) + lanes.length) %
|
|
468
|
+
lanes.length;
|
|
469
|
+
return { type: "handled" };
|
|
470
|
+
}
|
|
410
471
|
if (KEY_LEFT.has(value) || KEY_RIGHT.has(value)) {
|
|
411
472
|
const direction = KEY_RIGHT.has(value) ? 1 : -1;
|
|
412
473
|
const row: RhythmRow = lane.row ?? { voice: lane.voice };
|
|
@@ -427,7 +488,7 @@ export class EuclidEditor {
|
|
|
427
488
|
audition: lane.voice,
|
|
428
489
|
};
|
|
429
490
|
}
|
|
430
|
-
if (/^[0-9]$/.test(value)) {
|
|
491
|
+
if (/^[0-9]$/.test(value) && !this.onDrum) {
|
|
431
492
|
this.entry = value;
|
|
432
493
|
return { type: "handled" };
|
|
433
494
|
}
|
|
@@ -439,7 +500,7 @@ export class EuclidEditor {
|
|
|
439
500
|
command: `euclid ${lane.voice} ${RHYTHM_DEFAULTS.pulses} ${RHYTHM_DEFAULTS.steps}`,
|
|
440
501
|
audition: lane.voice,
|
|
441
502
|
};
|
|
442
|
-
this.entry = "";
|
|
503
|
+
if (!this.onDrum) this.entry = "";
|
|
443
504
|
return { type: "handled" };
|
|
444
505
|
}
|
|
445
506
|
if (KEY_RESET.has(value))
|
|
@@ -454,6 +515,23 @@ export class EuclidEditor {
|
|
|
454
515
|
return { type: "handled" };
|
|
455
516
|
}
|
|
456
517
|
|
|
518
|
+
/**
|
|
519
|
+
* The knob strip for the hint row: `●›kick ▲ pulses 4 ■ rotate 0 ◆ velocity
|
|
520
|
+
* 0.8`, the focused knob marked `›`. Glyphs carry which knob is which.
|
|
521
|
+
*/
|
|
522
|
+
private strip(drum: string | undefined, row: RhythmRow | undefined): string {
|
|
523
|
+
const focused = this.selectedKnob;
|
|
524
|
+
return KNOBS.map((knob) => {
|
|
525
|
+
const mark = knob.index === focused ? "›" : " ";
|
|
526
|
+
if (knob.index === 0) return `${knob.glyph}${mark}${drum ?? "·"}`;
|
|
527
|
+
const at = paramOfKnob(knob.index);
|
|
528
|
+
if (at === undefined) return `${knob.glyph} ·`;
|
|
529
|
+
const param = EUCLID_PARAMS[at]!;
|
|
530
|
+
const shown = row ? formatValue(param.value(row)) : "—";
|
|
531
|
+
return `${knob.glyph}${mark}${param.label} ${shown}`;
|
|
532
|
+
}).join(" ");
|
|
533
|
+
}
|
|
534
|
+
|
|
457
535
|
view(context: EuclidContext): EuclidView {
|
|
458
536
|
const lanes = this.lanes(context);
|
|
459
537
|
const index = Math.max(0, Math.min(this.lane, lanes.length - 1));
|
|
@@ -489,7 +567,10 @@ export class EuclidEditor {
|
|
|
489
567
|
const voice = lanes[index]?.voice ?? "";
|
|
490
568
|
const row = lanes[index]?.row;
|
|
491
569
|
const value = row ? formatValue(param.value(row)) : "—";
|
|
492
|
-
|
|
570
|
+
const focus = this.onDrum
|
|
571
|
+
? `${knobGlyph(0, true)} drum`
|
|
572
|
+
: `${param.label} ${value}`;
|
|
573
|
+
let title = `rhythm › ${context.trackId}${voice ? ` · ${voice}` : ""} · ${focus}`;
|
|
493
574
|
if (this.entry !== undefined) title += ` · ${param.label}: ${this.entry}▏`;
|
|
494
575
|
if (audition?.dirty) title = `● ${title}`;
|
|
495
576
|
if (audition?.status && this.entry === undefined)
|
|
@@ -499,7 +580,7 @@ export class EuclidEditor {
|
|
|
499
580
|
? HINTS.typing
|
|
500
581
|
: audition && (audition.looping || audition.dirty)
|
|
501
582
|
? audition.hint
|
|
502
|
-
:
|
|
583
|
+
: `${this.strip(lanes[index]?.label.trim(), row)} · ${KNOB_KEYS}`;
|
|
503
584
|
return {
|
|
504
585
|
title,
|
|
505
586
|
items: items.length
|