@hraness/dawg 0.4.0 → 0.5.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 +86 -0
- package/DAWG.md +300 -35
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +27 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +12 -2
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/convolution.ts +43 -5
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/space.ts +197 -50
- package/src/audio/engine.ts +175 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +87 -16
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +448 -59
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +826 -47
- package/src/render.ts +109 -7
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +58 -5
- package/src/tui/euclid.ts +50 -6
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +656 -16
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +95 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +414 -41
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +79 -12
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
package/src/tui/audition.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
meterBar,
|
|
34
34
|
previewScore,
|
|
35
35
|
type Preview,
|
|
36
|
+
type PreviewOptions,
|
|
36
37
|
type SoundLevel,
|
|
37
38
|
} from "../audio/preview.ts";
|
|
38
39
|
|
|
@@ -61,6 +62,17 @@ export type AuditionHost = {
|
|
|
61
62
|
changed?(): void;
|
|
62
63
|
/** Level of the loop now playing, for the title's meter. */
|
|
63
64
|
level?(): SoundLevel | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* A phrase that replaces the track's notes for the sound now playing (A:
|
|
67
|
+
* committed, B: staged), or undefined for the usual loop. The chord
|
|
68
|
+
* settings screen plays a progression with its settings this way.
|
|
69
|
+
*/
|
|
70
|
+
phrase?(showing: "A" | "B"): PreviewOptions["phrase"];
|
|
71
|
+
/**
|
|
72
|
+
* The drive the song's master solved for its target on the full mix (the
|
|
73
|
+
* last song loop), so previews hear a fixed master gain.
|
|
74
|
+
*/
|
|
75
|
+
masterGainDb?(): number | undefined;
|
|
64
76
|
};
|
|
65
77
|
|
|
66
78
|
export type AuditionOptions = Readonly<{
|
|
@@ -101,6 +113,8 @@ export class Audition {
|
|
|
101
113
|
private staged: Staged[] = [];
|
|
102
114
|
private work: Promise<unknown> = Promise.resolve();
|
|
103
115
|
private rendering = false;
|
|
116
|
+
/** The song master's solved drive when the loop started (see host). */
|
|
117
|
+
private masterGainDb: number | undefined;
|
|
104
118
|
private dirty = false;
|
|
105
119
|
private pendingKeyAt: number | undefined;
|
|
106
120
|
private lastRenderAt = -Infinity;
|
|
@@ -159,6 +173,7 @@ export class Audition {
|
|
|
159
173
|
|
|
160
174
|
public start(): void {
|
|
161
175
|
if (this.looping) return;
|
|
176
|
+
this.masterGainDb = this.host.masterGainDb?.();
|
|
162
177
|
this.looping = true;
|
|
163
178
|
this.lastRenderAt = -Infinity;
|
|
164
179
|
this.request();
|
|
@@ -185,6 +200,18 @@ export class Audition {
|
|
|
185
200
|
this.host.changed?.();
|
|
186
201
|
}
|
|
187
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Whether the loop plays the full mix: the `c` toggle, or forced while a
|
|
205
|
+
* master edit is staged, because the master works on the whole mix and a
|
|
206
|
+
* solo track says nothing about its loudness.
|
|
207
|
+
*/
|
|
208
|
+
public get inContext(): boolean {
|
|
209
|
+
return (
|
|
210
|
+
this.context ||
|
|
211
|
+
this.staged.some((entry) => MASTER_COMMAND.test(entry.command))
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
188
215
|
/** A/B; a no-op (stays on B) while nothing is staged. */
|
|
189
216
|
public toggleAB(): void {
|
|
190
217
|
if (!this.dirtyEdits) {
|
|
@@ -404,9 +431,19 @@ export class Audition {
|
|
|
404
431
|
private async flush(): Promise<void> {
|
|
405
432
|
if (!this.looping || this.rendering) return;
|
|
406
433
|
this.cancelTimer();
|
|
434
|
+
const phrase = this.host.phrase?.(this.dirtyEdits ? this.showing : "A");
|
|
435
|
+
// A staged master edit re-solves its target on the mix; otherwise the
|
|
436
|
+
// master plays at the song's solved drive, so a track edit is audible.
|
|
437
|
+
const masterStaged = this.staged.some((entry) =>
|
|
438
|
+
MASTER_COMMAND.test(entry.command),
|
|
439
|
+
);
|
|
407
440
|
const preview = previewScore(this.sounding, this.trackId, {
|
|
408
|
-
context: this.
|
|
441
|
+
context: this.inContext,
|
|
409
442
|
beat: this.host.beat?.() ?? 0,
|
|
443
|
+
...(phrase ? { phrase } : {}),
|
|
444
|
+
...(this.masterGainDb !== undefined && !masterStaged
|
|
445
|
+
? { masterGainDb: this.masterGainDb }
|
|
446
|
+
: {}),
|
|
410
447
|
});
|
|
411
448
|
if (!preview) return;
|
|
412
449
|
this.rendering = true;
|
|
@@ -442,7 +479,7 @@ export class Audition {
|
|
|
442
479
|
public status(): string | undefined {
|
|
443
480
|
if (!this.looping && !this.dirtyEdits && !this.fetching) return undefined;
|
|
444
481
|
const parts: string[] = [];
|
|
445
|
-
if (this.looping) parts.push(`♪ ${this.
|
|
482
|
+
if (this.looping) parts.push(`♪ ${this.inContext ? "in context" : "solo"}`);
|
|
446
483
|
else parts.push("loop off");
|
|
447
484
|
if (this.fetching) parts.push("fetching…");
|
|
448
485
|
if (this.dirtyEdits)
|
|
@@ -491,11 +528,27 @@ export function auditionKey(value: string): AuditionKey | undefined {
|
|
|
491
528
|
* before.
|
|
492
529
|
*/
|
|
493
530
|
const STAGEABLE =
|
|
494
|
-
/^\/?(?:(fx|effects|filter|lowpass|synth|wt|wavetable|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern)\s+\S|pack\s+use\s+\S)/i;
|
|
531
|
+
/^\/?(?:(fx|effects|filter|lowpass|synth|wt|wavetable|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern|euclid|art|articulation|bend|vibrato|glide|portamento|velcurve|vel-curve|pedal|sustain|humanize|tuning|tune|master)\s+\S|pack\s+use\s+\S)/i;
|
|
495
532
|
/** Subcommands that list or show instead of changing the sound. */
|
|
496
|
-
const READ_ONLY = /^\/?\S+\s+(list|show|info|help)\s*$/i;
|
|
533
|
+
const READ_ONLY = /^\/?\S+\s+(list|show|info|help|measure|meter)\s*$/i;
|
|
534
|
+
/** `master <unit>` alone only shows the unit. */
|
|
535
|
+
const MASTER_SHOW = /^\/?master\s+(eq|glue|tape|width|limiter)\s*$/i;
|
|
536
|
+
const MASTER_COMMAND = /^\/?master\s/i;
|
|
497
537
|
|
|
498
538
|
export function isStageable(command: string): boolean {
|
|
499
539
|
const text = command.trim();
|
|
500
|
-
return
|
|
540
|
+
return (
|
|
541
|
+
STAGEABLE.test(text) && !READ_ONLY.test(text) && !MASTER_SHOW.test(text)
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Chord settings and the song key, which stage only in the chord settings
|
|
547
|
+
* screen: its loop plays a progression with them (`AuditionHost.phrase`),
|
|
548
|
+
* while elsewhere they would change nothing audible.
|
|
549
|
+
*/
|
|
550
|
+
export function isChordStageable(command: string): boolean {
|
|
551
|
+
return (
|
|
552
|
+
/^\/chords\s+\S/i.test(command.trim()) || /^key\s+\S/i.test(command.trim())
|
|
553
|
+
);
|
|
501
554
|
}
|
package/src/tui/euclid.ts
CHANGED
|
@@ -10,8 +10,13 @@
|
|
|
10
10
|
* one undo step and the row's detail teaches the command.
|
|
11
11
|
*
|
|
12
12
|
* Keys: ↑↓ voice · ←→ (h l + -) nudge · tab / shift-tab (] [) parameter ·
|
|
13
|
-
* digits type a value · enter add/edit · space
|
|
13
|
+
* digits type a value · enter add/edit · space loop · x off · f freeze ·
|
|
14
14
|
* esc back.
|
|
15
|
+
*
|
|
16
|
+
* Auditioning (src/tui/audition.ts) works as in the menu: `Space` loops the
|
|
17
|
+
* track, and while it plays every change is staged (heard, not committed);
|
|
18
|
+
* `a` flips A/B, `c` solo ↔ in context, Enter keeps the staged changes as
|
|
19
|
+
* one undo step and Esc reverts them.
|
|
15
20
|
*/
|
|
16
21
|
import { HINTS } from "../../tui/grammar.ts";
|
|
17
22
|
import { DRUM_VOICES, isDrumInstrument } from "../../core/drums.ts";
|
|
@@ -30,15 +35,30 @@ import {
|
|
|
30
35
|
type TrackScore,
|
|
31
36
|
} from "../../core/score.ts";
|
|
32
37
|
import type { PickerItem } from "../../tui/app.ts";
|
|
38
|
+
import { auditionKey, type AuditionKey } from "./audition.ts";
|
|
39
|
+
import type { MenuAudition } from "./menu.ts";
|
|
33
40
|
|
|
34
|
-
export type EuclidContext = Readonly<{
|
|
41
|
+
export type EuclidContext = Readonly<{
|
|
42
|
+
/** The score shown: the staged one while auditioning. */
|
|
43
|
+
score: TrackScore;
|
|
44
|
+
trackId: string;
|
|
45
|
+
/** The audition loop, when the window hosts one (as in the menu). */
|
|
46
|
+
audition?: MenuAudition;
|
|
47
|
+
}>;
|
|
35
48
|
|
|
36
49
|
export type EuclidResult =
|
|
37
50
|
| { type: "handled" }
|
|
38
51
|
| { type: "close" }
|
|
39
52
|
/** Run `command`; `audition` names the voice to play once it lands. */
|
|
40
53
|
| { type: "run"; command: string; audition?: string }
|
|
54
|
+
/** Play `voice` once (no audition loop in this window). */
|
|
41
55
|
| { type: "audition"; voice: string }
|
|
56
|
+
/** Space (loop), `a` (A/B) or `c` (context) for the audition loop. */
|
|
57
|
+
| { type: "loop"; key: AuditionKey }
|
|
58
|
+
/** Commit the staged edits as one operation. */
|
|
59
|
+
| { type: "keep" }
|
|
60
|
+
/** Drop the staged edits. */
|
|
61
|
+
| { type: "revert" }
|
|
42
62
|
| { type: "pass" };
|
|
43
63
|
|
|
44
64
|
export type EuclidView = Readonly<{
|
|
@@ -362,7 +382,11 @@ export class EuclidEditor {
|
|
|
362
382
|
this.entry = (this.entry + value).slice(0, 12);
|
|
363
383
|
return { type: "handled" };
|
|
364
384
|
}
|
|
365
|
-
|
|
385
|
+
const audition = context.audition;
|
|
386
|
+
if (value === "\u001b")
|
|
387
|
+
return audition?.dirty ? { type: "revert" } : { type: "close" };
|
|
388
|
+
const loopKey = audition ? auditionKey(value) : undefined;
|
|
389
|
+
if (loopKey) return { type: "loop", key: loopKey };
|
|
366
390
|
if (KEY_UP.has(value) || KEY_DOWN.has(value)) {
|
|
367
391
|
if (lanes.length)
|
|
368
392
|
this.lane =
|
|
@@ -403,6 +427,7 @@ export class EuclidEditor {
|
|
|
403
427
|
return { type: "handled" };
|
|
404
428
|
}
|
|
405
429
|
if (KEY_ENTER.has(value)) {
|
|
430
|
+
if (audition?.dirty) return { type: "keep" };
|
|
406
431
|
if (!lane.row)
|
|
407
432
|
return {
|
|
408
433
|
type: "run",
|
|
@@ -429,6 +454,12 @@ export class EuclidEditor {
|
|
|
429
454
|
const index = Math.max(0, Math.min(this.lane, lanes.length - 1));
|
|
430
455
|
const param = EUCLID_PARAMS[this.param]!;
|
|
431
456
|
const width = Math.max(5, ...lanes.map((lane) => lane.label.length));
|
|
457
|
+
const audition = context.audition;
|
|
458
|
+
// Changed rows show `staged ← committed`, as in the menu.
|
|
459
|
+
const before = new Map<string, string>();
|
|
460
|
+
if (audition?.dirty)
|
|
461
|
+
for (const lane of this.lanes({ ...context, score: audition.committed }))
|
|
462
|
+
before.set(lane.voice, lane.row ? rowSummary(lane.row) : "off");
|
|
432
463
|
const items: PickerItem[] = lanes.map((lane, at) => {
|
|
433
464
|
const row = lane.row;
|
|
434
465
|
const shown = row
|
|
@@ -436,11 +467,16 @@ export class EuclidEditor {
|
|
|
436
467
|
? "grid"
|
|
437
468
|
: formatValue(param.value(row))
|
|
438
469
|
: "—";
|
|
470
|
+
const now = row ? rowSummary(row) : "off";
|
|
471
|
+
const was = before.get(lane.voice);
|
|
472
|
+
const changed = was !== undefined && was !== now ? ` ← ${was}` : "";
|
|
439
473
|
return {
|
|
440
474
|
label: `${lane.label.padEnd(width)} ${ringText(row)}`,
|
|
441
475
|
detail: row
|
|
442
|
-
? `${
|
|
443
|
-
:
|
|
476
|
+
? `${now}${changed} · ${param.label} ${shown}`
|
|
477
|
+
: changed
|
|
478
|
+
? `off${changed}`
|
|
479
|
+
: "enter adds E(4,16)",
|
|
444
480
|
value: String(at),
|
|
445
481
|
current: row !== undefined,
|
|
446
482
|
};
|
|
@@ -450,7 +486,15 @@ export class EuclidEditor {
|
|
|
450
486
|
const value = row ? formatValue(param.value(row)) : "—";
|
|
451
487
|
let title = `rhythm › ${context.trackId}${voice ? ` · ${voice}` : ""} · ${param.label} ${value}`;
|
|
452
488
|
if (this.entry !== undefined) title += ` · ${param.label}: ${this.entry}▏`;
|
|
453
|
-
|
|
489
|
+
if (audition?.dirty) title = `● ${title}`;
|
|
490
|
+
if (audition?.status && this.entry === undefined)
|
|
491
|
+
title += ` · ${audition.status}`;
|
|
492
|
+
const hint =
|
|
493
|
+
this.entry !== undefined
|
|
494
|
+
? HINTS.typing
|
|
495
|
+
: audition && (audition.looping || audition.dirty)
|
|
496
|
+
? audition.hint
|
|
497
|
+
: HINTS.euclid;
|
|
454
498
|
return {
|
|
455
499
|
title,
|
|
456
500
|
items: items.length
|
package/src/tui/fader.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fader mode: the keys and mouse gestures of the fader drawer
|
|
3
|
+
* (tui/drawer.ts), as pure functions over a list of fields.
|
|
4
|
+
*
|
|
5
|
+
* A field is a number (with a range, a step function and a formatter taken
|
|
6
|
+
* from the menu row, which in turn takes them from core/params.ts, the fx
|
|
7
|
+
* specs and the automation lanes) or a choice (a segmented selector). Every
|
|
8
|
+
* change is a command: the caller stages it on the audition loop, keyed by
|
|
9
|
+
* field, so repeated nudges replace one staged edit and Enter keeps them
|
|
10
|
+
* all as one revision.
|
|
11
|
+
*/
|
|
12
|
+
import type { DrawerField, DrawerView } from "../../tui/drawer.ts";
|
|
13
|
+
|
|
14
|
+
export type FaderNumber = Readonly<{
|
|
15
|
+
kind: "number";
|
|
16
|
+
label: string;
|
|
17
|
+
value: number | undefined;
|
|
18
|
+
min: number;
|
|
19
|
+
max: number;
|
|
20
|
+
step: (value: number, direction: 1 | -1) => number;
|
|
21
|
+
format: (value: number) => string;
|
|
22
|
+
command: (value: number) => string;
|
|
23
|
+
/** Typed text → command; undefined when it does not parse or fit. */
|
|
24
|
+
parse: (text: string) => string | undefined;
|
|
25
|
+
/** The command `0` / `d` runs to put the value back to its default. */
|
|
26
|
+
reset?: string | undefined;
|
|
27
|
+
/** Starting value for a nudge while off. */
|
|
28
|
+
start?: number | undefined;
|
|
29
|
+
off?: string | undefined;
|
|
30
|
+
}>;
|
|
31
|
+
|
|
32
|
+
export type FaderChoice = Readonly<{
|
|
33
|
+
kind: "choice";
|
|
34
|
+
label: string;
|
|
35
|
+
value: string;
|
|
36
|
+
options: readonly string[];
|
|
37
|
+
command: (option: string) => string;
|
|
38
|
+
}>;
|
|
39
|
+
|
|
40
|
+
export type FaderSpec = FaderNumber | FaderChoice;
|
|
41
|
+
|
|
42
|
+
export interface FaderState {
|
|
43
|
+
/** The focused field, by label (labels survive rebuilds; indexes may not). */
|
|
44
|
+
label: string;
|
|
45
|
+
/** Digits typed for the focused field. */
|
|
46
|
+
typing?: string | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type FaderResult =
|
|
50
|
+
| { type: "handled" }
|
|
51
|
+
/** Set a field: stage `command`, replacing the field's previous edit. */
|
|
52
|
+
| { type: "set"; command: string; key: string }
|
|
53
|
+
| { type: "keep" }
|
|
54
|
+
| { type: "revert" }
|
|
55
|
+
/** Leave the drawer (nothing staged, or after keep / revert). */
|
|
56
|
+
| { type: "close" }
|
|
57
|
+
| { type: "audition"; key: "loop" | "ab" | "context" }
|
|
58
|
+
| { type: "pass" };
|
|
59
|
+
|
|
60
|
+
/** The key a field's staged edit is filed under. */
|
|
61
|
+
export function faderKey(label: string): string {
|
|
62
|
+
return `fader:${label}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A wide range of positive values reads (and drags) on a log scale. */
|
|
66
|
+
function logScale(spec: FaderNumber): boolean {
|
|
67
|
+
return spec.min > 0 && spec.max / spec.min >= 100;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Where `value` sits along the bar, 0…1. */
|
|
71
|
+
export function faderPosition(spec: FaderNumber, value: number): number {
|
|
72
|
+
if (spec.max <= spec.min) return 0;
|
|
73
|
+
const clamped = Math.min(spec.max, Math.max(spec.min, value));
|
|
74
|
+
if (logScale(spec))
|
|
75
|
+
return Math.log(clamped / spec.min) / Math.log(spec.max / spec.min);
|
|
76
|
+
return (clamped - spec.min) / (spec.max - spec.min);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The value at `position` (0…1), snapped to the field's own step grid: the
|
|
81
|
+
* nearest value its step function can reach from the minimum, so a drag
|
|
82
|
+
* lands on the same values the keys do.
|
|
83
|
+
*/
|
|
84
|
+
export function faderValueAt(spec: FaderNumber, position: number): number {
|
|
85
|
+
const p = Math.min(1, Math.max(0, position));
|
|
86
|
+
if (p <= 0) return spec.min;
|
|
87
|
+
if (p >= 1) return spec.max;
|
|
88
|
+
const raw = logScale(spec)
|
|
89
|
+
? spec.min * (spec.max / spec.min) ** p
|
|
90
|
+
: spec.min + p * (spec.max - spec.min);
|
|
91
|
+
return snap(spec, raw);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The nearest step-reachable value to `raw` (step size near `raw`). */
|
|
95
|
+
function snap(spec: FaderNumber, raw: number): number {
|
|
96
|
+
const up = spec.step(raw, 1);
|
|
97
|
+
const down = spec.step(raw, -1);
|
|
98
|
+
// Linear steps round onto the grid; log steps move by a ratio. Either
|
|
99
|
+
// way the step size near `raw` bounds the precision worth keeping.
|
|
100
|
+
const size = Math.max(
|
|
101
|
+
1e-9,
|
|
102
|
+
Math.min(Math.abs(up - raw), Math.abs(raw - down)) ||
|
|
103
|
+
Math.abs(up - down) / 2,
|
|
104
|
+
);
|
|
105
|
+
const decimals = Math.max(0, Math.min(4, Math.ceil(-Math.log10(size))));
|
|
106
|
+
const candidates = [down, up, Number(raw.toFixed(decimals))];
|
|
107
|
+
// Prefer an exact grid value when one is within half a step.
|
|
108
|
+
let best = candidates[2]!;
|
|
109
|
+
for (const candidate of [down, up])
|
|
110
|
+
if (Math.abs(candidate - raw) <= size / 2 + 1e-9) best = candidate;
|
|
111
|
+
return Math.min(spec.max, Math.max(spec.min, best));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Fine (a tenth of a step), normal, coarse (five steps) or page (twenty). */
|
|
115
|
+
export type StepSize = "fine" | "normal" | "coarse" | "page";
|
|
116
|
+
|
|
117
|
+
export function stepValue(
|
|
118
|
+
spec: FaderNumber,
|
|
119
|
+
direction: 1 | -1,
|
|
120
|
+
size: StepSize = "normal",
|
|
121
|
+
): number {
|
|
122
|
+
if (spec.value === undefined) return spec.start ?? spec.min;
|
|
123
|
+
const clamp = (value: number) =>
|
|
124
|
+
Math.min(spec.max, Math.max(spec.min, value));
|
|
125
|
+
if (size === "fine") {
|
|
126
|
+
const next = spec.step(spec.value, direction);
|
|
127
|
+
const delta = (next - spec.value) / 10;
|
|
128
|
+
if (Math.abs(delta) < 1e-9) return spec.value;
|
|
129
|
+
const decimals = Math.max(
|
|
130
|
+
0,
|
|
131
|
+
Math.min(4, Math.ceil(-Math.log10(Math.abs(delta)))),
|
|
132
|
+
);
|
|
133
|
+
return clamp(Number((spec.value + delta).toFixed(decimals)));
|
|
134
|
+
}
|
|
135
|
+
const times = size === "coarse" ? 5 : size === "page" ? 20 : 1;
|
|
136
|
+
let value = spec.value;
|
|
137
|
+
for (let index = 0; index < times; index += 1) {
|
|
138
|
+
const next = clamp(spec.step(value, direction));
|
|
139
|
+
if (Math.abs(next - value) < 1e-12) break;
|
|
140
|
+
value = next;
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const LEFT = new Set(["\u001b[D", "\u001bOD", "h", "-", "_"]);
|
|
146
|
+
const RIGHT = new Set(["\u001b[C", "\u001bOC", "l", "+", "="]);
|
|
147
|
+
const COARSE_LEFT = new Set(["\u001b[1;2D", "{"]);
|
|
148
|
+
const COARSE_RIGHT = new Set(["\u001b[1;2C", "}"]);
|
|
149
|
+
const FINE_LEFT = new Set(["\u001b[1;3D", "\u001b[1;5D", "\u001bb", "["]);
|
|
150
|
+
const FINE_RIGHT = new Set(["\u001b[1;3C", "\u001b[1;5C", "\u001bf", "]"]);
|
|
151
|
+
const UP = new Set(["\u001b[A", "\u001bOA", "k", "\u001b[Z"]);
|
|
152
|
+
const DOWN = new Set(["\u001b[B", "\u001bOB", "j", "\t"]);
|
|
153
|
+
const PAGE_UP = "\u001b[5~";
|
|
154
|
+
const PAGE_DOWN = "\u001b[6~";
|
|
155
|
+
const HOME = new Set(["\u001b[H", "\u001bOH", "\u001b[1~"]);
|
|
156
|
+
const END = new Set(["\u001b[F", "\u001bOF", "\u001b[4~"]);
|
|
157
|
+
const ENTER = new Set(["\r", "\n"]);
|
|
158
|
+
const BACKSPACE = new Set(["\u007f", "\b"]);
|
|
159
|
+
|
|
160
|
+
export interface FaderKeyOptions {
|
|
161
|
+
/** Edits are staged (Enter keeps, Esc reverts). */
|
|
162
|
+
dirty: boolean;
|
|
163
|
+
/** The window hosts the audition loop (space, a, c). */
|
|
164
|
+
audition: boolean;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Index of the focused field (the first when its label is gone). */
|
|
168
|
+
export function focusIndex(
|
|
169
|
+
state: FaderState,
|
|
170
|
+
fields: readonly FaderSpec[],
|
|
171
|
+
): number {
|
|
172
|
+
return Math.max(
|
|
173
|
+
0,
|
|
174
|
+
fields.findIndex((field) => field.label === state.label),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function setNumber(spec: FaderNumber, value: number): FaderResult {
|
|
179
|
+
if (spec.value !== undefined && Math.abs(value - spec.value) < 1e-9)
|
|
180
|
+
return { type: "handled" };
|
|
181
|
+
return {
|
|
182
|
+
type: "set",
|
|
183
|
+
command: spec.command(value),
|
|
184
|
+
key: faderKey(spec.label),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function setChoice(spec: FaderChoice, index: number): FaderResult {
|
|
189
|
+
const option =
|
|
190
|
+
spec.options[Math.min(spec.options.length - 1, Math.max(0, index))];
|
|
191
|
+
if (option === undefined || option === spec.value) return { type: "handled" };
|
|
192
|
+
return {
|
|
193
|
+
type: "set",
|
|
194
|
+
command: spec.command(option),
|
|
195
|
+
key: faderKey(spec.label),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One key in fader mode. Mutates `state` (focus, typing). */
|
|
200
|
+
export function faderKeyPress(
|
|
201
|
+
state: FaderState,
|
|
202
|
+
fields: readonly FaderSpec[],
|
|
203
|
+
value: string,
|
|
204
|
+
options: FaderKeyOptions,
|
|
205
|
+
): FaderResult {
|
|
206
|
+
if (fields.length === 0) return { type: "close" };
|
|
207
|
+
// Ctrl-C, Ctrl-L and `?` (the keys panel) belong to the app.
|
|
208
|
+
if (value === "\u0003" || value === "\u000c") return { type: "pass" };
|
|
209
|
+
if (value === "?" && state.typing === undefined) return { type: "pass" };
|
|
210
|
+
const index = focusIndex(state, fields);
|
|
211
|
+
const field = fields[index]!;
|
|
212
|
+
state.label = field.label;
|
|
213
|
+
if (state.typing !== undefined) {
|
|
214
|
+
if (value === "\u001b") {
|
|
215
|
+
state.typing = undefined;
|
|
216
|
+
return { type: "handled" };
|
|
217
|
+
}
|
|
218
|
+
if (BACKSPACE.has(value)) {
|
|
219
|
+
state.typing = state.typing.slice(0, -1);
|
|
220
|
+
if (!state.typing) state.typing = undefined;
|
|
221
|
+
return { type: "handled" };
|
|
222
|
+
}
|
|
223
|
+
if (ENTER.has(value) || value === "\t") {
|
|
224
|
+
const text = state.typing;
|
|
225
|
+
state.typing = undefined;
|
|
226
|
+
const command = field.kind === "number" ? field.parse(text) : undefined;
|
|
227
|
+
return command
|
|
228
|
+
? { type: "set", command, key: faderKey(field.label) }
|
|
229
|
+
: { type: "handled" };
|
|
230
|
+
}
|
|
231
|
+
if (/^[0-9.\-]$/.test(value)) {
|
|
232
|
+
state.typing = (state.typing + value).slice(0, 16);
|
|
233
|
+
return { type: "handled" };
|
|
234
|
+
}
|
|
235
|
+
return { type: "handled" };
|
|
236
|
+
}
|
|
237
|
+
if (value === "\u001b")
|
|
238
|
+
return options.dirty ? { type: "revert" } : { type: "close" };
|
|
239
|
+
if (ENTER.has(value))
|
|
240
|
+
return options.dirty ? { type: "keep" } : { type: "close" };
|
|
241
|
+
const move = UP.has(value) ? -1 : DOWN.has(value) ? 1 : 0;
|
|
242
|
+
if (move) {
|
|
243
|
+
state.label = fields[(index + move + fields.length) % fields.length]!.label;
|
|
244
|
+
return { type: "handled" };
|
|
245
|
+
}
|
|
246
|
+
if (options.audition) {
|
|
247
|
+
if (value === " ") return { type: "audition", key: "loop" };
|
|
248
|
+
if (value === "a") return { type: "audition", key: "ab" };
|
|
249
|
+
if (value === "c") return { type: "audition", key: "context" };
|
|
250
|
+
}
|
|
251
|
+
if (field.kind === "choice") {
|
|
252
|
+
const at = Math.max(0, field.options.indexOf(field.value));
|
|
253
|
+
if (LEFT.has(value) || FINE_LEFT.has(value) || COARSE_LEFT.has(value))
|
|
254
|
+
return setChoice(field, at - 1);
|
|
255
|
+
if (RIGHT.has(value) || FINE_RIGHT.has(value) || COARSE_RIGHT.has(value))
|
|
256
|
+
return setChoice(field, at + 1);
|
|
257
|
+
if (HOME.has(value) || value === PAGE_DOWN) return setChoice(field, 0);
|
|
258
|
+
if (END.has(value) || value === PAGE_UP)
|
|
259
|
+
return setChoice(field, field.options.length - 1);
|
|
260
|
+
// 1…9 picks an option by number.
|
|
261
|
+
if (/^[1-9]$/.test(value)) return setChoice(field, Number(value) - 1);
|
|
262
|
+
return { type: "handled" };
|
|
263
|
+
}
|
|
264
|
+
const step = (direction: 1 | -1, size: StepSize) =>
|
|
265
|
+
setNumber(field, stepValue(field, direction, size));
|
|
266
|
+
if (LEFT.has(value)) return step(-1, "normal");
|
|
267
|
+
if (RIGHT.has(value)) return step(1, "normal");
|
|
268
|
+
if (COARSE_LEFT.has(value)) return step(-1, "coarse");
|
|
269
|
+
if (COARSE_RIGHT.has(value)) return step(1, "coarse");
|
|
270
|
+
if (FINE_LEFT.has(value)) return step(-1, "fine");
|
|
271
|
+
if (FINE_RIGHT.has(value)) return step(1, "fine");
|
|
272
|
+
if (value === PAGE_UP) return step(1, "page");
|
|
273
|
+
if (value === PAGE_DOWN) return step(-1, "page");
|
|
274
|
+
if (HOME.has(value)) return setNumber(field, field.min);
|
|
275
|
+
if (END.has(value)) return setNumber(field, field.max);
|
|
276
|
+
if (value === "0" || value === "d") {
|
|
277
|
+
const reset =
|
|
278
|
+
field.reset ??
|
|
279
|
+
(field.start !== undefined ? field.command(field.start) : undefined);
|
|
280
|
+
return reset && field.value !== undefined
|
|
281
|
+
? { type: "set", command: reset, key: faderKey(field.label) }
|
|
282
|
+
: { type: "handled" };
|
|
283
|
+
}
|
|
284
|
+
if (/^[1-9.]$/.test(value)) {
|
|
285
|
+
state.typing = value === "." ? "0." : value;
|
|
286
|
+
return { type: "handled" };
|
|
287
|
+
}
|
|
288
|
+
return { type: "handled" };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** A click or drag on a field's bar at `position` (0…1). */
|
|
292
|
+
export function faderSetPosition(
|
|
293
|
+
state: FaderState,
|
|
294
|
+
field: FaderSpec | undefined,
|
|
295
|
+
position: number,
|
|
296
|
+
): FaderResult {
|
|
297
|
+
if (!field) return { type: "handled" };
|
|
298
|
+
state.label = field.label;
|
|
299
|
+
state.typing = undefined;
|
|
300
|
+
if (field.kind === "choice")
|
|
301
|
+
return setChoice(
|
|
302
|
+
field,
|
|
303
|
+
Math.round(position * Math.max(0, field.options.length - 1)),
|
|
304
|
+
);
|
|
305
|
+
return setNumber(field, faderValueAt(field, position));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** A [−] / [+] click or a wheel notch on a field. */
|
|
309
|
+
export function faderStep(
|
|
310
|
+
state: FaderState,
|
|
311
|
+
field: FaderSpec | undefined,
|
|
312
|
+
direction: 1 | -1,
|
|
313
|
+
size: StepSize = "normal",
|
|
314
|
+
): FaderResult {
|
|
315
|
+
if (!field) return { type: "handled" };
|
|
316
|
+
state.label = field.label;
|
|
317
|
+
state.typing = undefined;
|
|
318
|
+
if (field.kind === "choice") {
|
|
319
|
+
const at = Math.max(0, field.options.indexOf(field.value));
|
|
320
|
+
return setChoice(field, at + direction);
|
|
321
|
+
}
|
|
322
|
+
return setNumber(field, stepValue(field, direction, size));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** A click on one option of a choice field. */
|
|
326
|
+
export function faderChoose(
|
|
327
|
+
state: FaderState,
|
|
328
|
+
field: FaderSpec | undefined,
|
|
329
|
+
option: number,
|
|
330
|
+
): FaderResult {
|
|
331
|
+
if (!field || field.kind !== "choice") return { type: "handled" };
|
|
332
|
+
state.label = field.label;
|
|
333
|
+
state.typing = undefined;
|
|
334
|
+
return setChoice(field, option);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const HINT =
|
|
338
|
+
"←→ adjust · ⇧←→ coarse · [ ] fine · ↑↓ param · 0-9 type · d default · enter keep · esc revert";
|
|
339
|
+
|
|
340
|
+
/** The drawer's paint model for `fields` (staged) beside `committed`. */
|
|
341
|
+
export function drawerView(
|
|
342
|
+
state: FaderState,
|
|
343
|
+
fields: readonly FaderSpec[],
|
|
344
|
+
committed: readonly FaderSpec[],
|
|
345
|
+
options: {
|
|
346
|
+
title: string;
|
|
347
|
+
dirty: boolean;
|
|
348
|
+
status?: string | undefined;
|
|
349
|
+
hint?: string;
|
|
350
|
+
},
|
|
351
|
+
): DrawerView {
|
|
352
|
+
const before = new Map(committed.map((field) => [field.label, field]));
|
|
353
|
+
const drawn: DrawerField[] = fields.map((field) => {
|
|
354
|
+
const was = before.get(field.label);
|
|
355
|
+
if (field.kind === "choice") {
|
|
356
|
+
const index = Math.max(0, field.options.indexOf(field.value));
|
|
357
|
+
const committedIndex =
|
|
358
|
+
was?.kind === "choice" && was.value !== field.value
|
|
359
|
+
? field.options.indexOf(was.value)
|
|
360
|
+
: undefined;
|
|
361
|
+
return {
|
|
362
|
+
kind: "choice",
|
|
363
|
+
label: field.label,
|
|
364
|
+
options: field.options,
|
|
365
|
+
index,
|
|
366
|
+
committedIndex:
|
|
367
|
+
committedIndex !== undefined && committedIndex >= 0
|
|
368
|
+
? committedIndex
|
|
369
|
+
: undefined,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const text =
|
|
373
|
+
field.value === undefined
|
|
374
|
+
? (field.off ?? "off")
|
|
375
|
+
: field.format(field.value);
|
|
376
|
+
const wasText =
|
|
377
|
+
was?.kind === "number"
|
|
378
|
+
? was.value === undefined
|
|
379
|
+
? (was.off ?? "off")
|
|
380
|
+
: was.format(was.value)
|
|
381
|
+
: undefined;
|
|
382
|
+
const changed = wasText !== undefined && wasText !== text;
|
|
383
|
+
return {
|
|
384
|
+
kind: "number",
|
|
385
|
+
label: field.label,
|
|
386
|
+
text,
|
|
387
|
+
committed: changed ? wasText : undefined,
|
|
388
|
+
position:
|
|
389
|
+
field.value === undefined
|
|
390
|
+
? undefined
|
|
391
|
+
: faderPosition(field, field.value),
|
|
392
|
+
committedPosition:
|
|
393
|
+
changed && was?.kind === "number" && was.value !== undefined
|
|
394
|
+
? faderPosition(field, was.value)
|
|
395
|
+
: undefined,
|
|
396
|
+
minText: field.format(field.min),
|
|
397
|
+
maxText: field.format(field.max),
|
|
398
|
+
};
|
|
399
|
+
});
|
|
400
|
+
return {
|
|
401
|
+
title: options.title,
|
|
402
|
+
fields: drawn,
|
|
403
|
+
focus: focusIndex(state, fields),
|
|
404
|
+
typing: state.typing,
|
|
405
|
+
dirty: options.dirty,
|
|
406
|
+
status: options.status,
|
|
407
|
+
hint: options.hint ?? HINT,
|
|
408
|
+
};
|
|
409
|
+
}
|