frutebox-node 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,59 @@
1
+ """The admin header's name, in ONE place, during the cutover from `x-stevai-*` to `x-frutebox-*`.
2
+
3
+ WHY A MODULE FOR A STRING
4
+ Because the string is live. Thomas sends this header by hand from his terminal, the gateways send
5
+ it to the directory, the directory sends it to the web app, and nine web routes check it. Renaming
6
+ it anywhere before everywhere means a request that used to authenticate stops authenticating, and
7
+ the failure looks like a permissions bug rather than a rename.
8
+
9
+ THE ORDER, WHICH IS THE WHOLE POINT (NC-1):
10
+ 1. every READER accepts BOTH spellings — deployed first, so nothing can break;
11
+ 2. every SENDER moves to the new spelling — code, scripts, docs;
12
+ 3. `ACCEPT_LEGACY` goes False and the fallback below is deleted — deployed last.
13
+ At no point does a window exist where neither spelling works.
14
+
15
+ THE SECOND TOKEN MOVES IN THE SAME WINDOW. `x-stevai-admin-token-2` is the quorum kill-switch's
16
+ second signature. Giving it its own cutover later would mean opening this same window twice, so it
17
+ travels with the first.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ #: The names we are moving to.
22
+ NEW = "x-frutebox-admin-token"
23
+ NEW_2 = "x-frutebox-admin-token-2"
24
+
25
+ #: The names we are moving from. Deleted at phase 3.
26
+ OLD = "x-stevai-admin-token"
27
+ OLD_2 = "x-stevai-admin-token-2"
28
+
29
+ #: PHASE 2 → 3 SWITCH. While True, a reader accepts either spelling. Phase 3 sets it False AND
30
+ #: deletes the legacy branch below; it is a constant rather than an env var on purpose — the
31
+ #: cutover should end in code that has no memory of the old name, not in a flag someone has to
32
+ #: remember is still set.
33
+ ACCEPT_LEGACY = True
34
+
35
+
36
+ def read(headers, *, second: bool = False) -> str:
37
+ """The admin token from a request's headers, under either spelling.
38
+
39
+ `headers` is anything with a `.get(name, default)` — Starlette/FastAPI request headers are
40
+ case-insensitive, which is why no lower()ing happens here.
41
+
42
+ Returns "" when absent, never None, so a caller can hand the result straight to
43
+ `secrets.compare_digest` without a type check.
44
+ """
45
+ new, old = (NEW_2, OLD_2) if second else (NEW, OLD)
46
+ value = headers.get(new, "") or ""
47
+ if value:
48
+ return value
49
+ if ACCEPT_LEGACY:
50
+ return headers.get(old, "") or ""
51
+ return ""
52
+
53
+
54
+ def send(token: str, *, second: bool = False) -> dict:
55
+ """The header dict a caller should send. Always the NEW spelling — senders never emit the old
56
+ one, because phase 1 already taught every reader to accept it."""
57
+ if not token:
58
+ return {}
59
+ return {NEW_2 if second else NEW: token}
frutebox/attest.py ADDED
@@ -0,0 +1,43 @@
1
+ """Software integrity attestation — is the node running the code we shipped?
2
+
3
+ On (re)connect a node reports a digest of its own agent code + declared version.
4
+ The operator compares it to the set of digests it has published for that version.
5
+ A mismatch means the node is running modified or unknown code (a patched agent
6
+ that logs prompts, say) and it is refused/quarantined before it can take traffic.
7
+
8
+ This is SOFTWARE attestation — a hash check plus the accountable-operator trust
9
+ model — explicitly NOT a hardware TEE. It raises the bar for a tampered node
10
+ without any special silicon; it is one tripwire among several, not a proof.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ from pathlib import Path
16
+ from typing import Iterable
17
+
18
+ # The agent files whose bytes define "the code we shipped". Kept small and
19
+ # stable; in production this would be the signed release manifest.
20
+ # engine_guard.py is in the set deliberately: it holds the model allowlist, the
21
+ # engine endpoint allowlist and the resource ceilings, so it is precisely the file a
22
+ # node operator would edit to make their machine accept work it should refuse.
23
+ _AGENT_FILES = ("provider.py", "supervisor.py", "inference.py", "engine_guard.py")
24
+
25
+
26
+ def agent_digest(root: Path | None = None, files: Iterable[str] = _AGENT_FILES) -> str:
27
+ """A stable SHA-256 over the agent's own source. Same code → same digest."""
28
+ root = root or Path(__file__).resolve().parent
29
+ h = hashlib.sha256()
30
+ for name in sorted(files):
31
+ p = root / name
32
+ h.update(name.encode())
33
+ h.update(b"\0")
34
+ if p.exists():
35
+ h.update(p.read_bytes())
36
+ h.update(b"\0")
37
+ return h.hexdigest()
38
+
39
+
40
+ def verify(reported_digest: str, allowed_digests: Iterable[str]) -> bool:
41
+ """True iff the node's reported digest is one the operator has published."""
42
+ allowed = set(allowed_digests or ())
43
+ return bool(reported_digest) and reported_digest in allowed
frutebox/audiogen.py ADDED
@@ -0,0 +1,364 @@
1
+ """Audio generation — runs on a GPU node. Speech (TTS), voice cloning and music/sound,
2
+ mirroring `imagegen`. Heavy libs are imported lazily and only present on an audio-capable
3
+ node, so `available()` is False elsewhere and the gateway never routes audio jobs here.
4
+ Output is WAV bytes.
5
+
6
+ WHAT CHANGED AND WHY IT MATTERED. This module used to pick its own models by env default:
7
+
8
+ _SPEECH_MODEL = envvar("SPEECH_MODEL", "suno/bark-small")
9
+ _MUSIC_MODEL = envvar("MUSIC_MODEL", "facebook/musicgen-small")
10
+ _CLONE_MODEL = envvar("VOICE_CLONE_MODEL", "resemble-ai/chatterbox")
11
+
12
+ All three were wrong, in three different ways:
13
+
14
+ * **MusicGen is CC-BY-NC 4.0.** Plan 23 §A excludes it by name, `libraries.md` lists it under
15
+ "⛔ AVOID — NON-commercial, CI-blocked", and `web/src/lib/audio/music-models.ts` already
16
+ recorded it as excluded "kept here so nobody re-adds it". It was nevertheless the live
17
+ default on every audio-capable node, because the file-09 licence CI matches registry IDS
18
+ and this surface spoke WEIGHTS REFS — `landmines_referenced(["facebook/musicgen-small"])`
19
+ returned `[]`. A commercial product was shipping a non-commercial model with green tests.
20
+ * **Bark is not declared in `registry/models.yaml`**, so it passed no licence, territory or
21
+ tier gate on its way to a GPU. Its own licence is fine; that is not the point. Plan 23 §A's
22
+ TTS primary is Kokoro-82M.
23
+ * **`resemble-ai/chatterbox` does not exist** (HTTP 401). The org is `ResembleAI`. The
24
+ voice-cloning path could never have fetched its model at all.
25
+
26
+ Model choice now comes from `mediaserving.resolve_media_model(kind, ...)` — registry-declared,
27
+ licence-gated, geo-fenced and sized to this card. The env overrides still exist and are
28
+ VALIDATED: `MUSIC_MODEL=facebook/musicgen-small` is refused with the licence as the reason
29
+ rather than honoured.
30
+
31
+ RUNTIMES. Each model needs its own loader (`libraries.md` §7 is authoritative): Kokoro is the
32
+ `kokoro` pip package, Chatterbox is `chatterbox.tts`, YuE is transformers, ACE-Step is its own
33
+ pip package or ComfyUI. Where the runtime for the resolved model is not installed on this node,
34
+ this raises and names the install — it never falls back to a different model, because the
35
+ resolver already chose the only one the plan sanctions for that kind.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import io
40
+ import os
41
+ import struct
42
+
43
+ from . import mediaserving
44
+
45
+ _speech = None
46
+ _music = None
47
+ _clone = None
48
+ _speech_ref = ""
49
+ _music_ref = ""
50
+ _clone_ref = ""
51
+
52
+ # Keywords that mean "make music/a sound" rather than "speak this".
53
+ _MUSIC_HINT = ("music", "melody", "song", "instrumental", "soundtrack", "beat", "tune", "compose")
54
+
55
+ #: The pip install each model's runtime needs, from `libraries.md` §7. Quoted in the error so a
56
+ #: node operator is told what to do rather than that something is missing.
57
+ _RUNTIME_INSTALL = {
58
+ "kokoro-82m": "pip install kokoro",
59
+ "chatterbox": "pip install chatterbox-tts",
60
+ # NOT `pip install acestep` — that package is ACE-Step v1 and its pipeline takes a local
61
+ # checkpoint dir, so pointing it at the 1.5 repo id downloads v1 instead (see _make_music).
62
+ "ace-step-1.5": "ACE-Step 1.5 needs transformers with trust_remote_code, or ComfyUI",
63
+ "yue-7b": "pip install transformers",
64
+ }
65
+
66
+
67
+ def speech_runtime_available() -> bool:
68
+ """Is a TTS runtime this node could actually speak with importable here?
69
+
70
+ `imagegen.available()` checks for `diffusers` and `videogen.available()` for ffmpeg. The
71
+ audio equivalent has to be checked too, and for a while it was not: `available()` asked
72
+ only for CUDA and a resolvable model, so ANY GPU node advertised `audio` and then failed
73
+ every job in `_runtime_missing`. That is exactly the "capacity that does not exist" this
74
+ module's own §B2 advertisement floor was added to prevent — the node must not claim what
75
+ it cannot serve.
76
+ """
77
+ for mod in ("kokoro", "chatterbox"):
78
+ try:
79
+ __import__(mod)
80
+ return True
81
+ except Exception:
82
+ continue
83
+ return False
84
+
85
+
86
+ def available() -> bool:
87
+ """True iff this node can generate audio: CUDA, a model it is ALLOWED to run, and a
88
+ runtime that can actually run it. All three, because any one of them missing means a job
89
+ routed here is a job that fails."""
90
+ try:
91
+ import torch # type: ignore
92
+ if not torch.cuda.is_available():
93
+ return False
94
+ except Exception:
95
+ return False
96
+ if not speech_runtime_available():
97
+ return False
98
+ try:
99
+ resolved_model("speech")
100
+ return True
101
+ except mediaserving.MediaUnavailable:
102
+ return False
103
+
104
+
105
+ def resolved_model(kind: str, *, node_region: str = "", user_region: str = ""):
106
+ """The registry-declared, licence-gated model for one audio kind, sized to this card.
107
+
108
+ `kind` is the resolver's vocabulary: "speech", "clone", "music", "song" or "sfx".
109
+ """
110
+ from .imagegen import device_vram_gb
111
+ area = {"speech": "SPEECH_MODEL", "clone": "VOICE_CLONE_MODEL"}.get(kind, "MUSIC_MODEL")
112
+ return mediaserving.resolve_media_model(
113
+ kind,
114
+ node_region=node_region, user_region=user_region,
115
+ override=mediaserving.model_override(area),
116
+ max_vram_gb=device_vram_gb(),
117
+ )
118
+
119
+
120
+ def _wav(samples, rate: int) -> bytes:
121
+ """Encode float/int mono PCM samples to a 16-bit WAV (no external deps)."""
122
+ # Same treatment as deterministic.py's sympy/pandas guards: the caller gets this module's own
123
+ # "not available on this node" RuntimeError, the way `generate_audio` already reports a missing
124
+ # capability, rather than a bare ModuleNotFoundError from inside the encoder.
125
+ try:
126
+ import numpy as np # type: ignore
127
+ except Exception as e:
128
+ raise RuntimeError("audio encoding is not available on this node: numpy is not installed") from e
129
+ arr = np.asarray(samples, dtype=np.float32).flatten()
130
+ if arr.size and arr.dtype.kind == "f":
131
+ arr = np.clip(arr, -1.0, 1.0)
132
+ pcm = (arr * 32767).astype("<i2").tobytes()
133
+ else:
134
+ pcm = np.asarray(samples, dtype="<i2").tobytes()
135
+ buf = io.BytesIO()
136
+ buf.write(b"RIFF"); buf.write(struct.pack("<I", 36 + len(pcm))); buf.write(b"WAVE")
137
+ buf.write(b"fmt "); buf.write(struct.pack("<IHHIIHH", 16, 1, 1, rate, rate * 2, 2, 16))
138
+ buf.write(b"data"); buf.write(struct.pack("<I", len(pcm))); buf.write(pcm)
139
+ return buf.getvalue()
140
+
141
+
142
+ def _kind_for(prompt: str) -> str:
143
+ low = prompt.lower()
144
+ return "music" if any(k in low for k in _MUSIC_HINT) else "speech"
145
+
146
+
147
+ def _runtime_missing(spec, exc: Exception) -> RuntimeError:
148
+ how = _RUNTIME_INSTALL.get(spec.model_id, "")
149
+ tail = f" — install it with: {how}" if how else ""
150
+ return RuntimeError(
151
+ f"{spec.model_id} is the model this node must use for {spec.kind!r}, but its runtime is "
152
+ f"not installed here{tail}. ({exc})")
153
+
154
+
155
+ def clone_available() -> bool:
156
+ """True iff this node can speak in a supplied voice (the cloning model is present).
157
+
158
+ Separate from `available()` on purpose: a node may generate speech and music
159
+ without carrying the cloning model, and the gateway must be able to tell —
160
+ a request to speak in someone's voice is never quietly answered in a
161
+ stranger's voice instead.
162
+ """
163
+ if not available():
164
+ return False
165
+ try:
166
+ resolved_model("clone")
167
+ except mediaserving.MediaUnavailable:
168
+ return False
169
+ try:
170
+ import chatterbox # type: ignore # noqa: F401
171
+ return True
172
+ except Exception:
173
+ return False
174
+
175
+
176
+ def generate_audio(prompt: str, *, kind: str = "", voice_sample: "bytes | None" = None,
177
+ language: str = "", node_region: str = "", user_region: str = "") -> bytes:
178
+ """Generate WAV audio from a text prompt. `kind` is "speech" | "music" | "song" | "sfx"
179
+ (auto-detected between speech and music if empty). With `voice_sample` (a few seconds of
180
+ WAV/MP3 of a real person, supplied with their consent) the speech is produced IN THAT VOICE.
181
+
182
+ Raises RuntimeError where the models aren't available — never a substitute
183
+ voice, because the point of the request was whose voice it is."""
184
+ if not prompt or not prompt.strip():
185
+ raise ValueError("empty audio prompt")
186
+ # Capability is asked ONCE, here, through `available()` / `clone_available()` — not
187
+ # re-derived inline from torch. Those two functions are what the gateway reads to decide
188
+ # whether to route here at all, so a check that bypasses them can diverge from the
189
+ # advertisement, and the request that lands is the one that must not be answered wrong.
190
+ if not available():
191
+ raise RuntimeError("audio generation is not available on this node")
192
+ kind = kind or _kind_for(prompt)
193
+
194
+ if voice_sample:
195
+ if kind != "speech":
196
+ raise ValueError(f"a voice sample makes no sense for {kind} generation")
197
+ # THE RULE: a request to speak in somebody's voice is answered in THAT voice or
198
+ # refused. Never a stranger's voice — whose voice it is was the entire point.
199
+ if not clone_available():
200
+ raise RuntimeError("voice cloning is not available on this node")
201
+ spec = resolved_model("clone", node_region=node_region, user_region=user_region)
202
+ return _clone_speech(spec, prompt, voice_sample, language)
203
+
204
+ spec = resolved_model(kind, node_region=node_region, user_region=user_region)
205
+ if spec.tier == "tts":
206
+ return _speak(spec, prompt, language)
207
+ return _make_music(spec, prompt)
208
+
209
+
210
+ def _speak(spec, text: str, language: str = "") -> bytes:
211
+ """Text-to-speech through the resolved TTS model's own runtime."""
212
+ global _speech, _speech_ref
213
+ if spec.model_id == "kokoro-82m":
214
+ try:
215
+ from kokoro import KPipeline # type: ignore
216
+ except Exception as e:
217
+ raise _runtime_missing(spec, e) from e
218
+ # Kokoro selects its voice pack by language code; the dialect tag narrows to the
219
+ # language the model knows, exactly as the cloning path does.
220
+ lang = (language.split("-")[0].lower() if language else "a") or "a"
221
+ # The language is part of the cache key, not just the ref: KPipeline binds a voice pack
222
+ # at construction, so a cached English pipeline would answer a Dutch request in English.
223
+ key = f"{spec.ref}|{lang}"
224
+ if _speech is None or _speech_ref != key:
225
+ # UNPINNED TODAY — NOT WIRED, not impossible. `KPipeline` itself takes no
226
+ # `revision`, but it accepts a prebuilt `model=KModel(...)`, and `KModel` takes
227
+ # `config` and `model` as LOCAL PATHS. So the pin is reachable in a few lines:
228
+ # cfg = hf_hub_download(spec.repo, "config.json", revision=spec.revision)
229
+ # wts = hf_hub_download(spec.repo, <weights>, revision=spec.revision)
230
+ # KPipeline(lang_code=lang, model=KModel(repo_id=spec.repo, config=cfg, model=wts))
231
+ # It is left unwired because it cannot be exercised here (no GPU, no kokoro
232
+ # package), and shipping an untested loader is how the ACE-Step defect happened.
233
+ # Written down rather than called impossible, because a wrong "cannot" is the kind
234
+ # of comment nobody ever revisits. Tracked in LANE_MEDIA.md.
235
+ _speech = KPipeline(lang_code=lang, repo_id=spec.repo)
236
+ _speech_ref = key
237
+ chunks = [audio for _gs, _ps, audio in _speech(text)]
238
+ if not chunks:
239
+ raise RuntimeError(f"{spec.model_id} produced no audio for this text")
240
+ try:
241
+ import numpy as np # type: ignore
242
+ except Exception as e:
243
+ raise RuntimeError("audio encoding is not available on this node: numpy is not installed") from e
244
+ joined = np.concatenate([
245
+ c.detach().cpu().numpy() if hasattr(c, "detach") else np.asarray(c) for c in chunks])
246
+ return _wav(joined, 24000)
247
+ if spec.model_id == "chatterbox":
248
+ # Chatterbox without a reference sample speaks in its own default voice.
249
+ return _clone_speech(spec, text, b"", language)
250
+ raise _runtime_missing(spec, NotImplementedError(
251
+ f"no TTS runtime is wired for {spec.model_id} in this node build"))
252
+
253
+
254
+ def _make_music(spec, prompt: str) -> bytes: # noqa: ARG001 — see below
255
+ """Music / song / SFX through the resolved model's own runtime.
256
+
257
+ There is deliberately no generic `transformers.pipeline("text-to-audio")` path here. That is
258
+ how `facebook/musicgen-small` became a one-line default in the first place: a generic loader
259
+ will happily load ANY repo string, so the licence decision quietly moves from the registry to
260
+ whoever last edited an env var.
261
+
262
+ `prompt` is unused TODAY because neither music runtime is wired (see each branch). It stays
263
+ in the signature because the caller has it and wiring either branch needs it — dropping it
264
+ would make the wiring a two-file change for no gain.
265
+ """
266
+ if spec.model_id == "ace-step-1.5":
267
+ # NOT WIRED, deliberately, and this is the safer of the two options.
268
+ #
269
+ # The obvious call — `ACEStepPipeline(checkpoint_dir=spec.repo)` from the `acestep`
270
+ # package — is WRONG in a way that defeats this whole module. `checkpoint_dir` is a
271
+ # LOCAL DIRECTORY, not a repo id: that call makes a directory literally named
272
+ # "ACE-Step/Ace-Step1.5" under the node's cwd and then downloads the package's own
273
+ # hardcoded `ACE-Step/ACE-Step-v1-3.5B` into it. A DIFFERENT model from the one the
274
+ # registry gated, at no pinned revision, having passed none of the checks above —
275
+ # which is precisely the class of defect `mediaserving` exists to make impossible.
276
+ #
277
+ # ACE-Step 1.5 is also not a v1-layout checkpoint: its card declares
278
+ # `library_name: transformers` with `custom_code`, so it loads through transformers
279
+ # with `trust_remote_code`, i.e. by EXECUTING code from the repo on the node. That is
280
+ # a decision to take deliberately with a tested loader, not to slip in untested.
281
+ #
282
+ # So this refuses, names the pinned ref, and music stays honestly unavailable rather
283
+ # than quietly running something else.
284
+ raise _runtime_missing(spec, NotImplementedError(
285
+ f"ACE-Step 1.5 loads through transformers with trust_remote_code, not the v1 "
286
+ f"`acestep` pipeline; that loader is not wired into this node build. "
287
+ f"Pinned ref: {spec.ref}"))
288
+ if spec.model_id == "yue-7b":
289
+ # YuE is a TWO-STAGE model: stage 1 writes the vocal/accompaniment tokens, stage 2
290
+ # upsamples them into audio. A single-stage call returns tokens, not a song, so the
291
+ # resolver carries both repos and a node missing stage 2 is told so rather than
292
+ # returning something that is not a song.
293
+ if not spec.stage2_repo:
294
+ raise RuntimeError(f"{spec.model_id} needs its stage-2 repo and none is pinned")
295
+ raise _runtime_missing(spec, NotImplementedError(
296
+ "YuE's two-stage pipeline is not wired into this node build yet; "
297
+ f"stage 1 {spec.repo}, stage 2 {spec.stage2_repo}"))
298
+ raise _runtime_missing(spec, NotImplementedError(
299
+ f"no music runtime is wired for {spec.model_id} in this node build"))
300
+
301
+
302
+ def _clone_speech(spec, text: str, voice_sample: bytes, language: str = "") -> bytes:
303
+ """Speak `text` in the voice of the person in `voice_sample`.
304
+
305
+ The sample is written to a temporary file and deleted immediately after: a
306
+ node never keeps a copy of someone's voice. It arrived for one utterance and
307
+ it leaves with it.
308
+
309
+ That file goes in the NODE'S OWN scratch directory (0700, and inside
310
+ $STEVAI_WORKSPACE when the supervisor provided one, so "stop sharing" really
311
+ wipes it) — not the shared system temp dir, where every other process on the
312
+ machine could read a stranger's voice while the utterance was being produced.
313
+ """
314
+ import tempfile
315
+
316
+ from .engine_guard import scratch_dir
317
+
318
+ global _clone, _clone_ref
319
+ # Reload when there is nothing cached, or when a cache WE populated is for a different
320
+ # resolved model. An empty `_clone_ref` means the model was supplied directly rather than
321
+ # loaded here, and a supplied model is used as given — replacing it would override the
322
+ # caller's explicit choice with a download.
323
+ if _clone is None or (_clone_ref and _clone_ref != spec.ref):
324
+ # Imported here, not at module load: a node without the cloning model
325
+ # still generates ordinary speech and music.
326
+ try:
327
+ from chatterbox.tts import ChatterboxTTS # type: ignore
328
+ except Exception as e:
329
+ raise _runtime_missing(spec, e) from e
330
+ # UNPINNED TODAY — NOT WIRED, not impossible, same as the Kokoro path.
331
+ # `from_pretrained(device)` hardcodes its repo, but it is a thin wrapper over the
332
+ # PUBLIC `from_local(ckpt_dir, device)`, so the pin is reachable:
333
+ # d = <dir of> hf_hub_download(spec.repo, <each file>, revision=spec.revision)
334
+ # ChatterboxTTS.from_local(d, "cuda")
335
+ # Left unwired for the same reason: it cannot be exercised here, and an untested
336
+ # loader on this path is exactly what went wrong with ACE-Step. The resolver still
337
+ # decides WHETHER chatterbox may run at all (licence, territory, tier). Keyed on
338
+ # `spec.ref` so a resolver change reloads rather than reusing the previous model.
339
+ _clone = ChatterboxTTS.from_pretrained(device="cuda")
340
+ _clone_ref = spec.ref
341
+
342
+ path = None
343
+ try:
344
+ kwargs = {}
345
+ if voice_sample:
346
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False,
347
+ dir=str(scratch_dir())) as f:
348
+ f.write(voice_sample)
349
+ path = f.name
350
+ os.chmod(path, 0o600)
351
+ kwargs["audio_prompt_path"] = path
352
+ if language:
353
+ # A dialect tag ("nl-BE-westvl") narrows to the language the model
354
+ # knows; the dialect itself survives in the words, not the model id.
355
+ kwargs["language_id"] = language.split("-")[0].lower()
356
+ wav = _clone.generate(text, **kwargs)
357
+ rate = int(getattr(_clone, "sr", 24000))
358
+ return _wav(wav.squeeze().cpu().numpy() if hasattr(wav, "cpu") else wav, rate)
359
+ finally:
360
+ if path:
361
+ try:
362
+ os.remove(path)
363
+ except OSError:
364
+ pass