@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/tui/drawer.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { HitMap } from "./hits.ts";
|
|
|
24
24
|
import { onBackground, type Style, type Theme } from "./theme.ts";
|
|
25
25
|
import { displayWidth, truncate } from "./text.ts";
|
|
26
26
|
import { asciiHint, fitHint } from "./grammar.ts";
|
|
27
|
+
import { knobGlyph, knobStyle, type KnobIndex } from "./knobs.ts";
|
|
27
28
|
|
|
28
29
|
export type DrawerField =
|
|
29
30
|
| Readonly<{
|
|
@@ -41,6 +42,10 @@ export type DrawerField =
|
|
|
41
42
|
maxText: string;
|
|
42
43
|
/** Just caught by a detent: the bar brightens for one frame. */
|
|
43
44
|
flash?: string | undefined;
|
|
45
|
+
/** Other panes with this parameter open (`B C`), dim at the right. */
|
|
46
|
+
peers?: string | undefined;
|
|
47
|
+
/** The knob this row is (front page): its glyph and colour. */
|
|
48
|
+
knob?: KnobIndex | undefined;
|
|
44
49
|
}>
|
|
45
50
|
| Readonly<{
|
|
46
51
|
kind: "choice";
|
|
@@ -48,6 +53,8 @@ export type DrawerField =
|
|
|
48
53
|
options: readonly string[];
|
|
49
54
|
index: number;
|
|
50
55
|
committedIndex?: number | undefined;
|
|
56
|
+
peers?: string | undefined;
|
|
57
|
+
knob?: KnobIndex | undefined;
|
|
51
58
|
}>;
|
|
52
59
|
|
|
53
60
|
export type DrawerView = Readonly<{
|
|
@@ -63,8 +70,35 @@ export type DrawerView = Readonly<{
|
|
|
63
70
|
/** `♪ solo · B` while the audition loop plays. */
|
|
64
71
|
status?: string | undefined;
|
|
65
72
|
hint: string;
|
|
73
|
+
/**
|
|
74
|
+
* Every param on the page behind Tab, while the drawer shows the four
|
|
75
|
+
* knobs: a wide drawer lists them beside the knobs (`DRAWER_MEASURE`).
|
|
76
|
+
*/
|
|
77
|
+
aside?: readonly DrawerAside[] | undefined;
|
|
66
78
|
}>;
|
|
67
79
|
|
|
80
|
+
/** One row of the drawer's side list: a param and its value. */
|
|
81
|
+
export type DrawerAside = Readonly<{
|
|
82
|
+
label: string;
|
|
83
|
+
text: string;
|
|
84
|
+
knob?: KnobIndex | undefined;
|
|
85
|
+
}>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The widest a drawer row (label, value, bar, range) runs: a fader bar past
|
|
89
|
+
* this is no easier to read or aim. Room right of it lists the page's other
|
|
90
|
+
* params when the view has an aside, else stays panel.
|
|
91
|
+
*/
|
|
92
|
+
export const DRAWER_MEASURE = 96;
|
|
93
|
+
/** The fewest columns the side list needs beside the rows. */
|
|
94
|
+
export const ASIDE_MIN_WIDTH = 28;
|
|
95
|
+
|
|
96
|
+
/** Columns the side list gets in a drawer `width` wide (0: none). */
|
|
97
|
+
export function asideWidth(width: number): number {
|
|
98
|
+
const room = width - 4 - DRAWER_MEASURE - 3;
|
|
99
|
+
return room >= ASIDE_MIN_WIDTH ? Math.min(room, 48) : 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
68
102
|
export type DrawerMode = "full" | "compact" | "line";
|
|
69
103
|
|
|
70
104
|
export interface DrawerLayout {
|
|
@@ -89,12 +123,20 @@ export function keepAbove(regionHeight: number): number {
|
|
|
89
123
|
* its bottom, as tall as its fields need within the budget.
|
|
90
124
|
*/
|
|
91
125
|
export function drawerLayout(
|
|
92
|
-
view: Pick<DrawerView, "fields" | "focus"
|
|
126
|
+
view: Pick<DrawerView, "fields" | "focus"> &
|
|
127
|
+
Partial<Pick<DrawerView, "aside">>,
|
|
93
128
|
region: { y: number; height: number },
|
|
129
|
+
width = 0,
|
|
94
130
|
): DrawerLayout {
|
|
95
131
|
const total = Math.max(1, view.fields.length);
|
|
96
132
|
const bottom = region.y + region.height;
|
|
97
133
|
const budget = region.height - keepAbove(region.height);
|
|
134
|
+
// A side list beside the knobs may make a full drawer taller, never
|
|
135
|
+
// past half the region.
|
|
136
|
+
const aside =
|
|
137
|
+
view.aside && asideWidth(width) > 0
|
|
138
|
+
? Math.min(view.aside.length + 1, Math.floor(region.height / 2) - 2)
|
|
139
|
+
: 0;
|
|
98
140
|
const window = (rows: number): { first: number; count: number } => {
|
|
99
141
|
const count = Math.max(1, Math.min(total, rows));
|
|
100
142
|
const focus = Math.min(Math.max(0, view.focus), total - 1);
|
|
@@ -102,7 +144,7 @@ export function drawerLayout(
|
|
|
102
144
|
return { first, count };
|
|
103
145
|
};
|
|
104
146
|
if (2 + 2 * total <= Math.floor(region.height / 2)) {
|
|
105
|
-
const height = 2 + 2 * total;
|
|
147
|
+
const height = 2 + Math.max(2 * total, aside);
|
|
106
148
|
return {
|
|
107
149
|
mode: "full",
|
|
108
150
|
top: bottom - height,
|
|
@@ -197,7 +239,7 @@ export function paintDrawer(
|
|
|
197
239
|
): DrawerLayout | undefined {
|
|
198
240
|
if (region.height <= 0 || width < 16 || view.fields.length === 0)
|
|
199
241
|
return undefined;
|
|
200
|
-
const layout = drawerLayout(view, region);
|
|
242
|
+
const layout = drawerLayout(view, region, width);
|
|
201
243
|
const flashing = !options.reducedMotion && !flashed.has(view);
|
|
202
244
|
if (view.fields.some((field) => field.kind === "number" && field.flash))
|
|
203
245
|
flashed.add(view);
|
|
@@ -290,6 +332,19 @@ export function paintDrawer(
|
|
|
290
332
|
on(roles.muted),
|
|
291
333
|
);
|
|
292
334
|
}
|
|
335
|
+
const outer = inner;
|
|
336
|
+
inner = {
|
|
337
|
+
left: inner.left,
|
|
338
|
+
right: Math.min(inner.right, inner.left + DRAWER_MEASURE),
|
|
339
|
+
};
|
|
340
|
+
if (layout.bordered && view.aside)
|
|
341
|
+
paintAside(buffer, layout, outer.right, view.aside, {
|
|
342
|
+
glyphs,
|
|
343
|
+
roles,
|
|
344
|
+
on,
|
|
345
|
+
theme: options.theme,
|
|
346
|
+
unicode: options.unicode,
|
|
347
|
+
});
|
|
293
348
|
const labelWidth = Math.min(
|
|
294
349
|
layout.mode === "full" ? 22 : 16,
|
|
295
350
|
Math.max(...view.fields.map((field) => displayWidth(field.label))),
|
|
@@ -308,12 +363,25 @@ export function paintDrawer(
|
|
|
308
363
|
const labelStyle = focused
|
|
309
364
|
? on({ ...roles.borderFocus, bold: true })
|
|
310
365
|
: on(roles.text);
|
|
311
|
-
|
|
366
|
+
if (field.knob !== undefined) {
|
|
367
|
+
// A knob row: its shape glyph in its colour, then the marker, so
|
|
368
|
+
// colour is never the only cue (NO_COLOR keeps the shape).
|
|
369
|
+
buffer.text(
|
|
370
|
+
inner.left,
|
|
371
|
+
y,
|
|
372
|
+
knobGlyph(field.knob, options.unicode),
|
|
373
|
+
on(knobStyle(field.knob, options.theme)),
|
|
374
|
+
);
|
|
375
|
+
buffer.text(inner.left + 1, y, focused ? glyphs.marker : " ", labelStyle);
|
|
376
|
+
} else buffer.text(inner.left, y, marker, labelStyle);
|
|
312
377
|
buffer.text(
|
|
313
378
|
inner.left + 2,
|
|
314
379
|
y,
|
|
315
380
|
truncate(field.label, labelWidth),
|
|
316
|
-
|
|
381
|
+
// The selected knob's label is reverse video (§7.2).
|
|
382
|
+
field.knob !== undefined && focused
|
|
383
|
+
? on({ ...roles.text, bold: true, reverse: true })
|
|
384
|
+
: labelStyle,
|
|
317
385
|
);
|
|
318
386
|
const valueX = inner.left + 2 + labelWidth + 2;
|
|
319
387
|
const typing = focused && view.typing !== undefined;
|
|
@@ -353,6 +421,7 @@ export function paintDrawer(
|
|
|
353
421
|
flashing,
|
|
354
422
|
roles,
|
|
355
423
|
on,
|
|
424
|
+
knob: knobFill(field, options.theme),
|
|
356
425
|
...(hits ? { hits } : {}),
|
|
357
426
|
});
|
|
358
427
|
} else {
|
|
@@ -373,7 +442,15 @@ export function paintDrawer(
|
|
|
373
442
|
inner.right,
|
|
374
443
|
field,
|
|
375
444
|
index,
|
|
376
|
-
{
|
|
445
|
+
{
|
|
446
|
+
glyphs,
|
|
447
|
+
focused,
|
|
448
|
+
flashing,
|
|
449
|
+
roles,
|
|
450
|
+
on,
|
|
451
|
+
knob: knobFill(field, options.theme),
|
|
452
|
+
...(hits ? { hits } : {}),
|
|
453
|
+
},
|
|
377
454
|
);
|
|
378
455
|
}
|
|
379
456
|
} else {
|
|
@@ -395,10 +472,87 @@ export function paintDrawer(
|
|
|
395
472
|
);
|
|
396
473
|
}
|
|
397
474
|
}
|
|
475
|
+
// Other panes with this parameter open: their letters, dim, at the
|
|
476
|
+
// row's right edge (design §12.7).
|
|
477
|
+
if (field.peers) {
|
|
478
|
+
const peers = ` ${field.peers}`;
|
|
479
|
+
buffer.text(
|
|
480
|
+
Math.max(valueX, inner.right - displayWidth(peers)),
|
|
481
|
+
y,
|
|
482
|
+
peers,
|
|
483
|
+
on(roles.muted),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
398
486
|
}
|
|
399
487
|
return layout;
|
|
400
488
|
}
|
|
401
489
|
|
|
490
|
+
/**
|
|
491
|
+
* `all params · tab` and every param on the page, one per row, beside the
|
|
492
|
+
* knobs: what Tab would show, without leaving the knobs.
|
|
493
|
+
*/
|
|
494
|
+
function paintAside(
|
|
495
|
+
buffer: CellBuffer,
|
|
496
|
+
layout: DrawerLayout,
|
|
497
|
+
right: number,
|
|
498
|
+
aside: readonly DrawerAside[],
|
|
499
|
+
paint: {
|
|
500
|
+
glyphs: Glyphs;
|
|
501
|
+
roles: Theme["roles"];
|
|
502
|
+
on: (style: Style) => Style;
|
|
503
|
+
theme: Theme;
|
|
504
|
+
unicode: boolean;
|
|
505
|
+
},
|
|
506
|
+
): void {
|
|
507
|
+
const width = asideWidth(right + 2);
|
|
508
|
+
if (width === 0) return;
|
|
509
|
+
const { roles, on } = paint;
|
|
510
|
+
const left = right - width;
|
|
511
|
+
const rows = layout.height - 2;
|
|
512
|
+
if (rows < 2) return;
|
|
513
|
+
const top = layout.top + 1;
|
|
514
|
+
const rule = paint.unicode ? "│" : "|";
|
|
515
|
+
for (let y = top; y < top + rows; y += 1)
|
|
516
|
+
buffer.set(left - 2, y, rule, on(roles.faint));
|
|
517
|
+
buffer.text(left, top, truncate("all params · tab", width), on(roles.muted));
|
|
518
|
+
const room = rows - 1;
|
|
519
|
+
const shown =
|
|
520
|
+
aside.length > room ? aside.slice(0, Math.max(0, room - 1)) : aside;
|
|
521
|
+
const labelWidth = Math.min(
|
|
522
|
+
Math.floor(width / 2),
|
|
523
|
+
Math.max(0, ...shown.map((row) => displayWidth(row.label))),
|
|
524
|
+
);
|
|
525
|
+
shown.forEach((row, index) => {
|
|
526
|
+
const y = top + 1 + index;
|
|
527
|
+
if (row.knob !== undefined)
|
|
528
|
+
buffer.text(
|
|
529
|
+
left,
|
|
530
|
+
y,
|
|
531
|
+
knobGlyph(row.knob, paint.unicode),
|
|
532
|
+
on(knobStyle(row.knob, paint.theme)),
|
|
533
|
+
);
|
|
534
|
+
buffer.text(left + 2, y, truncate(row.label, labelWidth), on(roles.text));
|
|
535
|
+
const valueX = left + 2 + labelWidth + 1;
|
|
536
|
+
buffer.text(
|
|
537
|
+
valueX,
|
|
538
|
+
y,
|
|
539
|
+
truncate(row.text, Math.max(0, right - valueX)),
|
|
540
|
+
on(roles.muted),
|
|
541
|
+
);
|
|
542
|
+
});
|
|
543
|
+
if (shown.length < aside.length)
|
|
544
|
+
buffer.text(
|
|
545
|
+
left + 2,
|
|
546
|
+
top + room,
|
|
547
|
+
truncate(`+${aside.length - shown.length} more · tab`, width - 2),
|
|
548
|
+
on(roles.faint),
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function knobFill(field: DrawerField, theme: Theme): Style | undefined {
|
|
553
|
+
return field.knob === undefined ? undefined : knobStyle(field.knob, theme);
|
|
554
|
+
}
|
|
555
|
+
|
|
402
556
|
interface RowPaint {
|
|
403
557
|
glyphs: Glyphs;
|
|
404
558
|
focused: boolean;
|
|
@@ -407,6 +561,8 @@ interface RowPaint {
|
|
|
407
561
|
roles: Theme["roles"];
|
|
408
562
|
on: (style: Style) => Style;
|
|
409
563
|
hits?: HitMap;
|
|
564
|
+
/** A knob row's bar fills in its knob colour. */
|
|
565
|
+
knob?: Style | undefined;
|
|
410
566
|
}
|
|
411
567
|
|
|
412
568
|
/** `[−] ━━━━━●─────── [+]` from `left` to `right`, with its hit regions. */
|
|
@@ -457,9 +613,11 @@ function paintBar(
|
|
|
457
613
|
const filled = on(
|
|
458
614
|
flash
|
|
459
615
|
? { ...roles.success, bold: true }
|
|
460
|
-
: paint.
|
|
461
|
-
?
|
|
462
|
-
:
|
|
616
|
+
: paint.knob
|
|
617
|
+
? paint.knob
|
|
618
|
+
: paint.focused
|
|
619
|
+
? roles.borderFocus
|
|
620
|
+
: roles.text,
|
|
463
621
|
);
|
|
464
622
|
const empty = on(roles.faint);
|
|
465
623
|
for (let column = 0; column < barWidth; column += 1) {
|
package/tui/grammar.ts
CHANGED
|
@@ -126,7 +126,7 @@ export const HINTS = {
|
|
|
126
126
|
action: " ↑↓ move · enter apply · / filter · esc back · ? keys ",
|
|
127
127
|
typing: " type a value · enter apply · esc clear ",
|
|
128
128
|
euclid:
|
|
129
|
-
" ↑↓
|
|
129
|
+
" ↑↓ knob · ←→ turn · tab field · space loop · x off · esc back · ? keys ",
|
|
130
130
|
text: " ↑↓ scroll · pgup pgdn page · esc back · ? keys ",
|
|
131
131
|
log: " ↑↓ scroll · / filter · esc back · ? keys ",
|
|
132
132
|
keys: " esc or ? closes ",
|
|
@@ -289,9 +289,9 @@ export const KEYS = {
|
|
|
289
289
|
{
|
|
290
290
|
title: "rhythm editor",
|
|
291
291
|
rows: [
|
|
292
|
-
["↑ ↓ j k", "drum"],
|
|
293
|
-
["← → h l - +", "
|
|
294
|
-
["tab shift-tab ] [", "next / previous field"],
|
|
292
|
+
["↑ ↓ j k", "knob: ● drum ▲ pulses ■ rotate ◆ velocity"],
|
|
293
|
+
["← → h l - +", "turn it (● picks the drum row)"],
|
|
294
|
+
["tab shift-tab ] [", "next / previous field, every field"],
|
|
295
295
|
["0-9", "type a value, enter applies"],
|
|
296
296
|
["enter", "add a row (or type a value) · staged: keep"],
|
|
297
297
|
["x d delete", "turn the row off"],
|
|
@@ -347,7 +347,7 @@ export const KEYS = {
|
|
|
347
347
|
["shift", "sustain while held"],
|
|
348
348
|
["tab", "sustain latch (play mode only)"],
|
|
349
349
|
["space", "play / pause (with count-in)"],
|
|
350
|
-
["r R", "record · record
|
|
350
|
+
["r R", "record · replace · keys record (a pass an undo)"],
|
|
351
351
|
["m", "metronome click"],
|
|
352
352
|
["i", "scale degrees ⇄ chromatic (home row in key)"],
|
|
353
353
|
["q", "chord mode: auto ⇄ manual"],
|
|
@@ -357,6 +357,35 @@ export const KEYS = {
|
|
|
357
357
|
],
|
|
358
358
|
},
|
|
359
359
|
],
|
|
360
|
+
tape: [
|
|
361
|
+
{
|
|
362
|
+
title: "tape · every track across the bars (each key echoes its command)",
|
|
363
|
+
rows: [
|
|
364
|
+
["space", "play / pause from the playhead"],
|
|
365
|
+
["↑ ↓", "pick a knob: ● playhead ▲ loop ■ tempo ◆ volume"],
|
|
366
|
+
["← → ⇧", "turn it (⇧ coarse) · jump 5.3 · loop 5-7 · tempo 96"],
|
|
367
|
+
[", .", "previous / next section · jump chorus"],
|
|
368
|
+
["< >", "slide the loop by its length · loop 7-8"],
|
|
369
|
+
["[ ]", "loop end a bar shorter / longer · loop 5-7"],
|
|
370
|
+
["{ }", "loop start a bar earlier / later · loop 4-7"],
|
|
371
|
+
["\\", "loop the section here; again: off · loop chorus"],
|
|
372
|
+
["1-9 tab", "focus a track row; tab: the next · track drums"],
|
|
373
|
+
["c C", "copy the range (track / all) · copy bass 5-6"],
|
|
374
|
+
["x X", "cut: copy, then clear · clear bass 5-6"],
|
|
375
|
+
["v V", "paste at the playhead (V inserts) · copy bass 5-6 to 7"],
|
|
376
|
+
["delete", "clear the range · clear bass 5-6"],
|
|
377
|
+
["~", "reverse the range · reverse bass 5-6"],
|
|
378
|
+
["s S", "split / join the section here · section split verse at 3"],
|
|
379
|
+
["h H", "mute / solo the track · mute bass · solo"],
|
|
380
|
+
["r R", "record the loop in play, a pass an undo · keys record"],
|
|
381
|
+
["m b", "click · stop"],
|
|
382
|
+
["- =", "zoom out / in (bar, beat, half beat a cell)"],
|
|
383
|
+
["enter", "the knob big in the drawer (reset there: x)"],
|
|
384
|
+
["/", "type a command, staying on tape"],
|
|
385
|
+
["esc ctrl-t", "back home · tape"],
|
|
386
|
+
],
|
|
387
|
+
},
|
|
388
|
+
],
|
|
360
389
|
chords: [
|
|
361
390
|
{
|
|
362
391
|
title: "chord mode · number row latches",
|
package/tui/guide.ts
CHANGED
|
@@ -119,6 +119,8 @@ export class GuideBrowser {
|
|
|
119
119
|
/** The guide open as a page, or undefined in the tree. */
|
|
120
120
|
page: string | undefined;
|
|
121
121
|
scroll = 0;
|
|
122
|
+
/** At the last page: resizes keep the end in view. */
|
|
123
|
+
private atEnd = false;
|
|
122
124
|
|
|
123
125
|
constructor(guides: readonly Guide[], open?: string) {
|
|
124
126
|
this.guides = guides;
|
|
@@ -146,6 +148,7 @@ export class GuideBrowser {
|
|
|
146
148
|
this.selected = guide.id;
|
|
147
149
|
this.page = guide.id;
|
|
148
150
|
this.scroll = 0;
|
|
151
|
+
this.atEnd = false;
|
|
149
152
|
return true;
|
|
150
153
|
}
|
|
151
154
|
|
|
@@ -212,6 +215,7 @@ export class GuideBrowser {
|
|
|
212
215
|
if (this.visible().some((row) => row.guide.id === this.selected)) {
|
|
213
216
|
this.page = this.selected;
|
|
214
217
|
this.scroll = 0;
|
|
218
|
+
this.atEnd = false;
|
|
215
219
|
}
|
|
216
220
|
}
|
|
217
221
|
|
|
@@ -244,11 +248,13 @@ export class GuideBrowser {
|
|
|
244
248
|
: 0;
|
|
245
249
|
if (step !== 0) {
|
|
246
250
|
this.scroll = Math.max(0, Math.min(max, this.scroll + step));
|
|
251
|
+
this.atEnd = max > 0 && this.scroll === max;
|
|
247
252
|
return "handled";
|
|
248
253
|
}
|
|
249
254
|
if (esc || left || value === "h" || value === "\u007f") {
|
|
250
255
|
this.page = undefined;
|
|
251
256
|
this.scroll = 0;
|
|
257
|
+
this.atEnd = false;
|
|
252
258
|
return "handled";
|
|
253
259
|
}
|
|
254
260
|
return "handled";
|
|
@@ -339,7 +345,8 @@ export class GuideBrowser {
|
|
|
339
345
|
if (page) {
|
|
340
346
|
const rows = wrapRows(guideLines(page.body, unicode), width);
|
|
341
347
|
const max = Math.max(0, rows.length - height);
|
|
342
|
-
|
|
348
|
+
// Pinned to the end at the last page: a resize keeps the end in view.
|
|
349
|
+
this.scroll = this.atEnd ? max : Math.max(0, Math.min(max, this.scroll));
|
|
343
350
|
const crumb = page.parent ? `${this.byId(page.parent)?.title} › ` : "";
|
|
344
351
|
return {
|
|
345
352
|
title: `guide · ${crumb}${page.title}`.replace(
|
package/tui/highway.ts
CHANGED
|
@@ -80,6 +80,8 @@ export interface TrackScoreSnapshot {
|
|
|
80
80
|
*/
|
|
81
81
|
barBeats?: readonly number[] | undefined;
|
|
82
82
|
loopBeats?: number | undefined;
|
|
83
|
+
/** The score's loop range (0-based start bar), for the header's `↻ 5–6`. */
|
|
84
|
+
loopRange?: Readonly<{ startBar: number; bars: number }> | undefined;
|
|
83
85
|
laneCount?: number | undefined;
|
|
84
86
|
/** Optional lane legend (drum voices) drawn just below the hit line. */
|
|
85
87
|
laneLabels?: readonly string[] | undefined;
|
|
@@ -278,7 +280,10 @@ export function pitchProjection(
|
|
|
278
280
|
}
|
|
279
281
|
|
|
280
282
|
/** Choose the projection a snapshot implies. */
|
|
281
|
-
export function projectionFor(
|
|
283
|
+
export function projectionFor(
|
|
284
|
+
score: TrackScoreSnapshot,
|
|
285
|
+
minimumSpan = 12,
|
|
286
|
+
): LaneProjection {
|
|
282
287
|
if (score.projection) return score.projection;
|
|
283
288
|
const explicit =
|
|
284
289
|
score.laneLabels !== undefined ||
|
|
@@ -295,7 +300,7 @@ export function projectionFor(score: TrackScoreSnapshot): LaneProjection {
|
|
|
295
300
|
.flatMap((layer) => layer.notes);
|
|
296
301
|
return pitchProjection(
|
|
297
302
|
melodic.length ? [...score.notes, ...melodic] : score.notes,
|
|
298
|
-
|
|
303
|
+
minimumSpan,
|
|
299
304
|
score.tuningPeriod,
|
|
300
305
|
);
|
|
301
306
|
}
|
|
@@ -494,6 +499,28 @@ export interface HighwayLayout {
|
|
|
494
499
|
legendRow: number | undefined;
|
|
495
500
|
}
|
|
496
501
|
|
|
502
|
+
/**
|
|
503
|
+
* The widest a lane gets. A wider terminal shows more pitches (up to
|
|
504
|
+
* `MAX_PITCH_SPAN`) instead of fatter tiles, and past that the lanes stay
|
|
505
|
+
* this wide, centred.
|
|
506
|
+
*/
|
|
507
|
+
export const MAX_LANE_WIDTH = 10;
|
|
508
|
+
/** The most semitones a melodic highway spreads across on a wide terminal. */
|
|
509
|
+
export const MAX_PITCH_SPAN = 36;
|
|
510
|
+
/**
|
|
511
|
+
* The tallest a beat gets. A taller terminal looks further ahead (more bars
|
|
512
|
+
* coming) instead of stretching each note.
|
|
513
|
+
*/
|
|
514
|
+
export const MAX_ROWS_PER_BEAT = 4;
|
|
515
|
+
|
|
516
|
+
/** The pitch span a highway `width` columns wide fits: one to three octaves. */
|
|
517
|
+
export function pitchSpanFor(width: number): number {
|
|
518
|
+
return Math.max(
|
|
519
|
+
12,
|
|
520
|
+
Math.min(MAX_PITCH_SPAN, Math.floor((width - 3) / MAX_LANE_WIDTH)),
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
497
524
|
export function highwayLayout(
|
|
498
525
|
region: HighwayRegion,
|
|
499
526
|
projection: LaneProjection,
|
|
@@ -505,13 +532,21 @@ export function highwayLayout(
|
|
|
505
532
|
const gutter = region.width >= 40 ? 3 : 0;
|
|
506
533
|
const area = Math.max(1, region.width - gutter);
|
|
507
534
|
const laneCount = projection.laneCount;
|
|
508
|
-
const laneWidth =
|
|
535
|
+
const laneWidth =
|
|
536
|
+
laneCount <= area
|
|
537
|
+
? Math.min(MAX_LANE_WIDTH, Math.floor(area / laneCount))
|
|
538
|
+
: 0;
|
|
509
539
|
const used = laneWidth > 0 ? laneWidth * laneCount : area;
|
|
510
540
|
const laneLeft = gutter + Math.floor((area - used) / 2);
|
|
511
541
|
const tileWidth =
|
|
512
542
|
laneWidth >= 4 ? laneWidth - 1 : Math.max(1, laneWidth || 1);
|
|
513
543
|
const lookahead =
|
|
514
|
-
lookaheadBeats ??
|
|
544
|
+
lookaheadBeats ??
|
|
545
|
+
Math.max(
|
|
546
|
+
2,
|
|
547
|
+
Math.min(8, Math.round(hitRow / 2.5)),
|
|
548
|
+
Math.floor(hitRow / MAX_ROWS_PER_BEAT),
|
|
549
|
+
);
|
|
515
550
|
return {
|
|
516
551
|
hitRow,
|
|
517
552
|
gutter,
|
|
@@ -561,7 +596,7 @@ export function paintHighway(
|
|
|
561
596
|
const glyphs = glyphsFor(capabilities);
|
|
562
597
|
const background = options.background ?? roles.canvas;
|
|
563
598
|
const bg = (style: Style): Style => onBackground(style, background);
|
|
564
|
-
const projection = projectionFor(score);
|
|
599
|
+
const projection = projectionFor(score, pitchSpanFor(region.width));
|
|
565
600
|
const layout = highwayLayout(region, projection, options.lookaheadBeats);
|
|
566
601
|
const { hitRow, rowsPerBeat, gutter } = layout;
|
|
567
602
|
const areaWidth = Math.max(1, region.width - gutter);
|
package/tui/hints.ts
CHANGED
|
@@ -80,6 +80,11 @@ const TYPE_FILLED = [
|
|
|
80
80
|
"try: style again · ctrl-z undo",
|
|
81
81
|
"try: add G4 at 2 · bars 8 · space play",
|
|
82
82
|
"try: volume 0.6 · pan -0.2 · /help",
|
|
83
|
+
"try: ctrl-t tape · loop 1-2 · space play",
|
|
84
|
+
"try: copy all 1-4 to 5 · loop 5-8",
|
|
85
|
+
"try: knobs · mix · /help mix",
|
|
86
|
+
"try: panes · pane tape · /help panes",
|
|
87
|
+
"try: loop 1-2 · keys record · ctrl-z takes back a pass",
|
|
83
88
|
] as const;
|
|
84
89
|
|
|
85
90
|
const TYPE_DRUMS = [
|
package/tui/hits.ts
CHANGED
|
@@ -27,7 +27,13 @@ export type HitTarget =
|
|
|
27
27
|
/** The piano roll / highway area. */
|
|
28
28
|
| { kind: "highway" }
|
|
29
29
|
/** Scrollable text (`/help`, the transcript, the `?` panel). */
|
|
30
|
-
| { kind: "text" }
|
|
30
|
+
| { kind: "text" }
|
|
31
|
+
/** TAPE's ruler: column `x - left + firstCell` is a cell (jump, loop drag). */
|
|
32
|
+
| { kind: "tape-ruler"; left: number; firstCell: number }
|
|
33
|
+
/** A TAPE track row (focus; shift-drag sets the loop). */
|
|
34
|
+
| { kind: "tape-row"; row: number; left: number; firstCell: number }
|
|
35
|
+
/** TAPE's clipboard chip (click pastes). */
|
|
36
|
+
| { kind: "tape-clipboard" };
|
|
31
37
|
|
|
32
38
|
export interface HitRegion {
|
|
33
39
|
x: number;
|