MomsVoiceAI / tts.py
minhahwang's picture
fix: Play produces no sound — switch to streaming audio + system deps
373219a
Raw
History Blame Contribute Delete
19.2 kB
"""
TTS module — unified interface for text-to-speech synthesis.
Supports two backends:
- Qwen3-TTS Base (1.7B): voice-cloned synthesis using a cached voice profile
- Qwen3-TTS CustomVoice (0.6B): fast predefined speakers (default stock voice)
Features:
- Audio cache: avoids re-synthesis for repeated text+voice combos
- Pre-generation: background-generates entire story on book select
- Transition chime: soft audio between paragraphs to mask gaps
- Eager pre-buffering: generates next chunks while current plays
Usage:
chunks = split_into_chunks(text)
for sr, wav, i, n, err in generate_audio_stream(chunks, voice_profile_id="abc123"):
...
"""
import hashlib
import logging
import os
import queue
import re
import threading
from collections import OrderedDict
import numpy as np
import soundfile as sf
from runtime_config import AUDIO_CACHE_DIR, GPU_INFERENCE_LOCK, APP_ROOT
logger = logging.getLogger(__name__)
_SENTENCE_RE = re.compile(r'(?<=[.!?;])\s+')
_CLAUSE_RE = re.compile(r'(?<=[,;:\u2014])\s+|(?<=\.)\s+|(?<=[!?])\s+')
_SENTINEL = object()
# Target chunk length — shorter chunks = lower latency per chunk
_MAX_CHUNK_CHARS = 120
# Audio cache directory
_CACHE_DIR = str(AUDIO_CACHE_DIR)
os.makedirs(_CACHE_DIR, exist_ok=True)
# Copy pre-generated audio from app bundle to runtime cache (HF Space: repo → /data)
_BUNDLED_CACHE = APP_ROOT / "audio_cache"
if _BUNDLED_CACHE.is_dir() and str(_BUNDLED_CACHE) != _CACHE_DIR:
import shutil
_copied = 0
for _wav in _BUNDLED_CACHE.glob("*.wav"):
_dest = os.path.join(_CACHE_DIR, _wav.name)
if not os.path.exists(_dest):
shutil.copy2(str(_wav), _dest)
_copied += 1
if _copied:
logger.info("Copied %d pre-generated audio files to runtime cache.", _copied)
# Transition chime (soft sine fade, 0.3s at 24kHz)
_CHIME_SR = 24000
_CHIME_DURATION = 0.3
_chime_t = np.linspace(0, _CHIME_DURATION, int(_CHIME_SR * _CHIME_DURATION), dtype=np.float32)
_TRANSITION_CHIME = 0.08 * np.sin(2 * np.pi * 440 * _chime_t) * np.linspace(1, 0, len(_chime_t))
# Background pre-generation state
_pregen_lock = threading.Lock()
_pregen_cache: OrderedDict[str, list[tuple[int, np.ndarray]]] = OrderedDict()
_pregen_in_progress: set[str] = set()
_pregen_progress: dict[str, dict[str, int | bool]] = {}
_pregen_cancel_events: dict[str, threading.Event] = {}
# Keep background work small so live Ask/playback can take the GPU quickly.
_PREGEN_CHUNK_LIMIT = int(os.environ.get("MOMSVOICE_PREGEN_CHUNKS", "3"))
_PREGEN_CACHE_MAX_STORIES = int(os.environ.get("MOMSVOICE_PREGEN_CACHE_STORIES", "4"))
_PREGEN_PROGRESS_MAX_STORIES = int(os.environ.get("MOMSVOICE_PREGEN_PROGRESS_STORIES", "16"))
_AUDIO_CACHE_MAX_FILES = int(os.environ.get("MOMSVOICE_AUDIO_CACHE_MAX_FILES", "400"))
_AUDIO_CACHE_MAX_BYTES = int(os.environ.get("MOMSVOICE_AUDIO_CACHE_MAX_BYTES", str(512 * 1024 * 1024)))
def _cache_key(text: str, voice_profile_id: str | None) -> str:
"""Generate a cache key from text + voice profile."""
raw = f"{voice_profile_id or 'stock'}:{text}"
return hashlib.md5(raw.encode()).hexdigest()
def _story_key(chunks: list[str], voice_profile_id: str | None) -> str:
return _cache_key("\n".join(chunks), voice_profile_id)
def _prune_pregen_progress_locked() -> None:
removable = [
story_key for story_key, progress in _pregen_progress.items()
if story_key not in _pregen_in_progress and not progress.get("in_progress")
]
while len(_pregen_progress) > _PREGEN_PROGRESS_MAX_STORIES and removable:
_pregen_progress.pop(removable.pop(0), None)
def cancel_pregeneration() -> None:
"""Ask all background pre-generation workers to stop after their current chunk."""
with _pregen_lock:
for event in _pregen_cancel_events.values():
event.set()
for story_key in list(_pregen_in_progress):
progress = _pregen_progress.get(story_key)
if progress is not None:
progress["in_progress"] = False
progress["cancelled"] = True
_pregen_in_progress.clear()
_prune_pregen_progress_locked()
def _prune_pregen_cache_locked() -> None:
while len(_pregen_cache) > _PREGEN_CACHE_MAX_STORIES:
_pregen_cache.popitem(last=False)
def get_pregeneration_status(
chunks: list[str],
voice_profile_id: str | None = None,
) -> dict[str, int | bool]:
"""Return how much of a story is actually cached or warmed."""
total = len(chunks)
if total == 0:
return {"cached": 0, "total": 0, "in_progress": False, "complete": False}
story_key = _story_key(chunks, voice_profile_id)
with _pregen_lock:
if story_key in _pregen_cache:
_pregen_cache.move_to_end(story_key)
return {
"cached": len(_pregen_cache[story_key]),
"total": total,
"target": total,
"in_progress": False,
"complete": True,
"errors": 0,
"cancelled": False,
}
progress = _pregen_progress.get(story_key)
if progress is not None:
return dict(progress)
target = min(total, _PREGEN_CHUNK_LIMIT)
cached = sum(
1 for chunk in chunks[:target]
if _get_cached_audio(chunk, voice_profile_id) is not None
)
return {
"cached": cached,
"total": total,
"target": target,
"in_progress": False,
"complete": cached == total,
"errors": 0,
"cancelled": False,
}
def _get_cached_audio(chunk_text: str, voice_profile_id: str | None) -> np.ndarray | None:
"""Check if audio for this chunk is already cached on disk."""
key = _cache_key(chunk_text, voice_profile_id)
path = os.path.join(_CACHE_DIR, f"{key}.wav")
if os.path.exists(path):
try:
wav, sr = sf.read(path, dtype='float32')
return wav
except Exception:
pass
return None
def _save_cached_audio(chunk_text: str, voice_profile_id: str | None, wav: np.ndarray, sr: int):
"""Save synthesized audio to disk cache."""
key = _cache_key(chunk_text, voice_profile_id)
path = os.path.join(_CACHE_DIR, f"{key}.wav")
try:
sf.write(path, wav, sr)
_prune_audio_cache()
except Exception:
pass
def _prune_audio_cache():
"""Bound disk cache by deleting oldest cached audio files."""
try:
entries = [
entry for entry in os.scandir(_CACHE_DIR)
if entry.is_file() and entry.name.endswith(".wav")
]
total_bytes = sum(entry.stat().st_size for entry in entries)
if len(entries) <= _AUDIO_CACHE_MAX_FILES and total_bytes <= _AUDIO_CACHE_MAX_BYTES:
return
entries.sort(key=lambda entry: entry.stat().st_mtime)
idx = 0
while idx < len(entries):
if len(entries) <= _AUDIO_CACHE_MAX_FILES and total_bytes <= _AUDIO_CACHE_MAX_BYTES:
break
entry = entries[idx]
try:
size = entry.stat().st_size
os.remove(entry.path)
total_bytes -= size
entries.pop(idx)
except OSError:
idx += 1
continue
except OSError:
return
def split_into_chunks(text: str) -> list[str]:
"""Split text into short chunks suitable for low-latency TTS streaming.
Splits by sentence first, then further splits long sentences by clause
boundaries (commas, semicolons, em-dashes) to keep each chunk under
~120 chars for faster TTS generation.
"""
sentences = _SENTENCE_RE.split(text.strip())
chunks = []
for sent in sentences:
sent = sent.strip()
if not sent:
continue
if len(sent) <= _MAX_CHUNK_CHARS:
chunks.append(sent)
else:
# Split long sentence into clauses
clauses = _CLAUSE_RE.split(sent)
current = ""
for clause in clauses:
clause = clause.strip()
if not clause:
continue
if current and len(current) + len(clause) + 1 > _MAX_CHUNK_CHARS:
chunks.append(current.strip())
current = clause
else:
current = f"{current} {clause}".strip() if current else clause
if current.strip():
chunks.append(current.strip())
return [c for c in chunks if c]
def pregenerate_story_audio(
chunks: list[str],
voice_profile_id: str | None = None,
max_chunks: int = _PREGEN_CHUNK_LIMIT,
):
"""Pre-generate the first few story chunks in background.
Call this on book selection to pre-warm initial playback. Non-blocking.
"""
story_key = _story_key(chunks, voice_profile_id)
target_chunks = chunks[:max(0, min(len(chunks), max_chunks))]
target = len(target_chunks)
if target == 0:
return
with _pregen_lock:
if story_key in _pregen_in_progress or story_key in _pregen_cache:
return # Already running or done
cancel_event = threading.Event()
_pregen_in_progress.add(story_key)
_pregen_cancel_events[story_key] = cancel_event
_pregen_progress[story_key] = {
"cached": 0,
"total": len(chunks),
"target": target,
"in_progress": True,
"complete": False,
"errors": 0,
"cancelled": False,
}
def _worker():
results = []
errors = 0
sr = 24000
for i, chunk in enumerate(target_chunks):
if cancel_event.is_set():
break
# Check disk cache first
cached = _get_cached_audio(chunk, voice_profile_id)
if cached is not None:
results.append((sr, cached))
with _pregen_lock:
if _pregen_cancel_events.get(story_key) is cancel_event:
_pregen_progress[story_key]["cached"] = len(results)
continue
# Synthesize
try:
acquired = False
while not cancel_event.is_set():
acquired = GPU_INFERENCE_LOCK.acquire(timeout=0.1)
if acquired:
break
if not acquired or cancel_event.is_set():
break
try:
wav, sample_rate = _synthesize_single(chunk, voice_profile_id)
finally:
GPU_INFERENCE_LOCK.release()
sr = sample_rate
results.append((sr, wav))
_save_cached_audio(chunk, voice_profile_id, wav, sr)
except Exception as e:
errors += 1
logger.warning("Pre-gen failed on chunk %d: %s", i, e)
with _pregen_lock:
if _pregen_cancel_events.get(story_key) is cancel_event:
_pregen_progress[story_key]["cached"] = len(results)
_pregen_progress[story_key]["errors"] = errors
with _pregen_lock:
if _pregen_cancel_events.get(story_key) is not cancel_event:
return
cancelled = cancel_event.is_set()
fully_cached = len(results) == len(chunks) and errors == 0 and not cancelled
if fully_cached:
_pregen_cache[story_key] = results
_pregen_cache.move_to_end(story_key)
_prune_pregen_cache_locked()
_pregen_progress[story_key] = {
"cached": len(results),
"total": len(chunks),
"target": target,
"in_progress": False,
"complete": fully_cached,
"errors": errors,
"cancelled": cancelled,
}
_pregen_in_progress.discard(story_key)
_pregen_cancel_events.pop(story_key, None)
_prune_pregen_progress_locked()
logger.info(
"Pre-generation finished: %d/%d initial chunks cached (errors=%d, cancelled=%s).",
len(results), target, errors, cancelled,
)
threading.Thread(target=_worker, daemon=True).start()
def _synthesize_single(text: str, voice_profile_id: str | None) -> tuple[np.ndarray, int]:
"""Synthesize a single chunk, choosing backend based on profile."""
if voice_profile_id:
from voice_clone import synthesize_cloned, synthesize_custom_voice
try:
wav, sr = synthesize_cloned(text, voice_profile_id)
return wav, sr
except Exception:
wav, sr = synthesize_custom_voice(text)
return wav, sr
else:
from voice_clone import synthesize_custom_voice
wav, sr = synthesize_custom_voice(text)
return wav, sr
def generate_audio_stream(
chunks: list[str],
voice_profile_id: str | None = None,
custom_voice_speaker: str = "vivian",
add_transitions: bool = True,
):
"""
Generator: synthesizes chunks with caching + pre-buffering + transitions.
If pre-generated audio is available (from pregenerate_story_audio), serves
instantly from cache. Otherwise synthesizes on-demand with background pre-buffer.
Yields (sample_rate, wav_array, chunk_idx, total_chunks, error_msg).
"""
n = len(chunks)
sample_rate = 24000
# Check if pre-generated cache is available
story_key = _story_key(chunks, voice_profile_id)
pregen_results = None
with _pregen_lock:
if story_key in _pregen_cache:
pregen_results = _pregen_cache[story_key]
if pregen_results and len(pregen_results) == n:
# Serve from pre-generated cache — near-zero latency
for i, (sr, wav) in enumerate(pregen_results):
if wav is not None and len(wav) > 0:
if add_transitions and i > 0:
# Prepend transition chime
wav = np.concatenate([_TRANSITION_CHIME, wav])
yield sr, wav, i, n, None
return
# Fallback: on-demand synthesis with pre-buffering (4 chunks ahead)
chunk_q: queue.Queue = queue.Queue(maxsize=4)
if voice_profile_id:
_start_qwen_worker(chunks, voice_profile_id, chunk_q)
else:
_start_custom_voice_worker(chunks, custom_voice_speaker, chunk_q)
# Batch small audio segments into larger blocks for smoother playback
audio_buffer = []
buffer_samples = 0
last_idx = 0
_TARGET_SAMPLES = 24000 * 5 # ~5s blocks
chunk_count = 0
while True:
item = chunk_q.get()
if item is _SENTINEL:
if audio_buffer:
combined = np.concatenate(audio_buffer).astype(np.float32)
yield sample_rate, combined, last_idx, n, None
break
i, wav, err = item
if err:
if audio_buffer:
combined = np.concatenate(audio_buffer).astype(np.float32)
yield sample_rate, combined, last_idx, n, None
audio_buffer = []
buffer_samples = 0
yield sample_rate, np.zeros(0, dtype=np.float32), i, n, err
break
last_idx = i
# Skip empty audio segments
if wav is None or len(wav) == 0:
continue
# Add transition chime between chunks (not before first)
if add_transitions and chunk_count > 0:
audio_buffer.append(_TRANSITION_CHIME)
buffer_samples += len(_TRANSITION_CHIME)
audio_buffer.append(wav)
buffer_samples += len(wav)
chunk_count += 1
# Cache this chunk for future replays
_save_cached_audio(chunks[i], voice_profile_id, wav, sample_rate)
# Yield when buffer reaches target size or this is the first chunk (fast start)
if buffer_samples >= _TARGET_SAMPLES or (i == 0 and buffer_samples > 0):
combined = np.concatenate(audio_buffer).astype(np.float32)
yield sample_rate, combined, i, n, None
audio_buffer = []
buffer_samples = 0
def _start_qwen_worker(chunks, profile_id, chunk_q):
"""Background thread: synthesize chunks with Qwen3-TTS voice clone (Base 1.7B).
Falls back to stock voice if cloned synthesis fails."""
def _worker():
from voice_clone import synthesize_cloned, synthesize_custom_voice
use_fallback = False
for i, stmt in enumerate(chunks):
# Check cache first
cached = _get_cached_audio(stmt, profile_id)
if cached is not None:
chunk_q.put((i, cached, None))
continue
try:
if use_fallback:
wav, _sr = synthesize_custom_voice(stmt)
else:
wav, _sr = synthesize_cloned(stmt, profile_id)
chunk_q.put((i, wav, None))
except Exception as exc:
if not use_fallback:
logger.warning("Cloned voice failed (chunk %d): %s — falling back to stock voice", i, exc)
use_fallback = True
try:
wav, _sr = synthesize_custom_voice(stmt)
chunk_q.put((i, wav, None))
except Exception as exc2:
logger.exception("Stock voice also failed on chunk %d", i)
chunk_q.put((i, None, str(exc2)))
return
else:
logger.exception("Stock voice synthesis failed on chunk %d", i)
chunk_q.put((i, None, str(exc)))
return
chunk_q.put(_SENTINEL)
threading.Thread(target=_worker, daemon=True).start()
def _start_custom_voice_worker(chunks, speaker, chunk_q):
"""Background thread: synthesize chunks with Qwen3-TTS CustomVoice (0.6B).
Uses sub-segment streaming for lower latency — yields partial audio as generated."""
def _worker():
from voice_clone import synthesize_custom_voice_streaming
import numpy as np
for i, stmt in enumerate(chunks):
# Check cache first
cached = _get_cached_audio(stmt, None)
if cached is not None:
chunk_q.put((i, cached, None))
continue
try:
segments = []
for seg, _sr in synthesize_custom_voice_streaming(stmt, speaker=speaker):
segments.append(seg)
if segments:
wav = np.concatenate(segments)
chunk_q.put((i, wav, None))
else:
chunk_q.put((i, np.zeros(0, dtype=np.float32), None))
except Exception as exc:
logger.exception("CustomVoice synthesis failed on chunk %d", i)
chunk_q.put((i, None, str(exc)))
return
chunk_q.put(_SENTINEL)
threading.Thread(target=_worker, daemon=True).start()