@hraness/dawg 0.4.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 +16 -0
- package/DAWG.md +7 -4
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/audio/effects/chain.ts +12 -2
- package/src/audio/effects/convolution.ts +43 -5
- package/src/audio/effects/space.ts +105 -48
- package/src/audio/engine.ts +85 -0
- package/src/audio/preview.ts +13 -2
- package/src/audio/wav.ts +148 -34
- package/src/main.ts +117 -14
- package/src/tui/audition.ts +21 -1
- package/src/tui/euclid.ts +50 -6
- package/src/tui/menu.ts +21 -2
- package/src/tui/play-chords.ts +93 -1
- package/tui/grammar.ts +24 -19
package/src/audio/wav.ts
CHANGED
|
@@ -34,12 +34,16 @@ import {
|
|
|
34
34
|
import { synthKit } from "../../core/kits.ts";
|
|
35
35
|
import { kitDrumSample, kitTailSeconds, newKitVoiceState } from "./kits.ts";
|
|
36
36
|
import {
|
|
37
|
+
addReverbWet,
|
|
37
38
|
applyMonoChain,
|
|
38
39
|
applyStereoChain,
|
|
39
40
|
delayTailFor,
|
|
40
41
|
interpolateAutomation,
|
|
42
|
+
reverbActive,
|
|
41
43
|
reverbImpulse,
|
|
42
44
|
reverbTailFor,
|
|
45
|
+
reverbWet,
|
|
46
|
+
type ReverbWet,
|
|
43
47
|
} from "./effects/chain.ts";
|
|
44
48
|
import {
|
|
45
49
|
createOrbitBus,
|
|
@@ -193,8 +197,16 @@ type Stem = {
|
|
|
193
197
|
key: string;
|
|
194
198
|
left: Float64Array;
|
|
195
199
|
right: Float64Array;
|
|
200
|
+
/** Stem, pre-reverb pair and tail (counted in the cache budget). */
|
|
196
201
|
bytes: number;
|
|
197
202
|
used: number;
|
|
203
|
+
/** For a track with a reverb: its stem before the reverb, and the tail. */
|
|
204
|
+
room?: Readonly<{
|
|
205
|
+
/** `stemKey` of the track with its reverb mix and mix lane left out. */
|
|
206
|
+
key: string;
|
|
207
|
+
pre: Readonly<{ left: Float64Array; right: Float64Array }>;
|
|
208
|
+
wet: ReverbWet;
|
|
209
|
+
}>;
|
|
198
210
|
};
|
|
199
211
|
|
|
200
212
|
/**
|
|
@@ -351,9 +363,38 @@ export class StemRenderer {
|
|
|
351
363
|
track ? reverbImpulse(track, sampleRate, bank.irs)?.id : undefined,
|
|
352
364
|
);
|
|
353
365
|
let stem = this.stems.get(trackId);
|
|
366
|
+
const caching = this.maxCacheBytes > 0;
|
|
367
|
+
// A cached track with a reverb also keeps its pre-reverb pair and the
|
|
368
|
+
// tail before its mix gain, keyed without the mix: a mix-only edit
|
|
369
|
+
// re-adds the tail instead of re-running the voices and the room.
|
|
370
|
+
const room =
|
|
371
|
+
caching && track && busOrbit === undefined && reverbActive(track)
|
|
372
|
+
? stemKey(
|
|
373
|
+
roomTrack(track),
|
|
374
|
+
notes,
|
|
375
|
+
context,
|
|
376
|
+
sampler ? bank : undefined,
|
|
377
|
+
wavetable?.id,
|
|
378
|
+
reverbImpulse(track, sampleRate, bank.irs)?.id,
|
|
379
|
+
)
|
|
380
|
+
: undefined;
|
|
354
381
|
if (stem?.key === key) stem.used = this.renders;
|
|
355
|
-
else {
|
|
356
|
-
const
|
|
382
|
+
else if (track && room !== undefined && stem?.room?.key === room) {
|
|
383
|
+
const { pre, wet } = stem.room;
|
|
384
|
+
const target = {
|
|
385
|
+
left: Float64Array.from(pre.left),
|
|
386
|
+
right: Float64Array.from(pre.right),
|
|
387
|
+
};
|
|
388
|
+
addReverbWet(target.left, target.right, wet, track, context);
|
|
389
|
+
stem = {
|
|
390
|
+
...stem,
|
|
391
|
+
key,
|
|
392
|
+
left: target.left,
|
|
393
|
+
right: target.right,
|
|
394
|
+
used: this.renders,
|
|
395
|
+
};
|
|
396
|
+
this.store(trackId, stem);
|
|
397
|
+
} else {
|
|
357
398
|
const target = caching
|
|
358
399
|
? {
|
|
359
400
|
left: new Float64Array(samples),
|
|
@@ -403,7 +444,27 @@ export class StemRenderer {
|
|
|
403
444
|
context,
|
|
404
445
|
stereo ? dryR : undefined,
|
|
405
446
|
);
|
|
406
|
-
|
|
447
|
+
let saved: Stem["room"];
|
|
448
|
+
if (track && room !== undefined) {
|
|
449
|
+
// Same chain, the reverb split in two (see `applyReverb`).
|
|
450
|
+
applyStereoChain(
|
|
451
|
+
target.left,
|
|
452
|
+
target.right,
|
|
453
|
+
track,
|
|
454
|
+
context,
|
|
455
|
+
true,
|
|
456
|
+
false,
|
|
457
|
+
);
|
|
458
|
+
const pre = {
|
|
459
|
+
left: Float64Array.from(target.left),
|
|
460
|
+
right: Float64Array.from(target.right),
|
|
461
|
+
};
|
|
462
|
+
const wet = reverbWet(target.left, target.right, track, context);
|
|
463
|
+
if (wet) {
|
|
464
|
+
addReverbWet(target.left, target.right, wet, track, context);
|
|
465
|
+
saved = { key: room, pre, wet };
|
|
466
|
+
}
|
|
467
|
+
} else if (track)
|
|
407
468
|
applyStereoChain(
|
|
408
469
|
target.left,
|
|
409
470
|
target.right,
|
|
@@ -415,8 +476,9 @@ export class StemRenderer {
|
|
|
415
476
|
key,
|
|
416
477
|
left: target.left,
|
|
417
478
|
right: target.right,
|
|
418
|
-
bytes: target.left.byteLength
|
|
479
|
+
bytes: target.left.byteLength * (saved ? 6 : 2),
|
|
419
480
|
used: this.renders,
|
|
481
|
+
...(saved ? { room: saved } : {}),
|
|
420
482
|
};
|
|
421
483
|
if (caching) this.store(trackId, stem);
|
|
422
484
|
}
|
|
@@ -426,17 +488,7 @@ export class StemRenderer {
|
|
|
426
488
|
buses.set(busOrbit, (bus = createOrbitBus(busOrbit, samples)));
|
|
427
489
|
sendToBus(bus, track, stem.left, stem.right, context, key);
|
|
428
490
|
}
|
|
429
|
-
|
|
430
|
-
if (gain)
|
|
431
|
-
for (let index = 0; index < samples; index += 1) {
|
|
432
|
-
mixL[index]! += stem.left[index]! * gain[index]!;
|
|
433
|
-
mixR[index]! += stem.right[index]! * gain[index]!;
|
|
434
|
-
}
|
|
435
|
-
else
|
|
436
|
-
for (let index = 0; index < samples; index += 1) {
|
|
437
|
-
mixL[index]! += stem.left[index]!;
|
|
438
|
-
mixR[index]! += stem.right[index]!;
|
|
439
|
-
}
|
|
491
|
+
addStem(mixL, mixR, stem.left, stem.right, samples, ducked.get(trackId));
|
|
440
492
|
}
|
|
441
493
|
// Bus returns join after every stem, in orbit order; the orbit's ducking
|
|
442
494
|
// applies to them as to its tracks.
|
|
@@ -455,12 +507,15 @@ export class StemRenderer {
|
|
|
455
507
|
wet = { key, ...renderOrbitBus(bus, context) };
|
|
456
508
|
if (this.maxCacheBytes > 0) this.buses.set(orbit, wet);
|
|
457
509
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
510
|
+
// (x * 1 === x, so an unducked bus adds its plain samples.)
|
|
511
|
+
addStem(
|
|
512
|
+
mixL,
|
|
513
|
+
mixR,
|
|
514
|
+
wet.left,
|
|
515
|
+
wet.right,
|
|
516
|
+
samples,
|
|
517
|
+
busGains.get(`bus:${orbit}`),
|
|
518
|
+
);
|
|
464
519
|
}
|
|
465
520
|
for (const orbit of [...this.buses.keys()])
|
|
466
521
|
if (!buses.has(orbit)) this.buses.delete(orbit);
|
|
@@ -468,19 +523,10 @@ export class StemRenderer {
|
|
|
468
523
|
// unsoloed tracks keep theirs so toggling them back is free.
|
|
469
524
|
for (const trackId of [...this.stems.keys()])
|
|
470
525
|
if (!groups.has(trackId)) this.evict(trackId);
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
mixL[index % frames]! += mixL[index]!;
|
|
476
|
-
mixR[index % frames]! += mixR[index]!;
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
const pcm = new Int16Array(frames * RENDER_CHANNELS);
|
|
480
|
-
for (let index = 0; index < frames; index += 1) {
|
|
481
|
-
pcm[index * 2] = clamp16(mixL[index]! * 32767);
|
|
482
|
-
pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
|
|
483
|
-
}
|
|
526
|
+
// Linear effects superpose, so folding the tail onto the start yields
|
|
527
|
+
// the steady state of the loop playing forever.
|
|
528
|
+
if (samples > frames) foldTail(mixL, mixR, frames, samples);
|
|
529
|
+
const pcm = toPcm(mixL, mixR, frames);
|
|
484
530
|
return Object.freeze({
|
|
485
531
|
sampleRate,
|
|
486
532
|
channels: RENDER_CHANNELS,
|
|
@@ -543,7 +589,75 @@ export class StemRenderer {
|
|
|
543
589
|
}
|
|
544
590
|
}
|
|
545
591
|
|
|
592
|
+
/*
|
|
593
|
+
* The mix loops live in small functions of their own: inside `render` (a
|
|
594
|
+
* large function the JIT optimizes late) they ran several times slower.
|
|
595
|
+
* Same operations in the same order, so the output is unchanged.
|
|
596
|
+
*/
|
|
597
|
+
|
|
598
|
+
/** Add a stem (times its ducking gain, if any) into the mix. */
|
|
599
|
+
function addStem(
|
|
600
|
+
mixL: Float64Array,
|
|
601
|
+
mixR: Float64Array,
|
|
602
|
+
left: Float64Array,
|
|
603
|
+
right: Float64Array,
|
|
604
|
+
samples: number,
|
|
605
|
+
gain: Float64Array | undefined,
|
|
606
|
+
): void {
|
|
607
|
+
if (gain)
|
|
608
|
+
for (let index = 0; index < samples; index += 1) {
|
|
609
|
+
mixL[index]! += left[index]! * gain[index]!;
|
|
610
|
+
mixR[index]! += right[index]! * gain[index]!;
|
|
611
|
+
}
|
|
612
|
+
else
|
|
613
|
+
for (let index = 0; index < samples; index += 1) {
|
|
614
|
+
mixL[index]! += left[index]!;
|
|
615
|
+
mixR[index]! += right[index]!;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Fold everything past `frames` back onto the loop start. */
|
|
620
|
+
function foldTail(
|
|
621
|
+
mixL: Float64Array,
|
|
622
|
+
mixR: Float64Array,
|
|
623
|
+
frames: number,
|
|
624
|
+
samples: number,
|
|
625
|
+
): void {
|
|
626
|
+
for (let index = frames; index < samples; index += 1) {
|
|
627
|
+
mixL[index % frames]! += mixL[index]!;
|
|
628
|
+
mixR[index % frames]! += mixR[index]!;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Interleaved 16-bit PCM of the first `frames` mix samples. */
|
|
633
|
+
function toPcm(
|
|
634
|
+
mixL: Float64Array,
|
|
635
|
+
mixR: Float64Array,
|
|
636
|
+
frames: number,
|
|
637
|
+
): Int16Array {
|
|
638
|
+
const pcm = new Int16Array(frames * RENDER_CHANNELS);
|
|
639
|
+
for (let index = 0; index < frames; index += 1) {
|
|
640
|
+
pcm[index * 2] = clamp16(mixL[index]! * 32767);
|
|
641
|
+
pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
|
|
642
|
+
}
|
|
643
|
+
return pcm;
|
|
644
|
+
}
|
|
645
|
+
|
|
546
646
|
/** Everything a track's stem depends on, except mute and solo. */
|
|
647
|
+
/**
|
|
648
|
+
* The track as its reverb tail sees it: everything but the reverb's mix
|
|
649
|
+
* and mix lane, which only scale the tail.
|
|
650
|
+
*/
|
|
651
|
+
function roomTrack(track: Track): Track {
|
|
652
|
+
const lanes = { ...track.fxAutomation };
|
|
653
|
+
delete lanes["reverb-mix"];
|
|
654
|
+
return {
|
|
655
|
+
...track,
|
|
656
|
+
reverb: { ...track.reverb!, mix: 1 },
|
|
657
|
+
fxAutomation: lanes,
|
|
658
|
+
} as Track;
|
|
659
|
+
}
|
|
660
|
+
|
|
547
661
|
function stemKey(
|
|
548
662
|
track: Track | undefined,
|
|
549
663
|
notes: readonly Note[],
|
package/src/main.ts
CHANGED
|
@@ -116,14 +116,24 @@ import {
|
|
|
116
116
|
type LiveEngine,
|
|
117
117
|
} from "./tui/play-session.ts";
|
|
118
118
|
import { EditMenu, MENU_SECTIONS, type MenuContext } from "./tui/menu.ts";
|
|
119
|
-
import {
|
|
120
|
-
|
|
119
|
+
import {
|
|
120
|
+
Audition,
|
|
121
|
+
SUPERSEDED,
|
|
122
|
+
isChordStageable,
|
|
123
|
+
isStageable,
|
|
124
|
+
} from "./tui/audition.ts";
|
|
125
|
+
import { EuclidEditor, type EuclidContext } from "./tui/euclid.ts";
|
|
121
126
|
import { HINTS, KEYS, keyLines, type KeySection } from "../tui/grammar.ts";
|
|
122
127
|
import { renderAudition } from "./audio/audition.ts";
|
|
123
128
|
import { renderScorePcm } from "./audio/wav.ts";
|
|
124
129
|
import type { PreviewHost } from "./agent/preview-tool.ts";
|
|
125
130
|
import { rhythmVoicePitch } from "../core/rhythm.ts";
|
|
126
|
-
import {
|
|
131
|
+
import {
|
|
132
|
+
applyChordsCommand,
|
|
133
|
+
chordPhrase,
|
|
134
|
+
defaultChordSettings,
|
|
135
|
+
type ChordSettings,
|
|
136
|
+
} from "./tui/play-chords.ts";
|
|
127
137
|
import {
|
|
128
138
|
cacheLines,
|
|
129
139
|
kitListLines,
|
|
@@ -469,6 +479,10 @@ const AGENT_PREVIEW_VOICE = 0x7fff_0002;
|
|
|
469
479
|
* instead of appending, and a remote revision that lands meanwhile waits in
|
|
470
480
|
* `committed` (the window's `score` is the staged base until it finishes).
|
|
471
481
|
*/
|
|
482
|
+
/** A `/chords …` settings command (staged as window state, not a score edit). */
|
|
483
|
+
const CHORDS_COMMAND = /^\/chords\s+(.+)$/i;
|
|
484
|
+
/** Whether the chord settings screen was open at the last menu redraw. */
|
|
485
|
+
let chordScreenWas = false;
|
|
472
486
|
let stageCapture: { next?: TrackScore; committed?: TrackScore } | undefined;
|
|
473
487
|
/** Redraw soon (the audition reports renders between frames). */
|
|
474
488
|
let requestFrame: () => void = () => undefined;
|
|
@@ -1032,11 +1046,23 @@ async function runInteractive(): Promise<void> {
|
|
|
1032
1046
|
euclid.close();
|
|
1033
1047
|
tui.closePicker();
|
|
1034
1048
|
if (back === "menu") openMenu();
|
|
1049
|
+
else leaveAuditionScreen();
|
|
1035
1050
|
} else if (result.type === "run") {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1051
|
+
if (!stageIfAuditioning(result.command)) {
|
|
1052
|
+
queuedPrompts.unshift(result.command);
|
|
1053
|
+
if (result.audition) pendingAudition = result.audition;
|
|
1054
|
+
void drainQueue();
|
|
1055
|
+
}
|
|
1039
1056
|
} else if (result.type === "audition") audition(result.voice);
|
|
1057
|
+
else if (result.type === "loop") auditionKeyPressed(result.key);
|
|
1058
|
+
else if (result.type === "revert") revertStaged();
|
|
1059
|
+
else if (result.type === "keep")
|
|
1060
|
+
void keepStaged()
|
|
1061
|
+
.then((outcome) => receipt(outcome))
|
|
1062
|
+
.catch((error) =>
|
|
1063
|
+
tui.activity.pushError(describeError("keep", error)),
|
|
1064
|
+
)
|
|
1065
|
+
.finally(() => tick(true));
|
|
1040
1066
|
if (result.type !== "pass") {
|
|
1041
1067
|
refreshEuclid();
|
|
1042
1068
|
tick(true);
|
|
@@ -2369,6 +2395,8 @@ function menuContext(): MenuContext {
|
|
|
2369
2395
|
committed: score,
|
|
2370
2396
|
hint: loop.hint(),
|
|
2371
2397
|
status: loop.status(),
|
|
2398
|
+
stageable: stageableNow,
|
|
2399
|
+
committedChords: chordSettings,
|
|
2372
2400
|
},
|
|
2373
2401
|
trackId: requestedTrack,
|
|
2374
2402
|
playing: clock.playing,
|
|
@@ -2376,7 +2404,7 @@ function menuContext(): MenuContext {
|
|
|
2376
2404
|
grids: GRIDS.map((grid) => grid.label),
|
|
2377
2405
|
clickOn: session?.clickOn ?? false,
|
|
2378
2406
|
countInBars: session?.countInBars ?? 1,
|
|
2379
|
-
chords: session?.chords.settings ?? chordSettings,
|
|
2407
|
+
chords: stagedChordSettings() ?? session?.chords.settings ?? chordSettings,
|
|
2380
2408
|
projectRoot: process.cwd(),
|
|
2381
2409
|
};
|
|
2382
2410
|
}
|
|
@@ -2415,8 +2443,20 @@ function openMenu(section?: string): void {
|
|
|
2415
2443
|
refreshMenu();
|
|
2416
2444
|
}
|
|
2417
2445
|
|
|
2418
|
-
function euclidContext() {
|
|
2419
|
-
|
|
2446
|
+
function euclidContext(): EuclidContext {
|
|
2447
|
+
const loop = auditionController();
|
|
2448
|
+
loop.focus(requestedTrack);
|
|
2449
|
+
return {
|
|
2450
|
+
score: loop.staging ? loop.score : score,
|
|
2451
|
+
trackId: requestedTrack,
|
|
2452
|
+
audition: {
|
|
2453
|
+
looping: loop.looping,
|
|
2454
|
+
dirty: loop.dirtyEdits,
|
|
2455
|
+
committed: score,
|
|
2456
|
+
hint: loop.hint(),
|
|
2457
|
+
status: loop.status(),
|
|
2458
|
+
},
|
|
2459
|
+
};
|
|
2420
2460
|
}
|
|
2421
2461
|
|
|
2422
2462
|
function openEuclid(voice?: string, origin?: string): void {
|
|
@@ -2493,6 +2533,15 @@ function auditionController(): Audition {
|
|
|
2493
2533
|
clearTimer: (handle) => clearTimeout(handle as Timer),
|
|
2494
2534
|
changed: () => requestFrame(),
|
|
2495
2535
|
level: () => previewEngine().level,
|
|
2536
|
+
// The chord settings screen loops a progression with its settings.
|
|
2537
|
+
phrase: (showing) => {
|
|
2538
|
+
if (!chordScreenOpen()) return undefined;
|
|
2539
|
+
const settings =
|
|
2540
|
+
showing === "A"
|
|
2541
|
+
? chordSettings
|
|
2542
|
+
: (stagedChordSettings() ?? chordSettings);
|
|
2543
|
+
return (base, track, bars) => chordPhrase(settings, base, track, bars);
|
|
2544
|
+
},
|
|
2496
2545
|
},
|
|
2497
2546
|
score,
|
|
2498
2547
|
requestedTrack,
|
|
@@ -2500,6 +2549,41 @@ function auditionController(): Audition {
|
|
|
2500
2549
|
return auditionLoop;
|
|
2501
2550
|
}
|
|
2502
2551
|
|
|
2552
|
+
/** True while the menu's Chords section (`/menu chords`) is open. */
|
|
2553
|
+
function chordScreenOpen(): boolean {
|
|
2554
|
+
return menu.open && menu.section === "chords";
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
/** What stages while auditioning on the screen open now. */
|
|
2558
|
+
function stageableNow(command: string): boolean {
|
|
2559
|
+
return (
|
|
2560
|
+
isStageable(command) || (chordScreenOpen() && isChordStageable(command))
|
|
2561
|
+
);
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
/**
|
|
2565
|
+
* The chord settings with the staged `/chords …` commands applied, or
|
|
2566
|
+
* undefined while none are staged. Chord settings are window state (not in
|
|
2567
|
+
* the score), so the staged ones are derived from the staged commands.
|
|
2568
|
+
*/
|
|
2569
|
+
function stagedChordSettings(): ChordSettings | undefined {
|
|
2570
|
+
const commands = auditionLoop?.dirtyEdits ? auditionLoop.commands : [];
|
|
2571
|
+
const chords = commands.filter((command) => CHORDS_COMMAND.test(command));
|
|
2572
|
+
if (chords.length === 0) return undefined;
|
|
2573
|
+
const settings = { ...chordSettings };
|
|
2574
|
+
for (const command of chords)
|
|
2575
|
+
applyChordsCommand(settings, command.match(CHORDS_COMMAND)![1]!);
|
|
2576
|
+
return settings;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/** Re-render the loop when the chord screen opens or closes. */
|
|
2580
|
+
function noteChordScreen(): void {
|
|
2581
|
+
const now = chordScreenOpen();
|
|
2582
|
+
if (now === chordScreenWas) return;
|
|
2583
|
+
chordScreenWas = now;
|
|
2584
|
+
auditionLoop?.request();
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2503
2587
|
/**
|
|
2504
2588
|
* Warm the pack cache for a staged command before it runs, so a lazy fetch
|
|
2505
2589
|
* (a sample kit, a pack sound or table) happens outside the capture and the
|
|
@@ -2542,6 +2626,12 @@ async function applyStaged(
|
|
|
2542
2626
|
base: TrackScore,
|
|
2543
2627
|
command: string,
|
|
2544
2628
|
): Promise<{ next?: TrackScore; ok: boolean; message: string }> {
|
|
2629
|
+
const chords = command.trim().match(CHORDS_COMMAND);
|
|
2630
|
+
if (chords) {
|
|
2631
|
+
// Window state: checked on a copy; kept settings apply on Enter.
|
|
2632
|
+
const result = applyChordsCommand({ ...chordSettings }, chords[1]!);
|
|
2633
|
+
return { next: base, ok: result.ok, message: result.message };
|
|
2634
|
+
}
|
|
2545
2635
|
await prefetchStaged(base, command);
|
|
2546
2636
|
// Staged commands run one at a time, between queued prompts.
|
|
2547
2637
|
while (stageCapture) await Bun.sleep(1);
|
|
@@ -2709,7 +2799,7 @@ function pickerAuditionKey(key: "loop" | "ab" | "context"): void {
|
|
|
2709
2799
|
/** Stage a menu command while auditioning; undefined when it should run. */
|
|
2710
2800
|
function stageIfAuditioning(command: string): boolean {
|
|
2711
2801
|
const loop = auditionLoop;
|
|
2712
|
-
if (!loop?.staging || !
|
|
2802
|
+
if (!loop?.staging || !stageableNow(command)) return false;
|
|
2713
2803
|
void loop.stage(command).then((result) => {
|
|
2714
2804
|
if (!result.ok)
|
|
2715
2805
|
tui.activity.pushCard(result.message, {
|
|
@@ -2727,12 +2817,21 @@ async function keepStaged(): Promise<Receipt> {
|
|
|
2727
2817
|
const taken = loop?.take();
|
|
2728
2818
|
if (!loop || !taken) return warn("nothing staged");
|
|
2729
2819
|
await materializeDraft();
|
|
2820
|
+
// Chord settings are window state: they apply here, outside the revision.
|
|
2821
|
+
const chordCommands = taken.commands.filter((command) =>
|
|
2822
|
+
CHORDS_COMMAND.test(command),
|
|
2823
|
+
);
|
|
2824
|
+
for (const command of chordCommands)
|
|
2825
|
+
applyChordsCommand(chordSettings, command.match(CHORDS_COMMAND)![1]!);
|
|
2826
|
+
const scoreCommands = taken.commands.filter(
|
|
2827
|
+
(command) => !CHORDS_COMMAND.test(command),
|
|
2828
|
+
);
|
|
2730
2829
|
let next = taken.score;
|
|
2731
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
2830
|
+
for (let attempt = 0; scoreCommands.length && next !== score; attempt += 1) {
|
|
2732
2831
|
try {
|
|
2733
2832
|
await commitScore(next, "preview.commit", {
|
|
2734
2833
|
trackId: loop.trackId,
|
|
2735
|
-
commands:
|
|
2834
|
+
commands: scoreCommands,
|
|
2736
2835
|
});
|
|
2737
2836
|
break;
|
|
2738
2837
|
} catch (error) {
|
|
@@ -2741,7 +2840,7 @@ async function keepStaged(): Promise<Receipt> {
|
|
|
2741
2840
|
record = await port.load();
|
|
2742
2841
|
score = scoreFromJSON(record.composition);
|
|
2743
2842
|
next = score;
|
|
2744
|
-
for (const command of
|
|
2843
|
+
for (const command of scoreCommands) {
|
|
2745
2844
|
const result = await applyStaged(next, command);
|
|
2746
2845
|
if (result.next) next = result.next;
|
|
2747
2846
|
}
|
|
@@ -2749,8 +2848,11 @@ async function keepStaged(): Promise<Receipt> {
|
|
|
2749
2848
|
}
|
|
2750
2849
|
loop.committedNow(score);
|
|
2751
2850
|
await projectSync?.flushScore();
|
|
2851
|
+
const kept = `kept ${taken.commands.length} change${taken.commands.length === 1 ? "" : "s"}`;
|
|
2752
2852
|
return ok(
|
|
2753
|
-
|
|
2853
|
+
scoreCommands.length
|
|
2854
|
+
? `${kept} · one undo step`
|
|
2855
|
+
: `${kept} · chord settings (window state, no revision)`,
|
|
2754
2856
|
);
|
|
2755
2857
|
}
|
|
2756
2858
|
|
|
@@ -2772,6 +2874,7 @@ function leaveAuditionScreen(): void {
|
|
|
2772
2874
|
}
|
|
2773
2875
|
|
|
2774
2876
|
function refreshMenu(): void {
|
|
2877
|
+
noteChordScreen();
|
|
2775
2878
|
if (!menu.open) return;
|
|
2776
2879
|
if (tui.ui.overlay !== undefined && tui.ui.picker?.id !== "menu") {
|
|
2777
2880
|
// Another overlay (help, a picker) replaced the menu.
|
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,12 @@ 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"];
|
|
64
71
|
};
|
|
65
72
|
|
|
66
73
|
export type AuditionOptions = Readonly<{
|
|
@@ -404,9 +411,11 @@ export class Audition {
|
|
|
404
411
|
private async flush(): Promise<void> {
|
|
405
412
|
if (!this.looping || this.rendering) return;
|
|
406
413
|
this.cancelTimer();
|
|
414
|
+
const phrase = this.host.phrase?.(this.dirtyEdits ? this.showing : "A");
|
|
407
415
|
const preview = previewScore(this.sounding, this.trackId, {
|
|
408
416
|
context: this.context,
|
|
409
417
|
beat: this.host.beat?.() ?? 0,
|
|
418
|
+
...(phrase ? { phrase } : {}),
|
|
410
419
|
});
|
|
411
420
|
if (!preview) return;
|
|
412
421
|
this.rendering = true;
|
|
@@ -491,7 +500,7 @@ export function auditionKey(value: string): AuditionKey | undefined {
|
|
|
491
500
|
* before.
|
|
492
501
|
*/
|
|
493
502
|
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;
|
|
503
|
+
/^\/?(?:(fx|effects|filter|lowpass|synth|wt|wavetable|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern|euclid)\s+\S|pack\s+use\s+\S)/i;
|
|
495
504
|
/** Subcommands that list or show instead of changing the sound. */
|
|
496
505
|
const READ_ONLY = /^\/?\S+\s+(list|show|info|help)\s*$/i;
|
|
497
506
|
|
|
@@ -499,3 +508,14 @@ export function isStageable(command: string): boolean {
|
|
|
499
508
|
const text = command.trim();
|
|
500
509
|
return STAGEABLE.test(text) && !READ_ONLY.test(text);
|
|
501
510
|
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Chord settings and the song key, which stage only in the chord settings
|
|
514
|
+
* screen: its loop plays a progression with them (`AuditionHost.phrase`),
|
|
515
|
+
* while elsewhere they would change nothing audible.
|
|
516
|
+
*/
|
|
517
|
+
export function isChordStageable(command: string): boolean {
|
|
518
|
+
return (
|
|
519
|
+
/^\/chords\s+\S/i.test(command.trim()) || /^key\s+\S/i.test(command.trim())
|
|
520
|
+
);
|
|
521
|
+
}
|
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
|