voice-api / app.py
kezhui's picture
Add MP3 output option for TTS
d58f856 verified
Raw
History Blame Contribute Delete
9.25 kB
import base64
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
from typing import Optional
import gradio as gr
import soundfile as sf
import spaces
from huggingface_hub import snapshot_download
ASR_REPO = os.getenv("ASR_REPO", "Daumee/Qwen3-ASR-0.6B-ONNX-CPU")
ASR_THREADS = int(os.getenv("ASR_THREADS", "2"))
VOICE_API_TOKEN = os.getenv("VOICE_API_TOKEN", "").strip()
started_at = time.time()
asr_lock = threading.Lock()
asr_pipeline = None
tts_lock = threading.Lock()
tts_pipelines = {}
def check_key(api_key: str) -> None:
if VOICE_API_TOKEN and api_key != VOICE_API_TOKEN:
raise gr.Error("invalid api_key")
def normalize_language(language: Optional[str]) -> Optional[str]:
if language is None:
return None
value = language.strip()
if not value or value.lower() in {"auto", "none", "null"}:
return None
aliases = {
"zh": "Chinese",
"cn": "Chinese",
"zh-cn": "Chinese",
"chinese": "Chinese",
"en": "English",
"en-us": "English",
"english": "English",
"yue": "Cantonese",
"cantonese": "Cantonese",
}
return aliases.get(value.lower(), value)
def tts_lang_from_voice(voice: str) -> str:
if voice.startswith("zf_") or voice.startswith("zm_"):
return "z"
if voice.startswith("jf_") or voice.startswith("jm_"):
return "j"
if voice.startswith("bf_") or voice.startswith("bm_"):
return "b"
return "a"
def decode_audio_b64(audio_base64: str, filename: str) -> Path:
if "," in audio_base64[:128]:
audio_base64 = audio_base64.split(",", 1)[1]
suffix = Path(filename or "audio.wav").suffix or ".wav"
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
tmp_path = Path(tmp.name)
try:
tmp.write(base64.b64decode(audio_base64))
tmp.close()
return tmp_path
except Exception:
tmp.close()
tmp_path.unlink(missing_ok=True)
raise gr.Error("invalid base64 audio")
def audio_bytes_to_b64(audio, sample_rate: int = 24000, audio_format: str = "wav") -> tuple[str, str]:
audio_format = (audio_format or "wav").lower().strip()
if audio_format not in {"wav", "mp3"}:
raise gr.Error("audio_format must be wav or mp3")
wav_buf = io.BytesIO()
sf.write(wav_buf, audio, sample_rate, format="WAV")
wav_bytes = wav_buf.getvalue()
if audio_format == "wav":
return base64.b64encode(wav_bytes).decode("ascii"), "audio/wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as wav_file:
wav_file.write(wav_bytes)
wav_path = Path(wav_file.name)
mp3_path = wav_path.with_suffix(".mp3")
try:
subprocess.run(
[
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(wav_path),
"-codec:a",
"libmp3lame",
"-b:a",
"128k",
str(mp3_path),
],
check=True,
)
return base64.b64encode(mp3_path.read_bytes()).decode("ascii"), "audio/mpeg"
except Exception as exc:
raise gr.Error(f"mp3 encoding failed: {exc}") from exc
finally:
wav_path.unlink(missing_ok=True)
mp3_path.unlink(missing_ok=True)
def get_asr_pipeline():
global asr_pipeline
with asr_lock:
if asr_pipeline is not None:
return asr_pipeline
model_root = Path(snapshot_download(repo_id=ASR_REPO))
onnx_dir = model_root / "onnx_models"
tokenizer_link = onnx_dir / "tokenizer.json"
root_tokenizer = model_root / "tokenizer.json"
if root_tokenizer.exists() and not tokenizer_link.exists():
tokenizer_link.symlink_to(root_tokenizer)
if str(model_root) not in sys.path:
sys.path.insert(0, str(model_root))
from onnx_inference import OnnxAsrPipeline
asr_pipeline = OnnxAsrPipeline(
onnx_dir=str(onnx_dir),
num_threads=ASR_THREADS,
quantize="int8",
)
return asr_pipeline
def get_tts_pipeline(voice: str):
from kokoro import KPipeline
lang_code = tts_lang_from_voice(voice)
with tts_lock:
if lang_code not in tts_pipelines:
tts_pipelines[lang_code] = KPipeline(lang_code=lang_code, device="cpu")
return tts_pipelines[lang_code]
def health(api_key: str = "") -> str:
check_key(api_key)
return json.dumps(
{
"ok": True,
"uptime_seconds": round(time.time() - started_at, 3),
"asr_loaded": asr_pipeline is not None,
"tts_loaded_languages": sorted(tts_pipelines.keys()),
},
ensure_ascii=False,
)
@spaces.GPU(duration=10)
def zerogpu_probe() -> str:
return "ok"
def transcribe_b64(
api_key: str,
audio_base64: str,
filename: str = "audio.wav",
language: str = "auto",
max_new_tokens: int = 512,
chunk_sec: int = 30,
) -> str:
check_key(api_key)
max_new_tokens = int(max_new_tokens)
chunk_sec = int(chunk_sec)
if max_new_tokens < 32 or max_new_tokens > 2048:
raise gr.Error("max_new_tokens must be between 32 and 2048")
if chunk_sec < 10 or chunk_sec > 60:
raise gr.Error("chunk_sec must be between 10 and 60")
path = decode_audio_b64(audio_base64, filename)
try:
pipeline = get_asr_pipeline()
result = pipeline.transcribe(
str(path),
language=normalize_language(language),
max_new_tokens=max_new_tokens,
chunk_sec=chunk_sec,
)
finally:
path.unlink(missing_ok=True)
timing = result.get("timing", {})
return json.dumps(
{
"language": result.get("language") or normalize_language(language) or "",
"text": result.get("text", ""),
"duration_seconds": timing.get("audio_duration_s"),
"processing_seconds": timing.get("total_s"),
"rtf": timing.get("rtf"),
"chunks": timing.get("sub_chunks"),
"timing": timing,
},
ensure_ascii=False,
)
def speech_b64(
api_key: str,
text: str,
voice: str = "zf_xiaoxiao",
speed: float = 1.0,
audio_format: str = "wav",
) -> str:
check_key(api_key)
speed = float(speed)
if not text.strip():
raise gr.Error("text is required")
if len(text) > 5000:
raise gr.Error("text is too long; max 5000 characters")
if speed < 0.5 or speed > 2.0:
raise gr.Error("speed must be between 0.5 and 2.0")
pipeline = get_tts_pipeline(voice)
chunks = []
for _, _, audio in pipeline(text, voice=voice, speed=speed):
chunks.append(audio)
if not chunks:
raise gr.Error("speech synthesis returned no audio")
import numpy as np
audio = np.concatenate(chunks)
audio_base64, mime_type = audio_bytes_to_b64(audio, 24000, audio_format)
return json.dumps(
{
"audio_base64": audio_base64,
"mime_type": mime_type,
"sample_rate": 24000,
"voice": voice,
"audio_format": audio_format,
},
ensure_ascii=False,
)
with gr.Blocks(title="Voice API") as demo:
gr.Markdown("# Voice API")
gr.Markdown("Use the named API endpoints from n8n. Audio input/output is base64 JSON for simple automation.")
api_key = gr.Textbox(label="api_key", type="password")
with gr.Tab("Health"):
health_out = gr.Textbox(label="result")
gr.Button("Check").click(health, inputs=[api_key], outputs=[health_out], api_name="health")
gr.Button("ZeroGPU probe", visible=False).click(
zerogpu_probe,
inputs=[],
outputs=[health_out],
api_name="zerogpu_probe",
)
with gr.Tab("ASR"):
audio_b64 = gr.Textbox(label="audio_base64", lines=5)
filename = gr.Textbox(label="filename", value="audio.wav")
language = gr.Textbox(label="language", value="auto")
max_tokens = gr.Number(label="max_new_tokens", value=512, precision=0)
chunk_sec = gr.Number(label="chunk_sec", value=30, precision=0)
asr_out = gr.Textbox(label="result", lines=8)
gr.Button("Transcribe").click(
transcribe_b64,
inputs=[api_key, audio_b64, filename, language, max_tokens, chunk_sec],
outputs=[asr_out],
api_name="transcribe_b64",
)
with gr.Tab("TTS"):
text = gr.Textbox(label="text", lines=4)
voice = gr.Textbox(label="voice", value="zf_xiaoxiao")
speed = gr.Number(label="speed", value=1.0)
audio_format = gr.Textbox(label="audio_format", value="wav")
tts_out = gr.Textbox(label="result", lines=8)
gr.Button("Synthesize").click(
speech_b64,
inputs=[api_key, text, voice, speed, audio_format],
outputs=[tts_out],
api_name="speech_b64",
)
demo.queue(default_concurrency_limit=1).launch()