Spaces:
Running
Running
File size: 11,708 Bytes
b3d0cca f9dd768 b3d0cca f9dd768 b3d0cca f9dd768 b3d0cca | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 | """Neutone Morpho client β hosted audio-to-audio transform.
Wraps `https://dev.neutone.ai:32475` β the cloud equivalent of the Neutone
Max/AU plugin. Each call is one full file in / one full file out (no
streaming endpoint). 30 transform models exposed via `/models/info`.
Auth: `X-API-Key` header. Key resolution order:
1. `session_key` arg (HF Spaces β held in gr.State)
2. `NEUTONE_API_KEY` env var
3. `~/.neutone_key` (chmod 600, desktop only β None on HF Spaces)
Pricing: per-input-second per-model. Each model has a 1000-hour
(3,600,000-second) budget tracked server-side and returned in
`X-Inference-Seconds-Used` / `X-Inference-Seconds-Limit` response
headers on every `/process_audio` call.
Reference: memory note `pj-neutone-morpho.md`.
"""
from __future__ import annotations
import json
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import requests
BASE_URL = os.environ.get("NEUTONE_BASE_URL", "https://dev.neutone.ai:32475")
# HF Spaces detection β same rule as wallet.py. On Spaces the key MUST NOT
# be read from a disk file (the filesystem is multi-user). It must arrive
# via the NEUTONE_API_KEY environment variable set on the Space, or be
# threaded through gr.State for per-session user-supplied keys.
IS_HF_SPACE = bool(os.environ.get("SPACE_ID"))
KEY_PATH = Path.home() / ".neutone_key"
class MorphoError(RuntimeError):
"""Raised on any Morpho-API call failure. Keeps the user-facing message
short β the full diagnostic goes to stderr in the call site."""
def _stored_key() -> str | None:
"""Reads ~/.neutone_key on desktop. Returns None on HF Spaces β the
key must come from env or the session-scoped UI value there."""
if IS_HF_SPACE:
return None
try:
return KEY_PATH.read_text().strip() or None
except (FileNotFoundError, PermissionError):
return None
def get_key(session_key: str | None = None) -> str | None:
"""Resolve a key for outbound /models/info or /process_audio calls.
HF-Spaces hardening (Codex P1): on Spaces (`SPACE_ID` env set) we
**skip the NEUTONE_API_KEY env-var fallback entirely**, even if the
deploy admin set it. The key was issued to us under confidence; we
must not auto-connect every visitor on the shared deploy key.
Spaces visitors paste their own key into the connect band; the value
lives in gr.State for the session only. Desktop and self-hosted
deploys still resolve in the documented env / file order."""
if session_key and session_key.strip():
return session_key.strip()
if IS_HF_SPACE:
return None
env = os.environ.get("NEUTONE_API_KEY", "").strip()
if env:
return env
return _stored_key()
def _auth_headers(session_key: str | None = None) -> dict[str, str]:
key = get_key(session_key)
if not key:
raise MorphoError(
"Neutone key not configured. Set NEUTONE_API_KEY env var, "
"or write a key to ~/.neutone_key (chmod 600)."
)
return {"X-API-Key": key}
# ---------------------------------------------------------------------------
# /models/info β fetched once per process and cached. 30 models, ~55 kB JSON.
# Cache invalidates on key change so a different session can refresh.
# ---------------------------------------------------------------------------
_MODELS_CACHE: dict[str, list[dict[str, Any]]] = {}
def models(session_key: str | None = None, *, timeout: float = 15.0,
force_refresh: bool = False) -> list[dict[str, Any]]:
"""GET /models/info β list of model dicts (see pj-neutone-morpho.md
for the per-model schema). Cached per-key for the lifetime of the
Python process."""
key = get_key(session_key) or ""
if not force_refresh and key in _MODELS_CACHE:
return _MODELS_CACHE[key]
r = requests.get(
f"{BASE_URL}/models/info",
headers=_auth_headers(session_key),
timeout=timeout,
)
if r.status_code == 401:
raise MorphoError("Neutone API rejected the key (401). Verify "
"NEUTONE_API_KEY / ~/.neutone_key matches the "
"value the Neutone team issued.")
if not r.ok:
raise MorphoError(f"HTTP {r.status_code} from /models/info: "
f"{r.text[:200]}")
try:
data = r.json()
except Exception as e:
raise MorphoError(f"/models/info returned non-JSON: {e}") from e
ms = data.get("models") if isinstance(data, dict) else data
if not isinstance(ms, list):
raise MorphoError(f"/models/info returned unexpected shape: {type(data).__name__}")
_MODELS_CACHE[key] = ms
return ms
def model_by_name(name: str, session_key: str | None = None) -> dict[str, Any] | None:
for m in models(session_key=session_key):
if m.get("model_name") == name or m.get("internal_name") == name:
return m
return None
# ---------------------------------------------------------------------------
# /process_audio β multipart upload, audio back. Wall-clock is 3-5Γ
# realtime warm; 7-10Γ cold. Output is stereo 44.1 kHz WAV regardless of
# input channel count.
# ---------------------------------------------------------------------------
@dataclass
class TransformResult:
path: str
bytes: int
wall_s: float
model_name: str
p1: float
p2: float
p3: float
p4: float
input_seconds: float | None # x-audio-duration-seconds header
seconds_used: float | None # x-inference-seconds-used header
seconds_limit: float | None # x-inference-seconds-limit header
content_type: str
def quota_pct(self) -> float | None:
# Codex P1: original `is None or self.seconds_limit` was inverted β
# returned None when both values were present, raised TypeError when
# used was None. Now: only compute when BOTH are real numbers.
if self.seconds_used is None or not self.seconds_limit:
return None
try:
return 100.0 * self.seconds_used / self.seconds_limit
except (TypeError, ZeroDivisionError):
return None
def transform(
in_wav: str | Path,
*,
model_name: str,
p1: float = 50.0,
p2: float = 50.0,
p3: float = 50.0,
p4: float = 50.0,
out_path: str | Path,
session_key: str | None = None,
timeout: float = 240.0,
) -> TransformResult:
"""POST /process_audio. Clamps p1..p4 to [0, 100] (server-side range).
Writes the response body to `out_path` and returns a TransformResult
with the quota headers so the UI can update an inference-seconds pill.
Raises MorphoError on auth / quota / network / non-audio response.
"""
in_path = Path(in_wav)
if not in_path.is_file():
raise MorphoError(f"input file not found: {in_path}")
# 20 MB cap (per Neutone docs).
size = in_path.stat().st_size
if size > 20 * 1024 * 1024:
raise MorphoError(
f"input is {size/1024/1024:.1f} MB; Neutone cap is 20 MB. "
"Shorten the file or downsample before re-trying."
)
def _clamp(v: float) -> float:
try:
f = float(v)
except (TypeError, ValueError):
return 50.0
return max(0.0, min(100.0, f))
params = {
"model_name": model_name,
"p1": _clamp(p1), "p2": _clamp(p2), "p3": _clamp(p3), "p4": _clamp(p4),
}
headers = _auth_headers(session_key)
t0 = time.perf_counter()
with in_path.open("rb") as fh:
files = {"file": (in_path.name, fh, "audio/wav")}
data = {"params": json.dumps(params)}
r = requests.post(
f"{BASE_URL}/process_audio",
headers=headers, files=files, data=data,
timeout=timeout,
)
wall = time.perf_counter() - t0
if r.status_code == 401:
raise MorphoError("Neutone API rejected the key (401).")
if r.status_code == 429:
# Per-model inference-seconds quota exhausted (1000h budget).
raise MorphoError(
f"Neutone model '{model_name}' is out of inference seconds "
"(429). Try a different model or wait for quota to reset."
)
if not r.ok:
snippet = r.text[:200].replace("\n", " ")
raise MorphoError(f"HTTP {r.status_code} from /process_audio: {snippet}")
ctype = (r.headers.get("content-type") or "").lower()
if not ctype.startswith("audio/"):
# Server should always return audio/* on a 200. If we got something
# else (e.g. JSON error wrapped in 200), bail loudly so we don't
# write garbage to the crate.
raise MorphoError(
f"expected audio/* response, got '{ctype}' Β· "
f"first 120 bytes: {r.content[:120]!r}"
)
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(r.content)
def _f(h: str) -> float | None:
v = r.headers.get(h)
try:
return float(v) if v is not None else None
except (TypeError, ValueError):
return None
return TransformResult(
path=str(out_path),
bytes=len(r.content),
wall_s=round(wall, 2),
model_name=model_name,
p1=params["p1"], p2=params["p2"], p3=params["p3"], p4=params["p4"],
input_seconds=_f("x-audio-duration-seconds"),
seconds_used=_f("x-inference-seconds-used"),
seconds_limit=_f("x-inference-seconds-limit"),
content_type=ctype,
)
# ---------------------------------------------------------------------------
# UI helpers β turn the per-model schema into the bits a Gradio panel needs.
# ---------------------------------------------------------------------------
def model_choices_grouped(session_key: str | None = None) -> list[tuple[str, str]]:
"""Return `[(display_label, model_name), β¦]` ordered by category then
name. Display label is `{CATEGORY} Β· {model_name}` so a flat dropdown
is browseable without nested grouping (Gradio v6 Dropdown can't nest)."""
try:
ms = models(session_key=session_key)
except MorphoError:
return []
rows = sorted(ms, key=lambda m: (m.get("model_category") or "zzz",
m.get("model_name") or ""))
return [
(f"{(m.get('model_category') or '?').upper():<10} Β· {m['model_name']}",
m["model_name"])
for m in rows
]
def param_labels(model_name: str, session_key: str | None = None) -> dict[str, dict[str, Any]]:
"""`{p1: {name, description, default}, β¦}` for a model. Falls back to
generic labels if the model is missing or the params dict is empty."""
m = model_by_name(model_name, session_key=session_key)
fallback = {f"p{i}": {"name": f"Macro {i}", "description": "",
"default": 50.0} for i in range(1, 5)}
if not m:
return fallback
params = m.get("parameters") or {}
out = {}
for k in ("p1", "p2", "p3", "p4"):
p = params.get(k) or {}
out[k] = {
"name": p.get("name") or f"Macro {k[1]}",
"description": p.get("description") or "",
"default": float(p.get("default_value", 50.0)),
}
return out
def preset_choices(model_name: str, session_key: str | None = None) -> list[dict[str, Any]]:
"""List of presets for a model: `[{name, p1, p2, p3, p4, tag}, β¦]`.
Empty list if the model has no presets configured."""
m = model_by_name(model_name, session_key=session_key)
if not m:
return []
return list(m.get("presets_minimal") or [])
|