@hraness/dawg 0.0.0-stage → 0.3.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 (113) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +327 -0
  3. package/LICENSE +21 -0
  4. package/README.md +213 -2
  5. package/core/diff.ts +249 -0
  6. package/core/drums.ts +102 -0
  7. package/core/key.ts +43 -0
  8. package/core/loop.ts +78 -0
  9. package/core/pitch.ts +60 -0
  10. package/core/score.ts +1388 -0
  11. package/core/sdk/eval-child.ts +113 -0
  12. package/core/sdk/eval.ts +257 -0
  13. package/core/sdk/print.ts +393 -0
  14. package/core/sdk/v1.ts +954 -0
  15. package/core/slug.ts +19 -0
  16. package/package.json +45 -4
  17. package/src/agent/agent.ts +853 -0
  18. package/src/agent/brief.ts +160 -0
  19. package/src/agent/gateway.ts +441 -0
  20. package/src/agent/models.ts +633 -0
  21. package/src/agent/ops.ts +157 -0
  22. package/src/agent/planner.ts +259 -0
  23. package/src/agent/provider.ts +454 -0
  24. package/src/agent/sse.ts +114 -0
  25. package/src/agent/tools.ts +1373 -0
  26. package/src/agent/usage.ts +296 -0
  27. package/src/agent/workspace.ts +683 -0
  28. package/src/agent/xcb-agent.ts +262 -0
  29. package/src/agent/xcb.ts +579 -0
  30. package/src/audio/click.ts +125 -0
  31. package/src/audio/clock.ts +68 -0
  32. package/src/audio/engine.ts +841 -0
  33. package/src/audio/live.ts +152 -0
  34. package/src/audio/lock.ts +57 -0
  35. package/src/audio/player.ts +134 -0
  36. package/src/audio/render-worker.ts +68 -0
  37. package/src/audio/renderer.ts +174 -0
  38. package/src/audio/sampler.ts +292 -0
  39. package/src/audio/samples.ts +683 -0
  40. package/src/audio/wav.ts +861 -0
  41. package/src/auth/cli.ts +231 -0
  42. package/src/auth/credentials.ts +411 -0
  43. package/src/auth/discover.ts +481 -0
  44. package/src/auth/login.ts +1191 -0
  45. package/src/auth/openrouter.ts +206 -0
  46. package/src/auth/picker.ts +282 -0
  47. package/src/auth/runner.ts +207 -0
  48. package/src/auth/tui.ts +107 -0
  49. package/src/commands/edit.ts +170 -0
  50. package/src/commands/help.ts +247 -0
  51. package/src/commands/history.ts +69 -0
  52. package/src/commands/music.ts +461 -0
  53. package/src/commands/sample.ts +302 -0
  54. package/src/daemon.ts +31 -0
  55. package/src/main.ts +2209 -0
  56. package/src/media/analyze.ts +364 -0
  57. package/src/media/backend.ts +253 -0
  58. package/src/media/cli.ts +173 -0
  59. package/src/media/download.ts +281 -0
  60. package/src/media/dsp.ts +281 -0
  61. package/src/media/import.ts +130 -0
  62. package/src/media/lyrics.ts +201 -0
  63. package/src/media/notes.ts +363 -0
  64. package/src/media/paths.ts +168 -0
  65. package/src/media/process.ts +226 -0
  66. package/src/media/registry.ts +9 -0
  67. package/src/media/sidecar.ts +72 -0
  68. package/src/media/stemdeck.ts +254 -0
  69. package/src/media/stems.ts +173 -0
  70. package/src/media/tools.ts +292 -0
  71. package/src/media/types.ts +92 -0
  72. package/src/media/vendor/basic-pitch.ts +261 -0
  73. package/src/media/vendor/drums.ts +817 -0
  74. package/src/media/vendor/grid.ts +203 -0
  75. package/src/media/vendor/util.ts +139 -0
  76. package/src/media/vendor/wav.ts +233 -0
  77. package/src/project/check.ts +80 -0
  78. package/src/project/init.ts +253 -0
  79. package/src/project/sync.ts +432 -0
  80. package/src/project/typecheck.ts +149 -0
  81. package/src/render.ts +121 -0
  82. package/src/session/attach.ts +181 -0
  83. package/src/session/client.ts +498 -0
  84. package/src/session/daemon.ts +740 -0
  85. package/src/session/delta.ts +249 -0
  86. package/src/session/list.ts +180 -0
  87. package/src/session/lock.ts +92 -0
  88. package/src/session/meta.ts +253 -0
  89. package/src/session/naming.ts +430 -0
  90. package/src/session/port.ts +481 -0
  91. package/src/session/presence.ts +159 -0
  92. package/src/session/protocol.ts +618 -0
  93. package/src/session/rebase.ts +168 -0
  94. package/src/session/store.ts +581 -0
  95. package/src/tui/menu.ts +1083 -0
  96. package/src/tui/play-mode.ts +442 -0
  97. package/src/tui/play-session.ts +636 -0
  98. package/src/web/fetch.ts +340 -0
  99. package/src/web/http.ts +137 -0
  100. package/src/web/search.ts +681 -0
  101. package/tui/activity.ts +364 -0
  102. package/tui/app.ts +1372 -0
  103. package/tui/drums.ts +65 -0
  104. package/tui/highway.ts +921 -0
  105. package/tui/input.ts +63 -0
  106. package/tui/keys.ts +102 -0
  107. package/tui/layers.ts +80 -0
  108. package/tui/play-strip.ts +143 -0
  109. package/tui/prompt.ts +609 -0
  110. package/tui/render.ts +124 -0
  111. package/tui/screen.ts +247 -0
  112. package/tui/text.ts +72 -0
  113. package/tui/theme.ts +451 -0
@@ -0,0 +1,579 @@
1
+ import type { CommandRunner } from "../auth/runner.ts";
2
+
3
+ /**
4
+ * Client for xcb's application API (`xcb --json generate`): one prompt in, one
5
+ * untrusted text reply out, zero tools, no streaming. See
6
+ * https://github.com/hraness/xcb/blob/main/docs/application-api.md
7
+ */
8
+ export const XCB_DOC_URL =
9
+ "https://github.com/hraness/xcb/blob/main/docs/application-api.md";
10
+ export const XCB_INSTALL = "curl -fsSL https://xcb.sh/install.sh | sh";
11
+ export const XCB_LIMITS = Object.freeze({
12
+ maxInputBytes: 1024 * 1024,
13
+ maxOutputBytes: 262_144,
14
+ minTimeoutMs: 1_000,
15
+ maxTimeoutMs: 300_000,
16
+ maxCapabilityBytes: 2 * 1024 * 1024,
17
+ maxAccounts: 128,
18
+ maxModelsPerAccount: 64,
19
+ });
20
+ const ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
21
+ const MODEL_PATTERN = /^[A-Za-z0-9._:/-]{1,200}$/;
22
+ const FAILURE_CODES = new Set([
23
+ "invalid_request",
24
+ "unavailable",
25
+ "busy",
26
+ "deadline",
27
+ "cancelled",
28
+ "provider_error",
29
+ "output_limit",
30
+ "custody_unproven",
31
+ ]);
32
+
33
+ export class XcbError extends Error {
34
+ constructor(
35
+ message: string,
36
+ readonly code: string = "provider_error",
37
+ ) {
38
+ super(message);
39
+ this.name = "XcbError";
40
+ }
41
+ }
42
+
43
+ export type XcbModel = Readonly<{
44
+ key: string;
45
+ label: string;
46
+ admission?: XcbAdmission;
47
+ }>;
48
+ export type XcbAccount = Readonly<{
49
+ id: string;
50
+ label: string;
51
+ provider: string;
52
+ available: boolean;
53
+ connected: boolean;
54
+ reason: string | null;
55
+ models: readonly XcbModel[];
56
+ /**
57
+ * xcb 0.20+: the best admission among the account's models. `pending`
58
+ * means the first call checks it (up to ~60 s slower). Absent when the
59
+ * account is unavailable or on older xcb builds.
60
+ */
61
+ admission?: XcbAdmission | undefined;
62
+ }>;
63
+ export type XcbCapabilities = Readonly<{
64
+ supported: boolean;
65
+ accounts: readonly XcbAccount[];
66
+ }>;
67
+
68
+ /** `XCB_BIN` if set, otherwise `xcb` on PATH. Never installs anything. */
69
+ export function resolveXcbBin(
70
+ env: Readonly<Record<string, string | undefined>>,
71
+ runner: CommandRunner,
72
+ ): string | undefined {
73
+ const explicit = env.XCB_BIN?.trim();
74
+ if (explicit) return explicit;
75
+ return runner.which("xcb");
76
+ }
77
+
78
+ function shortString(value: unknown, max: number, fallback = ""): string {
79
+ return typeof value === "string"
80
+ ? value.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, max)
81
+ : fallback;
82
+ }
83
+
84
+ /**
85
+ * xcb 0.20+ admission (docs/application-api.md): `pending` means the next
86
+ * `generate` checks the model first and is slower but usable; `admitted` and
87
+ * `qualified` are checked. It is `null` whenever the account has a `reason`,
88
+ * so admission alone never makes an account usable: `available` does.
89
+ */
90
+ export type XcbAdmission = "pending" | "admitted" | "qualified";
91
+ const ADMISSION_STATES = new Set<string>(["pending", "admitted", "qualified"]);
92
+
93
+ function admissionState(value: unknown): XcbAdmission | null | undefined {
94
+ if (value === null) return null;
95
+ return typeof value === "string" && ADMISSION_STATES.has(value)
96
+ ? (value as XcbAdmission)
97
+ : undefined;
98
+ }
99
+
100
+ /** The first xcb release with automatic application admission. */
101
+ export const XCB_MIN_VERSION = "0.20.0";
102
+
103
+ /**
104
+ * What a capability `reason` means for the user, with the next step. Unknown
105
+ * reasons stay unavailable with the raw code.
106
+ */
107
+ export function describeReason(reason: string | null): string {
108
+ switch (reason) {
109
+ case null:
110
+ return "ready";
111
+ case "application_disabled":
112
+ return "app access turned off; run `xcb application enable`";
113
+ case "authentication_required":
114
+ return "signed out; run `xcb accounts login <account>`";
115
+ case "not_connected":
116
+ return "not signed in";
117
+ case "account_disabled":
118
+ return "disabled in xcb";
119
+ case "account_busy":
120
+ return "busy (run limit reached); try again shortly";
121
+ case "runtime_unavailable":
122
+ return "provider runtime unavailable; run `xcb doctor`";
123
+ case "sandbox_unproven":
124
+ return "xcb could not confirm the provider sandbox; run `xcb doctor`";
125
+ case "admission_failed":
126
+ return "automatic check failed; retry after 15 minutes";
127
+ case "models_unavailable":
128
+ return "no models listed; run `xcb accounts refresh <account>`";
129
+ case "application_not_qualified":
130
+ return "needs xcb 0.20.0+ (upgrade xcb)";
131
+ default:
132
+ return `unavailable (${reason})`;
133
+ }
134
+ }
135
+
136
+ /** Parse `xcb --json generate --capabilities` output from `unknown`. */
137
+ export function parseCapabilities(value: unknown): XcbCapabilities {
138
+ if (!isRecord(value) || value.version !== 1)
139
+ throw new XcbError("xcb capabilities were not a version-1 object");
140
+ const rows = Array.isArray(value.accounts)
141
+ ? value.accounts.slice(0, XCB_LIMITS.maxAccounts)
142
+ : [];
143
+ const accounts: XcbAccount[] = [];
144
+ for (const row of rows) {
145
+ if (
146
+ !isRecord(row) ||
147
+ typeof row.id !== "string" ||
148
+ !ID_PATTERN.test(row.id)
149
+ )
150
+ continue;
151
+ const models: XcbModel[] = [];
152
+ const modelRows = Array.isArray(row.models)
153
+ ? row.models.slice(0, XCB_LIMITS.maxModelsPerAccount)
154
+ : [];
155
+ for (const model of modelRows) {
156
+ if (
157
+ !isRecord(model) ||
158
+ typeof model.key !== "string" ||
159
+ !MODEL_PATTERN.test(model.key)
160
+ )
161
+ continue;
162
+ // xcb 0.20+ lists every model with an admission; `null` means this
163
+ // account cannot use it. Older builds omit the field (all listed usable).
164
+ const admission = admissionState(model.admission);
165
+ if (admission === null) continue;
166
+ models.push({
167
+ key: model.key,
168
+ label: shortString(model.label, 64, model.key) || model.key,
169
+ ...(admission ? { admission } : {}),
170
+ });
171
+ }
172
+ const label =
173
+ shortString(row.label, 64) ||
174
+ shortString(row.name, 64) ||
175
+ shortString(row.email, 64) ||
176
+ row.id;
177
+ const reason =
178
+ typeof row.reason === "string" ? shortString(row.reason, 64) : null;
179
+ const admission = admissionState(row.admission);
180
+ accounts.push({
181
+ id: row.id,
182
+ label,
183
+ provider: shortString(row.provider, 32, "unknown") || "unknown",
184
+ // Usable iff xcb says so and it lists a model to call.
185
+ available: row.available === true && reason === null && models.length > 0,
186
+ ...(admission ? { admission } : {}),
187
+ connected: row.connected === true,
188
+ reason,
189
+ models,
190
+ });
191
+ }
192
+ return { supported: value.supported === true, accounts };
193
+ }
194
+
195
+ export async function readCapabilities(
196
+ bin: string,
197
+ runner: CommandRunner,
198
+ signal?: AbortSignal,
199
+ ): Promise<XcbCapabilities> {
200
+ const result = await runner.run(
201
+ bin,
202
+ ["--json", "generate", "--capabilities"],
203
+ {
204
+ timeoutMs: 15_000,
205
+ maxOutputBytes: XCB_LIMITS.maxCapabilityBytes,
206
+ ...(signal ? { signal } : {}),
207
+ },
208
+ );
209
+ if (result.code !== 0)
210
+ throw new XcbError(
211
+ `xcb capabilities failed (exit ${result.code})`,
212
+ "unavailable",
213
+ );
214
+ let parsed: unknown;
215
+ try {
216
+ parsed = JSON.parse(result.stdout);
217
+ } catch {
218
+ throw new XcbError("xcb capabilities were not valid JSON");
219
+ }
220
+ return parseCapabilities(parsed);
221
+ }
222
+
223
+ /** One row of `xcb --json accounts`: sign-in state, no secrets. */
224
+ export type XcbAccountRow = Readonly<{
225
+ id: string;
226
+ label: string;
227
+ provider: string;
228
+ enabled: boolean;
229
+ authenticationRequired: boolean;
230
+ }>;
231
+
232
+ /** Parse `xcb --json accounts` from `unknown`; unknown rows are skipped. */
233
+ export function parseAccountRows(value: unknown): XcbAccountRow[] {
234
+ if (!isRecord(value) || !Array.isArray(value.accounts)) return [];
235
+ const rows: XcbAccountRow[] = [];
236
+ for (const row of value.accounts.slice(0, XCB_LIMITS.maxAccounts)) {
237
+ if (
238
+ !isRecord(row) ||
239
+ typeof row.id !== "string" ||
240
+ !ID_PATTERN.test(row.id)
241
+ )
242
+ continue;
243
+ rows.push({
244
+ id: row.id,
245
+ label: shortString(row.name, 64) || shortString(row.email, 64) || row.id,
246
+ provider: shortString(row.provider, 32, "unknown") || "unknown",
247
+ enabled: row.enabled !== false,
248
+ authenticationRequired: row.authenticationRequired === true,
249
+ });
250
+ }
251
+ return rows;
252
+ }
253
+
254
+ /** `xcb --json accounts`, read-only. Empty on any failure. */
255
+ export async function readAccountRows(
256
+ bin: string,
257
+ runner: CommandRunner,
258
+ signal?: AbortSignal,
259
+ ): Promise<XcbAccountRow[]> {
260
+ const result = await runner
261
+ .run(bin, ["--json", "accounts"], {
262
+ timeoutMs: 15_000,
263
+ maxOutputBytes: XCB_LIMITS.maxCapabilityBytes,
264
+ ...(signal ? { signal } : {}),
265
+ })
266
+ .catch(() => undefined);
267
+ if (!result || result.code !== 0) return [];
268
+ try {
269
+ return parseAccountRows(JSON.parse(result.stdout));
270
+ } catch {
271
+ return [];
272
+ }
273
+ }
274
+
275
+ export type XcbGenerateRequest = Readonly<{
276
+ bin: string;
277
+ runner: CommandRunner;
278
+ account: string;
279
+ model: string;
280
+ prompt: string;
281
+ timeoutMs: number;
282
+ maxOutputBytes: number;
283
+ signal?: AbortSignal;
284
+ }>;
285
+
286
+ /** Extra child-process time for xcb's first-call admission check. */
287
+ export const XCB_ADMISSION_GRACE_MS = 75_000;
288
+ /** Backoff before retrying a `busy` result (two first calls on one account). */
289
+ export const XCB_BUSY_BACKOFF_MS = Object.freeze([2_000, 6_000]);
290
+
291
+ /**
292
+ * `xcbGenerate`, retried with backoff while xcb answers `busy` (e.g. the
293
+ * account is mid-admission for another caller). Other failures pass through.
294
+ */
295
+ export async function xcbGenerateWithRetry(
296
+ request: XcbGenerateRequest & {
297
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
298
+ },
299
+ ): Promise<string> {
300
+ const sleep = request.sleep ?? abortableSleep;
301
+ for (let attempt = 0; ; attempt += 1) {
302
+ try {
303
+ return await xcbGenerate(request);
304
+ } catch (error) {
305
+ const delay = XCB_BUSY_BACKOFF_MS[attempt];
306
+ if (
307
+ !(error instanceof XcbError) ||
308
+ error.code !== "busy" ||
309
+ delay === undefined ||
310
+ request.signal?.aborted
311
+ )
312
+ throw error;
313
+ await sleep(delay, request.signal);
314
+ if (request.signal?.aborted) throw new XcbError("cancelled", "cancelled");
315
+ }
316
+ }
317
+ }
318
+
319
+ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
320
+ return new Promise((resolve) => {
321
+ const timer = setTimeout(done, ms);
322
+ function done() {
323
+ clearTimeout(timer);
324
+ signal?.removeEventListener("abort", done);
325
+ resolve();
326
+ }
327
+ signal?.addEventListener("abort", done, { once: true });
328
+ });
329
+ }
330
+
331
+ /**
332
+ * `xcb accounts refresh <id>` for accounts whose catalog is empty
333
+ * (`models_unavailable`): about 5 s, no provider turn. Each account is
334
+ * refreshed at most once per process. Returns the ids that succeeded.
335
+ */
336
+ const refreshedAccounts = new Set<string>();
337
+ export async function refreshAccounts(
338
+ bin: string,
339
+ runner: CommandRunner,
340
+ ids: readonly string[],
341
+ options: { timeoutMs?: number; signal?: AbortSignal } = {},
342
+ ): Promise<string[]> {
343
+ const todo = ids.filter(
344
+ (id) => ID_PATTERN.test(id) && !refreshedAccounts.has(id),
345
+ );
346
+ for (const id of todo) refreshedAccounts.add(id);
347
+ const results = await Promise.all(
348
+ todo.map((id) =>
349
+ runner
350
+ .run(bin, ["accounts", "refresh", id], {
351
+ timeoutMs: options.timeoutMs ?? 10_000,
352
+ maxOutputBytes: 64 * 1024,
353
+ ...(options.signal ? { signal: options.signal } : {}),
354
+ })
355
+ .then((result) => (result.code === 0 ? id : undefined))
356
+ .catch(() => undefined),
357
+ ),
358
+ );
359
+ return results.filter((id): id is string => id !== undefined);
360
+ }
361
+
362
+ /** Test hook: forget which accounts this process refreshed. */
363
+ export function resetRefreshedAccounts(): void {
364
+ refreshedAccounts.clear();
365
+ }
366
+
367
+ /** `xcb --version` → `0.20.0`, or undefined. */
368
+ export async function readXcbVersion(
369
+ bin: string,
370
+ runner: CommandRunner,
371
+ signal?: AbortSignal,
372
+ ): Promise<string | undefined> {
373
+ const result = await runner
374
+ .run(bin, ["--version"], {
375
+ timeoutMs: 5_000,
376
+ maxOutputBytes: 4096,
377
+ ...(signal ? { signal } : {}),
378
+ })
379
+ .catch(() => undefined);
380
+ if (!result || result.code !== 0) return undefined;
381
+ return /(\d+\.\d+\.\d+)/.exec(result.stdout)?.[1];
382
+ }
383
+
384
+ /** True when `version` is at least `min` (plain x.y.z). */
385
+ export function versionAtLeast(version: string, min: string): boolean {
386
+ const a = version.split(".").map(Number);
387
+ const b = min.split(".").map(Number);
388
+ for (let index = 0; index < 3; index += 1) {
389
+ const left = a[index] ?? 0;
390
+ const right = b[index] ?? 0;
391
+ if (left !== right) return left > right;
392
+ }
393
+ return true;
394
+ }
395
+
396
+ /**
397
+ * One `xcb --json generate` call. Aborting sends SIGTERM and waits for xcb to
398
+ * finish its own cleanup (it keeps the account held until the provider exits).
399
+ */
400
+ export async function xcbGenerate(
401
+ request: XcbGenerateRequest,
402
+ ): Promise<string> {
403
+ if (!ID_PATTERN.test(request.account))
404
+ throw new XcbError("invalid xcb account id", "invalid_request");
405
+ if (!MODEL_PATTERN.test(request.model))
406
+ throw new XcbError("invalid xcb model key", "invalid_request");
407
+ const timeoutMs = clamp(
408
+ Math.round(request.timeoutMs),
409
+ XCB_LIMITS.minTimeoutMs,
410
+ XCB_LIMITS.maxTimeoutMs,
411
+ );
412
+ const maxOutputBytes = clamp(
413
+ Math.round(request.maxOutputBytes),
414
+ 1,
415
+ XCB_LIMITS.maxOutputBytes,
416
+ );
417
+ const prompt = request.prompt.replace(/\u0000/g, "");
418
+ if (prompt.trim().length === 0)
419
+ throw new XcbError("empty prompt", "invalid_request");
420
+ const body = JSON.stringify({
421
+ version: 1,
422
+ account: request.account,
423
+ model: request.model,
424
+ prompt,
425
+ timeoutMs,
426
+ maxOutputBytes,
427
+ });
428
+ if (new TextEncoder().encode(body).byteLength > XCB_LIMITS.maxInputBytes)
429
+ throw new XcbError(
430
+ "prompt exceeds xcb's 1 MiB input limit",
431
+ "invalid_request",
432
+ );
433
+ if (request.signal?.aborted) throw new XcbError("cancelled", "cancelled");
434
+ const result = await request.runner.run(request.bin, ["--json", "generate"], {
435
+ stdin: body,
436
+ // xcb enforces its own deadline; this backstop also covers the automatic
437
+ // admission check on a binding's first call (up to ~60 s on top).
438
+ timeoutMs: timeoutMs + XCB_ADMISSION_GRACE_MS,
439
+ maxOutputBytes: maxOutputBytes * 2 + 16 * 1024,
440
+ ...(request.signal ? { signal: request.signal } : {}),
441
+ });
442
+ if (result.killed || request.signal?.aborted)
443
+ throw new XcbError("cancelled", "cancelled");
444
+ let parsed: unknown;
445
+ try {
446
+ parsed = JSON.parse(result.stdout);
447
+ } catch {
448
+ throw new XcbError(`xcb generate returned no JSON (exit ${result.code})`);
449
+ }
450
+ if (!isRecord(parsed))
451
+ throw new XcbError("xcb generate returned a non-object");
452
+ if (
453
+ parsed.status === "completed" &&
454
+ result.code === 0 &&
455
+ typeof parsed.text === "string"
456
+ )
457
+ return parsed.text.slice(0, maxOutputBytes);
458
+ const code =
459
+ typeof parsed.code === "string" && FAILURE_CODES.has(parsed.code)
460
+ ? parsed.code
461
+ : "provider_error";
462
+ const requestId =
463
+ typeof parsed.requestId === "string" &&
464
+ /^[A-Za-z0-9_-]{1,80}$/.test(parsed.requestId)
465
+ ? ` (${parsed.requestId})`
466
+ : "";
467
+ throw new XcbError(`xcb generate failed: ${code}${requestId}`, code);
468
+ }
469
+
470
+ /**
471
+ * Find the first balanced top-level JSON object in `text`, honouring strings
472
+ * and escapes. Only the first `maxBytes` characters are scanned.
473
+ */
474
+ export function extractFirstJsonObject(
475
+ text: string,
476
+ maxChars = 64 * 1024,
477
+ ): string | undefined {
478
+ const scan = text.slice(0, maxChars);
479
+ let start = scan.indexOf("{");
480
+ while (start >= 0) {
481
+ let depth = 0;
482
+ let inString = false;
483
+ let escaped = false;
484
+ for (let index = start; index < scan.length; index += 1) {
485
+ const char = scan[index]!;
486
+ if (inString) {
487
+ if (escaped) escaped = false;
488
+ else if (char === "\\") escaped = true;
489
+ else if (char === '"') inString = false;
490
+ continue;
491
+ }
492
+ if (char === '"') inString = true;
493
+ else if (char === "{") depth += 1;
494
+ else if (char === "}") {
495
+ depth -= 1;
496
+ if (depth === 0) {
497
+ const candidate = scan.slice(start, index + 1);
498
+ try {
499
+ JSON.parse(candidate);
500
+ return candidate;
501
+ } catch {
502
+ break;
503
+ }
504
+ }
505
+ }
506
+ }
507
+ start = scan.indexOf("{", start + 1);
508
+ }
509
+ return undefined;
510
+ }
511
+
512
+ export type TextOp = Readonly<{ tool: string; args: Record<string, unknown> }>;
513
+ export type TextReply = Readonly<{
514
+ ops: readonly TextOp[];
515
+ say?: string;
516
+ done: boolean;
517
+ }>;
518
+ export const TEXT_REPLY_LIMITS = Object.freeze({
519
+ maxChars: 64 * 1024,
520
+ maxOps: 16,
521
+ maxSayChars: 400,
522
+ });
523
+
524
+ /** Parse a model reply into `{ops, say, done}` or throw with a short diagnostic. */
525
+ export function parseTextReply(text: string): TextReply {
526
+ const json = extractFirstJsonObject(text, TEXT_REPLY_LIMITS.maxChars);
527
+ if (json === undefined)
528
+ throw new XcbError(
529
+ text.length > TEXT_REPLY_LIMITS.maxChars
530
+ ? "reply exceeded 64 KiB without a complete JSON object"
531
+ : "reply did not contain a JSON object",
532
+ "invalid_reply",
533
+ );
534
+ const value: unknown = JSON.parse(json);
535
+ if (!isRecord(value))
536
+ throw new XcbError("reply was not a JSON object", "invalid_reply");
537
+ const rawOps = value.ops ?? [];
538
+ if (!Array.isArray(rawOps))
539
+ throw new XcbError("ops must be an array", "invalid_reply");
540
+ if (rawOps.length > TEXT_REPLY_LIMITS.maxOps)
541
+ throw new XcbError(
542
+ `at most ${TEXT_REPLY_LIMITS.maxOps} ops per reply`,
543
+ "invalid_reply",
544
+ );
545
+ const ops: TextOp[] = rawOps.map((op, index) => {
546
+ if (
547
+ !isRecord(op) ||
548
+ typeof op.tool !== "string" ||
549
+ op.tool.length === 0 ||
550
+ op.tool.length > 64
551
+ )
552
+ throw new XcbError(`ops[${index}] needs a tool name`, "invalid_reply");
553
+ const args = op.args ?? {};
554
+ if (!isRecord(args))
555
+ throw new XcbError(
556
+ `ops[${index}].args must be an object`,
557
+ "invalid_reply",
558
+ );
559
+ return { tool: op.tool, args };
560
+ });
561
+ const say =
562
+ typeof value.say === "string" && value.say.trim()
563
+ ? value.say.trim().slice(0, TEXT_REPLY_LIMITS.maxSayChars)
564
+ : undefined;
565
+ return { ops, done: value.done !== false, ...(say ? { say } : {}) };
566
+ }
567
+
568
+ /** A friendly short label: `claude/sonnet/low` → `claude/sonnet`. */
569
+ export function shortModelLabel(key: string): string {
570
+ return key.split("/").slice(0, 2).join("/");
571
+ }
572
+
573
+ function clamp(value: number, min: number, max: number): number {
574
+ return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : min;
575
+ }
576
+
577
+ function isRecord(value: unknown): value is Record<string, unknown> {
578
+ return typeof value === "object" && value !== null && !Array.isArray(value);
579
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The click track (metronome): an accented downbeat, a beat click and a
3
+ * lighter subdivision click at the transport tempo and time signature.
4
+ *
5
+ * The click is a monitoring bus only. The engine mixes it into the live
6
+ * stream; it is never part of a loop render, `dawg render` or an export, and
7
+ * nothing here touches the score.
8
+ */
9
+
10
+ export type ClickLevel = "accent" | "beat" | "sub";
11
+
12
+ export type ClickSettings = Readonly<{
13
+ /** 0..1 output level. */
14
+ volume: number;
15
+ beatsPerBar: number;
16
+ /** Clicks per beat: 1 = quarters, 2 = eighths, 4 = sixteenths. */
17
+ subdivision: number;
18
+ }>;
19
+
20
+ export const DEFAULT_CLICK_VOLUME = 0.6;
21
+ export const MAX_SUBDIVISION = 4;
22
+
23
+ /** One click due inside a block of stream frames. */
24
+ export type ClickEvent = Readonly<{
25
+ /** Frame offset inside the block. */
26
+ offset: number;
27
+ level: ClickLevel;
28
+ /** Absolute step (beat × subdivision); negative during a count-in. */
29
+ step: number;
30
+ }>;
31
+
32
+ /** Level of absolute step `step` (beat × subdivision). */
33
+ export function clickLevel(
34
+ step: number,
35
+ beatsPerBar: number,
36
+ subdivision: number,
37
+ ): ClickLevel {
38
+ const perBar = Math.max(1, beatsPerBar) * Math.max(1, subdivision);
39
+ const inBar = ((step % perBar) + perBar) % perBar;
40
+ if (inBar === 0) return "accent";
41
+ return inBar % Math.max(1, subdivision) === 0 ? "beat" : "sub";
42
+ }
43
+
44
+ /**
45
+ * Clicks whose time falls in the half-open beat span [fromBeat, toBeat)
46
+ * covered by `frames` stream frames. Contiguous blocks never repeat or skip
47
+ * a click, and a jump (seek, loop wrap) never fires a stray mid-beat click.
48
+ */
49
+ export function clicksIn(
50
+ fromBeat: number,
51
+ toBeat: number,
52
+ frames: number,
53
+ settings: Pick<ClickSettings, "beatsPerBar" | "subdivision">,
54
+ ): ClickEvent[] {
55
+ if (!(toBeat > fromBeat) || frames <= 0) return [];
56
+ const subdivision = Math.max(
57
+ 1,
58
+ Math.min(MAX_SUBDIVISION, Math.round(settings.subdivision)),
59
+ );
60
+ const events: ClickEvent[] = [];
61
+ const span = toBeat - fromBeat;
62
+ // Half-open: a click exactly at `toBeat` belongs to the next block, whose
63
+ // `fromBeat` is computed identically, so it fires once.
64
+ const first = Math.ceil(fromBeat * subdivision) + 0;
65
+ for (let step = first; step / subdivision < toBeat; step += 1) {
66
+ const offset = Math.floor(
67
+ ((step / subdivision - fromBeat) / span) * frames,
68
+ );
69
+ if (offset < 0 || offset >= frames) continue;
70
+ events.push({
71
+ offset,
72
+ step,
73
+ level: clickLevel(step, settings.beatsPerBar, subdivision),
74
+ });
75
+ if (events.length > 64) break;
76
+ }
77
+ return events;
78
+ }
79
+
80
+ const CLICK_TONES: Readonly<
81
+ Record<ClickLevel, { hz: number; gain: number; ms: number }>
82
+ > = Object.freeze({
83
+ accent: { hz: 1760, gain: 1, ms: 45 },
84
+ beat: { hz: 1320, gain: 0.7, ms: 35 },
85
+ sub: { hz: 990, gain: 0.35, ms: 25 },
86
+ });
87
+
88
+ /** Mono click waveforms (−1..1) for each level at `sampleRate`. */
89
+ export function clickSounds(
90
+ sampleRate: number,
91
+ ): Readonly<Record<ClickLevel, Float32Array>> {
92
+ const make = (level: ClickLevel): Float32Array => {
93
+ const tone = CLICK_TONES[level];
94
+ const frames = Math.max(1, Math.round((tone.ms * sampleRate) / 1000));
95
+ const out = new Float32Array(frames);
96
+ const attack = Math.max(1, Math.round(0.001 * sampleRate));
97
+ for (let index = 0; index < frames; index += 1) {
98
+ const t = index / sampleRate;
99
+ const decay = Math.exp((-index / frames) * 6);
100
+ const env = Math.min(1, index / attack) * decay;
101
+ out[index] = Math.sin(2 * Math.PI * tone.hz * t) * env * tone.gain;
102
+ }
103
+ return out;
104
+ };
105
+ return { accent: make("accent"), beat: make("beat"), sub: make("sub") };
106
+ }
107
+
108
+ /** `/click on|off|<volume>` → settings change, or an error message. */
109
+ export function parseClickArgument(
110
+ argument: string,
111
+ current: Readonly<{ on: boolean; volume: number }>,
112
+ ): { on: boolean; volume: number } | { error: string } {
113
+ const value = argument.trim().toLowerCase();
114
+ if (value === "" || value === "toggle")
115
+ return { on: !current.on, volume: current.volume };
116
+ if (value === "on") return { on: true, volume: current.volume };
117
+ if (value === "off") return { on: false, volume: current.volume };
118
+ const percent = value.endsWith("%");
119
+ const number = Number(percent ? value.slice(0, -1) : value);
120
+ if (!Number.isFinite(number) || number < 0)
121
+ return { error: "usage: /click on|off|<volume 0..1 or 0..100%>" };
122
+ const volume = percent || number > 1 ? number / 100 : number;
123
+ if (volume > 1) return { error: "click volume is 0..1 (or 0..100%)" };
124
+ return { on: volume > 0, volume };
125
+ }