@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.
- package/CHANGELOG.md +79 -0
- package/DAWG.md +196 -11
- package/README.md +79 -30
- package/core/diff.ts +249 -0
- package/core/key.ts +43 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +324 -1
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +280 -14
- package/src/agent/brief.ts +23 -3
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/planner.ts +38 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +398 -1
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +27 -12
- package/src/agent/xcb.ts +233 -21
- package/src/audio/click.ts +125 -0
- package/src/audio/engine.ts +407 -33
- package/src/audio/live.ts +152 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +290 -76
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +24 -7
- package/src/commands/sample.ts +302 -0
- package/src/main.ts +1043 -166
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +28 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +3 -37
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +261 -18
- package/tui/drums.ts +44 -0
- package/tui/highway.ts +18 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +143 -0
package/src/agent/xcb-agent.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { compositionBrief } from "./brief.ts";
|
|
2
2
|
import {
|
|
3
3
|
AGENT_LIMITS,
|
|
4
|
-
|
|
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
|
|
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,
|
|
110
|
-
:
|
|
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
|
-
|
|
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): ${
|
|
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
|
-
{
|
|
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
|
-
|
|
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<{
|
|
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
|
|
54
|
-
* means
|
|
55
|
-
*
|
|
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?:
|
|
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
|
-
/**
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
//
|
|
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
|
|
226
|
-
|
|
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
|
+
}
|