add VibeVoice-Realtime

This commit is contained in:
YaoyaoChang
2025-12-04 05:38:30 -08:00
parent e81395cf6d
commit fc83be5d92
39 changed files with 8190 additions and 6 deletions
+314
View File
@@ -0,0 +1,314 @@
import argparse
import os
import re
import traceback
from typing import List, Tuple, Union, Dict, Any
import time
import torch
import copy
from vibevoice.modular.modeling_vibevoice_streaming_inference import VibeVoiceStreamingForConditionalGenerationInference
from vibevoice.processor.vibevoice_streaming_processor import VibeVoiceStreamingProcessor
from transformers.utils import logging
logging.set_verbosity_info()
logger = logging.get_logger(__name__)
class VoiceMapper:
"""Maps speaker names to voice file paths"""
def __init__(self):
self.setup_voice_presets()
# change name according to our preset voice file
new_dict = {}
for name, path in self.voice_presets.items():
if '_' in name:
name = name.split('_')[0]
if '-' in name:
name = name.split('-')[-1]
new_dict[name] = path
self.voice_presets.update(new_dict)
# print(list(self.voice_presets.keys()))
def setup_voice_presets(self):
"""Setup voice presets by scanning the voices directory."""
voices_dir = os.path.join(os.path.dirname(__file__), "voices/streaming_model")
# Check if voices directory exists
if not os.path.exists(voices_dir):
print(f"Warning: Voices directory not found at {voices_dir}")
self.voice_presets = {}
self.available_voices = {}
return
# Scan for all VOICE files in the voices directory
self.voice_presets = {}
# Get all .pt files in the voices directory
pt_files = [f for f in os.listdir(voices_dir)
if f.lower().endswith('.pt') and os.path.isfile(os.path.join(voices_dir, f))]
# Create dictionary with filename (without extension) as key
for pt_file in pt_files:
# Remove .pt extension to get the name
name = os.path.splitext(pt_file)[0]
# Create full path
full_path = os.path.join(voices_dir, pt_file)
self.voice_presets[name] = full_path
# Sort the voice presets alphabetically by name for better UI
self.voice_presets = dict(sorted(self.voice_presets.items()))
# Filter out voices that don't exist (this is now redundant but kept for safety)
self.available_voices = {
name: path for name, path in self.voice_presets.items()
if os.path.exists(path)
}
print(f"Found {len(self.available_voices)} voice files in {voices_dir}")
print(f"Available voices: {', '.join(self.available_voices.keys())}")
def get_voice_path(self, speaker_name: str) -> str:
"""Get voice file path for a given speaker name"""
# First try exact match
if speaker_name in self.voice_presets:
return self.voice_presets[speaker_name]
# Try partial matching (case insensitive)
speaker_lower = speaker_name.lower()
for preset_name, path in self.voice_presets.items():
if preset_name.lower() in speaker_lower or speaker_lower in preset_name.lower():
return path
# Default to first voice if no match found
default_voice = list(self.voice_presets.values())[0]
print(f"Warning: No voice preset found for '{speaker_name}', using default voice: {default_voice}")
return default_voice
def parse_args():
parser = argparse.ArgumentParser(description="VibeVoiceStreaming Processor TXT Input Test")
parser.add_argument(
"--model_path",
type=str,
default="microsoft/VibeVoice-Realtime-0.5B",
help="Path to the HuggingFace model directory",
)
parser.add_argument(
"--txt_path",
type=str,
default="demo/text_examples/1p_vibevoice.txt",
help="Path to the txt file containing the script",
)
parser.add_argument(
"--speaker_name",
type=str,
default="Wayne",
help="Single speaker name (e.g., --speaker_name Wayne)",
)
parser.add_argument(
"--output_dir",
type=str,
default="./outputs",
help="Directory to save output audio files",
)
parser.add_argument(
"--device",
type=str,
default=("cuda" if torch.cuda.is_available() else ("mps" if torch.backends.mps.is_available() else "cpu")),
help="Device for inference: cuda | mps | cpu",
)
parser.add_argument(
"--cfg_scale",
type=float,
default=1.5,
help="CFG (Classifier-Free Guidance) scale for generation (default: 1.5)",
)
return parser.parse_args()
def main():
args = parse_args()
# Normalize potential 'mpx' typo to 'mps'
if args.device.lower() == "mpx":
print("Note: device 'mpx' detected, treating it as 'mps'.")
args.device = "mps"
# Validate mps availability if requested
if args.device == "mps" and not torch.backends.mps.is_available():
print("Warning: MPS not available. Falling back to CPU.")
args.device = "cpu"
print(f"Using device: {args.device}")
# Initialize voice mapper
voice_mapper = VoiceMapper()
# Check if txt file exists
if not os.path.exists(args.txt_path):
print(f"Error: txt file not found: {args.txt_path}")
return
# Read and parse txt file
print(f"Reading script from: {args.txt_path}")
with open(args.txt_path, 'r', encoding='utf-8') as f:
scripts = f.read().strip()
if not scripts:
print("Error: No valid scripts found in the txt file")
return
full_script = scripts.replace("", "'").replace('', '"').replace('', '"')
print(f"Loading processor & model from {args.model_path}")
processor = VibeVoiceStreamingProcessor.from_pretrained(args.model_path)
# Decide dtype & attention implementation
if args.device == "mps":
load_dtype = torch.float32 # MPS requires float32
attn_impl_primary = "sdpa" # flash_attention_2 not supported on MPS
elif args.device == "cuda":
load_dtype = torch.bfloat16
attn_impl_primary = "flash_attention_2"
else: # cpu
load_dtype = torch.float32
attn_impl_primary = "sdpa"
print(f"Using device: {args.device}, torch_dtype: {load_dtype}, attn_implementation: {attn_impl_primary}")
# Load model with device-specific logic
try:
if args.device == "mps":
model = VibeVoiceStreamingForConditionalGenerationInference.from_pretrained(
args.model_path,
torch_dtype=load_dtype,
attn_implementation=attn_impl_primary,
device_map=None, # load then move
)
model.to("mps")
elif args.device == "cuda":
model = VibeVoiceStreamingForConditionalGenerationInference.from_pretrained(
args.model_path,
torch_dtype=load_dtype,
device_map="cuda",
attn_implementation=attn_impl_primary,
)
else: # cpu
model = VibeVoiceStreamingForConditionalGenerationInference.from_pretrained(
args.model_path,
torch_dtype=load_dtype,
device_map="cpu",
attn_implementation=attn_impl_primary,
)
except Exception as e:
if attn_impl_primary == 'flash_attention_2':
print(f"[ERROR] : {type(e).__name__}: {e}")
print(traceback.format_exc())
print("Error loading the model. Trying to use SDPA. However, note that only flash_attention_2 has been fully tested, and using SDPA may result in lower audio quality.")
model = VibeVoiceStreamingForConditionalGenerationInference.from_pretrained(
args.model_path,
torch_dtype=load_dtype,
device_map=(args.device if args.device in ("cuda", "cpu") else None),
attn_implementation='sdpa'
)
if args.device == "mps":
model.to("mps")
else:
raise e
model.eval()
model.set_ddpm_inference_steps(num_steps=5)
if hasattr(model.model, 'language_model'):
print(f"Language model attention: {model.model.language_model.config._attn_implementation}")
target_device = args.device if args.device != "cpu" else "cpu"
voice_sample = voice_mapper.get_voice_path(args.speaker_name)
all_prefilled_outputs = torch.load(voice_sample, map_location=target_device, weights_only=False)
# Prepare inputs for the model
inputs = processor.process_input_with_cached_prompt(
text=full_script,
cached_prompt=all_prefilled_outputs,
padding=True,
return_tensors="pt",
return_attention_mask=True,
)
# Move tensors to target device
for k, v in inputs.items():
if torch.is_tensor(v):
inputs[k] = v.to(target_device)
print(f"Starting generation with cfg_scale: {args.cfg_scale}")
# Generate audio
start_time = time.time()
outputs = model.generate(
**inputs,
max_new_tokens=None,
cfg_scale=args.cfg_scale,
tokenizer=processor.tokenizer,
generation_config={'do_sample': False},
verbose=True,
all_prefilled_outputs=copy.deepcopy(all_prefilled_outputs) if all_prefilled_outputs is not None else None,
)
generation_time = time.time() - start_time
print(f"Generation time: {generation_time:.2f} seconds")
# Calculate audio duration and additional metrics
if outputs.speech_outputs and outputs.speech_outputs[0] is not None:
# Assuming 24kHz sample rate (common for speech synthesis)
sample_rate = 24000
audio_samples = outputs.speech_outputs[0].shape[-1] if len(outputs.speech_outputs[0].shape) > 0 else len(outputs.speech_outputs[0])
audio_duration = audio_samples / sample_rate
rtf = generation_time / audio_duration if audio_duration > 0 else float('inf')
print(f"Generated audio duration: {audio_duration:.2f} seconds")
print(f"RTF (Real Time Factor): {rtf:.2f}x")
else:
print("No audio output generated")
# Calculate token metrics
input_tokens = inputs['tts_text_ids'].shape[1] # Number of input tokens
output_tokens = outputs.sequences.shape[1] # Total tokens (input + generated)
generated_tokens = output_tokens - input_tokens - all_prefilled_outputs['tts_lm']['last_hidden_state'].size(1)
print(f"Prefilling text tokens: {input_tokens}")
print(f"Generated speech tokens: {generated_tokens}")
print(f"Total tokens: {output_tokens}")
# Save output (processor handles device internally)
txt_filename = os.path.splitext(os.path.basename(args.txt_path))[0]
output_path = os.path.join(args.output_dir, f"{txt_filename}_generated.wav")
os.makedirs(args.output_dir, exist_ok=True)
processor.save_audio(
outputs.speech_outputs[0], # First (and only) batch item
output_path=output_path,
)
print(f"Saved output to {output_path}")
# Print summary
print("\n" + "="*50)
print("GENERATION SUMMARY")
print("="*50)
print(f"Input file: {args.txt_path}")
print(f"Output file: {output_path}")
print(f"Speaker names: {args.speaker_name}")
print(f"Prefilling text tokens: {input_tokens}")
print(f"Generated speech tokens: {generated_tokens}")
print(f"Total tokens: {output_tokens}")
print(f"Generation time: {generation_time:.2f} seconds")
print(f"Audio duration: {audio_duration:.2f} seconds")
print(f"RTF (Real Time Factor): {rtf:.2f}x")
print("="*50)
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
Generating long-form, multi-speaker conversational audio like podcasts poses significant challenges for traditional Text-to-Speech (TTS) systems, particularly in scalability, speaker consistency, and natural turn-taking. This report presents VibeVoice, a novel model designed to synthesize long-form speech with multiple speakers by employing the next-token diffusion framework, a unified method for modeling continuous data by autoregressively generating latent vectors via diffusion.
A core component of our approach is the continuous speech tokenizers operating at an ultra-low frame rate of 7.5. This tokenizer effectively preserves audio fidelity while significantly boosting computational efficiency for processing long sequences. This enables VibeVoice to synthesize long-form speech for up to 90 minutes (in a 64K context window length) with up to 4 speakers, capturing the authentic conversational "vibe" and surpassing all known open-source and closed-source dialogue models (for example, Gemini 2.5 Pro Preview TTS). Code and checkpoint are available now.
+1
View File
@@ -0,0 +1 @@
VibeVoice is a novel framework designed for generating expressive, long-form, multi-speaker conversational audio, such as podcasts, from text. It addresses significant challenges in traditional Text-to-Speech (TTS) systems, particularly in scalability, speaker consistency, and natural turn-taking. A core innovation of VibeVoice is its use of continuous speech tokenizers operating at an ultra-low frame rate of 7.5 Hz. These tokenizers efficiently preserve audio fidelity while significantly boosting computational efficiency for processing long sequences. VibeVoice employs a next-token diffusion framework, leveraging a Large Language Model to understand textual context and dialogue flow, and a diffusion head to generate high-fidelity acoustic details. The model can synthesize speech up to 90 minutes long with up to 4 distinct speakers, surpassing the typical 1-2 speaker limits of many prior models.
+167
View File
@@ -0,0 +1,167 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "d1785adb",
"metadata": {
"colab_type": "text",
"id": "view-in-github"
},
"source": [
"<a href=\"https://colab.research.google.com/github/microsoft/VibeVoice/blob/main/demo/vibevoice_realtime_colab.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>"
]
},
{
"cell_type": "markdown",
"id": "WvIaUJD2y0yU",
"metadata": {
"id": "WvIaUJD2y0yU"
},
"source": [
"# VibeVoice-Realtime Colab — T4 Quickstart\n",
"\n"
]
},
{
"cell_type": "markdown",
"id": "e8fTKYGx7DZk",
"metadata": {
"id": "e8fTKYGx7DZk"
},
"source": [
"## Step 1: Setup Environment"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "4wxJ6QHM-ZOb",
"metadata": {
"id": "4wxJ6QHM-ZOb"
},
"outputs": [],
"source": [
"# Check for T4 GPU\n",
"import torch\n",
"if torch.cuda.is_available() and \"T4\" in torch.cuda.get_device_name(0):\n",
" print(\"✅ T4 GPU detected\")\n",
"else:\n",
" print(\"\"\"\n",
" ⚠️ WARNING: T4 GPU not detected\n",
"\n",
" The recommended runtime for this Colab notebook is \"T4 GPU\".\n",
"\n",
" To change the runtime type:\n",
"\n",
" 1. Click on \"Runtime\" in the top navigation menu\n",
" 2. Click on \"Change runtime type\"\n",
" 3. Select \"T4 GPU\"\n",
" 4. Click \"OK\" if a \"Disconnect and delete runtime\" window appears\n",
" 5. Click on \"Save\"\n",
"\n",
" \"\"\")\n",
"\n",
"# Clone the VibeVoice repository\n",
"![ -d /content/VibeVoice ] || git clone --quiet --branch main --depth 1 https://github.com/microsoft/VibeVoice.git /content/VibeVoice\n",
"print(\"✅ Cloned VibeVoice repository\")\n",
"\n",
"# Install project dependencies\n",
"!uv pip --quiet install --system -e /content/VibeVoice\n",
"!wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -O cloudflared && chmod +x cloudflared\n",
"print(\"✅ Installed dependencies\")\n",
"\n",
"# Download model\n",
"!HF_XET_HIGH_PERFORMANCE=1 hf download microsoft/VibeVoice-Realtime-0.5B --quiet --local-dir /content/models/VibeVoice-Realtime-0.5B > /dev/null\n",
"print(\"✅ Downloaded model: microsoft/VibeVoice-Realtime-0.5B\")\n"
]
},
{
"cell_type": "markdown",
"id": "pgKlV7153Ifi",
"metadata": {
"id": "pgKlV7153Ifi"
},
"source": [
"## Step 2: Launch VibeVoice-Realtime Demo"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "Yc1N9EHswFxA",
"metadata": {
"id": "Yc1N9EHswFxA"
},
"outputs": [],
"source": [
"import subprocess, re, time, threading\n",
"\n",
"srv = subprocess.Popen(\n",
" \"python /content/VibeVoice/demo/vibevoice_realtime_demo.py --model_path /content/models/VibeVoice-Realtime-0.5B --port 8000\",\n",
" shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True,\n",
")\n",
"cf = subprocess.Popen(\n",
" \"./cloudflared tunnel --url http://localhost:8000 --no-autoupdate\",\n",
" shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True,\n",
")\n",
"\n",
"public_url = None\n",
"server_ready = False\n",
"url_pattern = re.compile(r\"(https://[a-z0-9-]+\\.trycloudflare\\.com)\")\n",
"\n",
"def read_srv():\n",
" global server_ready\n",
" for ln in srv.stdout:\n",
" print(ln.strip())\n",
" if \"Uvicorn running on\" in ln:\n",
" server_ready = True\n",
"\n",
"def read_cf():\n",
" global public_url\n",
" for ln in cf.stdout:\n",
" m = url_pattern.search(ln)\n",
" if m:\n",
" public_url = m.group(1)\n",
" break\n",
"\n",
"threading.Thread(target=read_srv, daemon=True).start()\n",
"threading.Thread(target=read_cf, daemon=True).start()\n",
"\n",
"\n",
"while True:\n",
" if server_ready and public_url:\n",
" print(f\"✅ Public URL: {public_url}\\n\");\n",
" public_url = None\n",
" time.sleep(0.25)"
]
}
],
"metadata": {
"accelerator": "GPU",
"colab": {
"gpuType": "T4",
"include_colab_link": true,
"machine_shape": "hm",
"name": "VibeVoice_Colab.ipynb",
"provenance": []
},
"kernelspec": {
"display_name": "Python 3",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.10.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+17
View File
@@ -0,0 +1,17 @@
import argparse, os, uvicorn
def main():
p = argparse.ArgumentParser()
p.add_argument("--port", type=int, default=3000)
p.add_argument("--model_path", type=str, default="default_model")
p.add_argument("--device", type=str, default="cuda", choices=["cpu", "cuda", "mpx", "mps"])
p.add_argument("--reload", action="store_true", help="Reload the model or not")
args = p.parse_args()
os.environ["MODEL_PATH"] = args.model_path
os.environ["MODEL_DEVICE"] = args.device
uvicorn.run("web.app:app", host="0.0.0.0", port=args.port, reload=args.reload)
if __name__ == "__main__":
main()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+506
View File
@@ -0,0 +1,506 @@
import datetime
import builtins
import asyncio
import json
import os
import threading
import traceback
from pathlib import Path
from queue import Empty, Queue
from typing import Any, Callable, Dict, Iterator, Optional, Tuple, cast
import numpy as np
import torch
from fastapi import FastAPI, WebSocket
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.websockets import WebSocketDisconnect, WebSocketState
from vibevoice.modular.modeling_vibevoice_streaming_inference import (
VibeVoiceStreamingForConditionalGenerationInference,
)
from vibevoice.processor.vibevoice_streaming_processor import (
VibeVoiceStreamingProcessor,
)
from vibevoice.modular.streamer import AudioStreamer
import copy
BASE = Path(__file__).parent
SAMPLE_RATE = 24_000
def get_timestamp():
timestamp = datetime.datetime.utcnow().replace(
tzinfo=datetime.timezone.utc
).astimezone(
datetime.timezone(datetime.timedelta(hours=8))
).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
return timestamp
class StreamingTTSService:
def __init__(
self,
model_path: str,
device: str = "cuda",
inference_steps: int = 5,
) -> None:
self.model_path = Path(model_path)
self.inference_steps = inference_steps
self.sample_rate = SAMPLE_RATE
self.processor: Optional[VibeVoiceStreamingProcessor] = None
self.model: Optional[VibeVoiceStreamingForConditionalGenerationInference] = None
self.voice_presets: Dict[str, Path] = {}
self.default_voice_key: Optional[str] = None
self._voice_cache: Dict[str, Tuple[object, Path, str]] = {}
if device == "mpx":
print("Note: device 'mpx' detected, treating it as 'mps'.")
device = "mps"
if device == "mps" and not torch.backends.mps.is_available():
print("Warning: MPS not available. Falling back to CPU.")
device = "cpu"
self.device = device
self._torch_device = torch.device(device)
def load(self) -> None:
print(f"[startup] Loading processor from {self.model_path}")
self.processor = VibeVoiceStreamingProcessor.from_pretrained(str(self.model_path))
# Decide dtype & attention
if self.device == "mps":
load_dtype = torch.float32
device_map = None
attn_impl_primary = "sdpa"
elif self.device == "cuda":
load_dtype = torch.bfloat16
device_map = 'cuda'
attn_impl_primary = "flash_attention_2"
else:
load_dtype = torch.float32
device_map = 'cpu'
attn_impl_primary = "sdpa"
print(f"Using device: {device_map}, torch_dtype: {load_dtype}, attn_implementation: {attn_impl_primary}")
# Load model
try:
self.model = VibeVoiceStreamingForConditionalGenerationInference.from_pretrained(
str(self.model_path),
torch_dtype=load_dtype,
device_map=device_map,
attn_implementation=attn_impl_primary,
)
if self.device == "mps":
self.model.to("mps")
except Exception as e:
if attn_impl_primary == 'flash_attention_2':
print("Error loading the model. Trying to use SDPA. However, note that only flash_attention_2 has been fully tested, and using SDPA may result in lower audio quality.")
self.model = VibeVoiceStreamingForConditionalGenerationInference.from_pretrained(
str(self.model_path),
torch_dtype=load_dtype,
device_map=self.device,
attn_implementation='sdpa',
)
print("Load model with SDPA successfully ")
else:
raise e
self.model.eval()
self.model.model.noise_scheduler = self.model.model.noise_scheduler.from_config(
self.model.model.noise_scheduler.config,
algorithm_type="sde-dpmsolver++",
beta_schedule="squaredcos_cap_v2",
)
self.model.set_ddpm_inference_steps(num_steps=self.inference_steps)
self.voice_presets = self._load_voice_presets()
preset_name = os.environ.get("VOICE_PRESET")
self.default_voice_key = self._determine_voice_key(preset_name)
self._ensure_voice_cached(self.default_voice_key)
def _load_voice_presets(self) -> Dict[str, Path]:
voices_dir = BASE.parent / "voices" / "streaming_model"
if not voices_dir.exists():
raise RuntimeError(f"Voices directory not found: {voices_dir}")
presets: Dict[str, Path] = {}
for pt_path in voices_dir.glob("*.pt"):
presets[pt_path.stem] = pt_path
if not presets:
raise RuntimeError(f"No voice preset (.pt) files found in {voices_dir}")
print(f"[startup] Found {len(presets)} voice presets")
return dict(sorted(presets.items()))
def _determine_voice_key(self, name: Optional[str]) -> str:
if name and name in self.voice_presets:
return name
default_key = "en-WHTest_man"
if default_key in self.voice_presets:
return default_key
first_key = next(iter(self.voice_presets))
print(f"[startup] Using fallback voice preset: {first_key}")
return first_key
def _ensure_voice_cached(self, key: str) -> Tuple[object, Path, str]:
if key not in self.voice_presets:
raise RuntimeError(f"Voice preset {key!r} not found")
if key not in self._voice_cache:
preset_path = self.voice_presets[key]
print(f"[startup] Loading voice preset {key} from {preset_path}")
print(f"[startup] Loading prefilled prompt from {preset_path}")
prefilled_outputs = torch.load(
preset_path,
map_location=self._torch_device,
weights_only=False,
)
self._voice_cache[key] = prefilled_outputs
return self._voice_cache[key]
def _get_voice_resources(self, requested_key: Optional[str]) -> Tuple[str, object, Path, str]:
key = requested_key if requested_key and requested_key in self.voice_presets else self.default_voice_key
if key is None:
key = next(iter(self.voice_presets))
self.default_voice_key = key
prefilled_outputs = self._ensure_voice_cached(key)
return key, prefilled_outputs
def _prepare_inputs(self, text: str, prefilled_outputs: object):
if not self.processor or not self.model:
raise RuntimeError("StreamingTTSService not initialized")
processor_kwargs = {
"text": text.strip(),
"cached_prompt": prefilled_outputs,
"padding": True,
"return_tensors": "pt",
"return_attention_mask": True,
}
processed = self.processor.process_input_with_cached_prompt(**processor_kwargs)
prepared = {
key: value.to(self._torch_device) if hasattr(value, "to") else value
for key, value in processed.items()
}
return prepared
def _run_generation(
self,
inputs,
audio_streamer: AudioStreamer,
errors,
cfg_scale: float,
do_sample: bool,
temperature: float,
top_p: float,
refresh_negative: bool,
prefilled_outputs,
stop_event: threading.Event,
) -> None:
try:
self.model.generate(
**inputs,
max_new_tokens=None,
cfg_scale=cfg_scale,
tokenizer=self.processor.tokenizer,
generation_config={
"do_sample": do_sample,
"temperature": temperature if do_sample else 1.0,
"top_p": top_p if do_sample else 1.0,
},
audio_streamer=audio_streamer,
stop_check_fn=stop_event.is_set,
verbose=False,
refresh_negative=refresh_negative,
all_prefilled_outputs=copy.deepcopy(prefilled_outputs),
)
except Exception as exc: # pragma: no cover - diagnostic logging
errors.append(exc)
traceback.print_exc()
audio_streamer.end()
def stream(
self,
text: str,
cfg_scale: float = 1.5,
do_sample: bool = False,
temperature: float = 0.9,
top_p: float = 0.9,
refresh_negative: bool = True,
inference_steps: Optional[int] = None,
voice_key: Optional[str] = None,
log_callback: Optional[Callable[[str, Dict[str, Any]], None]] = None,
stop_event: Optional[threading.Event] = None,
) -> Iterator[np.ndarray]:
if not text.strip():
return
text = text.replace("", "'")
selected_voice, prefilled_outputs = self._get_voice_resources(voice_key)
def emit(event: str, **payload: Any) -> None:
if log_callback:
try:
log_callback(event, **payload)
except Exception as exc:
print(f"[log_callback] Error while emitting {event}: {exc}")
steps_to_use = self.inference_steps
if inference_steps is not None:
try:
parsed_steps = int(inference_steps)
if parsed_steps > 0:
steps_to_use = parsed_steps
except (TypeError, ValueError):
pass
if self.model:
self.model.set_ddpm_inference_steps(num_steps=steps_to_use)
self.inference_steps = steps_to_use
inputs = self._prepare_inputs(text, prefilled_outputs)
audio_streamer = AudioStreamer(batch_size=1, stop_signal=None, timeout=None)
errors: list = []
stop_signal = stop_event or threading.Event()
thread = threading.Thread(
target=self._run_generation,
kwargs={
"inputs": inputs,
"audio_streamer": audio_streamer,
"errors": errors,
"cfg_scale": cfg_scale,
"do_sample": do_sample,
"temperature": temperature,
"top_p": top_p,
"refresh_negative": refresh_negative,
"prefilled_outputs": prefilled_outputs,
"stop_event": stop_signal,
},
daemon=True,
)
thread.start()
generated_samples = 0
try:
stream = audio_streamer.get_stream(0)
for audio_chunk in stream:
if torch.is_tensor(audio_chunk):
audio_chunk = audio_chunk.detach().cpu().to(torch.float32).numpy()
else:
audio_chunk = np.asarray(audio_chunk, dtype=np.float32)
if audio_chunk.ndim > 1:
audio_chunk = audio_chunk.reshape(-1)
peak = np.max(np.abs(audio_chunk)) if audio_chunk.size else 0.0
if peak > 1.0:
audio_chunk = audio_chunk / peak
generated_samples += int(audio_chunk.size)
emit(
"model_progress",
generated_sec=generated_samples / self.sample_rate,
chunk_sec=audio_chunk.size / self.sample_rate,
)
chunk_to_yield = audio_chunk.astype(np.float32, copy=False)
yield chunk_to_yield
finally:
stop_signal.set()
audio_streamer.end()
thread.join()
if errors:
emit("generation_error", message=str(errors[0]))
raise errors[0]
def chunk_to_pcm16(self, chunk: np.ndarray) -> bytes:
chunk = np.clip(chunk, -1.0, 1.0)
pcm = (chunk * 32767.0).astype(np.int16)
return pcm.tobytes()
app = FastAPI()
@app.on_event("startup")
async def _startup() -> None:
model_path = os.environ.get("MODEL_PATH")
if not model_path:
raise RuntimeError("MODEL_PATH not set in environment")
device = os.environ.get("MODEL_DEVICE", "cuda")
service = StreamingTTSService(
model_path=model_path,
device=device
)
service.load()
app.state.tts_service = service
app.state.model_path = model_path
app.state.device = device
app.state.websocket_lock = asyncio.Lock()
print("[startup] Model ready.")
def streaming_tts(text: str, **kwargs) -> Iterator[np.ndarray]:
service: StreamingTTSService = app.state.tts_service
yield from service.stream(text, **kwargs)
@app.websocket("/stream")
async def websocket_stream(ws: WebSocket) -> None:
await ws.accept()
text = ws.query_params.get("text", "")
print(f"Client connected, text={text!r}")
cfg_param = ws.query_params.get("cfg")
steps_param = ws.query_params.get("steps")
voice_param = ws.query_params.get("voice")
try:
cfg_scale = float(cfg_param) if cfg_param is not None else 1.5
except ValueError:
cfg_scale = 1.5
if cfg_scale <= 0:
cfg_scale = 1.5
try:
inference_steps = int(steps_param) if steps_param is not None else None
if inference_steps is not None and inference_steps <= 0:
inference_steps = None
except ValueError:
inference_steps = None
service: StreamingTTSService = app.state.tts_service
lock: asyncio.Lock = app.state.websocket_lock
if lock.locked():
busy_message = {
"type": "log",
"event": "backend_busy",
"data": {"message": "Please wait for the other requests to complete."},
"timestamp": get_timestamp(),
}
print("Please wait for the other requests to complete.")
try:
await ws.send_text(json.dumps(busy_message))
except Exception:
pass
await ws.close(code=1013, reason="Service busy")
return
acquired = False
try:
await lock.acquire()
acquired = True
log_queue: "Queue[Dict[str, Any]]" = Queue()
def enqueue_log(event: str, **data: Any) -> None:
log_queue.put({"event": event, "data": data})
async def flush_logs() -> None:
while True:
try:
entry = log_queue.get_nowait()
except Empty:
break
message = {
"type": "log",
"event": entry.get("event"),
"data": entry.get("data", {}),
"timestamp": get_timestamp(),
}
try:
await ws.send_text(json.dumps(message))
except Exception:
break
enqueue_log(
"backend_request_received",
text_length=len(text or ""),
cfg_scale=cfg_scale,
inference_steps=inference_steps,
voice=voice_param,
)
stop_signal = threading.Event()
iterator = streaming_tts(
text,
cfg_scale=cfg_scale,
inference_steps=inference_steps,
voice_key=voice_param,
log_callback=enqueue_log,
stop_event=stop_signal,
)
sentinel = object()
first_ws_send_logged = False
await flush_logs()
try:
while ws.client_state == WebSocketState.CONNECTED:
await flush_logs()
chunk = await asyncio.to_thread(next, iterator, sentinel)
if chunk is sentinel:
break
chunk = cast(np.ndarray, chunk)
payload = service.chunk_to_pcm16(chunk)
await ws.send_bytes(payload)
if not first_ws_send_logged:
first_ws_send_logged = True
enqueue_log("backend_first_chunk_sent")
await flush_logs()
except WebSocketDisconnect:
print("Client disconnected (WebSocketDisconnect)")
enqueue_log("client_disconnected")
stop_signal.set()
finally:
stop_signal.set()
enqueue_log("backend_stream_complete")
await flush_logs()
try:
iterator_close = getattr(iterator, "close", None)
if callable(iterator_close):
iterator_close()
except Exception:
pass
# clear the log queue
while not log_queue.empty():
try:
log_queue.get_nowait()
except Empty:
break
if ws.client_state == WebSocketState.CONNECTED:
await ws.close()
print("WS handler exit")
finally:
if acquired:
lock.release()
@app.get("/")
def index():
return FileResponse(BASE / "index.html")
@app.get("/config")
def get_config():
service: StreamingTTSService = app.state.tts_service
voices = sorted(service.voice_presets.keys())
return {
"voices": voices,
"default_voice": service.default_voice_key,
}
+1017
View File
File diff suppressed because it is too large Load Diff