threenative-asset-mcp 0.9.1 → 0.9.2
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/README.md +87 -0
- package/dist/audio/fsutil.d.ts +7 -0
- package/dist/audio/fsutil.d.ts.map +1 -0
- package/dist/audio/fsutil.js +37 -0
- package/dist/audio/fsutil.js.map +1 -0
- package/dist/audio/generate.d.ts +95 -0
- package/dist/audio/generate.d.ts.map +1 -0
- package/dist/audio/generate.js +405 -0
- package/dist/audio/generate.js.map +1 -0
- package/dist/audio/inspect.d.ts +123 -0
- package/dist/audio/inspect.d.ts.map +1 -0
- package/dist/audio/inspect.js +464 -0
- package/dist/audio/inspect.js.map +1 -0
- package/dist/audio/semantic.d.ts +66 -0
- package/dist/audio/semantic.d.ts.map +1 -0
- package/dist/audio/semantic.js +197 -0
- package/dist/audio/semantic.js.map +1 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +33 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/audio.d.ts +622 -0
- package/dist/tools/audio.d.ts.map +1 -1
- package/dist/tools/audio.js +217 -1
- package/dist/tools/audio.js.map +1 -1
- package/dist/unreal/importer.d.ts +1 -1
- package/dist/unreal/importer.d.ts.map +1 -1
- package/dist/unreal/importer.js +17 -12
- package/dist/unreal/importer.js.map +1 -1
- package/dist/unreal/provision.d.ts +4 -1
- package/dist/unreal/provision.d.ts.map +1 -1
- package/dist/unreal/provision.js +8 -2
- package/dist/unreal/provision.js.map +1 -1
- package/dist/unreal/psa.d.ts.map +1 -1
- package/dist/unreal/psa.js +27 -9
- package/dist/unreal/psa.js.map +1 -1
- package/package.json +3 -1
- package/scripts/audio-semantic.py +168 -0
package/README.md
CHANGED
|
@@ -35,6 +35,11 @@ dependency relationships with pagination and resolution/format filters.
|
|
|
35
35
|
- A local environment capable of running Playwright Chromium when Fab requests
|
|
36
36
|
browser verification
|
|
37
37
|
- No Epic or Fab login is required or automated
|
|
38
|
+
- **FFmpeg and FFprobe on `PATH`** for `audio_inspect_asset` and
|
|
39
|
+
`audio_generate_sound`. Without them nothing is decoded, and the tools report
|
|
40
|
+
that they could not check rather than reporting a pass.
|
|
41
|
+
- An **ElevenLabs API key of your own** for `audio_generate_sound` only. Every
|
|
42
|
+
other tool, including local audio inspection, works without one.
|
|
38
43
|
|
|
39
44
|
## Install
|
|
40
45
|
|
|
@@ -192,6 +197,83 @@ Game audio:
|
|
|
192
197
|
`acceptLicense: true`. It uses an HTTPS host allowlist, validates every
|
|
193
198
|
redirect, streams with a byte cap, writes atomically without overwrite, and
|
|
194
199
|
returns the local path, byte size, and SHA-256.
|
|
200
|
+
- `audio_inspect_asset` — measures one local WAV, Ogg, MP3, or FLAC with the
|
|
201
|
+
pinned `@threenative/playtest` inspector: decode integrity, silence floor,
|
|
202
|
+
peak ceiling, DC offset, a five-band spectrum, the loop seam of a **declared**
|
|
203
|
+
loop, and the requested duration, plus a spectrogram PNG. Needs no ElevenLabs
|
|
204
|
+
key and never modifies or repairs the file. Optionally compares prompt and
|
|
205
|
+
emotional tone when local CLAP is provisioned.
|
|
206
|
+
- `audio_generate_sound` — **spends ElevenLabs credits.** Generates one sound
|
|
207
|
+
effect or ambience from a prompt using *your own* `ELEVENLABS_API_KEY`, saves
|
|
208
|
+
the original response plus a PCM16 WAV, and runs the same inspection
|
|
209
|
+
automatically. Reusing a `requestId` returns the saved result instead of
|
|
210
|
+
charging again.
|
|
211
|
+
|
|
212
|
+
### Generating a sound
|
|
213
|
+
|
|
214
|
+
The loop is **generate once → inspect → revise the one property that failed →
|
|
215
|
+
explicitly generate again.** There is no best-of-N retry: every generation is a
|
|
216
|
+
separate billable invocation you ask for on purpose.
|
|
217
|
+
|
|
218
|
+
Describe source, action, material, distance, and timing. A useful brief:
|
|
219
|
+
|
|
220
|
+
> One short metal latch closing, close microphone, dry recording, single event
|
|
221
|
+
> with natural decay, no speech or music.
|
|
222
|
+
|
|
223
|
+
These are prompting conventions absorbed from ElevenLabs' own
|
|
224
|
+
[sound-effects skill](https://github.com/elevenlabs/skills/blob/9edcbd4b80ed57b8e07a3f86ea520333969fbc3c/sound-effects/SKILL.md)
|
|
225
|
+
(MIT, reviewed at commit `9edcbd4b`), which is linked and attributed rather than
|
|
226
|
+
installed. They are conventions, not guaranteed exclusion controls.
|
|
227
|
+
|
|
228
|
+
`audio_generate_sound` requires `ELEVENLABS_API_KEY` in the server environment.
|
|
229
|
+
It is never bundled, defaulted, or logged: without your own key the tool fails
|
|
230
|
+
with `AUDIO_GENERATE_NO_CREDENTIALS` and an instruction to set one, while the
|
|
231
|
+
catalog and inspection tools keep working normally.
|
|
232
|
+
|
|
233
|
+
What the inspection does and does not tell you:
|
|
234
|
+
|
|
235
|
+
| It answers | It does not answer |
|
|
236
|
+
| --- | --- |
|
|
237
|
+
| Is it broken, silent, clipped, DC-offset, or the wrong duration? | Is it a *good* sound? |
|
|
238
|
+
| Does a declared loop click at the wrap? | Does it suit this scene? |
|
|
239
|
+
| Is the frequency content inside bounds *you* supplied? | Is the mix right? |
|
|
240
|
+
|
|
241
|
+
`artisticQuality` is always `"unverified"`. There is no `soundsGood` flag and no
|
|
242
|
+
single quality score. `recommendation: "audition"` means the technical checks
|
|
243
|
+
passed and every requested check completed — it means *go listen to it*, not
|
|
244
|
+
*ship it*. A definite technical failure recommends `"reject"`; a warning, a
|
|
245
|
+
possible mismatch, or an incomplete requested check recommends `"review"`.
|
|
246
|
+
|
|
247
|
+
If asset compilation rewrites the bytes, inspect the compiled output before
|
|
248
|
+
describing that output as checked.
|
|
249
|
+
|
|
250
|
+
### Optional local CLAP setup
|
|
251
|
+
|
|
252
|
+
Prompt fit and emotion fit are **off by default** (`semantic: "off"`) and add no
|
|
253
|
+
dependency to `npm install`. To enable them:
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
python3 -m venv ~/.venvs/clap
|
|
257
|
+
~/.venvs/clap/bin/pip install "laion-clap==1.1.6" "torch==2.4.1" \
|
|
258
|
+
"torchaudio==2.4.1" "librosa==0.10.2.post1" "numpy<2"
|
|
259
|
+
# Download 630k-audioset-best.pt from the LAION-AI/CLAP releases and check its hash.
|
|
260
|
+
export AUDIO_CLAP_CHECKPOINT=/path/to/630k-audioset-best.pt
|
|
261
|
+
export AUDIO_CLAP_PYTHON=~/.venvs/clap/bin/python
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Nothing is downloaded implicitly and no GPU is required. With `semantic: "clap"`
|
|
265
|
+
and no provisioned model, you get the technical result plus an explicit
|
|
266
|
+
unavailable semantic result and this instruction — never a semantic pass. A
|
|
267
|
+
generation that requests `semantic: "clap"` is refused in preflight, before
|
|
268
|
+
spending, when the model is missing.
|
|
269
|
+
|
|
270
|
+
Scores are cosine similarities used as **supporting evidence**, never
|
|
271
|
+
probabilities or quality grades. The emotion check builds otherwise identical
|
|
272
|
+
hypotheses — `The sound of {sourceDescription}, with a {mood} emotional tone.` —
|
|
273
|
+
so only the mood clause differs, and **this use of CLAP is a proposed heuristic,
|
|
274
|
+
not a demonstrated emotion-recognition capability**: mood status stays
|
|
275
|
+
`unverified` until the held-out emotion calibration qualifies your domain. Check
|
|
276
|
+
the CLAP code and checkpoint licensing separately before redistributing either.
|
|
195
277
|
|
|
196
278
|
Curated itch.io packs:
|
|
197
279
|
|
|
@@ -399,6 +481,11 @@ glTF's standard punctual-light extension has no area-light type.
|
|
|
399
481
|
| `AUDIO_DOWNLOAD_DIR` | `~/Downloads/threenative-asset-mcp/audio` | Dedicated directory for curated audio downloads. |
|
|
400
482
|
| `AUDIO_MAX_DOWNLOAD_BYTES` | `10737418240` | Maximum accepted bytes per audio archive (10 GiB). |
|
|
401
483
|
| `AUDIO_DOWNLOAD_TIMEOUT_MS` | `1800000` | Total timeout for one audio download (30 minutes). |
|
|
484
|
+
| `ELEVENLABS_API_KEY` | unset | **Your own** key; required only by `audio_generate_sound`. Never bundled or logged. |
|
|
485
|
+
| `AUDIO_INSPECT_ROOTS` | the audio dir plus the launch directory | Extra `:`-separated roots `audio_inspect_asset` may read. |
|
|
486
|
+
| `AUDIO_CLAP_PYTHON` | unset | Interpreter with LAION-CLAP installed; enables `semantic: "clap"`. |
|
|
487
|
+
| `AUDIO_CLAP_CHECKPOINT` | unset | Path to the `630k-audioset-best.pt` checkpoint. |
|
|
488
|
+
| `AUDIO_CLAP_CHECKPOINT_SHA256` | unset | Optional pin; a mismatched checkpoint is refused. |
|
|
402
489
|
| `ASSET_DOWNLOAD_DIR` | `~/Downloads/threenative-asset-mcp/assets` | Dedicated directory for direct provider downloads. |
|
|
403
490
|
| `ASSET_MAX_DOWNLOAD_BYTES` | `10737418240` | Maximum accepted bytes per provider file (10 GiB). |
|
|
404
491
|
| `ASSET_DOWNLOAD_TIMEOUT_MS` | `1800000` | Total timeout for one provider download (30 minutes). |
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** True when `path` is `parent` itself or lies beneath it. Both must already be real paths. */
|
|
2
|
+
export declare function isInside(path: string, parent: string): boolean;
|
|
3
|
+
export declare function sha256File(path: string): Promise<string>;
|
|
4
|
+
export declare function sha256Bytes(value: Uint8Array): string;
|
|
5
|
+
/** Stable hash of a plain JSON value: object key order must not change the digest. */
|
|
6
|
+
export declare function sha256Json(value: unknown): string;
|
|
7
|
+
//# sourceMappingURL=fsutil.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fsutil.d.ts","sourceRoot":"","sources":["../../src/audio/fsutil.ts"],"names":[],"mappings":"AAIA,+FAA+F;AAC/F,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAG9D;AAED,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAS9D;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAErD;AAED,sFAAsF;AACtF,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEjD"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { isAbsolute, relative } from "node:path";
|
|
4
|
+
/** True when `path` is `parent` itself or lies beneath it. Both must already be real paths. */
|
|
5
|
+
export function isInside(path, parent) {
|
|
6
|
+
const child = relative(parent, path);
|
|
7
|
+
return child === "" || (!child.startsWith("..") && !isAbsolute(child));
|
|
8
|
+
}
|
|
9
|
+
export async function sha256File(path) {
|
|
10
|
+
const hash = createHash("sha256");
|
|
11
|
+
await new Promise((done, fail) => {
|
|
12
|
+
const stream = createReadStream(path);
|
|
13
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
14
|
+
stream.on("error", fail);
|
|
15
|
+
stream.on("end", done);
|
|
16
|
+
});
|
|
17
|
+
return hash.digest("hex");
|
|
18
|
+
}
|
|
19
|
+
export function sha256Bytes(value) {
|
|
20
|
+
return createHash("sha256").update(value).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
/** Stable hash of a plain JSON value: object key order must not change the digest. */
|
|
23
|
+
export function sha256Json(value) {
|
|
24
|
+
return createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
25
|
+
}
|
|
26
|
+
function canonicalJson(value) {
|
|
27
|
+
if (value === null || typeof value !== "object")
|
|
28
|
+
return JSON.stringify(value) ?? "null";
|
|
29
|
+
if (Array.isArray(value))
|
|
30
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
31
|
+
const entries = Object.entries(value)
|
|
32
|
+
.filter(([, item]) => item !== undefined)
|
|
33
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
34
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`);
|
|
35
|
+
return `{${entries.join(",")}}`;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=fsutil.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fsutil.js","sourceRoot":"","sources":["../../src/audio/fsutil.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAEjD,+FAA+F;AAC/F,MAAM,UAAU,QAAQ,CAAC,IAAY,EAAE,MAAc;IACnD,MAAM,KAAK,GAAG,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IACrC,OAAO,KAAK,KAAK,EAAE,IAAI,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC;AACzE,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,IAAY;IAC3C,MAAM,IAAI,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC;IAClC,MAAM,IAAI,OAAO,CAAO,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE;QACrC,MAAM,MAAM,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;QACtC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;QACjD,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QACzB,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;IACzB,CAAC,CAAC,CAAC;IACH,OAAO,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC5B,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,KAAiB;IAC3C,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC1D,CAAC;AAED,sFAAsF;AACtF,MAAM,UAAU,UAAU,CAAC,KAAc;IACvC,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACzE,CAAC;AAED,SAAS,aAAa,CAAC,KAAc;IACnC,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC;IACxF,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;IAC3E,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,KAAgC,CAAC;SAC7D,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,IAAI,KAAK,SAAS,CAAC;SACxC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;SAChD,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,aAAa,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACzE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;AAClC,CAAC"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type AudioInspectRequest, type AudioInspectResult, type AudioInspectorOptions } from "./inspect.js";
|
|
2
|
+
/**
|
|
3
|
+
* One billed ElevenLabs sound-effect generation, saved and then inspected.
|
|
4
|
+
*
|
|
5
|
+
* The whole design here is about never charging twice. A `requestId` reserves a directory and a
|
|
6
|
+
* normalized-request hash *before* the POST, every durable step is written down as it completes, and
|
|
7
|
+
* a replay resumes from whichever verified bytes already exist. A submission whose outcome we never
|
|
8
|
+
* learned stays `outcome_unknown` forever: repeating that id never resubmits, because the provider
|
|
9
|
+
* may well have billed for it.
|
|
10
|
+
*/
|
|
11
|
+
export type AudioGenerateErrorCode = "AUDIO_GENERATE_NO_CREDENTIALS" | "AUDIO_GENERATE_INVALID_INPUT" | "AUDIO_GENERATE_REQUEST_CONFLICT" | "AUDIO_GENERATE_OUTCOME_UNKNOWN" | "AUDIO_GENERATE_PREREQUISITE_MISSING" | "AUDIO_GENERATE_AUTH_FAILED" | "AUDIO_GENERATE_RATE_LIMITED" | "AUDIO_GENERATE_REJECTED" | "AUDIO_GENERATE_UPSTREAM" | "AUDIO_GENERATE_TIMEOUT" | "AUDIO_GENERATE_TOO_LARGE" | "AUDIO_GENERATE_UNSAFE_REDIRECT" | "AUDIO_GENERATE_STORAGE" | "AUDIO_GENERATE_CONVERT_FAILED" | "AUDIO_GENERATE_HASH_MISMATCH";
|
|
12
|
+
export declare class AudioGenerateError extends Error {
|
|
13
|
+
readonly code: AudioGenerateErrorCode;
|
|
14
|
+
readonly retryable: boolean;
|
|
15
|
+
constructor(code: AudioGenerateErrorCode, message: string, retryable?: boolean);
|
|
16
|
+
}
|
|
17
|
+
export declare const GENERATE_LIMITS: {
|
|
18
|
+
/** Our policy cap, not a claimed provider maximum. */
|
|
19
|
+
readonly maxPromptChars: 2000;
|
|
20
|
+
readonly minDurationSeconds: 0.5;
|
|
21
|
+
readonly maxDurationSeconds: 30;
|
|
22
|
+
readonly maxResponseBytes: number;
|
|
23
|
+
readonly timeoutMs: 120000;
|
|
24
|
+
};
|
|
25
|
+
export interface AudioGenerateRequest {
|
|
26
|
+
requestId: string;
|
|
27
|
+
prompt: string;
|
|
28
|
+
durationSeconds?: number | undefined;
|
|
29
|
+
loop?: boolean | undefined;
|
|
30
|
+
promptInfluence?: number | undefined;
|
|
31
|
+
inspection?: Omit<AudioInspectRequest, "path" | "loop" | "expectedDurationSeconds" | "expectedPrompt"> | undefined;
|
|
32
|
+
}
|
|
33
|
+
interface NormalizedRequest {
|
|
34
|
+
text: string;
|
|
35
|
+
duration_seconds: number;
|
|
36
|
+
loop: boolean;
|
|
37
|
+
prompt_influence: number;
|
|
38
|
+
model_id: string;
|
|
39
|
+
output_format: string;
|
|
40
|
+
}
|
|
41
|
+
export interface AudioGenerateResult {
|
|
42
|
+
requestId: string;
|
|
43
|
+
provider: "elevenlabs";
|
|
44
|
+
model: string;
|
|
45
|
+
normalizedRequest: NormalizedRequest;
|
|
46
|
+
generatedAt: string;
|
|
47
|
+
generation: "saved";
|
|
48
|
+
sourcePath: string;
|
|
49
|
+
sourceSha256: string;
|
|
50
|
+
sourceSizeBytes: number;
|
|
51
|
+
wavPath: string;
|
|
52
|
+
wavSha256: string;
|
|
53
|
+
wavSizeBytes: number;
|
|
54
|
+
providerRequestId?: string;
|
|
55
|
+
billingUnits?: string;
|
|
56
|
+
commercialUse: "unverified";
|
|
57
|
+
providerTermsUrl: string;
|
|
58
|
+
receiptPath: string;
|
|
59
|
+
replayed: boolean;
|
|
60
|
+
inspection: AudioInspectResult;
|
|
61
|
+
}
|
|
62
|
+
export interface AudioGeneratorOptions extends AudioInspectorOptions {
|
|
63
|
+
fetch?: typeof globalThis.fetch;
|
|
64
|
+
apiKey?: string;
|
|
65
|
+
ffmpegPath?: string;
|
|
66
|
+
}
|
|
67
|
+
export declare class AudioGenerator {
|
|
68
|
+
private readonly fetch;
|
|
69
|
+
private readonly audioDir;
|
|
70
|
+
private readonly apiKeyOverride;
|
|
71
|
+
private readonly ffmpegPath;
|
|
72
|
+
private readonly inspector;
|
|
73
|
+
constructor(options?: AudioGeneratorOptions);
|
|
74
|
+
/**
|
|
75
|
+
* The key is the operator's, read from their environment at call time. It is never defaulted,
|
|
76
|
+
* never written to a receipt, never logged and never returned — a missing one is an error the
|
|
77
|
+
* agent is expected to relay to the person running the server.
|
|
78
|
+
*/
|
|
79
|
+
private apiKey;
|
|
80
|
+
generate(request: AudioGenerateRequest): Promise<AudioGenerateResult>;
|
|
81
|
+
/** Local-only completion: convert, inspect, finish. Never reaches the provider, never charges. */
|
|
82
|
+
private resume;
|
|
83
|
+
private preflight;
|
|
84
|
+
/** Exclusive reservation: two concurrent duplicates cannot both get past this. */
|
|
85
|
+
private reserve;
|
|
86
|
+
private submit;
|
|
87
|
+
/**
|
|
88
|
+
* Decode to PCM16 WAV with no resampling, no downmix, no normalization and no trimming. Turning
|
|
89
|
+
* MP3 into WAV does not restore what the encoder threw away, and pretending otherwise by
|
|
90
|
+
* "cleaning up" here would hide the defect the inspection exists to find.
|
|
91
|
+
*/
|
|
92
|
+
private toWav;
|
|
93
|
+
}
|
|
94
|
+
export {};
|
|
95
|
+
//# sourceMappingURL=generate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"generate.d.ts","sourceRoot":"","sources":["../../src/audio/generate.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { sha256Bytes, sha256File, sha256Json } from "./fsutil.js";
|
|
6
|
+
import { AudioInspector, } from "./inspect.js";
|
|
7
|
+
import { semanticAvailable } from "./semantic.js";
|
|
8
|
+
export class AudioGenerateError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
retryable;
|
|
11
|
+
constructor(code, message, retryable = false) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.retryable = retryable;
|
|
15
|
+
this.name = "AudioGenerateError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const ENDPOINT = "https://api.elevenlabs.io/v1/sound-generation";
|
|
19
|
+
const MODEL_ID = "eleven_text_to_sound_v2";
|
|
20
|
+
const OUTPUT_FORMAT = "mp3_44100_128";
|
|
21
|
+
const PROVIDER_TERMS_URL = "https://help.elevenlabs.io/hc/en-us/articles/13313564601361-Can-I-publish-the-content-I-generate-on-the-platform";
|
|
22
|
+
export const GENERATE_LIMITS = {
|
|
23
|
+
/** Our policy cap, not a claimed provider maximum. */
|
|
24
|
+
maxPromptChars: 2_000,
|
|
25
|
+
minDurationSeconds: 0.5,
|
|
26
|
+
maxDurationSeconds: 30,
|
|
27
|
+
maxResponseBytes: 32 * 1024 * 1024,
|
|
28
|
+
timeoutMs: 120_000,
|
|
29
|
+
};
|
|
30
|
+
export class AudioGenerator {
|
|
31
|
+
fetch;
|
|
32
|
+
audioDir;
|
|
33
|
+
apiKeyOverride;
|
|
34
|
+
ffmpegPath;
|
|
35
|
+
inspector;
|
|
36
|
+
constructor(options = {}) {
|
|
37
|
+
this.fetch = options.fetch ?? globalThis.fetch;
|
|
38
|
+
this.audioDir = resolve(options.audioDir ??
|
|
39
|
+
process.env.AUDIO_DOWNLOAD_DIR ??
|
|
40
|
+
join(homedir(), "Downloads", "threenative-asset-mcp", "audio"));
|
|
41
|
+
this.apiKeyOverride = options.apiKey;
|
|
42
|
+
this.ffmpegPath = options.ffmpegPath ?? "ffmpeg";
|
|
43
|
+
this.inspector = new AudioInspector({ ...options, audioDir: this.audioDir });
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The key is the operator's, read from their environment at call time. It is never defaulted,
|
|
47
|
+
* never written to a receipt, never logged and never returned — a missing one is an error the
|
|
48
|
+
* agent is expected to relay to the person running the server.
|
|
49
|
+
*/
|
|
50
|
+
apiKey() {
|
|
51
|
+
const key = this.apiKeyOverride ?? process.env.ELEVENLABS_API_KEY;
|
|
52
|
+
if (!key || key.trim().length === 0) {
|
|
53
|
+
throw new AudioGenerateError("AUDIO_GENERATE_NO_CREDENTIALS", "ELEVENLABS_API_KEY is not set, so no audio can be generated and nothing was charged. " +
|
|
54
|
+
"Ask the user to set their own ElevenLabs API key in the MCP server's environment; " +
|
|
55
|
+
"the catalog and local inspection tools keep working without it.");
|
|
56
|
+
}
|
|
57
|
+
return key.trim();
|
|
58
|
+
}
|
|
59
|
+
async generate(request) {
|
|
60
|
+
const normalized = normalize(request);
|
|
61
|
+
const normalizedHash = sha256Json(normalized);
|
|
62
|
+
const dir = join(this.audioDir, "generated", request.requestId);
|
|
63
|
+
const receiptPath = join(dir, "receipt.json");
|
|
64
|
+
// Preflight: everything that could fail locally fails before a paid request is made.
|
|
65
|
+
this.apiKey();
|
|
66
|
+
await this.preflight(dir, request);
|
|
67
|
+
const existing = await readReceipt(receiptPath);
|
|
68
|
+
if (existing) {
|
|
69
|
+
if (existing.normalizedHash !== normalizedHash) {
|
|
70
|
+
throw new AudioGenerateError("AUDIO_GENERATE_REQUEST_CONFLICT", "That requestId was already used with different options. Use a new requestId for a new attempt.");
|
|
71
|
+
}
|
|
72
|
+
if (existing.state === "outcome_unknown") {
|
|
73
|
+
throw new AudioGenerateError("AUDIO_GENERATE_OUTCOME_UNKNOWN", "A previous submission of this requestId never reported its outcome; it may already have been " +
|
|
74
|
+
"billed, so this id will never be resubmitted. Use a new requestId to deliberately try again.");
|
|
75
|
+
}
|
|
76
|
+
// Everything from here is local work on bytes already paid for.
|
|
77
|
+
return await this.resume(existing, dir, receiptPath, request);
|
|
78
|
+
}
|
|
79
|
+
await this.reserve(dir, receiptPath, {
|
|
80
|
+
requestId: request.requestId,
|
|
81
|
+
state: "submitted",
|
|
82
|
+
normalizedHash,
|
|
83
|
+
normalized,
|
|
84
|
+
provider: "elevenlabs",
|
|
85
|
+
model: MODEL_ID,
|
|
86
|
+
commercialUse: "unverified",
|
|
87
|
+
providerTermsUrl: PROVIDER_TERMS_URL,
|
|
88
|
+
});
|
|
89
|
+
const response = await this.submit(normalized, receiptPath);
|
|
90
|
+
const receipt = {
|
|
91
|
+
requestId: request.requestId,
|
|
92
|
+
state: "source_saved",
|
|
93
|
+
normalizedHash,
|
|
94
|
+
normalized,
|
|
95
|
+
provider: "elevenlabs",
|
|
96
|
+
model: MODEL_ID,
|
|
97
|
+
generatedAt: new Date().toISOString(),
|
|
98
|
+
...(response.providerRequestId === undefined
|
|
99
|
+
? {}
|
|
100
|
+
: { providerRequestId: response.providerRequestId }),
|
|
101
|
+
...(response.billingUnits === undefined ? {} : { billingUnits: response.billingUnits }),
|
|
102
|
+
commercialUse: "unverified",
|
|
103
|
+
providerTermsUrl: PROVIDER_TERMS_URL,
|
|
104
|
+
};
|
|
105
|
+
const sourcePath = join(dir, "source.mp3");
|
|
106
|
+
await writeExclusive(sourcePath, response.body);
|
|
107
|
+
receipt.source = {
|
|
108
|
+
path: sourcePath,
|
|
109
|
+
sha256: sha256Bytes(response.body),
|
|
110
|
+
sizeBytes: response.body.byteLength,
|
|
111
|
+
};
|
|
112
|
+
await writeReceipt(receiptPath, receipt);
|
|
113
|
+
return await this.resume(receipt, dir, receiptPath, request);
|
|
114
|
+
}
|
|
115
|
+
/** Local-only completion: convert, inspect, finish. Never reaches the provider, never charges. */
|
|
116
|
+
async resume(receipt, dir, receiptPath, request) {
|
|
117
|
+
if (!receipt.source) {
|
|
118
|
+
// `submitted` with nothing saved yet means another caller is mid-flight with this id. That is
|
|
119
|
+
// a conflict to wait on, not an unknown outcome to condemn the id for.
|
|
120
|
+
throw receipt.state === "submitted"
|
|
121
|
+
? new AudioGenerateError("AUDIO_GENERATE_REQUEST_CONFLICT", "That requestId is already in flight. Wait for it, or use a new requestId.")
|
|
122
|
+
: new AudioGenerateError("AUDIO_GENERATE_OUTCOME_UNKNOWN", "This request has no durable source audio, so it cannot be completed locally.");
|
|
123
|
+
}
|
|
124
|
+
// Recorded before this call finishes it: a replay is one that arrived already complete.
|
|
125
|
+
const replayed = receipt.state === "complete";
|
|
126
|
+
// A saved file that no longer matches its recorded hash fails; it is never re-fetched.
|
|
127
|
+
if ((await sha256File(receipt.source.path)) !== receipt.source.sha256) {
|
|
128
|
+
throw new AudioGenerateError("AUDIO_GENERATE_HASH_MISMATCH", "The saved source audio no longer matches the hash recorded when it was written.");
|
|
129
|
+
}
|
|
130
|
+
const wavPath = join(dir, "sound.wav");
|
|
131
|
+
if (!receipt.wav || !(await exists(wavPath))) {
|
|
132
|
+
await this.toWav(receipt.source.path, wavPath);
|
|
133
|
+
receipt.wav = {
|
|
134
|
+
path: wavPath,
|
|
135
|
+
sha256: await sha256File(wavPath),
|
|
136
|
+
sizeBytes: (await stat(wavPath)).size,
|
|
137
|
+
};
|
|
138
|
+
receipt.state = "wav_saved";
|
|
139
|
+
await writeReceipt(receiptPath, receipt);
|
|
140
|
+
}
|
|
141
|
+
else if ((await sha256File(wavPath)) !== receipt.wav.sha256) {
|
|
142
|
+
throw new AudioGenerateError("AUDIO_GENERATE_HASH_MISMATCH", "The saved WAV no longer matches the hash recorded when it was written.");
|
|
143
|
+
}
|
|
144
|
+
const inspection = await this.inspector.inspect({
|
|
145
|
+
...(request.inspection ?? {}),
|
|
146
|
+
path: wavPath,
|
|
147
|
+
loop: receipt.normalized.loop,
|
|
148
|
+
expectedDurationSeconds: receipt.normalized.duration_seconds,
|
|
149
|
+
expectedPrompt: receipt.normalized.text,
|
|
150
|
+
});
|
|
151
|
+
receipt.state = "complete";
|
|
152
|
+
receipt.inspectionCacheKey = inspection.cacheKey;
|
|
153
|
+
await writeReceipt(receiptPath, receipt);
|
|
154
|
+
return {
|
|
155
|
+
requestId: receipt.requestId,
|
|
156
|
+
provider: "elevenlabs",
|
|
157
|
+
model: receipt.model,
|
|
158
|
+
normalizedRequest: receipt.normalized,
|
|
159
|
+
generatedAt: receipt.generatedAt ?? new Date().toISOString(),
|
|
160
|
+
generation: "saved",
|
|
161
|
+
sourcePath: receipt.source.path,
|
|
162
|
+
sourceSha256: receipt.source.sha256,
|
|
163
|
+
sourceSizeBytes: receipt.source.sizeBytes,
|
|
164
|
+
wavPath: receipt.wav.path,
|
|
165
|
+
wavSha256: receipt.wav.sha256,
|
|
166
|
+
wavSizeBytes: receipt.wav.sizeBytes,
|
|
167
|
+
...(receipt.providerRequestId === undefined
|
|
168
|
+
? {}
|
|
169
|
+
: { providerRequestId: receipt.providerRequestId }),
|
|
170
|
+
...(receipt.billingUnits === undefined ? {} : { billingUnits: receipt.billingUnits }),
|
|
171
|
+
commercialUse: "unverified",
|
|
172
|
+
providerTermsUrl: PROVIDER_TERMS_URL,
|
|
173
|
+
receiptPath,
|
|
174
|
+
replayed,
|
|
175
|
+
inspection,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async preflight(dir, request) {
|
|
179
|
+
try {
|
|
180
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
throw new AudioGenerateError("AUDIO_GENERATE_STORAGE", "The audio output directory could not be created, so nothing was requested.");
|
|
184
|
+
}
|
|
185
|
+
await new Promise((done, fail) => {
|
|
186
|
+
execFile(this.ffmpegPath, ["-hide_banner", "-version"], { timeout: 20_000 }, (error) => error
|
|
187
|
+
? fail(new AudioGenerateError("AUDIO_GENERATE_PREREQUISITE_MISSING", "FFmpeg is not available, so the response could not be decoded. Nothing was requested."))
|
|
188
|
+
: done());
|
|
189
|
+
});
|
|
190
|
+
this.inspector.resolveInspectorBin();
|
|
191
|
+
// An explicitly requested semantic model that is not provisioned is refused before spending.
|
|
192
|
+
if (request.inspection?.semantic === "clap" && !(await semanticAvailable())) {
|
|
193
|
+
throw new AudioGenerateError("AUDIO_GENERATE_PREREQUISITE_MISSING", "Semantic inspection was requested but local CLAP is not provisioned. Nothing was requested or charged.");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Exclusive reservation: two concurrent duplicates cannot both get past this. */
|
|
197
|
+
async reserve(dir, receiptPath, receipt) {
|
|
198
|
+
let handle;
|
|
199
|
+
try {
|
|
200
|
+
handle = await open(receiptPath, "wx", 0o600);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
throw new AudioGenerateError("AUDIO_GENERATE_REQUEST_CONFLICT", "That requestId is already in flight. Wait for it, or use a new requestId.");
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
await handle.writeFile(JSON.stringify(receipt, null, 2));
|
|
207
|
+
await handle.sync();
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
await handle.close();
|
|
211
|
+
}
|
|
212
|
+
void dir;
|
|
213
|
+
}
|
|
214
|
+
async submit(normalized, receiptPath) {
|
|
215
|
+
const url = new URL(ENDPOINT);
|
|
216
|
+
url.searchParams.set("output_format", normalized.output_format);
|
|
217
|
+
let response;
|
|
218
|
+
try {
|
|
219
|
+
response = await this.fetch(url, {
|
|
220
|
+
method: "POST",
|
|
221
|
+
// An authenticated request must never be replayed against a redirect target.
|
|
222
|
+
redirect: "error",
|
|
223
|
+
signal: AbortSignal.timeout(GENERATE_LIMITS.timeoutMs),
|
|
224
|
+
headers: {
|
|
225
|
+
"xi-api-key": this.apiKey(),
|
|
226
|
+
"content-type": "application/json",
|
|
227
|
+
accept: "audio/mpeg",
|
|
228
|
+
"user-agent": "threenative-asset-mcp",
|
|
229
|
+
},
|
|
230
|
+
body: JSON.stringify({
|
|
231
|
+
text: normalized.text,
|
|
232
|
+
duration_seconds: normalized.duration_seconds,
|
|
233
|
+
loop: normalized.loop,
|
|
234
|
+
prompt_influence: normalized.prompt_influence,
|
|
235
|
+
model_id: normalized.model_id,
|
|
236
|
+
}),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
// We asked and never learned the answer, so we must assume it may have been billed.
|
|
241
|
+
await markUnknown(receiptPath);
|
|
242
|
+
throw new AudioGenerateError(isTimeout(error) ? "AUDIO_GENERATE_TIMEOUT" : "AUDIO_GENERATE_UPSTREAM", "The generation request did not complete and its outcome is unknown; this requestId will not be retried.");
|
|
243
|
+
}
|
|
244
|
+
if (!response.ok) {
|
|
245
|
+
// A refusal is a definite non-charge answer, so the reservation can simply be cleared.
|
|
246
|
+
await rm(receiptPath, { force: true });
|
|
247
|
+
throw sanitizedUpstream(response.status);
|
|
248
|
+
}
|
|
249
|
+
const providerRequestId = response.headers.get("request-id") ?? undefined;
|
|
250
|
+
const billingUnits = response.headers.get("character-cost") ?? undefined;
|
|
251
|
+
let body;
|
|
252
|
+
try {
|
|
253
|
+
body = await readCapped(response, GENERATE_LIMITS.maxResponseBytes);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (error instanceof AudioGenerateError) {
|
|
257
|
+
await rm(receiptPath, { force: true });
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
await markUnknown(receiptPath);
|
|
261
|
+
throw new AudioGenerateError("AUDIO_GENERATE_UPSTREAM", "The generated audio could not be read completely and its outcome is unknown.");
|
|
262
|
+
}
|
|
263
|
+
if (body.byteLength === 0) {
|
|
264
|
+
await markUnknown(receiptPath);
|
|
265
|
+
throw new AudioGenerateError("AUDIO_GENERATE_UPSTREAM", "The provider returned no audio; the outcome of this request is unknown.");
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
body,
|
|
269
|
+
...(providerRequestId === undefined ? {} : { providerRequestId }),
|
|
270
|
+
...(billingUnits === undefined ? {} : { billingUnits }),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Decode to PCM16 WAV with no resampling, no downmix, no normalization and no trimming. Turning
|
|
275
|
+
* MP3 into WAV does not restore what the encoder threw away, and pretending otherwise by
|
|
276
|
+
* "cleaning up" here would hide the defect the inspection exists to find.
|
|
277
|
+
*/
|
|
278
|
+
async toWav(sourcePath, wavPath) {
|
|
279
|
+
const temporary = `${wavPath}.partial`;
|
|
280
|
+
await new Promise((done, fail) => {
|
|
281
|
+
execFile(this.ffmpegPath, ["-v", "error", "-y", "-i", sourcePath, "-c:a", "pcm_s16le", "-f", "wav", temporary], { timeout: GENERATE_LIMITS.timeoutMs, maxBuffer: 1024 * 1024 }, (error) => error
|
|
282
|
+
? fail(new AudioGenerateError("AUDIO_GENERATE_CONVERT_FAILED", "The saved audio could not be decoded to WAV. The original response is kept.", true))
|
|
283
|
+
: done());
|
|
284
|
+
});
|
|
285
|
+
await rename(temporary, wavPath);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function normalize(request) {
|
|
289
|
+
const text = typeof request.prompt === "string" ? request.prompt.trim() : "";
|
|
290
|
+
if (text.length === 0 || text.length > GENERATE_LIMITS.maxPromptChars) {
|
|
291
|
+
throw new AudioGenerateError("AUDIO_GENERATE_INVALID_INPUT", `The prompt must be between 1 and ${String(GENERATE_LIMITS.maxPromptChars)} characters.`);
|
|
292
|
+
}
|
|
293
|
+
const duration = request.durationSeconds ?? 5;
|
|
294
|
+
if (!Number.isFinite(duration) ||
|
|
295
|
+
duration < GENERATE_LIMITS.minDurationSeconds ||
|
|
296
|
+
duration > GENERATE_LIMITS.maxDurationSeconds) {
|
|
297
|
+
throw new AudioGenerateError("AUDIO_GENERATE_INVALID_INPUT", `durationSeconds must be between ${String(GENERATE_LIMITS.minDurationSeconds)} and ${String(GENERATE_LIMITS.maxDurationSeconds)}.`);
|
|
298
|
+
}
|
|
299
|
+
const influence = request.promptInfluence ?? 0.3;
|
|
300
|
+
if (!Number.isFinite(influence) || influence < 0 || influence > 1) {
|
|
301
|
+
throw new AudioGenerateError("AUDIO_GENERATE_INVALID_INPUT", "promptInfluence must be between 0 and 1.");
|
|
302
|
+
}
|
|
303
|
+
// Generation owns these three; a caller who also sets them in `inspection` is contradicting itself.
|
|
304
|
+
const conflicting = request.inspection;
|
|
305
|
+
for (const key of ["loop", "expectedDurationSeconds", "expectedPrompt", "path"]) {
|
|
306
|
+
if (conflicting && key in conflicting) {
|
|
307
|
+
throw new AudioGenerateError("AUDIO_GENERATE_INVALID_INPUT", `inspection.${key} is supplied by generation itself and must not be set.`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
text,
|
|
312
|
+
duration_seconds: duration,
|
|
313
|
+
loop: request.loop ?? false,
|
|
314
|
+
prompt_influence: influence,
|
|
315
|
+
model_id: MODEL_ID,
|
|
316
|
+
output_format: OUTPUT_FORMAT,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/** Provider error bodies and headers never reach the caller; only a category does. */
|
|
320
|
+
function sanitizedUpstream(status) {
|
|
321
|
+
if (status === 401 || status === 403) {
|
|
322
|
+
return new AudioGenerateError("AUDIO_GENERATE_AUTH_FAILED", "ElevenLabs rejected the API key. Ask the user to check their own ELEVENLABS_API_KEY; nothing was saved.");
|
|
323
|
+
}
|
|
324
|
+
if (status === 429) {
|
|
325
|
+
return new AudioGenerateError("AUDIO_GENERATE_RATE_LIMITED", "ElevenLabs rate-limited or refused the request for quota reasons; nothing was saved.", true);
|
|
326
|
+
}
|
|
327
|
+
if (status === 422 || status === 400) {
|
|
328
|
+
return new AudioGenerateError("AUDIO_GENERATE_REJECTED", "ElevenLabs rejected the request parameters; nothing was saved.");
|
|
329
|
+
}
|
|
330
|
+
return new AudioGenerateError("AUDIO_GENERATE_UPSTREAM", `ElevenLabs returned an error (HTTP ${String(status)}); nothing was saved.`, status >= 500);
|
|
331
|
+
}
|
|
332
|
+
async function readCapped(response, cap) {
|
|
333
|
+
const declared = Number(response.headers.get("content-length") ?? "0");
|
|
334
|
+
if (Number.isFinite(declared) && declared > cap) {
|
|
335
|
+
throw new AudioGenerateError("AUDIO_GENERATE_TOO_LARGE", "The provider's audio response exceeds the size cap; nothing was saved.");
|
|
336
|
+
}
|
|
337
|
+
const reader = response.body?.getReader();
|
|
338
|
+
if (!reader)
|
|
339
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
340
|
+
const chunks = [];
|
|
341
|
+
let total = 0;
|
|
342
|
+
for (;;) {
|
|
343
|
+
const { done, value } = await reader.read();
|
|
344
|
+
if (done)
|
|
345
|
+
break;
|
|
346
|
+
if (value) {
|
|
347
|
+
total += value.byteLength;
|
|
348
|
+
if (total > cap) {
|
|
349
|
+
// Stop pulling bytes we have already decided to refuse.
|
|
350
|
+
await reader.cancel();
|
|
351
|
+
throw new AudioGenerateError("AUDIO_GENERATE_TOO_LARGE", "The provider's audio response exceeds the size cap; nothing was saved.");
|
|
352
|
+
}
|
|
353
|
+
chunks.push(value);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const body = new Uint8Array(total);
|
|
357
|
+
let offset = 0;
|
|
358
|
+
for (const chunk of chunks) {
|
|
359
|
+
body.set(chunk, offset);
|
|
360
|
+
offset += chunk.byteLength;
|
|
361
|
+
}
|
|
362
|
+
return body;
|
|
363
|
+
}
|
|
364
|
+
function isTimeout(error) {
|
|
365
|
+
return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
366
|
+
}
|
|
367
|
+
async function markUnknown(receiptPath) {
|
|
368
|
+
const receipt = await readReceipt(receiptPath);
|
|
369
|
+
if (!receipt)
|
|
370
|
+
return;
|
|
371
|
+
await writeReceipt(receiptPath, { ...receipt, state: "outcome_unknown" });
|
|
372
|
+
}
|
|
373
|
+
async function readReceipt(path) {
|
|
374
|
+
try {
|
|
375
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
async function writeReceipt(path, receipt) {
|
|
382
|
+
const temporary = `${path}.partial`;
|
|
383
|
+
await writeFile(temporary, JSON.stringify(receipt, null, 2), { mode: 0o600 });
|
|
384
|
+
await rename(temporary, path);
|
|
385
|
+
}
|
|
386
|
+
async function writeExclusive(path, bytes) {
|
|
387
|
+
const handle = await open(path, "wx", 0o600);
|
|
388
|
+
try {
|
|
389
|
+
await handle.writeFile(bytes);
|
|
390
|
+
await handle.sync();
|
|
391
|
+
}
|
|
392
|
+
finally {
|
|
393
|
+
await handle.close();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
async function exists(path) {
|
|
397
|
+
try {
|
|
398
|
+
await stat(path);
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
//# sourceMappingURL=generate.js.map
|