Add multi-source voice pipeline with VoiceDesign support
- generate_voices.py: scan /config/speakers and /voices recursively, support .ogg and .m4a (M4A auto-converted via ffmpeg), sanitise voice IDs - auto_transcribe.py: scan both host paths recursively, support all formats, use parakeet-asr on port 8010 - docker-compose.yml: mount /home/sparky/Projekte/TTS_Voices/speakers as /voices, add faster-qwen3-tts-voicedesign service on port 8021 - run_voicedesign_server.py: OpenAI-compatible server for VoiceDesign model - voicedesign_voices.json: 8 British/German VoiceDesign voices Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b7b917db66
commit
295de3059d
47
.gitignore
vendored
47
.gitignore
vendored
@ -1,21 +1,42 @@
|
|||||||
# Audio files (add your own voice references locally)
|
# Upstream faster-qwen3-tts repo (tracked separately)
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Personal voice recordings — keep only reference transcripts
|
||||||
config/speakers/*.wav
|
config/speakers/*.wav
|
||||||
config/speakers/*.mp3
|
config/speakers/*.mp3
|
||||||
config/speakers/originals_backup/
|
|
||||||
|
|
||||||
# Generated at runtime
|
# Private speakers — entire subtree
|
||||||
|
config/speakers/privat/
|
||||||
|
|
||||||
|
# Generated at container startup from speakers/
|
||||||
config/voices.json
|
config/voices.json
|
||||||
config/voices.json.bak
|
config/voices.json*
|
||||||
|
|
||||||
# Environment
|
# Converted private audio (M4A → WAV, generated at runtime)
|
||||||
.env
|
config/converted/
|
||||||
*.pyc
|
|
||||||
__pycache__/
|
|
||||||
|
|
||||||
# Editor backups
|
# Editor backup files
|
||||||
|
*.py~
|
||||||
|
*.yml~
|
||||||
|
*.yaml~
|
||||||
*~
|
*~
|
||||||
*.swp
|
|
||||||
|
|
||||||
# OS
|
# Claude Code settings
|
||||||
.DS_Store
|
.claude/
|
||||||
Thumbs.db
|
config/.claude/
|
||||||
|
|
||||||
|
# Random generated files in project root
|
||||||
|
json
|
||||||
|
os
|
||||||
|
*.txt
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|||||||
@ -1,93 +1,65 @@
|
|||||||
"""
|
|
||||||
Batch-transcribe speaker reference audio files using a local Whisper-compatible API.
|
|
||||||
|
|
||||||
Creates .reference.txt files alongside each audio file in the speakers directory.
|
|
||||||
These transcriptions are used by generate_voices.py to build the voice registry.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python auto_transcribe.py [--api-url http://localhost:8010/v1/audio/transcriptions]
|
|
||||||
|
|
||||||
IMPORTANT: Reference audio for voice cloning should be 5-15 seconds long.
|
|
||||||
Longer files will produce poor cloning results and slow down inference.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
import argparse
|
|
||||||
import requests
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
def main():
|
# Host-side paths (this script runs on the host, not inside the container)
|
||||||
parser = argparse.ArgumentParser(description="Batch-transcribe speaker reference audio")
|
SCAN_DIRS = [
|
||||||
parser.add_argument("--api-url", default="http://localhost:8010/v1/audio/transcriptions",
|
"/home/sparky/Docker/faster-qwen3-tts/config/speakers",
|
||||||
help="Whisper-compatible transcription API URL")
|
"/home/sparky/Projekte/TTS_Voices/speakers",
|
||||||
parser.add_argument("--speaker-dir", default="./speakers",
|
]
|
||||||
help="Directory containing speaker audio files")
|
AUDIO_EXTS = (".wav", ".mp3", ".ogg", ".m4a")
|
||||||
parser.add_argument("--model", default="whisper-1",
|
SKIP_DIRS = {"originals_backup", "xtts_multi_voice_sets", "txt"}
|
||||||
help="Transcription model name")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
speaker_dir = args.speaker_dir
|
whisper_api_url = "http://localhost:8010/v1/audio/transcriptions"
|
||||||
|
|
||||||
# Verify API is reachable
|
for scan_dir in SCAN_DIRS:
|
||||||
try:
|
if not os.path.exists(scan_dir):
|
||||||
requests.get(args.api_url.rsplit('/', 2)[0], timeout=5)
|
print(f"Skipping {scan_dir} (not found)")
|
||||||
except requests.ConnectionError:
|
continue
|
||||||
print(f"Error: Cannot reach transcription API at {args.api_url}")
|
|
||||||
print("Make sure your Whisper/ASR service is running.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if not os.path.exists(speaker_dir):
|
print(f"\nScanning {scan_dir} for missing transcripts...")
|
||||||
print(f"Error: Speaker directory not found: {speaker_dir}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
audio_files = [f for f in os.listdir(speaker_dir)
|
for root, dirs, files in os.walk(scan_dir):
|
||||||
if f.endswith(('.wav', '.mp3')) and not f.startswith('.')]
|
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
|
||||||
|
|
||||||
print(f"Found {len(audio_files)} audio files in {speaker_dir}")
|
for filename in sorted(files):
|
||||||
|
if not filename.lower().endswith(AUDIO_EXTS):
|
||||||
|
continue
|
||||||
|
|
||||||
for filename in sorted(audio_files):
|
base_name = os.path.splitext(filename)[0]
|
||||||
base_name = os.path.splitext(filename)[0]
|
ref_txt_path = os.path.join(root, f"{base_name}.reference.txt")
|
||||||
ref_txt_path = os.path.join(speaker_dir, f"{base_name}.reference.txt")
|
audio_path = os.path.join(root, filename)
|
||||||
|
|
||||||
if os.path.exists(ref_txt_path):
|
if os.path.exists(ref_txt_path):
|
||||||
print(f" Skipping {filename} (already transcribed)")
|
continue
|
||||||
continue
|
|
||||||
|
|
||||||
filepath = os.path.join(speaker_dir, filename)
|
print(f"Transcribing: {os.path.relpath(audio_path, scan_dir)}")
|
||||||
print(f" Transcribing {filename}...", end=" ", flush=True)
|
try:
|
||||||
|
with open(audio_path, "rb") as audio_file:
|
||||||
|
response = requests.post(
|
||||||
|
whisper_api_url,
|
||||||
|
files={"file": (filename, audio_file)},
|
||||||
|
data={"model": "large-v3", "response_format": "text"},
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
if response.status_code == 200:
|
||||||
with open(filepath, 'rb') as f:
|
transcript = response.text.strip()
|
||||||
response = requests.post(
|
if transcript.startswith("{"):
|
||||||
args.api_url,
|
try:
|
||||||
files={"file": (filename, f)},
|
transcript = json.loads(transcript).get("text", transcript).strip()
|
||||||
data={"model": args.model},
|
except json.JSONDecodeError:
|
||||||
timeout=60,
|
pass
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
with open(ref_txt_path, "w", encoding="utf-8") as f:
|
||||||
# Handle both JSON and plain text responses
|
f.write(transcript)
|
||||||
try:
|
print(f" ✓ {transcript[:80]}")
|
||||||
text = response.json().get("text", "").strip()
|
|
||||||
except (json.JSONDecodeError, AttributeError):
|
|
||||||
text = response.text.strip()
|
|
||||||
|
|
||||||
if text:
|
|
||||||
with open(ref_txt_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(text)
|
|
||||||
print(f"OK ({len(text)} chars)")
|
|
||||||
else:
|
else:
|
||||||
print("EMPTY (no speech detected)")
|
print(f" ✗ API error {response.status_code}: {response.text}")
|
||||||
else:
|
|
||||||
print(f"FAILED (HTTP {response.status_code})")
|
|
||||||
|
|
||||||
except requests.Timeout:
|
except requests.exceptions.ConnectionError:
|
||||||
print("TIMEOUT")
|
print(f" ✗ Cannot reach Whisper API at {whisper_api_url}")
|
||||||
except Exception as e:
|
raise SystemExit(1)
|
||||||
print(f"ERROR: {e}")
|
except Exception as e:
|
||||||
|
print(f" ✗ Error on {filename}: {e}")
|
||||||
|
|
||||||
print("Done.")
|
print("\nBatch transcription complete.")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|||||||
68
config/docker-compose.yml
Normal file
68
config/docker-compose.yml
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
services:
|
||||||
|
faster-qwen3-tts:
|
||||||
|
image: faster-qwen3-tts-dgx-spark:v4
|
||||||
|
container_name: faster-qwen3-tts
|
||||||
|
restart: unless-stopped
|
||||||
|
runtime: nvidia
|
||||||
|
environment:
|
||||||
|
- NVIDIA_VISIBLE_DEVICES=all
|
||||||
|
- HF_TOKEN=${HF_TOKEN}
|
||||||
|
ports:
|
||||||
|
- "8020:8000"
|
||||||
|
volumes:
|
||||||
|
- /home/sparky/LLMs/vllm/Alibaba/Qwen3-TTS-12Hz-1.7B-Base:/models/Qwen3-TTS:ro
|
||||||
|
- /home/sparky/Docker/faster-qwen3-tts/config:/config:rw
|
||||||
|
- /home/sparky/Projekte/TTS_Voices/speakers:/voices:ro
|
||||||
|
command: >
|
||||||
|
/bin/bash -c "
|
||||||
|
python3 /config/generate_voices.py &&
|
||||||
|
python3 /config/run_server.py
|
||||||
|
--model /models/Qwen3-TTS
|
||||||
|
--voices /config/voices.json
|
||||||
|
--port 8000
|
||||||
|
--max-seq-len 2048
|
||||||
|
"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: all
|
||||||
|
capabilities: [gpu]
|
||||||
|
networks:
|
||||||
|
- dgx_net
|
||||||
|
|
||||||
|
faster-qwen3-tts-voicedesign:
|
||||||
|
image: faster-qwen3-tts-dgx-spark:v4
|
||||||
|
container_name: faster-qwen3-tts-voicedesign
|
||||||
|
restart: unless-stopped
|
||||||
|
runtime: nvidia
|
||||||
|
environment:
|
||||||
|
- NVIDIA_VISIBLE_DEVICES=all
|
||||||
|
- HF_TOKEN=${HF_TOKEN}
|
||||||
|
ports:
|
||||||
|
- "8021:8000"
|
||||||
|
volumes:
|
||||||
|
- /home/sparky/LLMs/vllm/Alibaba/Qwen3-TTS-12Hz-1.7B-VoiceDesign:/models/Qwen3-TTS-VoiceDesign:ro
|
||||||
|
- /home/sparky/Docker/faster-qwen3-tts/config:/config:rw
|
||||||
|
command: >
|
||||||
|
/bin/bash -c "
|
||||||
|
python3 /config/run_voicedesign_server.py
|
||||||
|
--model /models/Qwen3-TTS-VoiceDesign
|
||||||
|
--voices /config/voicedesign_voices.json
|
||||||
|
--port 8000
|
||||||
|
--max-seq-len 2048
|
||||||
|
"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: all
|
||||||
|
capabilities: [gpu]
|
||||||
|
networks:
|
||||||
|
- dgx_net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dgx_net:
|
||||||
|
external: true
|
||||||
@ -1,65 +1,103 @@
|
|||||||
#generate_voices.py
|
#generate_voices.py
|
||||||
"""
|
|
||||||
Scan the speakers directory for reference audio files and generate voices.json.
|
|
||||||
|
|
||||||
Runs inside the container at startup to map all available voice reference
|
|
||||||
audio files into a format the OpenAI-compatible TTS server understands.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
# Internal container paths
|
|
||||||
speaker_dir = "/config/speakers"
|
|
||||||
output_file = "/config/voices.json"
|
output_file = "/config/voices.json"
|
||||||
|
converted_dir = "/config/converted"
|
||||||
|
|
||||||
|
# .m4a is converted to WAV on the fly because soundfile doesn't support AAC
|
||||||
|
AUDIO_EXTS = (".wav", ".mp3", ".ogg", ".m4a")
|
||||||
|
SKIP_DIRS = {"originals_backup", "xtts_multi_voice_sets", "txt"}
|
||||||
|
|
||||||
|
# (base_dir_on_container, container_path_prefix)
|
||||||
|
# /config/speakers — legacy location, writable
|
||||||
|
# /voices — new external mount, read-only
|
||||||
|
SCAN_DIRS = [
|
||||||
|
"/config/speakers",
|
||||||
|
"/voices",
|
||||||
|
]
|
||||||
|
|
||||||
voices = {}
|
voices = {}
|
||||||
|
|
||||||
# Ensure the directory exists just in case
|
|
||||||
if os.path.exists(speaker_dir):
|
def detect_language(base_name):
|
||||||
for filename in os.listdir(speaker_dir):
|
if base_name.startswith("EN_") or base_name.startswith("basic_ref_en"):
|
||||||
if filename.endswith((".wav", ".mp3")):
|
return "English"
|
||||||
|
if base_name.startswith("DE_"):
|
||||||
|
return "German"
|
||||||
|
if base_name.startswith("basic_ref_zh"):
|
||||||
|
return "Chinese"
|
||||||
|
return "Auto"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def make_voice_id(base_dir, root, base_name):
|
||||||
|
rel = os.path.relpath(root, base_dir)
|
||||||
|
parts = [] if rel == "." else rel.split(os.sep)
|
||||||
|
parts.append(base_name)
|
||||||
|
raw = "_".join(parts)
|
||||||
|
return re.sub(r"[^\w\-]", "_", raw)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_m4a(src_path, voice_id):
|
||||||
|
"""Convert M4A to WAV in /config/converted/. Returns the WAV path."""
|
||||||
|
os.makedirs(converted_dir, exist_ok=True)
|
||||||
|
dst_path = os.path.join(converted_dir, f"{voice_id}.wav")
|
||||||
|
if not os.path.exists(dst_path):
|
||||||
|
result = subprocess.run(
|
||||||
|
["ffmpeg", "-y", "-i", src_path, "-ar", "24000", "-ac", "1", dst_path],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" ✗ ffmpeg failed for {src_path}: {result.stderr.decode()[:200]}")
|
||||||
|
return None
|
||||||
|
print(f" Converted: {os.path.basename(src_path)} → {dst_path}")
|
||||||
|
return dst_path
|
||||||
|
|
||||||
|
|
||||||
|
for scan_dir in SCAN_DIRS:
|
||||||
|
if not os.path.exists(scan_dir):
|
||||||
|
print(f"Skipping {scan_dir} (not mounted)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for root, dirs, files in os.walk(scan_dir):
|
||||||
|
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
|
||||||
|
|
||||||
|
for filename in sorted(files):
|
||||||
|
if not filename.lower().endswith(AUDIO_EXTS):
|
||||||
|
continue
|
||||||
|
|
||||||
base_name = os.path.splitext(filename)[0]
|
base_name = os.path.splitext(filename)[0]
|
||||||
|
audio_path = os.path.join(root, filename)
|
||||||
|
voice_id = make_voice_id(scan_dir, root, base_name)
|
||||||
|
|
||||||
# Determine language based on file prefixes
|
if filename.lower().endswith(".m4a"):
|
||||||
lang = "Auto"
|
audio_path = convert_m4a(audio_path, voice_id)
|
||||||
if base_name.startswith("EN_") or base_name.startswith("basic_ref_en"):
|
if audio_path is None:
|
||||||
lang = "English"
|
continue
|
||||||
elif base_name.startswith("DE_"):
|
|
||||||
lang = "German"
|
|
||||||
elif base_name.startswith("basic_ref_zh"):
|
|
||||||
lang = "Chinese"
|
|
||||||
|
|
||||||
# # Create a clean voice ID
|
|
||||||
# voice_id = base_name.lower()
|
|
||||||
# prefixes_to_strip = ["en_m_", "en_f_", "de_m_", "de_f_"]
|
|
||||||
# for prefix in prefixes_to_strip:
|
|
||||||
# if voice_id.startswith(prefix):
|
|
||||||
# voice_id = voice_id.replace(prefix, "", 1)
|
|
||||||
# break
|
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
"ref_audio": f"/config/speakers/{filename}",
|
"ref_audio": audio_path,
|
||||||
"language": lang,
|
"language": detect_language(base_name),
|
||||||
"chunk_size": 4,
|
"chunk_size": 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Look for matching reference text files
|
ref_txt = os.path.join(root, f"{base_name}.reference.txt")
|
||||||
ref_txt_path = os.path.join(speaker_dir, f"{base_name}.reference.txt")
|
txt = os.path.join(root, f"{base_name}.txt")
|
||||||
txt_path = os.path.join(speaker_dir, f"{base_name}.txt")
|
if os.path.exists(ref_txt):
|
||||||
|
with open(ref_txt, encoding="utf-8") as f:
|
||||||
if os.path.exists(ref_txt_path):
|
|
||||||
with open(ref_txt_path, 'r', encoding='utf-8') as f:
|
|
||||||
entry["ref_text"] = f.read().strip()
|
entry["ref_text"] = f.read().strip()
|
||||||
elif os.path.exists(txt_path):
|
elif os.path.exists(txt):
|
||||||
with open(txt_path, 'r', encoding='utf-8') as f:
|
with open(txt, encoding="utf-8") as f:
|
||||||
entry["ref_text"] = f.read().strip()
|
entry["ref_text"] = f.read().strip()
|
||||||
|
|
||||||
voices[voice_id] = entry
|
voices[voice_id] = entry
|
||||||
|
|
||||||
with open(output_file, 'w', encoding='utf-8') as f:
|
with open(output_file, "w", encoding="utf-8") as f:
|
||||||
json.dump(voices, f, indent=2, ensure_ascii=False)
|
json.dump(voices, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
print(f"Success! Generated voices.json with {len(voices)} mapped voices.")
|
print(f"Success! Generated voices.json with {len(voices)} mapped voices.")
|
||||||
else:
|
|
||||||
print(f"Warning: Directory {speaker_dir} not found. Skipping voice generation.")
|
|
||||||
|
|||||||
234
config/run_voicedesign_server.py
Normal file
234
config/run_voicedesign_server.py
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
"""
|
||||||
|
OpenAI-compatible TTS server for Qwen3-TTS-12Hz-1.7B-VoiceDesign.
|
||||||
|
|
||||||
|
Voices are defined in voicedesign_voices.json as:
|
||||||
|
{ "voice_id": { "instruct": "...", "language": "..." } }
|
||||||
|
|
||||||
|
No ref_audio needed — the instruct text fully describes the voice.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import asyncio
|
||||||
|
import argparse
|
||||||
|
import numpy as np
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import Response, StreamingResponse, JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
sys.path.append("/app")
|
||||||
|
from faster_qwen3_tts.model import FasterQwen3TTS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
tts_model: FasterQwen3TTS = None
|
||||||
|
voices: dict = {}
|
||||||
|
default_voice: str = None
|
||||||
|
SAMPLE_RATE = 24000
|
||||||
|
_model_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request schema (OpenAI TTS compatible)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SpeechRequest(BaseModel):
|
||||||
|
model: str = "tts-1"
|
||||||
|
input: str
|
||||||
|
voice: str = "vd_british_male"
|
||||||
|
response_format: str = "wav"
|
||||||
|
speed: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Audio helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _to_pcm16(audio: np.ndarray) -> bytes:
|
||||||
|
return (audio * 32767).clip(-32768, 32767).astype(np.int16).tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def _wav_header(sample_rate: int) -> bytes:
|
||||||
|
import struct
|
||||||
|
return struct.pack(
|
||||||
|
"<4sI4s4sIHHIIHH4sI",
|
||||||
|
b"RIFF", 0xFFFFFFFF, b"WAVE",
|
||||||
|
b"fmt ", 16, 1, 1,
|
||||||
|
sample_rate, sample_rate * 2, 2, 16,
|
||||||
|
b"data", 0xFFFFFFFF,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_mp3_bytes(audio: np.ndarray, sr: int) -> bytes:
|
||||||
|
from pydub import AudioSegment
|
||||||
|
import io
|
||||||
|
pcm = _to_pcm16(audio)
|
||||||
|
seg = AudioSegment(pcm, frame_rate=sr, sample_width=2, channels=1)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
seg.export(buf, format="mp3")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_voice(name: str) -> dict:
|
||||||
|
cfg = voices.get(name)
|
||||||
|
if cfg:
|
||||||
|
return cfg
|
||||||
|
if default_voice and default_voice in voices:
|
||||||
|
logger.warning("Voice %r not found, falling back to %r", name, default_voice)
|
||||||
|
return voices[default_voice]
|
||||||
|
raise HTTPException(status_code=404, detail=f"Voice {name!r} not found")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Generation helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _stream_chunks(voice_cfg: dict, text: str):
|
||||||
|
q: queue.Queue = queue.Queue()
|
||||||
|
_DONE = object()
|
||||||
|
|
||||||
|
def producer():
|
||||||
|
try:
|
||||||
|
with _model_lock:
|
||||||
|
for chunk, _sr, _timing in tts_model.generate_voice_design_streaming(
|
||||||
|
text=text,
|
||||||
|
instruct=voice_cfg["instruct"],
|
||||||
|
language=voice_cfg.get("language", "English"),
|
||||||
|
):
|
||||||
|
q.put(chunk)
|
||||||
|
except Exception as exc:
|
||||||
|
q.put(exc)
|
||||||
|
finally:
|
||||||
|
q.put(_DONE)
|
||||||
|
|
||||||
|
threading.Thread(target=producer, daemon=True).start()
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
while True:
|
||||||
|
item = await loop.run_in_executor(None, q.get)
|
||||||
|
if item is _DONE:
|
||||||
|
break
|
||||||
|
if isinstance(item, Exception):
|
||||||
|
raise item
|
||||||
|
yield _to_pcm16(item)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "model_loaded": tts_model is not None}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/audio/speech")
|
||||||
|
async def create_speech(req: SpeechRequest):
|
||||||
|
if tts_model is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Model not loaded")
|
||||||
|
if not req.input.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="'input' text is empty")
|
||||||
|
|
||||||
|
voice_cfg = resolve_voice(req.voice)
|
||||||
|
fmt = req.response_format.lower()
|
||||||
|
|
||||||
|
_CONTENT_TYPES = {"wav": "audio/wav", "pcm": "audio/pcm", "mp3": "audio/mpeg"}
|
||||||
|
if fmt not in _CONTENT_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unsupported format: {fmt!r}")
|
||||||
|
|
||||||
|
if fmt == "mp3":
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
def _gen():
|
||||||
|
with _model_lock:
|
||||||
|
return tts_model.generate_voice_design(
|
||||||
|
text=req.input,
|
||||||
|
instruct=voice_cfg["instruct"],
|
||||||
|
language=voice_cfg.get("language", "English"),
|
||||||
|
)
|
||||||
|
audio_arrays, sr = await loop.run_in_executor(None, _gen)
|
||||||
|
audio = audio_arrays[0] if audio_arrays else np.zeros(1, dtype=np.float32)
|
||||||
|
return Response(content=_to_mp3_bytes(audio, sr), media_type="audio/mpeg")
|
||||||
|
|
||||||
|
async def audio_stream():
|
||||||
|
if fmt == "wav":
|
||||||
|
yield _wav_header(SAMPLE_RATE)
|
||||||
|
async for raw in _stream_chunks(voice_cfg, req.input):
|
||||||
|
yield raw
|
||||||
|
|
||||||
|
return StreamingResponse(audio_stream(), media_type=_CONTENT_TYPES[fmt])
|
||||||
|
|
||||||
|
|
||||||
|
_voice_list = None
|
||||||
|
_models_response = None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_voice_list():
|
||||||
|
global _voice_list, _models_response
|
||||||
|
_voice_list = [{"id": v, "object": "model", "created": 1686935002, "owned_by": "qwen"} for v in voices]
|
||||||
|
_models_response = {"object": "list", "data": _voice_list}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/models")
|
||||||
|
async def list_models():
|
||||||
|
return _models_response
|
||||||
|
|
||||||
|
@app.get("/v1/audio/voices")
|
||||||
|
async def list_audio_voices():
|
||||||
|
return _models_response
|
||||||
|
|
||||||
|
@app.get("/v1/audio/models")
|
||||||
|
async def list_audio_models():
|
||||||
|
return _models_response
|
||||||
|
|
||||||
|
@app.get("/speakers")
|
||||||
|
async def get_speakers():
|
||||||
|
return list(voices.keys())
|
||||||
|
|
||||||
|
@app.options("/{path:path}")
|
||||||
|
async def options_handler(path: str):
|
||||||
|
return JSONResponse(content={"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global tts_model, voices, default_voice, SAMPLE_RATE
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--model", default="/models/Qwen3-TTS-VoiceDesign")
|
||||||
|
parser.add_argument("--voices", default="/config/voicedesign_voices.json")
|
||||||
|
parser.add_argument("--port", type=int, default=8000)
|
||||||
|
parser.add_argument("--host", default="0.0.0.0")
|
||||||
|
parser.add_argument("--device", default="cuda")
|
||||||
|
parser.add_argument("--max-seq-len", type=int, default=2048)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
with open(args.voices) as f:
|
||||||
|
voices = json.load(f)
|
||||||
|
default_voice = next(iter(voices), None)
|
||||||
|
_build_voice_list()
|
||||||
|
|
||||||
|
import torch
|
||||||
|
logger.info("Loading VoiceDesign model %s …", args.model)
|
||||||
|
tts_model = FasterQwen3TTS.from_pretrained(
|
||||||
|
args.model,
|
||||||
|
device=args.device,
|
||||||
|
dtype=torch.bfloat16,
|
||||||
|
attn_implementation="sdpa",
|
||||||
|
max_seq_len=args.max_seq_len,
|
||||||
|
)
|
||||||
|
SAMPLE_RATE = tts_model.sample_rate
|
||||||
|
logger.info("Model ready. Sample rate: %d Hz", SAMPLE_RATE)
|
||||||
|
|
||||||
|
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
34
config/voicedesign_voices.json
Normal file
34
config/voicedesign_voices.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"vd_british_male": {
|
||||||
|
"instruct": "A native British English male speaker with received pronunciation (RP) accent, clear articulation, calm and authoritative tone",
|
||||||
|
"language": "English"
|
||||||
|
},
|
||||||
|
"vd_british_female": {
|
||||||
|
"instruct": "A native British English female speaker with received pronunciation (RP) accent, warm and clearly articulated",
|
||||||
|
"language": "English"
|
||||||
|
},
|
||||||
|
"vd_british_male_casual": {
|
||||||
|
"instruct": "A young British English male speaker with a natural conversational RP accent, friendly and relaxed",
|
||||||
|
"language": "English"
|
||||||
|
},
|
||||||
|
"vd_british_female_warm": {
|
||||||
|
"instruct": "A middle-aged British English female speaker, warm southern English accent, gentle and expressive",
|
||||||
|
"language": "English"
|
||||||
|
},
|
||||||
|
"vd_german_male": {
|
||||||
|
"instruct": "A native German male speaker with standard Hochdeutsch pronunciation, clear articulation, no foreign accent, professional tone",
|
||||||
|
"language": "German"
|
||||||
|
},
|
||||||
|
"vd_german_female": {
|
||||||
|
"instruct": "A native German female speaker with standard Hochdeutsch pronunciation, warm and natural, no foreign accent",
|
||||||
|
"language": "German"
|
||||||
|
},
|
||||||
|
"vd_german_male_casual": {
|
||||||
|
"instruct": "A young native German male speaker, natural conversational Hochdeutsch, friendly and relaxed, no foreign accent",
|
||||||
|
"language": "German"
|
||||||
|
},
|
||||||
|
"vd_german_female_warm": {
|
||||||
|
"instruct": "A middle-aged native German female speaker, warm Hochdeutsch, expressive and clear, no foreign accent",
|
||||||
|
"language": "German"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user