Spaces:
Sleeping
Sleeping
| """Local audio-gen server β reference example for audio-brief's "Local server" model. | |
| When you select "Local server (your machine)" in audio-brief's Generate tab, | |
| your browser POSTs `{prompt, duration}` to `<this server>/generate` and | |
| expects audio bytes back (WAV or MP3). The audio never leaves your machine | |
| on its way to a remote gen provider β the HF Spaces backend only receives | |
| the resulting audio (uploaded by the browser, not the server). | |
| Run me: | |
| pip install fastapi uvicorn | |
| # plus whatever your gen backend needs (mlx-audio, diffusers, etc.) | |
| python local-gen-server-example.py | |
| # serves at http://localhost:7864 | |
| CORS + Private Network Access: | |
| The browser fetch comes from `https://<your-space>.hf.space` β a | |
| different origin from `http://localhost:7864`. The server must return | |
| permissive CORS headers AND the PNA-Allow header. The middleware | |
| below does both. Without the PNA header the browser silently fails | |
| the request with `TypeError: Failed to fetch` β even though Chrome | |
| explicitly exempts http://localhost from mixed-content blocking. | |
| Mixed content: | |
| Chrome / Brave / Edge allow http://localhost from HTTPS pages | |
| (special exception). Safari is stricter β if Safari blocks, run | |
| this server with a self-signed cert on HTTPS, or test in Chrome. | |
| """ | |
| from __future__ import annotations | |
| import io | |
| import os | |
| import subprocess | |
| from pathlib import Path | |
| from fastapi import FastAPI, Request | |
| from fastapi.responses import Response | |
| from pydantic import BaseModel | |
| app = FastAPI() | |
| # CORS + Private Network Access in one explicit middleware. | |
| # | |
| # Chrome enforces Private Network Access (PNA) on cross-origin requests | |
| # from public origins (e.g. an HTTPS HF Space) to private-network IPs | |
| # (localhost, 127.0.0.1, 10/8, 192.168/16). It fires a CORS-style | |
| # preflight OPTIONS with header `Access-Control-Request-Private-Network: | |
| # true`, and the local server MUST answer with | |
| # `Access-Control-Allow-Private-Network: true` or the browser refuses | |
| # the real request with TypeError: Failed to fetch. | |
| # | |
| # FastAPI's CORSMiddleware does NOT add the PNA-Allow header, so we | |
| # replace it with this hand-rolled middleware that returns both. We | |
| # answer OPTIONS preflights directly (204, no body) so the SA3 binary | |
| # never spins up on a preflight. | |
| # | |
| # Spec: https://wicg.github.io/private-network-access/ | |
| async def cors_and_pna(request: Request, call_next): | |
| if request.method == "OPTIONS": | |
| response = Response(status_code=204) | |
| else: | |
| response = await call_next(request) | |
| origin = request.headers.get("origin", "*") | |
| response.headers["Access-Control-Allow-Origin"] = origin | |
| response.headers["Vary"] = "Origin" | |
| response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" | |
| # CORS header matching is case-insensitive per the Fetch spec, but | |
| # some browser builds/extensions have been observed comparing | |
| # case-sensitively. Cheap to widen the allowlist. | |
| response.headers["Access-Control-Allow-Headers"] = "Content-Type, content-type" | |
| # The PNA-Allow header is the load-bearing one β without it Chrome | |
| # silently fails the request before any body is sent. Keep this on. | |
| response.headers["Access-Control-Allow-Private-Network"] = "true" | |
| return response | |
| class GenRequest(BaseModel): | |
| prompt: str | |
| duration: int = 15 | |
| class TransformRequest(BaseModel): | |
| """Audio-to-audio (init-audio) gen. The browser POSTs from the Space | |
| page; it sends EITHER `audio_url` (preferred β the Space's public | |
| `/gradio_api/file=β¦` URL, which the bridge fetches itself) OR | |
| `audio_b64` (fallback β base64 of the raw bytes). audio_url is | |
| strongly preferred: avoids a 1.4Γ base64 inflation across the JSON | |
| boundary and keeps the bridge stateless about who couriered the | |
| bytes. Bridge converts to SA3's required WAV format (44.1 kHz / | |
| 16-bit PCM) via ffmpeg either way, then invokes SA3 --init-audio.""" | |
| prompt: str | |
| audio_url: str = "" # preferred: URL the bridge fetches itself | |
| audio_b64: str = "" # fallback: base64 of source audio bytes | |
| duration: int = 15 | |
| init_strength: float = 0.7 # β --init-noise-level. 0.4-0.8 typical; | |
| # 1.0 = full regen (init ignored). | |
| steps: int = 8 # β --steps. SA3 default; sweet spot. | |
| cfg: float = 1.0 # β --cfg. >1.0 enables negative_prompt. | |
| negative_prompt: str = "" # β --negative-prompt. Ignored when cfg==1.0. | |
| def run_mlx_sa3(prompt: str, duration: int) -> bytes: | |
| """Shell out to the MLX Stable Audio 3 Small-Music binary at | |
| `~/sa3_mlx/sa3`. CLI verified against the pj-battle engine | |
| (engine/audio-providers.js:302). Output is WAV. | |
| Default flags: `--dit sm-music --decoder same-s`. These match the | |
| engine's "small music" defaults β good quality, fits in 8 GB RAM. | |
| Spawn-per-request only; never two SA3 processes at once on this | |
| machine (each holds ~2 GB).""" | |
| binary = os.environ.get("SA3_BIN", str(Path.home() / "sa3_mlx" / "sa3")) | |
| if not Path(binary).exists(): | |
| raise RuntimeError( | |
| f"SA3_BIN not found at {binary}. Expected ~/sa3_mlx/sa3 β " | |
| "see ~/sa3_mlx/README.md for install." | |
| ) | |
| out_path = Path("/tmp") / f"local-gen-{os.getpid()}-{duration}.wav" | |
| # Match the engine's CLI: --dit sm-music --decoder same-s --seconds N | |
| subprocess.run( | |
| [ | |
| binary, | |
| "--prompt", prompt, | |
| "--dit", "sm-music", | |
| "--decoder", "same-s", | |
| "--seconds", str(duration), | |
| "--out", str(out_path), | |
| ], | |
| check=True, timeout=300, | |
| ) | |
| return out_path.read_bytes() | |
| def run_stub_sine(prompt: str, duration: int) -> bytes: | |
| """Fallback: render a single sine tone so you can verify the bridge | |
| works end-to-end before plugging in a real gen backend.""" | |
| import math, struct, wave | |
| sr = 44100 | |
| n_samples = int(sr * duration) | |
| freq = 440.0 | |
| buf = io.BytesIO() | |
| with wave.open(buf, "wb") as w: | |
| w.setnchannels(1) | |
| w.setsampwidth(2) | |
| w.setframerate(sr) | |
| for i in range(n_samples): | |
| v = int(20000 * math.sin(2.0 * math.pi * freq * i / sr)) | |
| w.writeframesraw(struct.pack("<h", v)) | |
| return buf.getvalue() | |
| def _convert_to_sa3_wav(audio_bytes: bytes, out_wav: Path) -> None: | |
| """SA3's --init-audio requires WAV at 44.1 kHz, 16-bit PCM, mono OR | |
| stereo. The caller may post any container ffmpeg can read (mp3, wav, | |
| flac, m4a β Pollinations gens are mp3, our /generate output is wav, | |
| user uploads could be anything). Always re-encode via ffmpeg so SA3 | |
| gets the exact format it expects. | |
| Why not soundfile-only: soundfile can read wav/flac but NOT mp3. | |
| Adding a graceful-fallback codepath makes the bridge more useful to | |
| less-technical users than a 400 'wav-only' rejection. | |
| ffmpeg must be installed (Homebrew: `brew install ffmpeg`).""" | |
| import shutil | |
| ffmpeg = shutil.which("ffmpeg") | |
| if not ffmpeg: | |
| raise RuntimeError( | |
| "ffmpeg not found on PATH. /transform needs it to convert " | |
| "the source audio into SA3's required WAV format. " | |
| "Install with `brew install ffmpeg` (or your distro equivalent)." | |
| ) | |
| in_tmp = Path("/tmp") / f"sa3-in-{os.getpid()}.bin" | |
| in_tmp.write_bytes(audio_bytes) | |
| try: | |
| # -y overwrite output | |
| # -i in read whatever the bytes are | |
| # -ar 44100 resample to SA3's required rate | |
| # -acodec pcm_s16le 16-bit PCM as required | |
| # -ac 2 force stereo (SA3 accepts mono OR stereo; | |
| # stereo is the safer default for music tiles) | |
| subprocess.run( | |
| [ffmpeg, "-y", "-loglevel", "error", | |
| "-i", str(in_tmp), | |
| "-ar", "44100", | |
| "-acodec", "pcm_s16le", | |
| "-ac", "2", | |
| str(out_wav)], | |
| check=True, capture_output=True, text=True, timeout=30, | |
| ) | |
| except subprocess.CalledProcessError as e: | |
| raise RuntimeError( | |
| f"ffmpeg failed to convert source audio " | |
| f"(exit {e.returncode}): {(e.stderr or '')[-300:]}" | |
| ) from None | |
| finally: | |
| try: | |
| in_tmp.unlink() | |
| except FileNotFoundError: | |
| pass | |
| # Default origin allowlist for audio_url. Override with the env var | |
| # ALLOWED_AUDIO_ORIGINS (comma-separated, case-insensitive) when running | |
| # against a forked / private Space β e.g. | |
| # ALLOWED_AUDIO_ORIGINS="myname-myfork.hf.space,myname.hf.space" | |
| _DEFAULT_AUDIO_ORIGINS = ( | |
| "kalamishere-audio-brief.hf.space", | |
| # *.hf.space + the canonical wrapper aren't included by default β | |
| # tighter is safer. Users running forks should set the env var. | |
| ) | |
| _AUDIO_URL_PATH_PREFIX = "/gradio_api/file=" | |
| def _fetch_audio_url(url: str) -> bytes: | |
| """Download source audio from the Space's file-proxy URL. | |
| Security perimeter β only fetches URLs that pass ALL of: | |
| β’ scheme = https | |
| β’ host is in ALLOWED_AUDIO_ORIGINS env (or built-in default) | |
| β’ path starts with `/gradio_api/file=` | |
| β’ response size <= 100 MB | |
| Anything else is rejected with 400 before the bridge touches the | |
| network. Without this gate, /transform would be a generic | |
| request-forwarder happy to fetch http://internal-router/admin or | |
| file:// URLs the FastAPI process can read. | |
| """ | |
| import urllib.request | |
| import urllib.parse | |
| from urllib.parse import urlparse | |
| parsed = urlparse(url) | |
| if parsed.scheme != "https": | |
| raise RuntimeError( | |
| f"audio_url must be https (got scheme '{parsed.scheme}')") | |
| if not parsed.path.startswith(_AUDIO_URL_PATH_PREFIX): | |
| raise RuntimeError( | |
| f"audio_url path must start with {_AUDIO_URL_PATH_PREFIX!r}; " | |
| f"got {parsed.path[:80]!r}") | |
| env_allow = os.environ.get("ALLOWED_AUDIO_ORIGINS", "").strip() | |
| if env_allow: | |
| allowed = tuple(h.strip().lower() for h in env_allow.split(",") if h.strip()) | |
| else: | |
| allowed = _DEFAULT_AUDIO_ORIGINS | |
| host = (parsed.netloc or "").lower() | |
| # Strip any :port the user might have included. | |
| if ":" in host: | |
| host = host.split(":", 1)[0] | |
| if host not in allowed: | |
| raise RuntimeError( | |
| f"audio_url host '{host}' not in allowlist {allowed}. " | |
| "Override via ALLOWED_AUDIO_ORIGINS env var.") | |
| # Cap download size β 100 MB is a generous ceiling for an audio | |
| # tile (a 180s 44.1k stereo WAV is ~60 MB; mp3 is <10 MB). | |
| MAX = 100 * 1024 * 1024 | |
| req = urllib.request.Request( | |
| url, headers={"User-Agent": "audio-brief-local-bridge/1.0"}) | |
| with urllib.request.urlopen(req, timeout=30) as r: | |
| # Some content-length headers are missing or lying β read up to | |
| # MAX+1 and reject if we hit the ceiling. | |
| data = r.read(MAX + 1) | |
| if len(data) > MAX: | |
| raise RuntimeError(f"audio_url response exceeded {MAX} bytes cap") | |
| if not data: | |
| raise RuntimeError("audio_url returned empty body") | |
| return data | |
| def run_mlx_sa3_transform(req: "TransformRequest") -> bytes: | |
| """Audio-to-audio via SA3 init-audio. Resolves the source bytes | |
| (URL-fetch preferred over b64-payload), re-encodes to WAV at | |
| 44.1 kHz / 16-bit PCM via ffmpeg, then shells out to ~/sa3_mlx/sa3 | |
| with --init-audio + --init-noise-level. WAV bytes back. | |
| init_strength is exposed as --init-noise-level in [0.01, 1.0]: | |
| 0.4-0.6 Β· subtle variation, mostly preserves source | |
| 0.7 Β· sweet spot for noticeable but recognisable transform | |
| 0.8-1.0 Β· heavy regeneration; init becomes a loose suggestion | |
| """ | |
| import base64 | |
| binary = os.environ.get("SA3_BIN", str(Path.home() / "sa3_mlx" / "sa3")) | |
| if not Path(binary).exists(): | |
| raise RuntimeError( | |
| f"SA3_BIN not found at {binary}. Expected ~/sa3_mlx/sa3 β " | |
| "see ~/sa3_mlx/README.md for install." | |
| ) | |
| # Resolve the source bytes. URL preferred when present β saves a | |
| # base64 round-trip across the browser β bridge JSON boundary. | |
| audio_url = (req.audio_url or "").strip() | |
| if audio_url: | |
| audio_bytes = _fetch_audio_url(audio_url) | |
| elif req.audio_b64: | |
| try: | |
| audio_bytes = base64.b64decode(req.audio_b64) | |
| except Exception as e: | |
| raise RuntimeError(f"audio_b64 decode failed: {e}") from None | |
| else: | |
| raise RuntimeError( | |
| "neither audio_url nor audio_b64 was provided") | |
| if not audio_bytes: | |
| raise RuntimeError("source audio resolved to empty bytes") | |
| init_wav = Path("/tmp") / f"sa3-init-{os.getpid()}.wav" | |
| out_wav = Path("/tmp") / f"sa3-transform-{os.getpid()}.wav" | |
| _convert_to_sa3_wav(audio_bytes, init_wav) | |
| # Clamp init_strength to SA3's valid range [0.01, β). 1.0 is the | |
| # "ignore init" boundary; above 1.0 is allowed by SA3 but rarely | |
| # useful, so cap at 1.0 to avoid surprising users. | |
| init_noise = max(0.01, min(1.0, float(req.init_strength))) | |
| steps = max(1, int(req.steps)) | |
| cfg = max(0.0, float(req.cfg)) | |
| args = [ | |
| binary, | |
| "--prompt", req.prompt or "", | |
| "--init-audio", str(init_wav), | |
| "--init-noise-level", str(init_noise), | |
| "--dit", "sm-music", | |
| "--decoder", "same-s", | |
| "--seconds", str(int(req.duration)), | |
| "--steps", str(steps), | |
| "--out", str(out_wav), | |
| ] | |
| # CFG only enables a second uncond branch when > 1.0 (SA3 skips it | |
| # otherwise). negative_prompt is only useful when CFG kicks in. | |
| if cfg != 1.0: | |
| args += ["--cfg", str(cfg)] | |
| if req.negative_prompt: | |
| args += ["--negative-prompt", req.negative_prompt] | |
| try: | |
| subprocess.run(args, check=True, timeout=600) | |
| except subprocess.CalledProcessError as e: | |
| raise RuntimeError(f"SA3 transform exit {e.returncode}") from None | |
| finally: | |
| try: | |
| init_wav.unlink() | |
| except FileNotFoundError: | |
| pass | |
| try: | |
| return out_wav.read_bytes() | |
| finally: | |
| try: | |
| out_wav.unlink() | |
| except FileNotFoundError: | |
| pass | |
| def transform(req: TransformRequest): | |
| """Audio-to-audio transform. Browser POSTs base64'd source audio + | |
| prompt; server returns WAV bytes via SA3's --init-audio mode. See | |
| TransformRequest for the request shape. | |
| Only the mlx-sa3 backend supports this β the stub sine has no | |
| audio-input mode. Returns 503 when LOCAL_GEN_BACKEND != mlx-sa3 | |
| so the UI can surface the right reason instead of a vague 500.""" | |
| backend = os.environ.get("LOCAL_GEN_BACKEND", "stub").lower() | |
| if backend != "mlx-sa3": | |
| return Response( | |
| content=(f"transform requires LOCAL_GEN_BACKEND=mlx-sa3 " | |
| f"(currently '{backend}'). Restart the bridge with " | |
| "LOCAL_GEN_BACKEND=mlx-sa3 python3 ...").encode(), | |
| status_code=503, | |
| ) | |
| try: | |
| audio_bytes = run_mlx_sa3_transform(req) | |
| except Exception as e: | |
| return Response( | |
| content=f"local transform failed: {e}".encode(), | |
| status_code=500, | |
| ) | |
| return Response(content=audio_bytes, media_type="audio/wav") | |
| def generate(req: GenRequest): | |
| """Generate audio from a prompt. Returns audio/wav (or audio/mpeg if | |
| your backend produces MP3). The browser uploads these bytes to | |
| audio-brief's HF Spaces backend for crate-add + analysis.""" | |
| # Pick your backend: | |
| backend = os.environ.get("LOCAL_GEN_BACKEND", "stub").lower() | |
| try: | |
| if backend == "mlx-sa3": | |
| audio_bytes = run_mlx_sa3(req.prompt, req.duration) | |
| else: | |
| # Default to the stub sine so first-time users can verify the | |
| # bridge before wiring real gen. | |
| audio_bytes = run_stub_sine(req.prompt, req.duration) | |
| except Exception as e: | |
| return Response(content=f"local gen failed: {e}".encode(), status_code=500) | |
| media_type = "audio/wav" # change to "audio/mpeg" if your backend yields MP3 | |
| return Response(content=audio_bytes, media_type=media_type) | |
| def root(): | |
| backend = os.environ.get("LOCAL_GEN_BACKEND", "stub").lower() | |
| return { | |
| "service": "audio-brief local gen example", | |
| "endpoints": [ | |
| "POST /generate {prompt, duration} β WAV", | |
| "POST /transform {prompt, audio_b64, duration, init_strength," | |
| " steps, cfg, negative_prompt} β WAV (mlx-sa3 backend only)", | |
| ], | |
| "backend": backend, | |
| "transform_supported": backend == "mlx-sa3", | |
| } | |
| if __name__ == "__main__": | |
| import uvicorn | |
| port = int(os.environ.get("PORT", 7864)) | |
| uvicorn.run("local-gen-server-example:app", host="127.0.0.1", port=port, reload=False) | |