@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
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The audio menu's one output and one input, saved per machine in
3
+ * `<config>/audio.json` (never in the project: a song moves between
4
+ * machines, its speakers do not). `DAWG_AUDIO_DEVICE` still overrides the
5
+ * output. Choosing a device needs the native sink, which lists and opens
6
+ * devices by name; sox can play on a named output (AUDIODEV) but cannot
7
+ * list them; ffplay and afplay always use the system default.
8
+ */
9
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { configDir } from "../auth/credentials.ts";
12
+ import type { AudioBackendInfo } from "./engine.ts";
13
+ import { NativeCapture, type SinkDevice, type SinkLibrary } from "./native.ts";
14
+
15
+ export type AudioChoice = Readonly<{
16
+ /** Output device name; absent is the system default. */
17
+ output?: string;
18
+ /** Input device name; absent is the system default. */
19
+ input?: string;
20
+ }>;
21
+
22
+ const MAX_NAME = 200;
23
+
24
+ export function audioChoicePath(
25
+ env: Readonly<Record<string, string | undefined>> = process.env,
26
+ ): string {
27
+ return join(configDir(env), "audio.json");
28
+ }
29
+
30
+ function cleanName(value: unknown): string | undefined {
31
+ if (typeof value !== "string") return undefined;
32
+ const name = value.trim();
33
+ return name && name.length <= MAX_NAME && name.toLowerCase() !== "default"
34
+ ? name
35
+ : undefined;
36
+ }
37
+
38
+ /** The saved choice; a missing or malformed file is the defaults. */
39
+ export function readAudioChoice(path = audioChoicePath()): AudioChoice {
40
+ try {
41
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
42
+ if (typeof parsed !== "object" || parsed === null) return {};
43
+ const record = parsed as Record<string, unknown>;
44
+ const output = cleanName(record.output);
45
+ const input = cleanName(record.input);
46
+ return { ...(output ? { output } : {}), ...(input ? { input } : {}) };
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ /** Save one side of the choice (undefined is the system default). */
53
+ export function writeAudioChoice(
54
+ side: "output" | "input",
55
+ name: string | undefined,
56
+ path = audioChoicePath(),
57
+ ): AudioChoice {
58
+ const current = readAudioChoice(path);
59
+ const next: Record<string, string> = { ...current };
60
+ const clean = cleanName(name);
61
+ if (clean) next[side] = clean;
62
+ else delete next[side];
63
+ mkdirSync(join(path, ".."), { recursive: true });
64
+ const temp = `${path}.${process.pid}.tmp`;
65
+ writeFileSync(temp, `${JSON.stringify({ version: 1, ...next }, null, 2)}\n`);
66
+ renameSync(temp, path);
67
+ return next;
68
+ }
69
+
70
+ /** The output the engine opens: DAWG_AUDIO_DEVICE, then the saved choice. */
71
+ export function resolveOutputDevice(
72
+ env: Readonly<Record<string, string | undefined>> = process.env,
73
+ choice: AudioChoice = readAudioChoice(audioChoicePath(env)),
74
+ ): string | undefined {
75
+ return cleanName(env.DAWG_AUDIO_DEVICE) ?? choice.output;
76
+ }
77
+
78
+ /** What the backend can do with a device choice. */
79
+ export type DeviceSupport =
80
+ | Readonly<{ kind: "native"; library: SinkLibrary }>
81
+ | Readonly<{ kind: "sox" }>
82
+ | Readonly<{ kind: "none"; reason: string }>;
83
+
84
+ export function deviceSupport(info: AudioBackendInfo): DeviceSupport {
85
+ if (info.backend === "native" && info.native)
86
+ return { kind: "native", library: info.native };
87
+ if (info.backend === "sox") return { kind: "sox" };
88
+ const why = info.nativeUnavailable ?? info.detail;
89
+ const player =
90
+ info.backend === "none"
91
+ ? "audio is off"
92
+ : `${info.backend} plays on the system default`;
93
+ return {
94
+ kind: "none",
95
+ reason: `device choice needs the native sink, which lists and opens devices by name · ${player} · native sink: ${why}`,
96
+ };
97
+ }
98
+
99
+ /** Device names on the native sink; empty when enumeration fails. */
100
+ export function listDevices(
101
+ library: SinkLibrary,
102
+ input: boolean,
103
+ ): SinkDevice[] {
104
+ try {
105
+ return library.devices(input);
106
+ } catch {
107
+ return [];
108
+ }
109
+ }
110
+
111
+ export type AudioCommand =
112
+ | Readonly<{ kind: "show" }>
113
+ | Readonly<{
114
+ kind: "set";
115
+ side: "output" | "input";
116
+ name: string | undefined;
117
+ }>
118
+ | Readonly<{ kind: "test" }>;
119
+
120
+ /**
121
+ * `audio`, `audio out <name|default>`, `audio in <name|default>`,
122
+ * `audio test`. Undefined when the words do not parse.
123
+ */
124
+ export function parseAudioCommand(text: string): AudioCommand | undefined {
125
+ const words = text.trim();
126
+ if (!words) return { kind: "show" };
127
+ if (/^test$/i.test(words)) return { kind: "test" };
128
+ const set = words.match(/^(out|output|in|input)\s+(.+)$/i);
129
+ if (!set) return undefined;
130
+ const name = set[2]!.trim();
131
+ if (name.length > MAX_NAME) return undefined;
132
+ return {
133
+ kind: "set",
134
+ side: set[1]!.toLowerCase().startsWith("o") ? "output" : "input",
135
+ name: name.toLowerCase() === "default" ? undefined : name,
136
+ };
137
+ }
138
+
139
+ /** Match a typed name to a listed device: exact, then case-insensitive, then prefix. */
140
+ export function matchDevice(
141
+ devices: readonly SinkDevice[],
142
+ name: string,
143
+ ): SinkDevice | undefined {
144
+ const lower = name.toLowerCase();
145
+ return (
146
+ devices.find((device) => device.name === name) ??
147
+ devices.find((device) => device.name.toLowerCase() === lower) ??
148
+ (() => {
149
+ const prefixed = devices.filter((device) =>
150
+ device.name.toLowerCase().startsWith(lower),
151
+ );
152
+ return prefixed.length === 1 ? prefixed[0] : undefined;
153
+ })()
154
+ );
155
+ }
156
+
157
+ /** `Speakers` or `default (Speakers)`. */
158
+ export function deviceLabel(
159
+ name: string | undefined,
160
+ devices: readonly SinkDevice[],
161
+ ): string {
162
+ if (name) return name;
163
+ const system = devices.find((device) => device.default);
164
+ return system ? `default (${system.name})` : "default";
165
+ }
166
+
167
+ const BLIP_SECONDS = 0.12;
168
+
169
+ /** A short, soft sine blip (or a test tone): interleaved stereo. */
170
+ export function blipSamples(
171
+ rate: number,
172
+ seconds = BLIP_SECONDS,
173
+ hz = 880,
174
+ gain = 0.12,
175
+ ): Float32Array {
176
+ const frames = Math.round(rate * seconds);
177
+ const out = new Float32Array(frames * 2);
178
+ const ramp = Math.max(1, Math.round(rate * 0.01));
179
+ for (let frame = 0; frame < frames; frame += 1) {
180
+ const envelope = Math.min(1, frame / ramp, (frames - 1 - frame) / ramp);
181
+ const value = Math.sin((2 * Math.PI * hz * frame) / rate) * gain * envelope;
182
+ out[frame * 2] = value;
183
+ out[frame * 2 + 1] = value;
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * Play `samples` once on `device` through its own short-lived stream, so
190
+ * the song's player is untouched. Resolves after the sound has played.
191
+ */
192
+ export async function playOnce(
193
+ library: SinkLibrary,
194
+ device: string | undefined,
195
+ samples: Float32Array,
196
+ rate = 48_000,
197
+ wait: (ms: number) => Promise<void> = (ms) => Bun.sleep(ms),
198
+ ): Promise<boolean> {
199
+ const endpoint = library.openOutput({
200
+ ...(device ? { device } : {}),
201
+ rate,
202
+ channels: 2,
203
+ bufferFrames: 0,
204
+ ringFrames: samples.length / 2 + rate,
205
+ });
206
+ if (!endpoint) return false;
207
+ try {
208
+ endpoint.write(samples);
209
+ await wait(Math.ceil((samples.length / 2 / rate) * 1000) + 80);
210
+ return true;
211
+ } finally {
212
+ endpoint.close();
213
+ }
214
+ }
215
+
216
+ /** Peak and RMS of interleaved samples, in dBFS (-Infinity for silence). */
217
+ export function inputLevel(samples: Float32Array): {
218
+ peakDb: number;
219
+ rmsDb: number;
220
+ } {
221
+ let peak = 0;
222
+ let sum = 0;
223
+ for (const sample of samples) {
224
+ const value = Math.abs(sample);
225
+ if (value > peak) peak = value;
226
+ sum += sample * sample;
227
+ }
228
+ const rms = samples.length > 0 ? Math.sqrt(sum / samples.length) : 0;
229
+ const db = (value: number) =>
230
+ value > 0 ? 20 * Math.log10(value) : Number.NEGATIVE_INFINITY;
231
+ return { peakDb: db(peak), rmsDb: db(rms) };
232
+ }
233
+
234
+ /** `▇▇▇▇▅▁▁▁▁▁ -18 dBFS`: -60…0 dBFS across ten cells. */
235
+ export function levelMeter(db: number, cells = 10): string {
236
+ if (!Number.isFinite(db)) return `${"▁".repeat(cells)} silent`;
237
+ const fill = Math.max(0, Math.min(1, (db + 60) / 60)) * cells;
238
+ let bar = "";
239
+ for (let cell = 0; cell < cells; cell += 1)
240
+ bar += fill >= cell + 1 ? "▇" : fill > cell + 0.5 ? "▅" : "▁";
241
+ return `${bar} ${Math.round(db)} dBFS`;
242
+ }
243
+
244
+ /** Capture `seconds` from the input and measure it. */
245
+ export async function measureInput(
246
+ library: SinkLibrary,
247
+ device: string | undefined,
248
+ seconds = 1,
249
+ rate = 48_000,
250
+ wait: (ms: number) => Promise<void> = (ms) => Bun.sleep(ms),
251
+ ): Promise<{ peakDb: number; rmsDb: number }> {
252
+ const capture = NativeCapture.open(library, {
253
+ rate,
254
+ channels: 1,
255
+ ...(device ? { device } : {}),
256
+ });
257
+ let samples: Float32Array;
258
+ try {
259
+ await wait(seconds * 1000);
260
+ } finally {
261
+ samples = capture.close();
262
+ }
263
+ return inputLevel(samples);
264
+ }
@@ -1,14 +1,21 @@
1
1
  /**
2
2
  * `dawg doctor`: how dawg makes sound on this machine. The backend and why
3
3
  * it was chosen, the native sink's verification (or why it fell back), the
4
- * audio devices, and the play-mode lead.
4
+ * audio devices, the output and input saved from the audio menu, and the
5
+ * play-mode lead.
5
6
  */
6
7
  import {
7
8
  AudioEngine,
8
9
  detectAudioBackend,
9
10
  type AudioBackendInfo,
10
11
  } from "./engine.ts";
11
- import { PREBUILT_DIR, nativeTarget } from "./native.ts";
12
+ import {
13
+ audioChoicePath,
14
+ matchDevice,
15
+ readAudioChoice,
16
+ type AudioChoice,
17
+ } from "./devices.ts";
18
+ import { PREBUILT_DIR, nativeTarget, type SinkDevice } from "./native.ts";
12
19
  import { DEFAULT_SAMPLE_RATE } from "./wav.ts";
13
20
 
14
21
  export type DoctorReport = Readonly<{
@@ -23,19 +30,59 @@ export type DoctorReport = Readonly<{
23
30
  playLeadMs: number;
24
31
  outputs: readonly string[];
25
32
  inputs: readonly string[];
33
+ /**
34
+ * The audio menu's saved choice (`audio out`/`audio in`): a name, or
35
+ * `default`; `missing` when the native sink lists devices and the saved
36
+ * one is not among them (playback falls back to the default).
37
+ */
38
+ chosen: Readonly<{
39
+ output: string;
40
+ input: string;
41
+ path: string;
42
+ /** `DAWG_AUDIO_DEVICE` overrides the saved output. */
43
+ override?: string;
44
+ missing?: readonly ("output" | "input")[];
45
+ }>;
46
+ }>;
47
+
48
+ export type DoctorOptions = Readonly<{
49
+ env?: Readonly<Record<string, string | undefined>>;
50
+ choice?: AudioChoice;
26
51
  }>;
27
52
 
28
- export function audioDoctor(info = detectAudioBackend()): DoctorReport {
53
+ export function audioDoctor(
54
+ info = detectAudioBackend(),
55
+ options: DoctorOptions = {},
56
+ ): DoctorReport {
57
+ const env = options.env ?? process.env;
58
+ const path = audioChoicePath(env);
59
+ const choice = options.choice ?? readAudioChoice(path);
29
60
  const engine = new AudioEngine({ info, worker: false, timer: false });
30
61
  const playLeadMs = engine.playLeadMs;
31
62
  void engine.dispose();
32
- const list = (input: boolean) =>
33
- info.native
34
- ?.devices(input)
35
- .map(
36
- (d) =>
37
- `${d.name}${d.default ? " (default)" : ""} · ${d.channels} ch · ${d.rate} Hz`,
38
- ) ?? [];
63
+ const devices = (input: boolean): SinkDevice[] | undefined => {
64
+ try {
65
+ return info.native?.devices(input);
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ };
70
+ const outputDevices = devices(false);
71
+ const inputDevices = devices(true);
72
+ const list = (found: readonly SinkDevice[] | undefined) =>
73
+ found?.map(
74
+ (d) =>
75
+ `${d.name}${d.default ? " (default)" : ""} · ${d.channels} ch · ${d.rate} Hz`,
76
+ ) ?? [];
77
+ const override = env.DAWG_AUDIO_DEVICE?.trim() || undefined;
78
+ const missing: ("output" | "input")[] = [];
79
+ const isMissing = (
80
+ name: string | undefined,
81
+ found: readonly SinkDevice[] | undefined,
82
+ ) => !!name && !!found && found.length > 0 && !matchDevice(found, name);
83
+ if (isMissing(override ?? choice.output, outputDevices))
84
+ missing.push("output");
85
+ if (isMissing(choice.input, inputDevices)) missing.push("input");
39
86
  return {
40
87
  backend: info.backend,
41
88
  detail: info.detail,
@@ -46,11 +93,34 @@ export function audioDoctor(info = detectAudioBackend()): DoctorReport {
46
93
  ...(info.nativeUnavailable ? { reason: info.nativeUnavailable } : {}),
47
94
  },
48
95
  playLeadMs,
49
- outputs: list(false),
50
- inputs: list(true),
96
+ outputs: list(outputDevices),
97
+ inputs: list(inputDevices),
98
+ chosen: {
99
+ output: choice.output ?? "default",
100
+ input: choice.input ?? "default",
101
+ path,
102
+ ...(override ? { override } : {}),
103
+ ...(missing.length > 0 ? { missing } : {}),
104
+ },
51
105
  };
52
106
  }
53
107
 
108
+ /** `chosen: output AirPods · input default`, with what overrides or is gone. */
109
+ function chosenLines(chosen: DoctorReport["chosen"]): string[] {
110
+ const missing = new Set(chosen.missing ?? []);
111
+ const side = (label: "output" | "input", name: string) =>
112
+ `${label} ${name}${missing.has(label) ? " (missing, using default)" : ""}`;
113
+ const lines = [
114
+ `chosen: ${side("output", chosen.override ?? chosen.output)} · ${side("input", chosen.input)} · ${chosen.path}`,
115
+ ];
116
+ if (chosen.override)
117
+ lines.push(
118
+ ` DAWG_AUDIO_DEVICE overrides the saved output (${chosen.output})`,
119
+ );
120
+ lines.push(" change it: audio out <name|default> · audio in <name|default>");
121
+ return lines;
122
+ }
123
+
54
124
  export function formatAudioDoctor(report: DoctorReport): string[] {
55
125
  const lines = [
56
126
  `audio: ${report.backend} · ${report.detail}`,
@@ -58,6 +128,7 @@ export function formatAudioDoctor(report: DoctorReport): string[] {
58
128
  ? `native sink: loaded (${report.native.target})`
59
129
  : `native sink: not in use · ${report.native.reason ?? "another backend was forced"} · falls back to ffplay, sox or afplay`,
60
130
  `play lead: ${report.playLeadMs} ms · render rate ${DEFAULT_SAMPLE_RATE} Hz`,
131
+ ...chosenLines(report.chosen),
61
132
  ];
62
133
  if (report.outputs.length > 0)
63
134
  lines.push("outputs:", ...report.outputs.map((d) => ` ${d}`));
@@ -72,7 +143,7 @@ export async function runDoctorCommand(
72
143
  ): Promise<number> {
73
144
  if (args.some((arg) => arg === "--help" || arg === "-h")) {
74
145
  stdout.write(
75
- "usage: dawg doctor [--json] audio backend, native sink, devices\n",
146
+ "usage: dawg doctor [--json] audio backend, native sink, devices, saved choice\n",
76
147
  );
77
148
  return 0;
78
149
  }