@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.
- package/CHANGELOG.md +201 -0
- package/DAWG.md +762 -11
- package/README.md +79 -30
- package/core/chords.ts +1724 -0
- package/core/diff.ts +261 -0
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/key.ts +43 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/pitch.ts +60 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +1054 -14
- package/core/sdk/eval-child.ts +125 -0
- package/core/sdk/eval.ts +260 -0
- package/core/sdk/print.ts +675 -0
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +4159 -0
- package/core/slug.ts +19 -0
- package/core/synth.ts +1001 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +317 -16
- package/src/agent/brief.ts +50 -5
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +55 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +705 -14
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +35 -14
- package/src/agent/xcb.ts +233 -21
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +337 -0
- package/src/audio/engine.ts +430 -33
- package/src/audio/kits.ts +200 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +420 -0
- package/src/audio/samples.ts +1025 -0
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +550 -334
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- 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/drums.ts +299 -0
- package/src/commands/edit.ts +192 -0
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +535 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +34 -15
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +462 -0
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +2136 -170
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +186 -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 +397 -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/media/wavetable.ts +202 -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 +49 -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 +6 -38
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +2125 -0
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +984 -0
- package/src/tui/sketch.ts +108 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +684 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +398 -33
- package/tui/drums.ts +44 -0
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +24 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +188 -0
package/src/agent/xcb-agent.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
45
|
-
|
|
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
|
|
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,
|
|
110
|
-
:
|
|
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
|
-
|
|
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): ${
|
|
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
|
-
{
|
|
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
|
-
|
|
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<{
|
|
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,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
|
+
}
|