audio-brief / docs /local-gen-server-example.py
kalamishere's picture
refactor(transform): URL-pass pattern β€” bridge fetches source, not browser
7a57c31
Raw
History Blame Contribute Delete
17.1 kB
"""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/
@app.middleware("http")
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
@app.post("/transform")
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")
@app.post("/generate")
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)
@app.get("/")
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)