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.
- frutebox/adminheader.py +59 -0
- frutebox/attest.py +43 -0
- frutebox/audiogen.py +364 -0
- frutebox/config.py +367 -0
- frutebox/deltasync.py +211 -0
- frutebox/economics.py +270 -0
- frutebox/engine_guard.py +718 -0
- frutebox/envelope.py +77 -0
- frutebox/envvars.py +167 -0
- frutebox/gpuprices.py +641 -0
- frutebox/gpuproviders.py +4489 -0
- frutebox/gui.py +270 -0
- frutebox/imagegen.py +152 -0
- frutebox/inference.py +624 -0
- frutebox/manifest.py +218 -0
- frutebox/mediaserving.py +603 -0
- frutebox/packaging.py +542 -0
- frutebox/pqctransport.py +660 -0
- frutebox/provider.py +1867 -0
- frutebox/providers.py +942 -0
- frutebox/registry/__init__.py +189 -0
- frutebox/registry/generate.py +311 -0
- frutebox/registry/generate_providers.py +120 -0
- frutebox/registry/models.yaml +1759 -0
- frutebox/registry/providers.yaml +518 -0
- frutebox/registry/registry.gen.json +2043 -0
- frutebox/registry_gen.py +3 -0
- frutebox/runpod_templates.py +143 -0
- frutebox/serving.py +691 -0
- frutebox/signedconfig.py +259 -0
- frutebox/signing.py +208 -0
- frutebox/thinkbudget.py +155 -0
- frutebox/videogen.py +118 -0
- frutebox_node-0.1.0.dist-info/METADATA +100 -0
- frutebox_node-0.1.0.dist-info/RECORD +36 -0
- frutebox_node-0.1.0.dist-info/WHEEL +4 -0
frutebox/adminheader.py
ADDED
|
@@ -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
|