224 lines
6.6 KiB
Python
224 lines
6.6 KiB
Python
"""Shared constants, helpers, and payload building for Qwen3-TTS Gradio demos."""
|
|
|
|
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
|
|
|
|
SUPPORTED_LANGUAGES = [
|
|
"Auto",
|
|
"Chinese",
|
|
"English",
|
|
"Japanese",
|
|
"Korean",
|
|
"German",
|
|
"French",
|
|
"Russian",
|
|
"Portuguese",
|
|
"Spanish",
|
|
"Italian",
|
|
]
|
|
|
|
TASK_TYPES = ["CustomVoice", "VoiceDesign", "Base"]
|
|
|
|
PCM_SAMPLE_RATE = 24000
|
|
|
|
DEFAULT_API_BASE = "http://localhost:8000"
|
|
|
|
|
|
def fetch_voices(api_base: str) -> list[str]:
|
|
"""Fetch available voices from the server."""
|
|
try:
|
|
with httpx.Client(timeout=10.0) as client:
|
|
resp = client.get(
|
|
f"{api_base}/v1/audio/voices",
|
|
headers={"Authorization": "Bearer EMPTY"},
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
voices = data.get("voices") or []
|
|
if voices:
|
|
return voices
|
|
except Exception:
|
|
pass
|
|
return ["Vivian", "Ryan"]
|
|
|
|
|
|
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,
|
|
task_type: str,
|
|
voice: str,
|
|
language: str,
|
|
instructions: str,
|
|
ref_audio: tuple | None,
|
|
ref_audio_url: str,
|
|
ref_text: str,
|
|
x_vector_only: bool,
|
|
response_format: str = "pcm",
|
|
speed: float = 1.0,
|
|
stream: bool = True,
|
|
) -> dict:
|
|
"""Build the /v1/audio/speech request payload.
|
|
|
|
Raises gr.Error for invalid input so callers don't need to validate.
|
|
"""
|
|
if not text or not text.strip():
|
|
raise gr.Error("Please enter text to synthesize.")
|
|
|
|
payload: dict = {
|
|
"input": text.strip(),
|
|
"response_format": "pcm" if stream else response_format,
|
|
"stream": stream,
|
|
}
|
|
if not stream:
|
|
payload["speed"] = speed
|
|
|
|
if task_type:
|
|
payload["task_type"] = task_type
|
|
if language:
|
|
payload["language"] = language
|
|
|
|
if task_type == "CustomVoice":
|
|
if voice:
|
|
payload["voice"] = voice
|
|
if instructions and instructions.strip():
|
|
payload["instructions"] = instructions.strip()
|
|
|
|
elif task_type == "VoiceDesign":
|
|
if not instructions or not instructions.strip():
|
|
raise gr.Error("VoiceDesign task requires voice style instructions.")
|
|
payload["instructions"] = instructions.strip()
|
|
|
|
elif task_type == "Base":
|
|
ref_audio_url_stripped = ref_audio_url.strip() if ref_audio_url else ""
|
|
if ref_audio_url_stripped:
|
|
payload["ref_audio"] = ref_audio_url_stripped
|
|
elif ref_audio is not None:
|
|
payload["ref_audio"] = encode_audio_to_base64(ref_audio)
|
|
else:
|
|
raise gr.Error("Base (voice clone) task requires reference audio. Upload a file or provide a URL.")
|
|
if ref_text and ref_text.strip():
|
|
payload["ref_text"] = ref_text.strip()
|
|
if x_vector_only:
|
|
payload["x_vector_only_mode"] = True
|
|
|
|
return payload
|
|
|
|
|
|
def on_task_type_change(task_type: str):
|
|
"""Update UI visibility based on selected task type."""
|
|
if task_type == "CustomVoice":
|
|
return (
|
|
gr.update(visible=True), # voice dropdown
|
|
gr.update(visible=True, info="Optional style/emotion instructions"),
|
|
gr.update(visible=False), # ref_audio
|
|
gr.update(visible=False), # ref_audio_url
|
|
gr.update(visible=False), # ref_text
|
|
gr.update(visible=False), # x_vector_only
|
|
)
|
|
elif task_type == "VoiceDesign":
|
|
return (
|
|
gr.update(visible=False),
|
|
gr.update(visible=True, info="Required: describe the voice style"),
|
|
gr.update(visible=False),
|
|
gr.update(visible=False),
|
|
gr.update(visible=False),
|
|
gr.update(visible=False),
|
|
)
|
|
elif task_type == "Base":
|
|
return (
|
|
gr.update(visible=False),
|
|
gr.update(visible=False),
|
|
gr.update(visible=True),
|
|
gr.update(visible=True),
|
|
gr.update(visible=True),
|
|
gr.update(visible=True),
|
|
)
|
|
return (
|
|
gr.update(visible=True),
|
|
gr.update(visible=True),
|
|
gr.update(visible=False),
|
|
gr.update(visible=False),
|
|
gr.update(visible=False),
|
|
gr.update(visible=False),
|
|
)
|
|
|
|
|
|
def stream_pcm_chunks(api_base: str, payload: dict):
|
|
"""Stream raw PCM bytes from the server, yielding int16 numpy arrays.
|
|
|
|
Handles odd-byte boundaries between network chunks.
|
|
"""
|
|
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
|
|
yield np.frombuffer(raw[:usable], dtype=np.int16).copy()
|
|
|
|
|
|
def add_common_args(parser):
|
|
"""Add CLI arguments shared by both demos."""
|
|
parser.add_argument(
|
|
"--api-base",
|
|
default=DEFAULT_API_BASE,
|
|
help=f"Base URL for the vLLM API server (default: {DEFAULT_API_BASE}).",
|
|
)
|
|
parser.add_argument(
|
|
"--host",
|
|
default="0.0.0.0",
|
|
help="Host/IP for Gradio server (default: 0.0.0.0).",
|
|
)
|
|
parser.add_argument(
|
|
"--port",
|
|
type=int,
|
|
default=7860,
|
|
help="Port for Gradio server (default: 7860).",
|
|
)
|
|
parser.add_argument(
|
|
"--share",
|
|
action="store_true",
|
|
help="Share the Gradio demo publicly.",
|
|
)
|
|
return parser
|