@hraness/dawg 0.2.0 → 0.4.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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -1,10 +1,14 @@
1
1
  import { compositionBrief } from "./brief.ts";
2
+ import { CHORD_PROCESS } from "../../core/chords.ts";
2
3
  import {
3
4
  AGENT_LIMITS,
4
- AgentTimeoutError,
5
+ MEDIA_PROMPT,
5
6
  classifyAgentError,
6
7
  executeCall,
8
+ hostProjectOutline,
7
9
  tighten,
10
+ turnDeadline,
11
+ WORKSPACE_PROMPT,
8
12
  type AgentBudget,
9
13
  type AgentEvent,
10
14
  type AgentHost,
@@ -41,11 +45,19 @@ export const TEXT_AGENT_SYSTEM_PROMPT = [
41
45
  "You edit the score by returning operations. Each op is validated and applied immediately, in order, as its own revision.",
42
46
  "Times are in beats from the loop start (0-based). Keep notes inside loopBeats unless you extend the loop first.",
43
47
  "Prefer a few well-formed ops (one add_notes op per track part) over many tiny ones.",
44
- 'For drums, create a track with instrument "kit" and use add_drums; drum pitches select voices, so do not use add_notes for beats.',
45
- "Effects (set_effects, set_automation): low-pass filter cutoff 20..20000 Hz and resonance 0..1; stereo delay beats 0.0625..4, feedback 0..0.9, mix 0..1; stereo reverb mix 0..1 (0.15..0.35 is a natural room) and size 0..1; pan -1..1 is equal-power stereo. Automatable lanes: volume, pan, filter, resonance, delay-feedback, delay-mix.",
48
+ 'For drums, create a track with instrument "kit" and prefer set_rhythm (Euclidean rows: pulses over steps, rotate, repeats for rolls, accent, probability, swing) so the beat stays editable as parameters; for a genre groove start from apply_drum_pattern (list_drum_patterns) and pick a sound with set_drum_kit; use add_drums only for one-off fills. Drum pitches select voices, so do not use add_notes for beats.',
49
+ CHORD_PROCESS,
50
+ "Synth voices: set_synth shapes a synth track with Strudel params (ADSR, lpf/lpenv filter envelopes, fm/fmh, supersaw unison/detune, vib, penv, noise; z_* ZzFX sounds take slide/pitchJump/lfo/zcrush…) or a preset (pad lead pluck bass keys bell…).",
51
+ "Sampler voices: set_sample sets Strudel sample controls per voice (begin end speed unit loop loopBegin loopEnd clip/legato fit loopAt accelerate squiz cut); splice = slices with fit.",
52
+ "Effects (set_fx; set_effects and set_automation): the brief's effects list is the fixed chain order with each effect's simple params. set_fx turns an effect on with good defaults, loads a preset or sets params by dawg or Strudel name. Lanes: volume, pan, filter, resonance, delay-feedback, delay-mix, wt and <effect>-<param> (e.g. autofilter-cutoff).",
53
+ "Previewing: preview_sound renders a track, or candidate sound tool calls (changes: [{tool, args}]), without committing, returns loudness, brightness and a comparison, and plays it once in the user's window. Use it when choosing between sounds (tables, presets, effect amounts), say how it sounds, then commit with the normal tools.",
54
+ "Wavetable synth: set_wavetable picks a table (built-ins offline, Strudel wt_ sets fetched once) and scans it by position wt.",
46
55
  '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
56
  '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
57
  "If an op was rejected, read its diagnostic and either send a corrected op or stop with done:true.",
58
+ WORKSPACE_PROMPT,
59
+ MEDIA_PROMPT,
60
+ 'Media tools return their outputs in the op result; send them alone with "done":false and chain on the result.',
49
61
  ].join(" ");
50
62
 
51
63
  /** Render the tool registry as a compact op catalog for a text-only model. */
@@ -100,14 +112,10 @@ export async function runTextAgentTurn(
100
112
  ((trackId: string, revision: number, index: number) =>
101
113
  `${trackId.slice(0, 40)}-${revision}-${nonce}${index}`);
102
114
  const startedAt = Date.now();
103
- const timeout = new AbortController();
104
- const timer = setTimeout(
105
- () => timeout.abort(new AgentTimeoutError(limits.timeoutMs)),
106
- limits.timeoutMs,
107
- );
115
+ const deadline = turnDeadline(limits.timeoutMs);
108
116
  const signal = options.signal
109
- ? AbortSignal.any([options.signal, timeout.signal])
110
- : timeout.signal;
117
+ ? AbortSignal.any([options.signal, deadline.signal])
118
+ : deadline.signal;
111
119
 
112
120
  let applied = 0;
113
121
  let rejected = 0;
@@ -118,7 +126,7 @@ export async function runTextAgentTurn(
118
126
  let feedback = "";
119
127
  const currentRevision = () => options.host.snapshot().revision;
120
128
  const finish = (result: AgentTurnResult): AgentTurnResult => {
121
- clearTimeout(timer);
129
+ deadline.clear();
122
130
  emit(result);
123
131
  return result;
124
132
  };
@@ -141,10 +149,15 @@ export async function runTextAgentTurn(
141
149
  const remainingBytes = limits.maxResponseBytes - bytesUsed;
142
150
  if (remainingBytes <= 0)
143
151
  throw new SseBudgetError(limits.maxResponseBytes);
152
+ const snapshot = options.host.snapshot();
153
+ const brief = compositionBrief({
154
+ ...snapshot,
155
+ project: await hostProjectOutline(options.host, snapshot),
156
+ });
144
157
  const prompt = [
145
158
  TEXT_AGENT_SYSTEM_PROMPT,
146
159
  `Tools:\n${catalog}`,
147
- `Composition brief (JSON): ${compositionBrief(options.host.snapshot())}`,
160
+ `Composition brief (JSON): ${brief}`,
148
161
  `User request: ${options.prompt.slice(0, MAX_PROMPT_CHARS)}`,
149
162
  ...steering.map((text) => `Steering from the user mid-turn: ${text}`),
150
163
  feedback
@@ -207,7 +220,15 @@ export async function runTextAgentTurn(
207
220
  emit({ type: "tool-start", callId, name: op.tool, step });
208
221
  const outcome = await executeCall(
209
222
  { id: callId, name: op.tool, arguments: JSON.stringify(op.args) },
210
- { tools, host: options.host, newNoteId },
223
+ {
224
+ tools,
225
+ host: options.host,
226
+ newNoteId,
227
+ signal,
228
+ suspendTimeout: deadline.suspend,
229
+ onProgress: (line) =>
230
+ emit({ type: "tool-progress", callId, name: op.tool, line }),
231
+ },
211
232
  );
212
233
  if (outcome.ok) {
213
234
  applied += outcome.mutated ? 1 : 0;
@@ -242,6 +263,6 @@ export async function runTextAgentTurn(
242
263
  revision: currentRevision(),
243
264
  });
244
265
  } finally {
245
- clearTimeout(timer);
266
+ deadline.clear();
246
267
  }
247
268
  }
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,93 @@
1
+ /**
2
+ * Audition: a short phrase of one track (or some of its pitches) rendered
3
+ * through the offline renderer, as a live voice the engine can mix over
4
+ * silence. The Euclidean editor plays the edited voice's lane once after
5
+ * every change, and the pattern picker previews a pattern before it lands.
6
+ *
7
+ * Like `live.ts` this only builds a smaller score; the renderer is shared,
8
+ * so an audition sounds exactly like the loop.
9
+ */
10
+ import {
11
+ SCORE_LIMITS,
12
+ TrackScore,
13
+ type Note,
14
+ type Track,
15
+ } from "../../core/score.ts";
16
+ import type { LiveNotePcm } from "./live.ts";
17
+ import type { SampleBank } from "./samples.ts";
18
+ import { RENDER_CHANNELS, renderScorePcm } from "./wav.ts";
19
+
20
+ export type AuditionRequest = Readonly<{
21
+ score: TrackScore;
22
+ trackId: string;
23
+ /** Only these pitches (one drum voice); all of the track's notes when absent. */
24
+ pitches?: ReadonlySet<number>;
25
+ /** Bars rendered from the loop start (default 1, at most the loop). */
26
+ bars?: number;
27
+ sampleRate: number;
28
+ samples?: SampleBank;
29
+ }>;
30
+
31
+ /** Longest audition, so a slow tempo cannot hold a voice for long. */
32
+ export const MAX_AUDITION_SECONDS = 6;
33
+
34
+ export function renderAudition(
35
+ request: AuditionRequest,
36
+ ): LiveNotePcm | undefined {
37
+ const { score } = request;
38
+ const track = score.tracks.find(
39
+ (candidate) => candidate.id === request.trackId,
40
+ );
41
+ if (!track) return undefined;
42
+ const secondsPerBar = (score.beatsPerBar * 60) / score.tempoBpm;
43
+ const bars = Math.max(
44
+ 1,
45
+ Math.min(
46
+ score.bars,
47
+ request.bars ?? 1,
48
+ Math.floor(MAX_AUDITION_SECONDS / secondsPerBar) || 1,
49
+ ),
50
+ );
51
+ const endTick = bars * score.beatsPerBar * score.ticksPerBeat;
52
+ const notes: Note[] = score.notes.filter(
53
+ (note) =>
54
+ note.trackId === track.id &&
55
+ note.startTick < endTick &&
56
+ (!request.pitches || request.pitches.has(note.pitch)),
57
+ );
58
+ if (notes.length === 0) return undefined;
59
+ const single = new TrackScore({
60
+ tempoBpm: score.tempoBpm,
61
+ beatsPerBar: score.beatsPerBar,
62
+ bars,
63
+ ticksPerBeat: score.ticksPerBeat,
64
+ tracks: [auditionTrack(track)],
65
+ notes: notes.map((note) => ({
66
+ ...note,
67
+ durationTicks: Math.max(
68
+ 1,
69
+ Math.min(
70
+ note.durationTicks,
71
+ endTick - note.startTick,
72
+ SCORE_LIMITS.maxTick,
73
+ ),
74
+ ),
75
+ })),
76
+ });
77
+ const audio = renderScorePcm(single, {
78
+ sampleRate: request.sampleRate,
79
+ maxSeconds: MAX_AUDITION_SECONDS + 4,
80
+ ...(request.samples ? { samples: request.samples } : {}),
81
+ });
82
+ let last = audio.pcm.length - 1;
83
+ while (last >= 0 && audio.pcm[last] === 0) last -= 1;
84
+ const frames = Math.ceil((last + 1) / RENDER_CHANNELS);
85
+ if (frames === 0) return undefined;
86
+ return { pcm: audio.pcm.subarray(0, frames * RENDER_CHANNELS), frames };
87
+ }
88
+
89
+ /** Audible regardless of mute and solo; the generator rows are not needed. */
90
+ function auditionTrack(track: Track): Track {
91
+ const { solo: _solo, rhythm: _rhythm, ...rest } = track;
92
+ return { ...rest, muted: false };
93
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Disk cache budgets and LRU pruning for pack downloads and decoded assets.
3
+ *
4
+ * Two caches hold audio on disk:
5
+ *
6
+ * - raw pack files, `~/.cache/dawg/packs/files/<sha256(url)>.bin`, shared by
7
+ * every project (default cap `DEFAULT_PACKS_CACHE_BYTES`, env
8
+ * `DAWG_PACKS_CACHE_MAX`);
9
+ * - decoded PCM, `<project>/.dawg/assets/<sha256>.pcm`, per project (default
10
+ * cap `DEFAULT_ASSETS_CACHE_BYTES`, env `DAWG_ASSETS_CACHE_MAX`).
11
+ *
12
+ * Both evict least recently used files first (hits refresh the mtime) and
13
+ * never evict a protected file: the ones the open project pins. An evicted
14
+ * pack file is fetched again by its pinned URL and checked against its
15
+ * pinned sha256, so eviction only ever costs a download.
16
+ */
17
+ import { readdir, stat, unlink } from "node:fs/promises";
18
+ import { join } from "node:path";
19
+ import { SCORE_LIMITS } from "../../core/score.ts";
20
+
21
+ const MiB = 1024 * 1024;
22
+ const GiB = 1024 * MiB;
23
+
24
+ /**
25
+ * Raw pack downloads. 2 GiB holds every file a typical user touches across
26
+ * the built-in packs (a whole drum-machine bank is a few MiB, a GM
27
+ * instrument 1–4 MiB of MP3, a uzu wavetable 1–2 MiB) with room to spare,
28
+ * and stays small next to a browser cache on a laptop disk.
29
+ */
30
+ export const DEFAULT_PACKS_CACHE_BYTES = 2 * GiB;
31
+ /**
32
+ * Decoded float32 PCM per project. Decoding grows audio 2× (16-bit WAV) to
33
+ * ~10× (MP3), so 1 GiB is about 100 minutes of mono 44.1 kHz audio: far
34
+ * more than a project plays, while a few projects cannot fill a disk.
35
+ */
36
+ export const DEFAULT_ASSETS_CACHE_BYTES = 1 * GiB;
37
+ /** Decoded samples kept in memory by one `SampleLibrary`. */
38
+ export const DEFAULT_MEMORY_CACHE_BYTES = SCORE_LIMITS.maxSampleCacheBytes;
39
+
40
+ const SIZE =
41
+ /^\s*([0-9]+(?:\.[0-9]+)?)\s*(b|k|kb|kib|m|mb|mib|g|gb|gib|t|tb|tib)?\s*$/i;
42
+
43
+ /**
44
+ * Bytes from `"2GiB"`, `"512M"`, `"1.5g"`, `"1048576"` (units are binary);
45
+ * undefined when malformed or negative.
46
+ */
47
+ export function parseByteSize(value: string): number | undefined {
48
+ const match = SIZE.exec(value);
49
+ if (!match) return undefined;
50
+ const amount = Number(match[1]);
51
+ const unit = (match[2] ?? "b").toLowerCase()[0]!;
52
+ const scale =
53
+ unit === "k"
54
+ ? 1024
55
+ : unit === "m"
56
+ ? MiB
57
+ : unit === "g"
58
+ ? GiB
59
+ : unit === "t"
60
+ ? 1024 * GiB
61
+ : 1;
62
+ const bytes = Math.floor(amount * scale);
63
+ return Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
64
+ }
65
+
66
+ function fromEnv(
67
+ name: string,
68
+ fallback: number,
69
+ env: NodeJS.ProcessEnv,
70
+ ): number {
71
+ const raw = env[name];
72
+ if (raw === undefined || raw.trim() === "") return fallback;
73
+ return parseByteSize(raw) ?? fallback;
74
+ }
75
+
76
+ /** Raw pack file cap: `DAWG_PACKS_CACHE_MAX` or 2 GiB. */
77
+ export function packsCacheMax(env: NodeJS.ProcessEnv = process.env): number {
78
+ return fromEnv("DAWG_PACKS_CACHE_MAX", DEFAULT_PACKS_CACHE_BYTES, env);
79
+ }
80
+
81
+ /** Decoded asset cap: `DAWG_ASSETS_CACHE_MAX` or 1 GiB. */
82
+ export function assetsCacheMax(env: NodeJS.ProcessEnv = process.env): number {
83
+ return fromEnv("DAWG_ASSETS_CACHE_MAX", DEFAULT_ASSETS_CACHE_BYTES, env);
84
+ }
85
+
86
+ export type CacheUsage = Readonly<{ files: number; bytes: number }>;
87
+
88
+ type Entry = { name: string; path: string; bytes: number; used: number };
89
+
90
+ async function entries(dir: string, pattern: RegExp): Promise<Entry[]> {
91
+ const names = await readdir(dir).catch(() => [] as string[]);
92
+ const out: Entry[] = [];
93
+ for (const name of names) {
94
+ if (!pattern.test(name)) continue;
95
+ const path = join(dir, name);
96
+ const info = await stat(path).catch(() => undefined);
97
+ if (info?.isFile())
98
+ out.push({ name, path, bytes: info.size, used: info.mtimeMs });
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /** Files and bytes of the cache entries in `dir` matching `pattern`. */
104
+ export async function cacheUsage(
105
+ dir: string,
106
+ pattern: RegExp,
107
+ ): Promise<CacheUsage> {
108
+ const list = await entries(dir, pattern);
109
+ return {
110
+ files: list.length,
111
+ bytes: list.reduce((sum, entry) => sum + entry.bytes, 0),
112
+ };
113
+ }
114
+
115
+ export type PruneResult = Readonly<{
116
+ removed: number;
117
+ freed: number;
118
+ /** Bytes left, protected files included. */
119
+ bytes: number;
120
+ }>;
121
+
122
+ /**
123
+ * Deletes least recently used entries until `dir` holds at most `maxBytes`.
124
+ * `keep(name)` protects an entry (the open project's pins, the file being
125
+ * written); protected entries count toward the total but are never removed,
126
+ * so a project larger than the cap keeps everything it uses.
127
+ */
128
+ export async function pruneLru(
129
+ dir: string,
130
+ pattern: RegExp,
131
+ maxBytes: number,
132
+ keep: (name: string) => boolean = () => false,
133
+ ): Promise<PruneResult> {
134
+ const list = await entries(dir, pattern);
135
+ let total = list.reduce((sum, entry) => sum + entry.bytes, 0);
136
+ list.sort((a, b) => a.used - b.used || a.name.localeCompare(b.name));
137
+ let removed = 0;
138
+ let freed = 0;
139
+ for (const entry of list) {
140
+ if (total <= maxBytes) break;
141
+ if (keep(entry.name)) continue;
142
+ try {
143
+ await unlink(entry.path);
144
+ } catch {
145
+ continue;
146
+ }
147
+ total -= entry.bytes;
148
+ freed += entry.bytes;
149
+ removed += 1;
150
+ }
151
+ return { removed, freed, bytes: total };
152
+ }
153
+
154
+ /** `1.5 GiB`, `320 MiB`, `12 KiB`. */
155
+ export function formatBytes(bytes: number): string {
156
+ if (bytes >= GiB) return `${Math.round((bytes / GiB) * 10) / 10} GiB`;
157
+ if (bytes >= MiB) return `${Math.round((bytes / MiB) * 10) / 10} MiB`;
158
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KiB`;
159
+ return `${bytes} B`;
160
+ }