@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/tui/knobs.ts ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The four knobs (design §7, the OP-1's four colour encoders adapted): one
3
+ * table names each knob's colour, shape glyph and meaning, and every screen
4
+ * that shows knobs (the fader drawer's front page, the mixer page, euclid,
5
+ * and later TAPE and PLAY) reads it, so a knob looks and means the same
6
+ * everywhere.
7
+ *
8
+ * ● blue moves position, selection, which
9
+ * ▲ green sizes length, range, amount of steps
10
+ * ■ white shapes tone, time, character
11
+ * ◆ orange level loudness, mix, velocity
12
+ *
13
+ * Colour is never the only cue: each knob has a shape glyph, which is all
14
+ * that is left under NO_COLOR and the mono theme.
15
+ *
16
+ * Keys, the same on every screen: ↑↓ picks a knob (skipping empty slots),
17
+ * ←→ turns it, ⇧←→ turns it coarse, x resets it, enter opens it big.
18
+ * Every turn is a typed command, so the receipt teaches the words.
19
+ *
20
+ * Pure: the reducer returns what to run; the caller runs it.
21
+ */
22
+ import type { CellBuffer } from "./screen.ts";
23
+ import { displayWidth, truncate } from "./text.ts";
24
+ import type { SemanticRole, Style, Theme } from "./theme.ts";
25
+ import {
26
+ KEY_COARSE_LEFT,
27
+ KEY_COARSE_RIGHT,
28
+ KEY_DOWN,
29
+ KEY_ENTER,
30
+ KEY_LEFT,
31
+ KEY_RESET,
32
+ KEY_RIGHT,
33
+ KEY_UP,
34
+ } from "./grammar.ts";
35
+
36
+ export type KnobIndex = 0 | 1 | 2 | 3;
37
+
38
+ export type Knob = Readonly<{
39
+ index: KnobIndex;
40
+ colour: "blue" | "green" | "white" | "orange";
41
+ /** What this knob means on every screen. */
42
+ meaning: "moves" | "sizes" | "shapes" | "level";
43
+ role: Extract<SemanticRole, "knob1" | "knob2" | "knob3" | "knob4">;
44
+ /** Shape glyph (width 1), so colour is never the only cue. */
45
+ glyph: string;
46
+ /** The glyph where the terminal has no Unicode. */
47
+ ascii: string;
48
+ }>;
49
+
50
+ /** The one table: glyphs, colours and meanings of the four knobs. */
51
+ export const KNOBS: readonly [Knob, Knob, Knob, Knob] = Object.freeze([
52
+ {
53
+ index: 0,
54
+ colour: "blue",
55
+ meaning: "moves",
56
+ role: "knob1",
57
+ glyph: "●",
58
+ ascii: "o",
59
+ },
60
+ {
61
+ index: 1,
62
+ colour: "green",
63
+ meaning: "sizes",
64
+ role: "knob2",
65
+ glyph: "▲",
66
+ ascii: "^",
67
+ },
68
+ {
69
+ index: 2,
70
+ colour: "white",
71
+ meaning: "shapes",
72
+ role: "knob3",
73
+ glyph: "■",
74
+ ascii: "#",
75
+ },
76
+ {
77
+ index: 3,
78
+ colour: "orange",
79
+ meaning: "level",
80
+ role: "knob4",
81
+ glyph: "◆",
82
+ ascii: "*",
83
+ },
84
+ ] as const);
85
+
86
+ export function knobGlyph(index: KnobIndex, unicode: boolean): string {
87
+ const knob = KNOBS[index];
88
+ return unicode ? knob.glyph : knob.ascii;
89
+ }
90
+
91
+ export function knobStyle(index: KnobIndex, theme: Theme): Style {
92
+ return theme.roles[KNOBS[index].role];
93
+ }
94
+
95
+ /** The footer hint of a knob page, the same words on every screen. */
96
+ export const KNOB_HINT =
97
+ "↑↓ knob · ←→ turn · ⇧ coarse · x reset · tab more · esc close";
98
+
99
+ /** One knob slot as a screen shows and turns it. */
100
+ export type KnobSlot = Readonly<{
101
+ label: string;
102
+ /** Formatted value: `1200 Hz`, `saw`, `−4.9 dB`. */
103
+ text: string;
104
+ /** 0…1 along the gauge; undefined while off or for a choice. */
105
+ position?: number | undefined;
106
+ /** The command one turn runs, or undefined at the end of the range. */
107
+ turn: (direction: 1 | -1, coarse: boolean) => string | undefined;
108
+ /** The command `x` runs, when the slot has a default. */
109
+ reset?: string | undefined;
110
+ }>;
111
+
112
+ /** Four slots; an undefined one draws as `·` and ↑↓ skips it. */
113
+ export type KnobSlots = readonly [
114
+ KnobSlot | undefined,
115
+ KnobSlot | undefined,
116
+ KnobSlot | undefined,
117
+ KnobSlot | undefined,
118
+ ];
119
+
120
+ export type KnobState = { selected: KnobIndex };
121
+
122
+ export type KnobResult =
123
+ | { type: "handled" }
124
+ | { type: "pass" }
125
+ | { type: "run"; command: string }
126
+ | { type: "open"; knob: KnobIndex };
127
+
128
+ /**
129
+ * The next knob from `from` in `direction`, wrapping blue → green → white →
130
+ * orange and skipping empty slots; `from` itself when every slot is empty.
131
+ */
132
+ export function nextKnob(
133
+ from: KnobIndex,
134
+ direction: 1 | -1,
135
+ present: (index: KnobIndex) => boolean,
136
+ ): KnobIndex {
137
+ for (let step = 1; step <= 4; step += 1) {
138
+ const index = (((from + direction * step) % 4) + 4) % 4;
139
+ if (present(index as KnobIndex)) return index as KnobIndex;
140
+ }
141
+ return from;
142
+ }
143
+
144
+ /** The first non-empty slot, for opening on a page. */
145
+ export function firstKnob(slots: KnobSlots): KnobIndex {
146
+ const at = slots.findIndex(Boolean);
147
+ return (at < 0 ? 0 : at) as KnobIndex;
148
+ }
149
+
150
+ /** One key on a knob strip. */
151
+ export function knobKey(
152
+ state: KnobState,
153
+ slots: KnobSlots,
154
+ value: string,
155
+ ): KnobResult {
156
+ if (!slots[state.selected]) state.selected = firstKnob(slots);
157
+ const up = KEY_UP.has(value);
158
+ if (up || KEY_DOWN.has(value)) {
159
+ state.selected = nextKnob(state.selected, up ? -1 : 1, (index) =>
160
+ Boolean(slots[index]),
161
+ );
162
+ return { type: "handled" };
163
+ }
164
+ const slot = slots[state.selected];
165
+ if (!slot) return { type: "pass" };
166
+ const coarseLeft = KEY_COARSE_LEFT.has(value);
167
+ const coarseRight = KEY_COARSE_RIGHT.has(value);
168
+ if (
169
+ coarseLeft ||
170
+ coarseRight ||
171
+ KEY_LEFT.has(value) ||
172
+ KEY_RIGHT.has(value)
173
+ ) {
174
+ const direction = coarseRight || KEY_RIGHT.has(value) ? 1 : -1;
175
+ const command = slot.turn(direction, coarseLeft || coarseRight);
176
+ return command ? { type: "run", command } : { type: "handled" };
177
+ }
178
+ if (KEY_RESET.has(value))
179
+ return slot.reset
180
+ ? { type: "run", command: slot.reset }
181
+ : { type: "handled" };
182
+ if (KEY_ENTER.has(value)) return { type: "open", knob: state.selected };
183
+ return { type: "pass" };
184
+ }
185
+
186
+ /**
187
+ * `●›cutoff 1.2k ▲ q 0.7 ■ type lowpass ◆ mix 0.3`: the strip as plain
188
+ * text, for pickers and receipts. The selected knob carries `›` (`>`).
189
+ */
190
+ export function knobStripText(
191
+ slots: KnobSlots,
192
+ selected: KnobIndex,
193
+ unicode: boolean,
194
+ ): string {
195
+ return slots
196
+ .map((slot, index) => {
197
+ const knob = index as KnobIndex;
198
+ const mark = knob === selected ? (unicode ? "›" : ">") : " ";
199
+ const glyph = knobGlyph(knob, unicode);
200
+ return slot ? `${glyph}${mark}${slot.label} ${slot.text}` : `${glyph} ·`;
201
+ })
202
+ .join(" ");
203
+ }
204
+
205
+ /**
206
+ * Paint a one-row knob strip at (x, y) within `width`: each slot is its
207
+ * glyph in its knob colour, the label, the value and a short gauge. The
208
+ * selected slot's label is reverse video. Returns the cells used.
209
+ */
210
+ /** The widest one knob-strip slot gets (label, value and gauge). */
211
+ export const KNOB_CELL_MAX = 40;
212
+
213
+ export function paintKnobStrip(
214
+ buffer: CellBuffer,
215
+ x: number,
216
+ y: number,
217
+ width: number,
218
+ slots: KnobSlots,
219
+ selected: KnobIndex,
220
+ options: { theme: Theme; unicode: boolean; background?: Style },
221
+ ): number {
222
+ // Past KNOB_CELL_MAX a slot's gauge only stretches; the strip stays
223
+ // left-aligned and the rest of the row is free.
224
+ const cell = Math.min(KNOB_CELL_MAX, Math.floor(width / 4));
225
+ if (cell < 6) return 0;
226
+ const background = options.background ?? {};
227
+ const on = (style: Style): Style => ({ ...background, ...style });
228
+ slots.forEach((slot, index) => {
229
+ const knob = index as KnobIndex;
230
+ const left = x + cell * index;
231
+ const style = knobStyle(knob, options.theme);
232
+ buffer.text(left, y, knobGlyph(knob, options.unicode), on(style));
233
+ if (!slot) {
234
+ buffer.text(left + 2, y, "·", on(options.theme.roles.faint));
235
+ return;
236
+ }
237
+ const label = truncate(`${slot.label} ${slot.text}`, cell - 3);
238
+ buffer.text(
239
+ left + 2,
240
+ y,
241
+ label,
242
+ on(
243
+ knob === selected
244
+ ? { ...options.theme.roles.text, reverse: true }
245
+ : options.theme.roles.text,
246
+ ),
247
+ );
248
+ const gauge = cell - 3 - displayWidth(label) - 1;
249
+ if (slot.position !== undefined && gauge >= 3) {
250
+ const filled = Math.round(
251
+ Math.min(1, Math.max(0, slot.position)) * gauge,
252
+ );
253
+ buffer.text(
254
+ left + 3 + displayWidth(label),
255
+ y,
256
+ (options.unicode ? "█" : "=").repeat(filled),
257
+ on(style),
258
+ );
259
+ buffer.text(
260
+ left + 3 + displayWidth(label) + filled,
261
+ y,
262
+ (options.unicode ? "─" : "-").repeat(gauge - filled),
263
+ on(options.theme.roles.faint),
264
+ );
265
+ }
266
+ });
267
+ return cell * 4;
268
+ }
package/tui/play-strip.ts CHANGED
@@ -34,6 +34,8 @@ export type PlayHeaderView = Readonly<{
34
34
  /** Recording right now (armed and the transport is running). */
35
35
  recording: boolean;
36
36
  replace: boolean;
37
+ /** Loop recording: `↻ 5–6 · pass 3`. */
38
+ pass?: string | undefined;
37
39
  click: boolean;
38
40
  sustain: boolean;
39
41
  /** `count-in 3` while counting in. */
@@ -67,6 +69,11 @@ export function playHeaderText(view: PlayHeaderView, unicode = true): string {
67
69
  view.armed
68
70
  ? `${unicode ? "●" : "*"} ${view.recording ? "REC" : "rec armed"}${view.replace ? " replace" : ""}`
69
71
  : "",
72
+ view.pass
73
+ ? unicode
74
+ ? view.pass
75
+ : view.pass.replace("↻", "@").replace("–", "-")
76
+ : "",
70
77
  view.click ? "click" : "",
71
78
  view.sustain ? "SUSTAIN" : "",
72
79
  view.chords ?? "",
@@ -109,6 +116,11 @@ export function paintPlayHeader(
109
116
  `${unicode ? "●" : "*"} ${view.recording ? "REC" : "rec armed"}${view.replace ? " replace" : ""}`,
110
117
  view.recording ? roles.error : roles.warning,
111
118
  );
119
+ if (view.pass)
120
+ put(
121
+ unicode ? view.pass : view.pass.replace("↻", "@").replace("–", "-"),
122
+ roles.knob2,
123
+ );
112
124
  // Velocity, grid and click details live in the `?` panel.
113
125
  if (view.click) put("click", roles.success);
114
126
  if (view.sustain) put("SUSTAIN", roles.pillQueue);
package/tui/screen.ts CHANGED
@@ -33,10 +33,10 @@ export class CellBuffer {
33
33
  constructor(width: number, height: number, fill: Style = EMPTY) {
34
34
  this.width = Math.max(0, Math.floor(width));
35
35
  this.height = Math.max(0, Math.floor(height));
36
- this.cells = Array.from({ length: this.width * this.height }, () => ({
37
- ch: " ",
38
- style: fill,
39
- }));
36
+ const count = this.width * this.height;
37
+ this.cells = new Array<Cell>(count);
38
+ for (let index = 0; index < count; index += 1)
39
+ this.cells[index] = { ch: " ", style: fill };
40
40
  }
41
41
 
42
42
  get(x: number, y: number): Cell | undefined {
@@ -200,6 +200,9 @@ export class CellBuffer {
200
200
  private clearWide(x: number, y: number): void {
201
201
  const offset = y * this.width;
202
202
  const cell = this.cells[offset + x]!;
203
+ // Fast path: one UTF-16 unit below U+1100 is never wide (nor a wide
204
+ // character's empty trail), and it is nearly every cell of a frame.
205
+ if (cell.ch.length === 1 && cell.ch.charCodeAt(0) < 0x1100) return;
203
206
  if (cell.ch === "" && x > 0) {
204
207
  const lead = this.cells[offset + x - 1]!;
205
208
  if (graphemeWidth(lead.ch) === 2) lead.ch = " ";