@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/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 caching = this.maxCacheBytes > 0;
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
- if (track)
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 + target.right.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
- const gain = ducked.get(trackId);
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
- const gain = busGains.get(`bus:${orbit}`);
459
- for (let index = 0; index < samples; index += 1) {
460
- const g = gain ? gain[index]! : 1;
461
- mixL[index]! += wet.left[index]! * g;
462
- mixR[index]! += wet.right[index]! * g;
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
- if (samples > frames) {
472
- // Linear effects superpose, so folding the tail onto the start yields
473
- // the steady state of the loop playing forever.
474
- for (let index = frames; index < samples; index += 1) {
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 { Audition, SUPERSEDED, isStageable } from "./tui/audition.ts";
120
- import { EuclidEditor } from "./tui/euclid.ts";
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 { applyChordsCommand, defaultChordSettings } from "./tui/play-chords.ts";
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
- queuedPrompts.unshift(result.command);
1037
- if (result.audition) pendingAudition = result.audition;
1038
- void drainQueue();
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
- return { score, trackId: requestedTrack };
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 || !isStageable(command)) return false;
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: taken.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 taken.commands) {
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
- `kept ${taken.commands.length} change${taken.commands.length === 1 ? "" : "s"} · one undo step`,
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.
@@ -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 audition · x off · f freeze ·
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<{ score: TrackScore; trackId: string }>;
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
- if (value === "\u001b") return { type: "close" };
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
- ? `${rowSummary(row)} · ${param.label} ${shown}`
443
- : "enter adds E(4,16)",
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
- const hint = this.entry !== undefined ? HINTS.typing : HINTS.euclid;
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