Signed-off-by: Lidang-Jiang <lidangjiang@gmail.com> Signed-off-by: Lidang Jiang <lidangjiang@gmail.com>
276 lines
11 KiB
Python
276 lines
11 KiB
Python
"""Gradio demo for Fish Speech S2 Pro online serving via /v1/audio/speech API.
|
|
|
|
Supports:
|
|
- Text-to-speech synthesis
|
|
- Voice cloning from reference audio (upload or URL)
|
|
- Streaming (progressive PCM chunks) and non-streaming modes
|
|
|
|
Usage:
|
|
# Start the server first (see run_server.sh), then:
|
|
python gradio_demo.py --api-base http://localhost:8091
|
|
|
|
# Or use run_gradio_demo.sh to start both server and demo together.
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
import io
|
|
|
|
try:
|
|
import gradio as gr
|
|
except ImportError:
|
|
raise ImportError("gradio is required to run this demo. Install it with: pip install 'vllm-omni[demo]'") from None
|
|
import httpx
|
|
import numpy as np
|
|
import soundfile as sf
|
|
|
|
PCM_SAMPLE_RATE = 44100
|
|
DEFAULT_API_BASE = "http://localhost:8091"
|
|
|
|
|
|
def encode_audio_to_base64(audio_data: tuple) -> str:
|
|
"""Encode Gradio audio input (sample_rate, numpy_array) to base64 data URL."""
|
|
sample_rate, audio_np = audio_data
|
|
if audio_np.dtype != np.int16:
|
|
if audio_np.dtype in (np.float32, np.float64):
|
|
audio_np = np.clip(audio_np, -1.0, 1.0)
|
|
audio_np = (audio_np * 32767).astype(np.int16)
|
|
else:
|
|
audio_np = audio_np.astype(np.int16)
|
|
buf = io.BytesIO()
|
|
sf.write(buf, audio_np, sample_rate, format="WAV")
|
|
wav_b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
|
return f"data:audio/wav;base64,{wav_b64}"
|
|
|
|
|
|
def build_payload(
|
|
text: str,
|
|
ref_audio: tuple | None,
|
|
ref_audio_url: str,
|
|
ref_text: str,
|
|
response_format: str = "wav",
|
|
stream: bool = False,
|
|
) -> dict:
|
|
"""Build the /v1/audio/speech request payload."""
|
|
if not text or not text.strip():
|
|
raise gr.Error("Please enter text to synthesize.")
|
|
|
|
payload: dict = {
|
|
"input": text.strip(),
|
|
"voice": "default",
|
|
"response_format": "pcm" if stream else response_format,
|
|
"stream": stream,
|
|
}
|
|
|
|
# Voice cloning: ref_audio takes priority over URL.
|
|
ref_url_stripped = ref_audio_url.strip() if ref_audio_url else ""
|
|
if ref_audio is not None:
|
|
payload["ref_audio"] = encode_audio_to_base64(ref_audio)
|
|
elif ref_url_stripped:
|
|
payload["ref_audio"] = ref_url_stripped
|
|
|
|
if "ref_audio" in payload:
|
|
if not ref_text or not ref_text.strip():
|
|
raise gr.Error("Voice cloning requires a transcript of the reference audio.")
|
|
payload["ref_text"] = ref_text.strip()
|
|
|
|
return payload
|
|
|
|
|
|
def generate_speech(api_base: str, text: str, ref_audio, ref_audio_url, ref_text, response_format):
|
|
"""Non-streaming: call /v1/audio/speech and return full audio."""
|
|
payload = build_payload(text, ref_audio, ref_audio_url, ref_text, response_format, stream=False)
|
|
|
|
try:
|
|
with httpx.Client(timeout=300.0) as client:
|
|
resp = client.post(
|
|
f"{api_base}/v1/audio/speech",
|
|
json=payload,
|
|
headers={"Content-Type": "application/json", "Authorization": "Bearer EMPTY"},
|
|
)
|
|
except httpx.TimeoutException:
|
|
raise gr.Error("Request timed out. The server may be busy.")
|
|
except httpx.ConnectError:
|
|
raise gr.Error(f"Cannot connect to server at {api_base}. Is the server running?")
|
|
|
|
if resp.status_code != 200:
|
|
raise gr.Error(f"Server error ({resp.status_code}): {resp.text}")
|
|
|
|
content_type = resp.headers.get("content-type", "")
|
|
if "application/json" in content_type:
|
|
try:
|
|
raise gr.Error(f"Server error: {resp.json()}")
|
|
except ValueError:
|
|
pass
|
|
|
|
try:
|
|
if response_format == "pcm":
|
|
audio_np = np.frombuffer(resp.content, dtype=np.int16).astype(np.float32) / 32767.0
|
|
return (PCM_SAMPLE_RATE, audio_np)
|
|
audio_np, sample_rate = sf.read(io.BytesIO(resp.content))
|
|
if audio_np.ndim > 1:
|
|
audio_np = audio_np[:, 0]
|
|
return (sample_rate, audio_np.astype(np.float32))
|
|
except Exception as e:
|
|
raise gr.Error(f"Failed to decode audio: {e}")
|
|
|
|
|
|
def generate_speech_stream(api_base, text, ref_audio, ref_audio_url, ref_text, response_format, chunk_seconds=0.5):
|
|
"""Streaming: yield PCM chunks for progressive playback."""
|
|
payload = build_payload(text, ref_audio, ref_audio_url, ref_text, response_format, stream=True)
|
|
|
|
min_chunk_samples = int(PCM_SAMPLE_RATE * chunk_seconds)
|
|
prebuffer_chunks = 2
|
|
pending: list[np.ndarray] = []
|
|
pending_len = 0
|
|
chunks_yielded = 0
|
|
prebuffer: list[np.ndarray] = []
|
|
|
|
try:
|
|
leftover = b""
|
|
with httpx.Client(timeout=300.0) as client:
|
|
with client.stream(
|
|
"POST",
|
|
f"{api_base}/v1/audio/speech",
|
|
json=payload,
|
|
headers={"Content-Type": "application/json", "Authorization": "Bearer EMPTY"},
|
|
) as resp:
|
|
if resp.status_code != 200:
|
|
resp.read()
|
|
raise gr.Error(f"Server error ({resp.status_code}): {resp.text}")
|
|
for chunk in resp.iter_bytes():
|
|
if not chunk:
|
|
continue
|
|
raw = leftover + chunk
|
|
usable = len(raw) - (len(raw) % 2)
|
|
leftover = raw[usable:]
|
|
if usable == 0:
|
|
continue
|
|
float_samples = np.frombuffer(raw[:usable], dtype=np.int16).copy().astype(np.float32) / 32767.0
|
|
pending.append(float_samples)
|
|
pending_len += len(float_samples)
|
|
if pending_len >= min_chunk_samples:
|
|
audio_chunk = np.concatenate(pending)
|
|
pending.clear()
|
|
pending_len = 0
|
|
if chunks_yielded < prebuffer_chunks:
|
|
prebuffer.append(audio_chunk)
|
|
chunks_yielded += 1
|
|
if chunks_yielded == prebuffer_chunks:
|
|
yield (PCM_SAMPLE_RATE, np.concatenate(prebuffer))
|
|
prebuffer.clear()
|
|
else:
|
|
yield (PCM_SAMPLE_RATE, audio_chunk)
|
|
remaining = prebuffer + pending
|
|
if remaining:
|
|
yield (PCM_SAMPLE_RATE, np.concatenate(remaining))
|
|
except httpx.TimeoutException:
|
|
raise gr.Error("Request timed out. The server may be busy.")
|
|
except httpx.ConnectError:
|
|
raise gr.Error(f"Cannot connect to server at {api_base}. Is the server running?")
|
|
|
|
|
|
def on_stream_change(stream: bool):
|
|
"""Lock format to PCM when streaming."""
|
|
if stream:
|
|
return gr.update(value="pcm", interactive=False)
|
|
return gr.update(value="wav", interactive=True)
|
|
|
|
|
|
def build_interface(api_base: str, stream_chunk_seconds: float = 0.5):
|
|
"""Build the Gradio interface."""
|
|
with gr.Blocks(title="Fish Speech S2 Pro Demo") as demo:
|
|
gr.Markdown("# Fish Speech S2 Pro - Text to Speech")
|
|
gr.Markdown(f"**Server:** `{api_base}` | **Model:** fishaudio/s2-pro | **Output:** 44.1kHz")
|
|
|
|
with gr.Row():
|
|
with gr.Column(scale=3):
|
|
text_input = gr.Textbox(
|
|
label="Text to Synthesize",
|
|
placeholder="Enter text here...",
|
|
lines=4,
|
|
)
|
|
|
|
with gr.Accordion("Voice Cloning (optional)", open=False):
|
|
gr.Markdown(
|
|
"Upload or link a short reference audio (10-30s) and provide its transcript to clone the voice."
|
|
)
|
|
ref_audio = gr.Audio(
|
|
label="Reference Audio",
|
|
type="numpy",
|
|
sources=["upload", "microphone"],
|
|
)
|
|
ref_audio_url = gr.Textbox(
|
|
label="Reference Audio URL (alternative to upload)",
|
|
placeholder="https://example.com/reference.wav",
|
|
lines=1,
|
|
)
|
|
ref_text = gr.Textbox(
|
|
label="Reference Audio Transcript (required for cloning)",
|
|
placeholder="Exact transcript of the reference audio...",
|
|
lines=2,
|
|
)
|
|
|
|
with gr.Row():
|
|
response_format = gr.Dropdown(
|
|
choices=["wav", "mp3", "flac", "pcm"],
|
|
value="wav",
|
|
label="Audio Format",
|
|
scale=1,
|
|
)
|
|
stream_checkbox = gr.Checkbox(
|
|
label="Stream output",
|
|
value=False,
|
|
info="Progressive PCM streaming",
|
|
scale=1,
|
|
)
|
|
|
|
generate_btn = gr.Button("Generate Speech", variant="primary", size="lg")
|
|
|
|
with gr.Column(scale=2):
|
|
audio_output = gr.Audio(
|
|
label="Generated Audio",
|
|
interactive=False,
|
|
streaming=True,
|
|
autoplay=True,
|
|
)
|
|
gr.Markdown(
|
|
"### About\n"
|
|
"- **Fish Speech S2 Pro** by FishAudio: 4B dual-AR model\n"
|
|
"- **Voice cloning**: upload 10-30s reference + transcript\n"
|
|
"- **Streaming**: real-time PCM output\n"
|
|
"- **44.1kHz** output via DAC codec"
|
|
)
|
|
|
|
stream_checkbox.change(fn=on_stream_change, inputs=[stream_checkbox], outputs=[response_format])
|
|
|
|
all_inputs = [text_input, ref_audio, ref_audio_url, ref_text, response_format]
|
|
|
|
def dispatch(stream_enabled, *args):
|
|
if stream_enabled:
|
|
yield from generate_speech_stream(api_base, *args, chunk_seconds=stream_chunk_seconds)
|
|
else:
|
|
yield generate_speech(api_base, *args)
|
|
|
|
generate_btn.click(fn=dispatch, inputs=[stream_checkbox] + all_inputs, outputs=[audio_output])
|
|
demo.queue()
|
|
return demo
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Gradio demo for Fish Speech S2 Pro")
|
|
parser.add_argument("--api-base", default=DEFAULT_API_BASE, help=f"API base URL (default: {DEFAULT_API_BASE})")
|
|
parser.add_argument("--host", default="0.0.0.0", help="Gradio host (default: 0.0.0.0)")
|
|
parser.add_argument("--port", type=int, default=7860, help="Gradio port (default: 7860)")
|
|
parser.add_argument("--share", action="store_true", help="Share publicly")
|
|
parser.add_argument("--stream-chunk-seconds", type=float, default=0.5, help="Seconds per streaming chunk")
|
|
args = parser.parse_args()
|
|
|
|
print(f"Connecting to vLLM server at: {args.api_base}")
|
|
demo = build_interface(args.api_base, stream_chunk_seconds=args.stream_chunk_seconds)
|
|
demo.launch(server_name=args.host, server_port=args.port, share=args.share)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|