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 [])