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 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