@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/tui/menu.ts CHANGED
@@ -119,6 +119,13 @@ export type MenuAudition = Readonly<{
119
119
  hint: string;
120
120
  /** `♪ solo · B staged 2 · 42 ms`, shown in the title. */
121
121
  status?: string | undefined;
122
+ /**
123
+ * Which commands stage while auditioning (default `isStageable`); the
124
+ * chord settings screen also stages `/chords …` and `key …`.
125
+ */
126
+ stageable?: (command: string) => boolean;
127
+ /** The committed chord settings, for `staged ← committed` on Chords rows. */
128
+ committedChords?: ChordSettings;
122
129
  }>;
123
130
 
124
131
  type NumberField = Readonly<{
@@ -1543,6 +1550,8 @@ function transportNodes(context: MenuContext): MenuNode[] {
1543
1550
 
1544
1551
  type Frame = {
1545
1552
  title: string;
1553
+ /** The submenu's node id (`chords`), for `EditMenu.section`. */
1554
+ id?: string;
1546
1555
  build: (context: MenuContext) => MenuNode[];
1547
1556
  index: number;
1548
1557
  query: string;
@@ -1573,6 +1582,7 @@ function childFrame(
1573
1582
  ): Frame {
1574
1583
  return {
1575
1584
  title: node.label,
1585
+ id: node.id,
1576
1586
  index: 0,
1577
1587
  query: "",
1578
1588
  hover: `menu:${node.id}`,
@@ -1603,6 +1613,11 @@ export class EditMenu {
1603
1613
  return this.filtering || this.entry !== undefined;
1604
1614
  }
1605
1615
 
1616
+ /** The root section open now (`chords`), or undefined at the root. */
1617
+ get section(): string | undefined {
1618
+ return this.stack[1]?.id;
1619
+ }
1620
+
1606
1621
  /** Open at the root, or at a section id (`effects`, `automation`). */
1607
1622
  show(context: MenuContext, section?: string): void {
1608
1623
  this.stack = [{ title: "menu", build: rootNodes, index: 0, query: "" }];
@@ -1777,7 +1792,7 @@ export class EditMenu {
1777
1792
  if (node.kind === "action")
1778
1793
  return frame.hover &&
1779
1794
  context.audition?.looping &&
1780
- isStageable(node.command)
1795
+ (context.audition.stageable ?? isStageable)(node.command)
1781
1796
  ? { type: "choose", command: node.command, key: frame.hover }
1782
1797
  : { type: "run", command: node.command };
1783
1798
  if (node.kind === "choice") {
@@ -1822,7 +1837,8 @@ export class EditMenu {
1822
1837
  private hovered(frame: Frame, context: MenuContext): MenuResult {
1823
1838
  if (!frame.hover || !context.audition?.looping) return { type: "handled" };
1824
1839
  const node = this.selected(context);
1825
- if (node?.kind !== "action" || !isStageable(node.command))
1840
+ const stageable = context.audition?.stageable ?? isStageable;
1841
+ if (node?.kind !== "action" || !stageable(node.command))
1826
1842
  return { type: "handled" };
1827
1843
  frame.hovering = true;
1828
1844
  return { type: "hover", command: node.command, key: frame.hover };
@@ -1932,6 +1948,9 @@ export class EditMenu {
1932
1948
  for (const node of frame.build({
1933
1949
  ...context,
1934
1950
  score: audition.committed,
1951
+ ...(audition.committedChords
1952
+ ? { chords: audition.committedChords }
1953
+ : {}),
1935
1954
  }))
1936
1955
  before.set(node.label, valueText(node));
1937
1956
  const items = nodes.map((node, at) => {
@@ -25,6 +25,11 @@
25
25
  import {
26
26
  BASS_MODES,
27
27
  CHORD_PATTERNS,
28
+ DEFAULT_ARP_RATE,
29
+ DEFAULT_STRUM,
30
+ generateProgression,
31
+ presetChords,
32
+ renderProgression,
28
33
  CHORD_TYPES,
29
34
  EXTENSIONS,
30
35
  MAX_VOICING_STEP,
@@ -56,7 +61,12 @@ import {
56
61
  type ProgressionStyle,
57
62
  type Spread,
58
63
  } from "../../core/chords.ts";
59
- import { isSamplerInstrument, type Track } from "../../core/score.ts";
64
+ import {
65
+ isSamplerInstrument,
66
+ type NoteInput,
67
+ type Track,
68
+ type TrackScore,
69
+ } from "../../core/score.ts";
60
70
 
61
71
  export const CHORD_MODES = ["auto", "manual", "off"] as const;
62
72
  export type ChordMode = (typeof CHORD_MODES)[number];
@@ -536,3 +546,85 @@ export function chordsSummary(settings: ChordSettings): string {
536
546
  `style ${s.style}`,
537
547
  ].join(" · ");
538
548
  }
549
+
550
+ /** Chords in the chord settings screen's audition phrase. */
551
+ const PHRASE_CHORDS = 4;
552
+
553
+ /**
554
+ * What the chord settings sound like, for the audition loop: the chosen
555
+ * progression (the preset, or four chords of the style's seeded walk) in the
556
+ * song key, voiced and performed with `settings` (voicing, spread, sevenths,
557
+ * perform mode, pattern, arp rate and octaves, bass mode), one chord per bar
558
+ * or two per bar in a one-bar loop. With chords `off` it plays each chord's
559
+ * root alone, as play mode would. Deterministic: the same settings and key
560
+ * always give the same notes.
561
+ */
562
+ export function chordPhrase(
563
+ settings: ChordSettings,
564
+ score: TrackScore,
565
+ track: Track,
566
+ bars: number,
567
+ ): NoteInput[] {
568
+ const key = parseKey(score.key) ?? { tonic: 0, mode: "major" as const };
569
+ const preset =
570
+ settings.preset === "none" ? undefined : findPreset(settings.preset);
571
+ const progression = preset
572
+ ? presetChords(key, preset)
573
+ : generateProgression({
574
+ key,
575
+ length: PHRASE_CHORDS,
576
+ style: settings.style,
577
+ seed: 1,
578
+ sevenths: settings.sevenths,
579
+ });
580
+ const tpb = score.ticksPerBeat;
581
+ const totalBeats = Math.max(1, bars) * score.beatsPerBar;
582
+ const count = Math.max(2, Math.max(1, bars));
583
+ const span = totalBeats / count;
584
+ const chords = Array.from(
585
+ { length: count },
586
+ (_, index) => progression[index % progression.length]!,
587
+ );
588
+ const mode = settings.perform;
589
+ const rendered = renderProgression({
590
+ key,
591
+ chords,
592
+ beatsPerChord: span,
593
+ inversion: settings.inversion,
594
+ spread: settings.spread,
595
+ bassMode: settings.mode === "off" ? "solo" : settings.bass,
596
+ perform: {
597
+ mode,
598
+ rate:
599
+ settings.rate === "grid" ? DEFAULT_ARP_RATE : RATE_BEATS[settings.rate],
600
+ octaves: settings.octaves,
601
+ strum: mode === "harp" ? DEFAULT_STRUM * 2 : DEFAULT_STRUM,
602
+ seed: 1,
603
+ velocity: 0.8,
604
+ ...(mode === "pattern" ? { pattern: settings.pattern } : {}),
605
+ },
606
+ });
607
+ // With chords off a key plays one note: the root, in the chord's octave.
608
+ const performed =
609
+ settings.mode === "off"
610
+ ? rendered.bass.map((note) => ({ ...note, pitch: note.pitch + 24 }))
611
+ : [...rendered.notes, ...rendered.bass];
612
+ const end = totalBeats * tpb;
613
+ const notes: NoteInput[] = [];
614
+ for (const note of performed) {
615
+ const startTick = Math.round(note.start * tpb);
616
+ if (startTick >= end) continue;
617
+ notes.push({
618
+ id: `chords-${notes.length + 1}`,
619
+ trackId: track.id,
620
+ startTick,
621
+ durationTicks: Math.max(
622
+ 1,
623
+ Math.min(Math.round(note.length * tpb), end - startTick),
624
+ ),
625
+ pitch: note.pitch,
626
+ velocity: note.velocity,
627
+ });
628
+ }
629
+ return notes;
630
+ }
package/tui/grammar.ts CHANGED
@@ -80,7 +80,7 @@ export const HINTS = {
80
80
  action: " ↑↓ move · enter apply · / filter · esc back · ? keys ",
81
81
  typing: " type a value · enter apply · esc cancel ",
82
82
  euclid:
83
- " ↑↓ voice · ←→ adjust · tab field · space hear · x off · esc back · ? keys ",
83
+ " ↑↓ voice · ←→ adjust · tab field · space loop · x off · esc back · ? keys ",
84
84
  text: " ↑↓ scroll · pgup pgdn page · esc back · ? keys ",
85
85
  log: " ↑↓ scroll · / filter · esc back · ? keys ",
86
86
  keys: " any key closes ",
@@ -88,6 +88,27 @@ export const HINTS = {
88
88
 
89
89
  // ── the `?` panel ─────────────────────────────────────────────────────
90
90
 
91
+ /** The audition loop's keys, shared by the menu and the rhythm editor. */
92
+ const AUDITIONING: readonly KeySection[] = [
93
+ {
94
+ title: "auditioning",
95
+ rows: [
96
+ ["space", "loop the focused track · again stops"],
97
+ ["c", "solo ↔ in context (the whole mix)"],
98
+ ["a", "A/B: committed ↔ staged"],
99
+ ["enter", "keep staged changes (one undo step)"],
100
+ ["esc", "revert staged changes"],
101
+ ],
102
+ },
103
+ {
104
+ title: "every change",
105
+ rows: [
106
+ ["", "while looping, stages; otherwise runs the command shown"],
107
+ ["ctrl-z", "undoes it"],
108
+ ],
109
+ },
110
+ ];
111
+
91
112
  const LIST: readonly KeyRow[] = [
92
113
  ["↑ ↓ j k", "move"],
93
114
  ["pgup pgdn", "page"],
@@ -166,23 +187,7 @@ export const KEYS = {
166
187
  ["x delete", "reset to default (deletes an automation point)"],
167
188
  ],
168
189
  },
169
- {
170
- title: "auditioning",
171
- rows: [
172
- ["space", "loop the focused track · again stops"],
173
- ["c", "solo ↔ in context (the whole mix)"],
174
- ["a", "A/B: committed ↔ staged"],
175
- ["enter", "keep staged changes (one undo step)"],
176
- ["esc", "revert staged changes"],
177
- ],
178
- },
179
- {
180
- title: "every change",
181
- rows: [
182
- ["", "while looping, stages; otherwise runs the command shown"],
183
- ["ctrl-z", "undoes it"],
184
- ],
185
- },
190
+ ...AUDITIONING,
186
191
  ],
187
192
  euclid: [
188
193
  {
@@ -193,12 +198,12 @@ export const KEYS = {
193
198
  ["tab shift-tab ] [", "next / previous field"],
194
199
  ["0-9", "type a value, enter applies"],
195
200
  ["enter", "add a row (or type a value)"],
196
- ["space", "hear the voice"],
197
201
  ["x", "turn the row off"],
198
202
  ["f", "freeze into plain hits"],
199
203
  ["esc", "back"],
200
204
  ],
201
205
  },
206
+ ...AUDITIONING,
202
207
  ],
203
208
  text: [
204
209
  {