@hraness/dawg 0.7.0 → 0.8.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/DAWG.md +117 -30
  3. package/README.md +7 -5
  4. package/core/diff.ts +2 -0
  5. package/core/ids.ts +52 -0
  6. package/core/loop.ts +3 -0
  7. package/core/range.ts +581 -0
  8. package/core/score.ts +123 -1
  9. package/core/sdk/print.ts +11 -2
  10. package/core/sdk/v1.ts +281 -2
  11. package/core/sections.ts +198 -19
  12. package/guides/arrange.md +9 -8
  13. package/guides/audio.md +31 -0
  14. package/guides/mix.md +3 -1
  15. package/guides/panes.md +32 -0
  16. package/guides/project.md +3 -2
  17. package/guides/rhythm.md +2 -1
  18. package/guides/tape.md +32 -0
  19. package/package.json +2 -1
  20. package/src/agent/agent.ts +4 -4
  21. package/src/agent/command-agent.ts +19 -10
  22. package/src/agent/ops.ts +15 -0
  23. package/src/agent/planner.ts +12 -0
  24. package/src/agent/range-tools.ts +216 -0
  25. package/src/agent/show-me.ts +1 -1
  26. package/src/agent/tools.ts +2 -0
  27. package/src/agent/xcb-agent.ts +2 -3
  28. package/src/audio/arrange.ts +12 -2
  29. package/src/audio/audio-command.ts +218 -0
  30. package/src/audio/devices.ts +264 -0
  31. package/src/audio/doctor.ts +84 -13
  32. package/src/audio/engine.ts +216 -28
  33. package/src/commands/arrange.ts +60 -25
  34. package/src/commands/grammar.ts +3 -1
  35. package/src/commands/help.ts +187 -9
  36. package/src/commands/history.ts +122 -11
  37. package/src/commands/keys.ts +17 -0
  38. package/src/commands/parses.ts +6 -1
  39. package/src/commands/range.ts +763 -0
  40. package/src/commands/resample.ts +13 -10
  41. package/src/commands/sample.ts +11 -1
  42. package/src/commands/sing.ts +2 -6
  43. package/src/commands/vocal-pitch.ts +2 -1
  44. package/src/identity/actor.ts +127 -0
  45. package/src/lang/glossary.ts +18 -0
  46. package/src/launch-args.ts +105 -1
  47. package/src/main.ts +1142 -86
  48. package/src/session/client.ts +107 -3
  49. package/src/session/clipboard.ts +79 -0
  50. package/src/session/daemon.ts +196 -5
  51. package/src/session/live-host.ts +232 -0
  52. package/src/session/origin.ts +20 -0
  53. package/src/session/port.ts +87 -10
  54. package/src/session/protocol.ts +360 -6
  55. package/src/session/rebase.ts +46 -8
  56. package/src/session/receipt.ts +7 -0
  57. package/src/session/shared-live.ts +131 -0
  58. package/src/session/store.ts +61 -2
  59. package/src/tui/arrange-menu.ts +169 -1
  60. package/src/tui/euclid.ts +95 -14
  61. package/src/tui/fader.ts +55 -2
  62. package/src/tui/knob-fields.ts +107 -0
  63. package/src/tui/knob-map.ts +198 -0
  64. package/src/tui/menu.ts +228 -2
  65. package/src/tui/play-session.ts +196 -21
  66. package/src/tui/tape-mode.ts +580 -0
  67. package/src/tui/tape-view.ts +263 -0
  68. package/tui/app.ts +217 -44
  69. package/tui/delight.ts +49 -0
  70. package/tui/drawer.ts +167 -9
  71. package/tui/grammar.ts +34 -5
  72. package/tui/guide.ts +8 -1
  73. package/tui/highway.ts +40 -5
  74. package/tui/hints.ts +5 -0
  75. package/tui/hits.ts +7 -1
  76. package/tui/knobs.ts +268 -0
  77. package/tui/play-strip.ts +12 -0
  78. package/tui/screen.ts +7 -4
  79. package/tui/tape.ts +439 -0
  80. package/tui/text.ts +35 -2
  81. package/tui/theme.ts +19 -1
package/src/tui/fader.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  * field, so repeated nudges replace one staged edit and Enter keeps them
10
10
  * all as one revision.
11
11
  */
12
- import type { DrawerField, DrawerView } from "../../tui/drawer.ts";
12
+ import type { DrawerAside, DrawerField, DrawerView } from "../../tui/drawer.ts";
13
+ import type { KnobIndex } from "../../tui/knobs.ts";
13
14
  import {
14
15
  KEY_BACKSPACE,
15
16
  KEY_BACKTAB,
@@ -488,6 +489,14 @@ export const FADER_HINT =
488
489
  * The hint for a field the loop cannot stage (tempo, meter, loop length):
489
490
  * each step is a new revision, so there is nothing to keep or revert.
490
491
  */
492
+ /** The knob front page (design §8.6): the four knobs, then Tab for all. */
493
+ export const KNOB_FADER_HINT =
494
+ "↑↓ knob · ←→ turn · tab all · enter keep · ⇧ coarse · x reset · esc revert";
495
+
496
+ /** Every param behind Tab: Tab goes back to the four knobs. */
497
+ export const ALL_FADER_HINT =
498
+ "←→ adjust · ↑↓ param · tab knobs · enter keep · ⇧ coarse · x reset · esc revert";
499
+
491
500
  export const FADER_HINT_AT_ONCE =
492
501
  "←→ adjust · applies at once · x reset · 0-9 type · ↑↓ param · ⇧←→ coarse · [ ] fine · esc back";
493
502
 
@@ -511,6 +520,8 @@ export function drawerView(
511
520
  hint?: string;
512
521
  /** The focused field applies at once (cannot stage on the loop). */
513
522
  atOnce?: boolean | undefined;
523
+ /** The drawer has a knob page: which side of Tab is up. */
524
+ knobs?: "knobs" | "all" | undefined;
514
525
  },
515
526
  ): DrawerView {
516
527
  const before = new Map(committed.map((field) => [field.label, field]));
@@ -528,6 +539,7 @@ export function drawerView(
528
539
  return {
529
540
  kind: "choice",
530
541
  label: field.label,
542
+ knob: knobOf(field),
531
543
  options: field.options,
532
544
  index,
533
545
  committedIndex:
@@ -550,6 +562,7 @@ export function drawerView(
550
562
  return {
551
563
  kind: "number",
552
564
  label: field.label,
565
+ knob: knobOf(field),
553
566
  text,
554
567
  committed: changed ? wasText : undefined,
555
568
  position:
@@ -576,10 +589,50 @@ export function drawerView(
576
589
  dirty: options.dirty,
577
590
  badge: options.dirty ? stagedBadge(staged) : undefined,
578
591
  status: options.status,
579
- hint: options.hint ?? (options.atOnce ? FADER_HINT_AT_ONCE : FADER_HINT),
592
+ hint:
593
+ options.hint ??
594
+ (options.knobs
595
+ ? (options.knobs === "knobs" ? KNOB_FADER_HINT : ALL_FADER_HINT)
596
+ .replace(
597
+ "enter keep",
598
+ options.atOnce ? "applies at once" : "enter keep",
599
+ )
600
+ .replace("esc revert", options.atOnce ? "esc back" : "esc revert")
601
+ : options.atOnce
602
+ ? FADER_HINT_AT_ONCE
603
+ : FADER_HINT),
580
604
  };
581
605
  }
582
606
 
607
+ /**
608
+ * The drawer's side list: every param on the page with its value, each one
609
+ * a knob also drives wearing that knob (matched by label).
610
+ */
611
+ export function drawerAside(
612
+ page: readonly FaderSpec[],
613
+ knobs: readonly FaderSpec[],
614
+ ): DrawerAside[] {
615
+ const knobByLabel = new Map(
616
+ knobs.map((field) => [field.label, knobOf(field)] as const),
617
+ );
618
+ return page.map((field) => ({
619
+ label: field.label,
620
+ text:
621
+ field.kind === "choice"
622
+ ? field.value
623
+ : field.value === undefined
624
+ ? (field.off ?? "off")
625
+ : field.format(field.value),
626
+ knob: knobByLabel.get(field.label),
627
+ }));
628
+ }
629
+
630
+ /** A knob field's knob (0 blue … 3 orange); plain rows have none. */
631
+ function knobOf(field: FaderSpec): KnobIndex | undefined {
632
+ const knob = (field as FaderSpec & { knob?: KnobIndex }).knob;
633
+ return knob;
634
+ }
635
+
583
636
  /** `A/B: 1 change staged · enter keep · esc revert`. */
584
637
  export function stagedBadge(count: number): string {
585
638
  const n = Math.max(1, count);
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The four knobs of a page as fader fields: each path of a KNOB_MAPS row
3
+ * (src/tui/knob-map.ts) resolved in the live Ctrl-K tree, so a knob turns
4
+ * exactly what that menu row turns and runs the same typed command.
5
+ */
6
+ import type { KnobIndex } from "../../tui/knobs.ts";
7
+ import type { FaderSpec } from "./fader.ts";
8
+ import { knobMap, type KnobMap } from "./knob-map.ts";
9
+ import {
10
+ faderSpecs,
11
+ rootNodes,
12
+ type MenuContext,
13
+ type MenuNode,
14
+ } from "./menu.ts";
15
+
16
+ export type KnobField = FaderSpec & { knob: KnobIndex };
17
+
18
+ const norm = (text: string) => text.trim().toLowerCase();
19
+
20
+ function matches(node: MenuNode, segment: string): boolean {
21
+ const want = norm(segment);
22
+ if (want.endsWith("*"))
23
+ return node.kind === "menu" && norm(node.id).startsWith(want.slice(0, -1));
24
+ if (node.kind === "menu" && norm(node.id) === want) return true;
25
+ return norm(node.label) === want;
26
+ }
27
+
28
+ /** The row a knob path reaches from the root, or undefined. */
29
+ export function resolveKnobPath(
30
+ context: MenuContext,
31
+ path: string,
32
+ ): MenuNode | undefined {
33
+ let nodes: readonly MenuNode[] = rootNodes(context);
34
+ let found: MenuNode | undefined;
35
+ for (const segment of path.split("/")) {
36
+ found = nodes.find((node) => matches(node, segment));
37
+ if (!found) {
38
+ // Effects beyond the first few live under "more effects".
39
+ const more = nodes.find(
40
+ (node) => node.kind === "menu" && node.id === "more effects",
41
+ );
42
+ if (more?.kind === "menu")
43
+ found = more.build(context).find((node) => matches(node, segment));
44
+ }
45
+ if (!found) return undefined;
46
+ nodes = found.kind === "menu" ? found.build(context) : [];
47
+ }
48
+ return found;
49
+ }
50
+
51
+ /**
52
+ * The knob label: the row's own label, or `reverb mix` when the row lives in
53
+ * another effect than the page's (the mixer's reverb send).
54
+ */
55
+ function knobLabel(page: string, path: string, label: string): string {
56
+ const segments = path.split("/");
57
+ if (segments[0] === "effects" && segments.length >= 3) {
58
+ const effect = segments[1]!;
59
+ if (page !== `fx:${effect}`) return `${effect} ${label}`;
60
+ }
61
+ return label;
62
+ }
63
+
64
+ /**
65
+ * The four slots of `page` for `context`: a field per slot whose path
66
+ * resolves to a number or choice row, else undefined (drawn `·`, skipped).
67
+ * A page without a row (an effect not in the table) derives one from
68
+ * `fallback`: its first three number rows, and `mix` on orange.
69
+ */
70
+ export function knobSlots(
71
+ context: MenuContext,
72
+ page: string,
73
+ fallback: readonly FaderSpec[] = [],
74
+ ): (KnobField | undefined)[] {
75
+ const map: KnobMap | undefined = knobMap(page);
76
+ if (!map) {
77
+ const numbers = fallback.filter(
78
+ (field) => field.kind === "number" && field.label !== "mix",
79
+ );
80
+ const mix = fallback.find((field) => field.label === "mix");
81
+ return [numbers[0], numbers[1], numbers[2], mix].map((field, index) =>
82
+ field ? { ...field, knob: index as KnobIndex } : undefined,
83
+ );
84
+ }
85
+ return map.map((path, index) => {
86
+ if (!path || path.startsWith("@") || !path.includes("/")) return undefined;
87
+ const node = resolveKnobPath(context, path);
88
+ const spec = node ? faderSpecs([node])[0] : undefined;
89
+ if (!spec) return undefined;
90
+ return {
91
+ ...spec,
92
+ label: knobLabel(page, path, spec.label),
93
+ knob: index as KnobIndex,
94
+ } as KnobField;
95
+ });
96
+ }
97
+
98
+ /** The present knobs of `page`, in knob order: the drawer's front page. */
99
+ export function knobFields(
100
+ context: MenuContext,
101
+ page: string,
102
+ fallback: readonly FaderSpec[] = [],
103
+ ): KnobField[] {
104
+ return knobSlots(context, page, fallback).filter(
105
+ (field): field is KnobField => field !== undefined,
106
+ );
107
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Which value each of the four knobs turns, per page (design §7.3): one
3
+ * table, read by the fader drawer's front page, the mixer page and euclid.
4
+ * A row is four menu paths from the Ctrl-K root (`sound/attack`,
5
+ * `effects/reverb/mix`, `mix/volume`): segments match a submenu's id or a
6
+ * row's label, and `more effects` is searched on the way. An undefined slot
7
+ * shows as `·` and ↑↓ skips it.
8
+ *
9
+ * The rule (tui/knobs.ts): blue moves, green sizes, white shapes, orange
10
+ * level. Pages not in the table (an effect without a row) derive one: their
11
+ * first three number rows on blue, green and white, and `mix` on orange.
12
+ *
13
+ * Data, not code, so the patcher can map a patch's macros onto the same
14
+ * four knobs by adding rows (`patch:<id>`), and a test checks every path
15
+ * resolves for every instrument family. A `*` at the end of a segment
16
+ * matches the first submenu whose id starts with it (`sample:*`).
17
+ */
18
+ import { isWavetableInstrument, type Track } from "../../core/score.ts";
19
+ import { isDrumInstrument } from "../../core/drums.ts";
20
+ import {
21
+ isElectricFamily,
22
+ isKeysFamily,
23
+ isOrganFamily,
24
+ } from "../../core/keys.ts";
25
+ import { isStringTrack } from "../../core/strings.ts";
26
+ import { isGranularInstrument } from "../../core/granular.ts";
27
+
28
+ export type KnobRef = string | undefined;
29
+ export type KnobMap = readonly [KnobRef, KnobRef, KnobRef, KnobRef];
30
+
31
+ /**
32
+ * Pages, by id. `sound:<family>` rows use each engine's own param names (the
33
+ * rows its Sound menu shows); orange is the track level on every sound page,
34
+ * so ◆ always means "how loud".
35
+ */
36
+ export const KNOB_MAPS: Readonly<Record<string, KnobMap>> = Object.freeze({
37
+ "sound:synth": [
38
+ "sound/preset",
39
+ "sound/attack",
40
+ "sound/synth filter",
41
+ "mix/volume",
42
+ ],
43
+ "sound:wavetable": [
44
+ "sound/position (wt)",
45
+ "sound/attack",
46
+ "sound/synth filter",
47
+ "mix/volume",
48
+ ],
49
+ "sound:piano": [
50
+ "sound/preset",
51
+ "sound/decay",
52
+ "sound/hardness",
53
+ "mix/volume",
54
+ ],
55
+ "sound:electric": ["sound/preset", "sound/bell", "sound/tone", "mix/volume"],
56
+ "sound:organ": ["sound/preset", "sound/perc", "sound/drive", "mix/volume"],
57
+ "sound:string": [
58
+ "sound/preset",
59
+ "sound/ring s",
60
+ "sound/bright",
61
+ "mix/volume",
62
+ ],
63
+ "sound:wind": [
64
+ "sound/breath",
65
+ "sound/wind:advanced/vib",
66
+ "sound/bright",
67
+ "mix/volume",
68
+ ],
69
+ "sound:modal": [
70
+ "sound/position",
71
+ "sound/ring",
72
+ "sound/hardness",
73
+ "mix/volume",
74
+ ],
75
+ "sound:sing": ["sound/vowel", "sound/voices", "sound/bright", "mix/volume"],
76
+ "sound:granular": [
77
+ "sound/granular/position",
78
+ "sound/granular/grain (s)",
79
+ "sound/granular/spray (s)",
80
+ "mix/volume",
81
+ ],
82
+ // `sample:*` is the track's first sample voice.
83
+ "sound:sampler": [
84
+ "sound/sample:*/begin",
85
+ "sound/sample:*/len",
86
+ "sound/sample:*/speed",
87
+ "mix/volume",
88
+ ],
89
+ // A kit has no voice params: blue loosens the timing, green the room it
90
+ // plays in, white its tone.
91
+ "sound:kit": [
92
+ "sound/performance/humanize timing (ms)",
93
+ "effects/reverb/mix",
94
+ "effects/filter/cutoff",
95
+ "mix/volume",
96
+ ],
97
+ // The mixer page: this track's pan, reverb send, tone and level.
98
+ mix: ["mix/pan", "effects/reverb/mix", "effects/filter/cutoff", "mix/volume"],
99
+ master: [
100
+ "mix/master/master:eq/low",
101
+ "mix/master/master:eq/bell1",
102
+ "mix/master/master:eq/high",
103
+ "mix/master/target LUFS",
104
+ ],
105
+ tempo: [
106
+ "project/tempo",
107
+ "project/loop length",
108
+ "project/beats per bar",
109
+ undefined,
110
+ ],
111
+ // Effects: time or rate, feedback / size / depth, tone, mix.
112
+ "fx:filter": [
113
+ "effects/filter/type",
114
+ "effects/filter/resonance",
115
+ "effects/filter/cutoff",
116
+ undefined,
117
+ ],
118
+ "fx:delay": [
119
+ "effects/delay/beats",
120
+ "effects/delay/feedback",
121
+ undefined,
122
+ "effects/delay/mix",
123
+ ],
124
+ "fx:reverb": [
125
+ "effects/reverb/impulse (ir)",
126
+ "effects/reverb/size",
127
+ undefined,
128
+ "effects/reverb/mix",
129
+ ],
130
+ "fx:chorus": [
131
+ "effects/chorus/rate",
132
+ "effects/chorus/depth",
133
+ undefined,
134
+ "effects/chorus/mix",
135
+ ],
136
+ /**
137
+ * The Euclid editor (design §8.7): its row's own fields, not menu paths.
138
+ * Blue picks the drum row; ←→ on it moves between rows.
139
+ */
140
+ euclid: ["drum", "pulses", "rotate", "velocity"],
141
+ /**
142
+ * TAPE (design §7.3): its own values, not menu paths. Blue the playhead
143
+ * (a beat; ⇧ a bar), green the loop length, white the tempo of the
144
+ * segment under the playhead, orange the focused track's volume.
145
+ */
146
+ tape: ["playhead", "loop", "tempo", "volume"],
147
+ "fx:distort": [
148
+ undefined,
149
+ "effects/distort/drive",
150
+ "effects/distort/tone",
151
+ "effects/distort/mix",
152
+ ],
153
+ });
154
+
155
+ /** The sound page's family for a track: which engine its Sound menu shows. */
156
+ export function soundFamily(track: Track): string {
157
+ if (isDrumInstrument(track.instrument)) return "kit";
158
+ if (track.sampler) return "sampler";
159
+ if (isGranularInstrument(track.instrument)) return "granular";
160
+ if (isOrganFamily(track.instrument) && track.keys) return "organ";
161
+ if (isKeysFamily(track.instrument) && track.keys)
162
+ return isElectricFamily(track.instrument) ? "electric" : "piano";
163
+ if (isStringTrack(track)) return "string";
164
+ if (track.instrument === "modal") return "modal";
165
+ if (track.instrument === "wind" && track.wind) return "wind";
166
+ if (track.instrument === "sing" && track.sing) return "sing";
167
+ if (isWavetableInstrument(track.instrument)) return "wavetable";
168
+ return "synth";
169
+ }
170
+
171
+ /** The page id for `knobs <page>`, or undefined when no page has that name. */
172
+ export function knobPageId(
173
+ word: string | undefined,
174
+ track: Track | undefined,
175
+ ): string | undefined {
176
+ const name = (word ?? "sound").trim().toLowerCase().replace(/\s+/g, " ");
177
+ if (name === "sound" || name === "")
178
+ return `sound:${track ? soundFamily(track) : "synth"}`;
179
+ if (name === "mix" || name === "master" || name === "tempo") return name;
180
+ const fx = name.match(/^(?:fx|effect|effects)\s+(\S+)$/)?.[1];
181
+ if (fx) return `fx:${fx}`;
182
+ if (`sound:${name}` in KNOB_MAPS) return `sound:${name}`;
183
+ return undefined;
184
+ }
185
+
186
+ /** The page's row; an unmapped effect gets none (the menu derives one). */
187
+ export function knobMap(page: string): KnobMap | undefined {
188
+ return KNOB_MAPS[page];
189
+ }
190
+
191
+ /** The pages `knobs` names, for help and completion. */
192
+ export const KNOB_PAGE_WORDS = Object.freeze([
193
+ "sound",
194
+ "mix",
195
+ "master",
196
+ "tempo",
197
+ "fx <effect>",
198
+ ]);