@hraness/dawg 0.3.0 → 0.4.1
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 +138 -0
- package/DAWG.md +608 -39
- package/README.md +4 -4
- package/core/chords.ts +1724 -0
- package/core/diff.ts +13 -1
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +735 -18
- package/core/sdk/eval-child.ts +34 -22
- package/core/sdk/eval.ts +4 -1
- package/core/sdk/print.ts +304 -22
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +3226 -21
- package/core/synth.ts +1001 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -3
- package/src/agent/brief.ts +29 -4
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +17 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/tools.ts +308 -14
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +109 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +339 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +394 -0
- package/src/audio/engine.ts +112 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +481 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/sampler.ts +157 -29
- package/src/audio/samples.ts +386 -44
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +411 -295
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +26 -4
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +298 -10
- package/src/commands/music.ts +10 -8
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +162 -2
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +1222 -30
- package/src/media/cli.ts +15 -2
- package/src/media/tools.ts +106 -1
- package/src/media/wavetable.ts +202 -0
- package/src/render.ts +22 -1
- package/src/session/naming.ts +3 -1
- package/src/tui/audition.ts +521 -0
- package/src/tui/euclid.ts +516 -0
- package/src/tui/menu.ts +1305 -244
- package/src/tui/play-chords.ts +630 -0
- package/src/tui/play-session.ts +364 -16
- package/src/tui/sketch.ts +108 -0
- package/src/web/search.ts +4 -1
- package/tui/app.ts +161 -39
- package/tui/grammar.ts +286 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Euclidean rhythm editor (`/euclid`, or Rhythm in `/menu`): one row per
|
|
3
|
+
* voice of the focused track, Torso T-1 style. Each row shows the voice's
|
|
4
|
+
* step ring unrolled as a grid and the selected parameter; arrows nudge it.
|
|
5
|
+
*
|
|
6
|
+
* Like the edit menu (`src/tui/menu.ts`) the editor is pure: rows are
|
|
7
|
+
* rebuilt from the score on every key and a change is returned as the
|
|
8
|
+
* prompt command it stands for (`euclid hat rotate 3`), which the caller
|
|
9
|
+
* runs through the normal prompt path, so every change is one revision and
|
|
10
|
+
* one undo step and the row's detail teaches the command.
|
|
11
|
+
*
|
|
12
|
+
* Keys: ↑↓ voice · ←→ (h l + -) nudge · tab / shift-tab (] [) parameter ·
|
|
13
|
+
* digits type a value · enter add/edit · space loop · x off · f freeze ·
|
|
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.
|
|
20
|
+
*/
|
|
21
|
+
import { HINTS } from "../../tui/grammar.ts";
|
|
22
|
+
import { DRUM_VOICES, isDrumInstrument } from "../../core/drums.ts";
|
|
23
|
+
import {
|
|
24
|
+
DIVISIONS,
|
|
25
|
+
RHYTHM_DEFAULTS,
|
|
26
|
+
RHYTHM_LIMITS,
|
|
27
|
+
rowPattern,
|
|
28
|
+
rowSteps,
|
|
29
|
+
rowSummary,
|
|
30
|
+
type RhythmRow,
|
|
31
|
+
} from "../../core/euclid.ts";
|
|
32
|
+
import {
|
|
33
|
+
isSamplerInstrument,
|
|
34
|
+
type Track,
|
|
35
|
+
type TrackScore,
|
|
36
|
+
} from "../../core/score.ts";
|
|
37
|
+
import type { PickerItem } from "../../tui/app.ts";
|
|
38
|
+
import { auditionKey, type AuditionKey } from "./audition.ts";
|
|
39
|
+
import type { MenuAudition } from "./menu.ts";
|
|
40
|
+
|
|
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
|
+
}>;
|
|
48
|
+
|
|
49
|
+
export type EuclidResult =
|
|
50
|
+
| { type: "handled" }
|
|
51
|
+
| { type: "close" }
|
|
52
|
+
/** Run `command`; `audition` names the voice to play once it lands. */
|
|
53
|
+
| { type: "run"; command: string; audition?: string }
|
|
54
|
+
/** Play `voice` once (no audition loop in this window). */
|
|
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" }
|
|
62
|
+
| { type: "pass" };
|
|
63
|
+
|
|
64
|
+
export type EuclidView = Readonly<{
|
|
65
|
+
title: string;
|
|
66
|
+
items: PickerItem[];
|
|
67
|
+
index: number;
|
|
68
|
+
hint: string;
|
|
69
|
+
}>;
|
|
70
|
+
|
|
71
|
+
type Param = Readonly<{
|
|
72
|
+
/** Field name in `RhythmRow` and in the `euclid` grammar. */
|
|
73
|
+
field: keyof RhythmRow & string;
|
|
74
|
+
label: string;
|
|
75
|
+
value: (row: RhythmRow) => number | string;
|
|
76
|
+
/** Next value one step in `direction`, or undefined at a limit. */
|
|
77
|
+
step: (row: RhythmRow, direction: 1 | -1) => number | string | undefined;
|
|
78
|
+
}>;
|
|
79
|
+
|
|
80
|
+
const round = (value: number) => Math.round(value * 1000) / 1000;
|
|
81
|
+
|
|
82
|
+
function linear(
|
|
83
|
+
field: keyof RhythmRow & string,
|
|
84
|
+
fallback: number,
|
|
85
|
+
size: number,
|
|
86
|
+
min: number,
|
|
87
|
+
max: (row: RhythmRow) => number,
|
|
88
|
+
) {
|
|
89
|
+
return (row: RhythmRow, direction: 1 | -1) => {
|
|
90
|
+
const current = (row[field] as number | undefined) ?? fallback;
|
|
91
|
+
const next = round(
|
|
92
|
+
Math.min(max(row), Math.max(min, current + size * direction)),
|
|
93
|
+
);
|
|
94
|
+
return next === current ? undefined : next;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function cycle(
|
|
99
|
+
field: "division" | "time",
|
|
100
|
+
fallback: (row: RhythmRow) => string,
|
|
101
|
+
) {
|
|
102
|
+
return (row: RhythmRow, direction: 1 | -1) => {
|
|
103
|
+
const current = (row[field] as string | undefined) ?? fallback(row);
|
|
104
|
+
const at = DIVISIONS.indexOf(current);
|
|
105
|
+
// Divisions run fastest to slowest; → makes steps longer.
|
|
106
|
+
const next = DIVISIONS[at < 0 ? 0 : at + direction];
|
|
107
|
+
return next === undefined || next === current ? undefined : next;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const division = (row: RhythmRow) => row.division ?? RHYTHM_DEFAULTS.division;
|
|
112
|
+
|
|
113
|
+
/** The editable parameters, in T-1 order (Shape, then Groove). */
|
|
114
|
+
export const EUCLID_PARAMS: readonly Param[] = Object.freeze([
|
|
115
|
+
{
|
|
116
|
+
field: "pulses",
|
|
117
|
+
label: "pulses",
|
|
118
|
+
value: (row) => row.pulses ?? RHYTHM_DEFAULTS.pulses,
|
|
119
|
+
step: linear("pulses", RHYTHM_DEFAULTS.pulses, 1, 0, rowSteps),
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
field: "steps",
|
|
123
|
+
label: "steps",
|
|
124
|
+
value: rowSteps,
|
|
125
|
+
step: (row, direction) => {
|
|
126
|
+
const next = Math.min(
|
|
127
|
+
RHYTHM_LIMITS.maxSteps,
|
|
128
|
+
Math.max(1, rowSteps(row) + direction),
|
|
129
|
+
);
|
|
130
|
+
return next === rowSteps(row) ? undefined : next;
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
field: "rotate",
|
|
135
|
+
label: "rotate",
|
|
136
|
+
value: (row) => row.rotate ?? 0,
|
|
137
|
+
step: (row, direction) => {
|
|
138
|
+
// Rotation wraps around the ring, so it stays in 0..steps-1.
|
|
139
|
+
const steps = rowSteps(row);
|
|
140
|
+
if (steps <= 1) return undefined;
|
|
141
|
+
const current = (((row.rotate ?? 0) % steps) + steps) % steps;
|
|
142
|
+
return (current + direction + steps) % steps;
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
field: "division",
|
|
147
|
+
label: "division",
|
|
148
|
+
value: division,
|
|
149
|
+
step: cycle("division", division),
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
field: "repeats",
|
|
153
|
+
label: "repeats",
|
|
154
|
+
value: (row) => row.repeats ?? 0,
|
|
155
|
+
step: linear("repeats", 0, 1, 0, () => RHYTHM_LIMITS.maxRepeats),
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
field: "time",
|
|
159
|
+
label: "time",
|
|
160
|
+
value: (row) => row.time ?? division(row),
|
|
161
|
+
step: cycle("time", division),
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
field: "pace",
|
|
165
|
+
label: "pace",
|
|
166
|
+
value: (row) => row.pace ?? 0,
|
|
167
|
+
step: linear("pace", 0, 0.1, -1, () => 1),
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
field: "ramp",
|
|
171
|
+
label: "ramp",
|
|
172
|
+
value: (row) => row.ramp ?? 0,
|
|
173
|
+
step: linear("ramp", 0, 0.1, -1, () => 1),
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
field: "velocity",
|
|
177
|
+
label: "velocity",
|
|
178
|
+
value: (row) => row.velocity ?? RHYTHM_DEFAULTS.velocity,
|
|
179
|
+
step: linear("velocity", RHYTHM_DEFAULTS.velocity, 0.05, 0, () => 1),
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
field: "accent",
|
|
183
|
+
label: "accent",
|
|
184
|
+
value: (row) => row.accent ?? 0,
|
|
185
|
+
step: linear("accent", 0, 0.1, 0, () => 1),
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
field: "accents",
|
|
189
|
+
label: "accents",
|
|
190
|
+
value: (row) => row.accents ?? 1,
|
|
191
|
+
step: linear(
|
|
192
|
+
"accents",
|
|
193
|
+
1,
|
|
194
|
+
1,
|
|
195
|
+
0,
|
|
196
|
+
(row) => row.pulses ?? RHYTHM_DEFAULTS.pulses,
|
|
197
|
+
),
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
field: "gate",
|
|
201
|
+
label: "gate",
|
|
202
|
+
value: (row) => row.gate ?? 1,
|
|
203
|
+
step: linear(
|
|
204
|
+
"gate",
|
|
205
|
+
1,
|
|
206
|
+
0.25,
|
|
207
|
+
RHYTHM_LIMITS.minGate,
|
|
208
|
+
() => RHYTHM_LIMITS.maxGate,
|
|
209
|
+
),
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
field: "probability",
|
|
213
|
+
label: "probability",
|
|
214
|
+
value: (row) => row.probability ?? 1,
|
|
215
|
+
step: linear("probability", 1, 0.05, 0, () => 1),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
field: "seed",
|
|
219
|
+
label: "seed",
|
|
220
|
+
value: (row) => row.seed ?? 0,
|
|
221
|
+
step: linear("seed", 0, 1, 0, () => RHYTHM_LIMITS.maxSeed),
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
field: "swing",
|
|
225
|
+
label: "swing",
|
|
226
|
+
value: (row) => row.swing ?? 0,
|
|
227
|
+
step: linear(
|
|
228
|
+
"swing",
|
|
229
|
+
0,
|
|
230
|
+
0.05,
|
|
231
|
+
-RHYTHM_LIMITS.maxSwing,
|
|
232
|
+
() => RHYTHM_LIMITS.maxSwing,
|
|
233
|
+
),
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
field: "nudge",
|
|
237
|
+
label: "nudge",
|
|
238
|
+
value: (row) => row.nudge ?? 0,
|
|
239
|
+
step: linear(
|
|
240
|
+
"nudge",
|
|
241
|
+
0,
|
|
242
|
+
0.05,
|
|
243
|
+
-RHYTHM_LIMITS.maxNudge,
|
|
244
|
+
() => RHYTHM_LIMITS.maxNudge,
|
|
245
|
+
),
|
|
246
|
+
},
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
/** Parameters a grid row has (its steps are explicit). */
|
|
250
|
+
const GRID_SKIPS = new Set(["pulses", "steps", "rotate"]);
|
|
251
|
+
|
|
252
|
+
type Lane = Readonly<{ voice: string; label: string; row?: RhythmRow }>;
|
|
253
|
+
|
|
254
|
+
/** One lane per drum voice (or sampler voice), plus pitch rows on melodic tracks. */
|
|
255
|
+
export function euclidLanes(track: Track): Lane[] {
|
|
256
|
+
const rows = new Map((track.rhythm ?? []).map((row) => [row.voice, row]));
|
|
257
|
+
const lanes: Lane[] = [];
|
|
258
|
+
const seen = new Set<string>();
|
|
259
|
+
const add = (voice: string, label: string) => {
|
|
260
|
+
seen.add(voice);
|
|
261
|
+
const row = rows.get(voice);
|
|
262
|
+
lanes.push(row ? { voice, label, row } : { voice, label });
|
|
263
|
+
};
|
|
264
|
+
if (
|
|
265
|
+
isSamplerInstrument(track.instrument) &&
|
|
266
|
+
track.sampler?.mode === "oneshot"
|
|
267
|
+
)
|
|
268
|
+
for (const voice of Object.keys(track.sampler.voices).sort())
|
|
269
|
+
add(voice, voice);
|
|
270
|
+
else if (isDrumInstrument(track.instrument))
|
|
271
|
+
for (const info of DRUM_VOICES) add(info.voice, info.label);
|
|
272
|
+
for (const row of track.rhythm ?? [])
|
|
273
|
+
if (!seen.has(row.voice)) add(row.voice, row.voice);
|
|
274
|
+
return lanes;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** `x...x...` with accents as `X`, rests as `·`. */
|
|
278
|
+
export function ringText(row: RhythmRow | undefined): string {
|
|
279
|
+
if (!row) return "·".repeat(RHYTHM_DEFAULTS.steps);
|
|
280
|
+
const pattern = rowPattern(row);
|
|
281
|
+
const grid = row.grid;
|
|
282
|
+
return pattern
|
|
283
|
+
.map((on, index) => (on ? (grid?.[index] === "X" ? "X" : "x") : "·"))
|
|
284
|
+
.join("");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const KEY_UP = new Set(["\u001b[A", "\u001bOA", "k"]);
|
|
288
|
+
const KEY_DOWN = new Set(["\u001b[B", "\u001bOB", "j"]);
|
|
289
|
+
const KEY_LEFT = new Set(["\u001b[D", "\u001bOD", "h", "-", "_"]);
|
|
290
|
+
const KEY_RIGHT = new Set(["\u001b[C", "\u001bOC", "l", "+", "="]);
|
|
291
|
+
const KEY_NEXT_PARAM = new Set(["\t", "]"]);
|
|
292
|
+
const KEY_PREV_PARAM = new Set(["\u001b[Z", "["]);
|
|
293
|
+
const KEY_ENTER = new Set(["\r", "\n"]);
|
|
294
|
+
const KEY_BACKSPACE = new Set(["\u007f", "\b"]);
|
|
295
|
+
const KEY_OFF = new Set(["x", "\u001b[3~"]);
|
|
296
|
+
|
|
297
|
+
export class EuclidEditor {
|
|
298
|
+
private visible = false;
|
|
299
|
+
private lane = 0;
|
|
300
|
+
private param = 0;
|
|
301
|
+
private entry: string | undefined;
|
|
302
|
+
/** Where Esc returns to (`menu` when opened from `/menu`). */
|
|
303
|
+
private origin: string | undefined;
|
|
304
|
+
|
|
305
|
+
get open(): boolean {
|
|
306
|
+
return this.visible;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Where the editor was opened from, for Esc to return there. */
|
|
310
|
+
/** True while a value is being typed. */
|
|
311
|
+
get typing(): boolean {
|
|
312
|
+
return this.entry !== undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
get returnTo(): string | undefined {
|
|
316
|
+
return this.origin;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
show(context: EuclidContext, voice?: string, origin?: string): void {
|
|
320
|
+
this.visible = true;
|
|
321
|
+
this.entry = undefined;
|
|
322
|
+
this.origin = origin;
|
|
323
|
+
const lanes = this.lanes(context);
|
|
324
|
+
const at = voice ? lanes.findIndex((lane) => lane.voice === voice) : -1;
|
|
325
|
+
if (at >= 0) this.lane = at;
|
|
326
|
+
else {
|
|
327
|
+
// Land on the first voice that already has a row.
|
|
328
|
+
const first = lanes.findIndex((lane) => lane.row);
|
|
329
|
+
this.lane = Math.max(0, Math.min(this.lane, lanes.length - 1), first);
|
|
330
|
+
if (first >= 0) this.lane = first;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
close(): void {
|
|
335
|
+
this.visible = false;
|
|
336
|
+
this.entry = undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private lanes(context: EuclidContext): Lane[] {
|
|
340
|
+
const track = context.score.tracks.find((t) => t.id === context.trackId);
|
|
341
|
+
return track ? euclidLanes(track) : [];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Voice under the cursor. */
|
|
345
|
+
selectedVoice(context: EuclidContext): string | undefined {
|
|
346
|
+
const lanes = this.lanes(context);
|
|
347
|
+
return lanes[Math.min(this.lane, lanes.length - 1)]?.voice;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Parameter under the cursor. */
|
|
351
|
+
get selectedParam(): string {
|
|
352
|
+
return EUCLID_PARAMS[this.param]!.field;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
key(value: string, context: EuclidContext): EuclidResult {
|
|
356
|
+
if (!this.visible) return { type: "pass" };
|
|
357
|
+
if (value === "\u0003" || value === "\u000c") return { type: "pass" };
|
|
358
|
+
const lanes = this.lanes(context);
|
|
359
|
+
if (lanes.length > 0) this.lane = Math.min(this.lane, lanes.length - 1);
|
|
360
|
+
const lane = lanes[this.lane];
|
|
361
|
+
const param = EUCLID_PARAMS[this.param]!;
|
|
362
|
+
if (this.entry !== undefined) {
|
|
363
|
+
if (value === "\u001b") {
|
|
364
|
+
this.entry = undefined;
|
|
365
|
+
return { type: "handled" };
|
|
366
|
+
}
|
|
367
|
+
if (KEY_BACKSPACE.has(value)) {
|
|
368
|
+
this.entry = this.entry.slice(0, -1);
|
|
369
|
+
return { type: "handled" };
|
|
370
|
+
}
|
|
371
|
+
if (KEY_ENTER.has(value)) {
|
|
372
|
+
const text = this.entry.trim();
|
|
373
|
+
this.entry = undefined;
|
|
374
|
+
if (!lane || !/^-?[0-9./tT]+$/.test(text)) return { type: "handled" };
|
|
375
|
+
return {
|
|
376
|
+
type: "run",
|
|
377
|
+
command: `euclid ${lane.voice} ${param.field} ${text}`,
|
|
378
|
+
audition: lane.voice,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (/^[0-9./tT-]$/.test(value))
|
|
382
|
+
this.entry = (this.entry + value).slice(0, 12);
|
|
383
|
+
return { type: "handled" };
|
|
384
|
+
}
|
|
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 };
|
|
390
|
+
if (KEY_UP.has(value) || KEY_DOWN.has(value)) {
|
|
391
|
+
if (lanes.length)
|
|
392
|
+
this.lane =
|
|
393
|
+
(this.lane + (KEY_UP.has(value) ? -1 : 1) + lanes.length) %
|
|
394
|
+
lanes.length;
|
|
395
|
+
return { type: "handled" };
|
|
396
|
+
}
|
|
397
|
+
if (KEY_NEXT_PARAM.has(value) || KEY_PREV_PARAM.has(value)) {
|
|
398
|
+
const direction = KEY_NEXT_PARAM.has(value) ? 1 : -1;
|
|
399
|
+
this.param =
|
|
400
|
+
(this.param + direction + EUCLID_PARAMS.length) % EUCLID_PARAMS.length;
|
|
401
|
+
return { type: "handled" };
|
|
402
|
+
}
|
|
403
|
+
if (!lane) return { type: "handled" };
|
|
404
|
+
if (value === " ") return { type: "audition", voice: lane.voice };
|
|
405
|
+
if (KEY_LEFT.has(value) || KEY_RIGHT.has(value)) {
|
|
406
|
+
const direction = KEY_RIGHT.has(value) ? 1 : -1;
|
|
407
|
+
const row: RhythmRow = lane.row ?? { voice: lane.voice };
|
|
408
|
+
if (row.grid !== undefined && GRID_SKIPS.has(param.field)) {
|
|
409
|
+
// A nudge on a grid row's shape turns it back into E(pulses, steps).
|
|
410
|
+
const pulses = [...row.grid].filter((c) => c !== ".").length;
|
|
411
|
+
return {
|
|
412
|
+
type: "run",
|
|
413
|
+
command: `euclid ${lane.voice} ${pulses} ${row.grid.length}`,
|
|
414
|
+
audition: lane.voice,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
const next = param.step(row, direction);
|
|
418
|
+
if (next === undefined) return { type: "handled" };
|
|
419
|
+
return {
|
|
420
|
+
type: "run",
|
|
421
|
+
command: `euclid ${lane.voice} ${param.field} ${next}`,
|
|
422
|
+
audition: lane.voice,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
if (/^[0-9]$/.test(value)) {
|
|
426
|
+
this.entry = value;
|
|
427
|
+
return { type: "handled" };
|
|
428
|
+
}
|
|
429
|
+
if (KEY_ENTER.has(value)) {
|
|
430
|
+
if (audition?.dirty) return { type: "keep" };
|
|
431
|
+
if (!lane.row)
|
|
432
|
+
return {
|
|
433
|
+
type: "run",
|
|
434
|
+
command: `euclid ${lane.voice} ${RHYTHM_DEFAULTS.pulses} ${RHYTHM_DEFAULTS.steps}`,
|
|
435
|
+
audition: lane.voice,
|
|
436
|
+
};
|
|
437
|
+
this.entry = "";
|
|
438
|
+
return { type: "handled" };
|
|
439
|
+
}
|
|
440
|
+
if (KEY_OFF.has(value))
|
|
441
|
+
return lane.row
|
|
442
|
+
? { type: "run", command: `euclid ${lane.voice} off` }
|
|
443
|
+
: { type: "handled" };
|
|
444
|
+
if (value === "f")
|
|
445
|
+
return lane.row
|
|
446
|
+
? { type: "run", command: `euclid ${lane.voice} freeze` }
|
|
447
|
+
: { type: "handled" };
|
|
448
|
+
// Swallow other printable keys so nothing leaks into the prompt.
|
|
449
|
+
return { type: "handled" };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
view(context: EuclidContext): EuclidView {
|
|
453
|
+
const lanes = this.lanes(context);
|
|
454
|
+
const index = Math.max(0, Math.min(this.lane, lanes.length - 1));
|
|
455
|
+
const param = EUCLID_PARAMS[this.param]!;
|
|
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");
|
|
463
|
+
const items: PickerItem[] = lanes.map((lane, at) => {
|
|
464
|
+
const row = lane.row;
|
|
465
|
+
const shown = row
|
|
466
|
+
? row.grid !== undefined && GRID_SKIPS.has(param.field)
|
|
467
|
+
? "grid"
|
|
468
|
+
: formatValue(param.value(row))
|
|
469
|
+
: "—";
|
|
470
|
+
const now = row ? rowSummary(row) : "off";
|
|
471
|
+
const was = before.get(lane.voice);
|
|
472
|
+
const changed = was !== undefined && was !== now ? ` ← ${was}` : "";
|
|
473
|
+
return {
|
|
474
|
+
label: `${lane.label.padEnd(width)} ${ringText(row)}`,
|
|
475
|
+
detail: row
|
|
476
|
+
? `${now}${changed} · ${param.label} ${shown}`
|
|
477
|
+
: changed
|
|
478
|
+
? `off${changed}`
|
|
479
|
+
: "enter adds E(4,16)",
|
|
480
|
+
value: String(at),
|
|
481
|
+
current: row !== undefined,
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
const voice = lanes[index]?.voice ?? "";
|
|
485
|
+
const row = lanes[index]?.row;
|
|
486
|
+
const value = row ? formatValue(param.value(row)) : "—";
|
|
487
|
+
let title = `rhythm › ${context.trackId}${voice ? ` · ${voice}` : ""} · ${param.label} ${value}`;
|
|
488
|
+
if (this.entry !== undefined) title += ` · ${param.label}: ${this.entry}▏`;
|
|
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;
|
|
498
|
+
return {
|
|
499
|
+
title,
|
|
500
|
+
items: items.length
|
|
501
|
+
? items
|
|
502
|
+
: [
|
|
503
|
+
{
|
|
504
|
+
label: "no voices · instrument kit makes a drum track",
|
|
505
|
+
value: "none",
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
index,
|
|
509
|
+
hint,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function formatValue(value: number | string): string {
|
|
515
|
+
return typeof value === "number" ? String(round(value)) : value;
|
|
516
|
+
}
|