@hraness/dawg 0.2.0 → 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 (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. package/tui/play-strip.ts +143 -0
@@ -1,10 +1,13 @@
1
1
  import { compositionBrief } from "./brief.ts";
2
2
  import {
3
3
  AGENT_LIMITS,
4
- AgentTimeoutError,
4
+ MEDIA_PROMPT,
5
5
  classifyAgentError,
6
6
  executeCall,
7
+ hostProjectOutline,
7
8
  tighten,
9
+ turnDeadline,
10
+ WORKSPACE_PROMPT,
8
11
  type AgentBudget,
9
12
  type AgentEvent,
10
13
  type AgentHost,
@@ -46,6 +49,9 @@ export const TEXT_AGENT_SYSTEM_PROMPT = [
46
49
  'Reply with exactly one JSON object and nothing else, shaped {"ops":[{"tool":"<tool name>","args":{...}}],"say":"<one short sentence describing the musical change>","done":true}.',
47
50
  'Set "done":false only if you need to see the results of these ops before continuing; you will then get each op\'s result and can send more ops.',
48
51
  "If an op was rejected, read its diagnostic and either send a corrected op or stop with done:true.",
52
+ WORKSPACE_PROMPT,
53
+ MEDIA_PROMPT,
54
+ 'Media tools return their outputs in the op result; send them alone with "done":false and chain on the result.',
49
55
  ].join(" ");
50
56
 
51
57
  /** Render the tool registry as a compact op catalog for a text-only model. */
@@ -100,14 +106,10 @@ export async function runTextAgentTurn(
100
106
  ((trackId: string, revision: number, index: number) =>
101
107
  `${trackId.slice(0, 40)}-${revision}-${nonce}${index}`);
102
108
  const startedAt = Date.now();
103
- const timeout = new AbortController();
104
- const timer = setTimeout(
105
- () => timeout.abort(new AgentTimeoutError(limits.timeoutMs)),
106
- limits.timeoutMs,
107
- );
109
+ const deadline = turnDeadline(limits.timeoutMs);
108
110
  const signal = options.signal
109
- ? AbortSignal.any([options.signal, timeout.signal])
110
- : timeout.signal;
111
+ ? AbortSignal.any([options.signal, deadline.signal])
112
+ : deadline.signal;
111
113
 
112
114
  let applied = 0;
113
115
  let rejected = 0;
@@ -118,7 +120,7 @@ export async function runTextAgentTurn(
118
120
  let feedback = "";
119
121
  const currentRevision = () => options.host.snapshot().revision;
120
122
  const finish = (result: AgentTurnResult): AgentTurnResult => {
121
- clearTimeout(timer);
123
+ deadline.clear();
122
124
  emit(result);
123
125
  return result;
124
126
  };
@@ -141,10 +143,15 @@ export async function runTextAgentTurn(
141
143
  const remainingBytes = limits.maxResponseBytes - bytesUsed;
142
144
  if (remainingBytes <= 0)
143
145
  throw new SseBudgetError(limits.maxResponseBytes);
146
+ const snapshot = options.host.snapshot();
147
+ const brief = compositionBrief({
148
+ ...snapshot,
149
+ project: await hostProjectOutline(options.host, snapshot),
150
+ });
144
151
  const prompt = [
145
152
  TEXT_AGENT_SYSTEM_PROMPT,
146
153
  `Tools:\n${catalog}`,
147
- `Composition brief (JSON): ${compositionBrief(options.host.snapshot())}`,
154
+ `Composition brief (JSON): ${brief}`,
148
155
  `User request: ${options.prompt.slice(0, MAX_PROMPT_CHARS)}`,
149
156
  ...steering.map((text) => `Steering from the user mid-turn: ${text}`),
150
157
  feedback
@@ -207,7 +214,15 @@ export async function runTextAgentTurn(
207
214
  emit({ type: "tool-start", callId, name: op.tool, step });
208
215
  const outcome = await executeCall(
209
216
  { id: callId, name: op.tool, arguments: JSON.stringify(op.args) },
210
- { tools, host: options.host, newNoteId },
217
+ {
218
+ tools,
219
+ host: options.host,
220
+ newNoteId,
221
+ signal,
222
+ suspendTimeout: deadline.suspend,
223
+ onProgress: (line) =>
224
+ emit({ type: "tool-progress", callId, name: op.tool, line }),
225
+ },
211
226
  );
212
227
  if (outcome.ok) {
213
228
  applied += outcome.mutated ? 1 : 0;
@@ -242,6 +257,6 @@ export async function runTextAgentTurn(
242
257
  revision: currentRevision(),
243
258
  });
244
259
  } finally {
245
- clearTimeout(timer);
260
+ deadline.clear();
246
261
  }
247
262
  }
package/src/agent/xcb.ts CHANGED
@@ -40,7 +40,11 @@ export class XcbError extends Error {
40
40
  }
41
41
  }
42
42
 
43
- export type XcbModel = Readonly<{ key: string; label: string }>;
43
+ export type XcbModel = Readonly<{
44
+ key: string;
45
+ label: string;
46
+ admission?: XcbAdmission;
47
+ }>;
44
48
  export type XcbAccount = Readonly<{
45
49
  id: string;
46
50
  label: string;
@@ -50,11 +54,11 @@ export type XcbAccount = Readonly<{
50
54
  reason: string | null;
51
55
  models: readonly XcbModel[];
52
56
  /**
53
- * xcb's per-account application admission, when it reports one. `pending`
54
- * means xcb admits the account on first use (that call is slower); it
55
- * treats it as usable. Absent on xcb builds without automatic admission.
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.
56
60
  */
57
- admission?: "pending" | "admitted" | "denied" | undefined;
61
+ admission?: XcbAdmission | undefined;
58
62
  }>;
59
63
  export type XcbCapabilities = Readonly<{
60
64
  supported: boolean;
@@ -77,17 +81,59 @@ function shortString(value: unknown, max: number, fallback = ""): string {
77
81
  : fallback;
78
82
  }
79
83
 
80
- /** Parse `xcb --json generate --capabilities` output from `unknown`. */
81
- const ADMISSION_STATES = new Set(["pending", "admitted", "denied"]);
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"]);
82
92
 
83
- /** `"pending"` or `{ state | status: "pending" }`; anything else is ignored. */
84
- function admissionState(value: unknown): XcbAccount["admission"] {
85
- const raw = isRecord(value) ? (value.state ?? value.status) : value;
86
- return typeof raw === "string" && ADMISSION_STATES.has(raw)
87
- ? (raw as XcbAccount["admission"])
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)
88
97
  : undefined;
89
98
  }
90
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`. */
91
137
  export function parseCapabilities(value: unknown): XcbCapabilities {
92
138
  if (!isRecord(value) || value.version !== 1)
93
139
  throw new XcbError("xcb capabilities were not a version-1 object");
@@ -113,9 +159,14 @@ export function parseCapabilities(value: unknown): XcbCapabilities {
113
159
  !MODEL_PATTERN.test(model.key)
114
160
  )
115
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;
116
166
  models.push({
117
167
  key: model.key,
118
168
  label: shortString(model.label, 64, model.key) || model.key,
169
+ ...(admission ? { admission } : {}),
119
170
  });
120
171
  }
121
172
  const label =
@@ -123,20 +174,18 @@ export function parseCapabilities(value: unknown): XcbCapabilities {
123
174
  shortString(row.name, 64) ||
124
175
  shortString(row.email, 64) ||
125
176
  row.id;
177
+ const reason =
178
+ typeof row.reason === "string" ? shortString(row.reason, 64) : null;
126
179
  const admission = admissionState(row.admission);
127
180
  accounts.push({
128
181
  id: row.id,
129
182
  label,
130
183
  provider: shortString(row.provider, 32, "unknown") || "unknown",
131
- // A pending admission is usable: xcb admits the account on first call.
132
- available:
133
- models.length > 0 &&
134
- admission !== "denied" &&
135
- (row.available === true || admission === "pending"),
184
+ // Usable iff xcb says so and it lists a model to call.
185
+ available: row.available === true && reason === null && models.length > 0,
136
186
  ...(admission ? { admission } : {}),
137
187
  connected: row.connected === true,
138
- reason:
139
- typeof row.reason === "string" ? shortString(row.reason, 64) : null,
188
+ reason,
140
189
  models,
141
190
  });
142
191
  }
@@ -171,6 +220,58 @@ export async function readCapabilities(
171
220
  return parseCapabilities(parsed);
172
221
  }
173
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
+
174
275
  export type XcbGenerateRequest = Readonly<{
175
276
  bin: string;
176
277
  runner: CommandRunner;
@@ -182,6 +283,116 @@ export type XcbGenerateRequest = Readonly<{
182
283
  signal?: AbortSignal;
183
284
  }>;
184
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
+
185
396
  /**
186
397
  * One `xcb --json generate` call. Aborting sends SIGTERM and waits for xcb to
187
398
  * finish its own cleanup (it keeps the account held until the provider exits).
@@ -222,8 +433,9 @@ export async function xcbGenerate(
222
433
  if (request.signal?.aborted) throw new XcbError("cancelled", "cancelled");
223
434
  const result = await request.runner.run(request.bin, ["--json", "generate"], {
224
435
  stdin: body,
225
- // xcb enforces its own deadline; this is a backstop for a wedged process.
226
- timeoutMs: timeoutMs + 30_000,
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,
227
439
  maxOutputBytes: maxOutputBytes * 2 + 16 * 1024,
228
440
  ...(request.signal ? { signal: request.signal } : {}),
229
441
  });
@@ -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
+ }