""" SoulX-Singer SVC on Hugging Face Space 两种运行方式: - **嵌入模式**(Docker Space / 本地已 clone 仓库):同一进程内挂载 `soulx_svc.api` 到 `/api`,Gradio 默认请求 `http://127.0.0.1:$PORT/api`。 - **轻量模式**(仅 Gradio + httpx):未安装 soulx_svc 时,仅演示调用外部 Base URL(与旧行为一致)。 对外 HTTP 契约:`POST {公网}/api/v1/svc`(与原先 `POST {base}/v1/svc` 等价,多一层 `/api` 前缀)。 """ from __future__ import annotations import importlib.util import os import tempfile from pathlib import Path from urllib.parse import urlencode import httpx def _port() -> int: return int(os.environ.get("PORT", "7860")) def _internal_api_base() -> str: return f"http://127.0.0.1:{_port()}/api" def public_api_base() -> str: """对外公开的 API 根路径(含 /api),用于展示与 curl;优先环境变量,其次 HF Space 注入变量。""" explicit = os.environ.get("SVC_PUBLIC_API_BASE", "").strip().rstrip("/") if explicit: return explicit sid = os.environ.get("SPACE_ID", "").strip() if "/" in sid: author, name = sid.split("/", 1) slug = f"{author}-{name}".lower().replace("_", "-") return f"https://{slug}.hf.space/api" author = os.environ.get("SPACE_AUTHOR_NAME", "").strip() repo = os.environ.get("SPACE_REPO_NAME", "").strip() if author and repo: slug = f"{author}-{repo}".lower().replace("_", "-") return f"https://{slug}.hf.space/api" return "" def use_embedded_api() -> bool: if os.environ.get("SVC_API_EMBEDDED", "").lower() in ("0", "false", "no"): return False try: return importlib.util.find_spec("soulx_svc.api") is not None except Exception: return False def default_api_base() -> str: if use_embedded_api(): return _internal_api_base() return os.environ.get("SVC_API_BASE_URL", "").strip() DEFAULT_TIMEOUT = float(os.environ.get("SVC_API_TIMEOUT_SEC", "3600")) def _build_query( prompt_vocal_sep: bool, target_vocal_sep: bool, auto_shift: bool, auto_mix_acc: bool, pitch_shift: int, n_steps: int, cfg: float, seed: int, ) -> str: q = { "prompt_vocal_sep": str(prompt_vocal_sep).lower(), "target_vocal_sep": str(target_vocal_sep).lower(), "auto_shift": str(auto_shift).lower(), "auto_mix_acc": str(auto_mix_acc).lower(), "pitch_shift": pitch_shift, "n_steps": n_steps, "cfg": cfg, "seed": seed, } return urlencode(q) def build_curl( base_url: str, prompt_path: str | None, target_path: str | None, prompt_vocal_sep: bool, target_vocal_sep: bool, auto_shift: bool, auto_mix_acc: bool, pitch_shift: int, n_steps: int, cfg: float, seed: int, *, use_public_base: bool = False, ) -> str: pub = public_api_base() if use_public_base and pub: base = pub else: base = (base_url or "").strip().rstrip("/") if not base: return ( "# 未配置 API 地址。\n" "# · 嵌入模式:界面已默认本 Space 的 /api;若需在外部执行 curl," "请在 Space 变量中设置 SVC_PUBLIC_API_BASE(或依赖 SPACE_* 自动拼接)。\n" "# · 轻量模式:请在上方填写外部 soulx_svc.api 的 Base URL。" ) qs = _build_query( prompt_vocal_sep, target_vocal_sep, auto_shift, auto_mix_acc, pitch_shift, n_steps, cfg, seed, ) lines = [ f'BASE="{base}"', f'curl -sS -X POST "$BASE/v1/svc?{qs}" \\', ' -F "prompt_audio=@/path/to/prompt.wav" \\', ' -F "target_audio=@/path/to/target.wav" \\', ' -o generated.wav', ] if use_embedded_api() and not use_public_base and not pub: lines.append( "# 提示:从你自己电脑调用时,请把 BASE 换成浏览器地址栏里的 Space 域名 + /api" ) return "\n".join(lines) def call_svc_api( base_url: str, prompt_audio, target_audio, api_key: str, prompt_vocal_sep: bool, target_vocal_sep: bool, auto_shift: bool, auto_mix_acc: bool, pitch_shift: int, n_steps: int, cfg: float, seed: int, ): base = (base_url or "").strip().rstrip("/") if not base: return None, "请填写 API Base URL(嵌入模式启动后应已自动填入本机 /api)。", build_curl( base_url, None, None, prompt_vocal_sep, target_vocal_sep, auto_shift, auto_mix_acc, pitch_shift, n_steps, cfg, seed, use_public_base=False, ) if prompt_audio is None or target_audio is None: return None, "请上传参考音频(prompt)与目标音频(target)。", build_curl( base, None, None, prompt_vocal_sep, target_vocal_sep, auto_shift, auto_mix_acc, pitch_shift, n_steps, cfg, seed, ) p = Path(prompt_audio) if not isinstance(prompt_audio, str) else Path(prompt_audio) t = Path(target_audio) if not isinstance(target_audio, str) else Path(target_audio) if not p.is_file() or not t.is_file(): return None, "音频路径无效,请重新上传。", build_curl( base, str(p) if p.is_file() else None, str(t) if t.is_file() else None, prompt_vocal_sep, target_vocal_sep, auto_shift, auto_mix_acc, pitch_shift, n_steps, cfg, seed, ) qs = _build_query( prompt_vocal_sep, target_vocal_sep, auto_shift, auto_mix_acc, pitch_shift, n_steps, cfg, seed, ) url = f"{base}/v1/svc?{qs}" curl_internal = build_curl( base, str(p), str(t), prompt_vocal_sep, target_vocal_sep, auto_shift, auto_mix_acc, pitch_shift, n_steps, cfg, seed, use_public_base=False, ) curl_public = build_curl( base, str(p), str(t), prompt_vocal_sep, target_vocal_sep, auto_shift, auto_mix_acc, pitch_shift, n_steps, cfg, seed, use_public_base=True, ) curl_preview = ( f"{curl_public}\n\n# —— 容器内调试(与界面请求一致)——\n{curl_internal}" if use_embedded_api() and public_api_base() else curl_internal ) headers = {} key = (api_key or "").strip() if key: headers["Authorization"] = f"Bearer {key}" try: with httpx.Client(timeout=DEFAULT_TIMEOUT, follow_redirects=True) as client: with p.open("rb") as fp, t.open("rb") as ft: files = { "prompt_audio": (p.name, fp, "application/octet-stream"), "target_audio": (t.name, ft, "application/octet-stream"), } r = client.post(url, files=files, headers=headers) except httpx.RequestError as e: return None, f"网络错误: {e}", curl_preview if r.status_code != 200: body = r.text[:2000] if r.text else "" return None, f"HTTP {r.status_code}\n{body}", curl_preview ct = (r.headers.get("content-type") or "").lower() if "wav" not in ct and r.content[:4] != b"RIFF": return None, f"响应不是 WAV(Content-Type: {ct or 'unknown'})", curl_preview fd, out_path = tempfile.mkstemp(suffix=".wav") os.close(fd) Path(out_path).write_bytes(r.content) return ( out_path, f"成功,{len(r.content)} 字节,Content-Type: {ct or 'audio/wav'}", curl_preview, ) def api_urls_markdown() -> str: """页首:由本服务给出的、可给第三方的 URL 说明。""" pub = public_api_base() if use_embedded_api(): if pub: return ( "### 本 Space 对外提供的 HTTP API(可直接给外部系统使用)\n\n" f"| 用途 | URL |\n|------|-----|\n" f"| **歌声转换** | `{pub}/v1/svc`(`POST`,multipart:`prompt_audio`、`target_audio`) |\n" f"| **健康检查** | `{pub}/health`(`GET`) |\n" f"| **OpenAPI 文档** | `{pub}/docs` |\n\n" "浏览器或其它服务器均可请求上述地址(已启用 CORS,便于前端跨域调试)。" "若域名与上表不符,请在 Space **Settings → Variables** 设置 `SVC_PUBLIC_API_BASE`" "(例如 `https://你的用户名-space名.hf.space/api`)。\n" ) return ( "### 本 Space 已内置 API\n\n" "当前未能自动推断公网地址。请在 Space **Variables** 中设置 **`SVC_PUBLIC_API_BASE`**" "(值为 `https://<你的 hf.space 域名>/api`,无尾斜杠),保存后刷新本页即可显示完整 URL。\n\n" "- 转换:`POST .../api/v1/svc`\n" "- 健康:`GET .../api/health`\n" ) if pub: return ( "### 轻量模式 · 文档用 API 根路径\n\n" f"已配置 **`SVC_PUBLIC_API_BASE`**:`{pub}`。本 Space **未**内置推理," "仅演示从浏览器/脚本调用该地址;实际推理需在别处部署 `soulx_svc.api`。\n" ) return ( "### 轻量模式\n\n" "本 Space **未**内置 `soulx_svc`。请在下方 **「要调用的 API Base URL」** 填写你已部署的 " "`soulx_svc` 地址;或在 Variables 中设置 **`SVC_PUBLIC_API_BASE`** 以便页首展示固定文档 URL。\n" ) def build_gradio(): import gradio as gr pub = public_api_base() # 嵌入模式:实际请求走容器内回环,不向用户索要 URL internal = _internal_api_base() if use_embedded_api() else "" manual_default = ( "" if use_embedded_api() else (default_api_base() or pub) ) with gr.Blocks( title="SoulX SVC API Playground", theme=gr.themes.Soft(primary_hue="indigo", secondary_hue="slate"), ) as demo: gr.Markdown(api_urls_markdown()) if use_embedded_api(): gr.Markdown( "_下方演示按钮会向 **容器内** `127.0.0.1` 发起请求,与外部用户访问上表公网 URL 等价。_" ) base_in = gr.Textbox( label="(内部)请求使用的 Base URL — 已固定,无需修改", value=internal, interactive=False, ) else: base_in = gr.Textbox( label="要调用的 API Base URL(不含尾斜杠;可与上方文档地址一致)", placeholder="https://your-api.example.com 或 https://xxx.hf.space/api", value=manual_default, ) api_key_in = gr.Textbox( label="可选:Authorization Bearer(若网关加了鉴权)", type="password", placeholder="留空则不带 Authorization 头", ) with gr.Row(): prompt_in = gr.Audio( label="参考音色 prompt_audio", type="filepath", sources=["upload"], ) target_in = gr.Audio( label="待转换 target_audio", type="filepath", sources=["upload"], ) with gr.Accordion("推理参数(query)", open=False): with gr.Row(): pv = gr.Checkbox(label="prompt_vocal_sep", value=False) tv = gr.Checkbox(label="target_vocal_sep", value=True) with gr.Row(): ash = gr.Checkbox(label="auto_shift", value=True) amx = gr.Checkbox(label="auto_mix_acc", value=True) with gr.Row(): ps = gr.Slider(-36, 36, value=0, step=1, label="pitch_shift") ns = gr.Slider(1, 128, value=32, step=1, label="n_steps") with gr.Row(): cf = gr.Slider(0.0, 10.0, value=1.0, step=0.1, label="cfg") sd = gr.Number(value=42, label="seed", precision=0) btn = gr.Button("调用 API", variant="primary") status = gr.Textbox(label="状态", lines=4) curl_out = gr.Code( label="等效 curl(公网 + 容器内)", language="shell", ) audio_out = gr.Audio(label="生成结果", type="filepath") inputs = [ base_in, prompt_in, target_in, api_key_in, pv, tv, ash, amx, ps, ns, cf, sd, ] def on_change_curl(b, pr, ta, _k, *rest): return build_curl(b, None, None, *rest, use_public_base=True) rest_sliders = [pv, tv, ash, amx, ps, ns, cf, sd] for comp in [base_in, pv, tv, ash, amx, ps, ns, cf, sd]: comp.change( on_change_curl, [base_in, prompt_in, target_in, api_key_in] + rest_sliders, curl_out, ) def _run_call(b, pr, ta, ak, *rest): base = _internal_api_base() if use_embedded_api() else (b or "").strip() return call_svc_api(base, pr, ta, ak, *rest) btn.click( _run_call, inputs, [audio_out, status, curl_out], ) return demo def create_combined_app(): """FastAPI:/api → soulx_svc(对外开放 + CORS),/ → Gradio。""" from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from gradio import mount_gradio_app from soulx_svc.api import app as svc_app demo = build_gradio() main = FastAPI(title="SoulX-Singer SVC Space", version="0.2.0") _cors = os.environ.get("SVC_CORS_ORIGINS", "*").strip() origins = ( ["*"] if _cors == "*" else [x.strip() for x in _cors.split(",") if x.strip()] ) _wildcard = origins == ["*"] main.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=not _wildcard, allow_methods=["*"], allow_headers=["*"], ) main.mount("/api", svc_app) mount_gradio_app(main, demo, path="/") return main def main() -> None: port = _port() if use_embedded_api(): import uvicorn app = create_combined_app() uvicorn.run(app, host="0.0.0.0", port=port) return import gradio as gr demo = build_gradio() demo.queue() demo.launch(server_name="0.0.0.0", server_port=port) if __name__ == "__main__": main()