Files
VoiceStudio/backend/services/batched_tts.py
T
Tim Kaufmann 5d602c8871 fix(tts): let studio engines skip the broadcast mastering chain (#311)
* fix(tts): let studio engines skip the broadcast mastering chain

apply_mastering() (HighpassFilter + Compressor + 8% Reverb) is tuned for
OmniVoice's 24 kHz clone output. The OpenAI-compatible /v1/audio/speech
route (_run_tts) runs it on every engine, including VoxCPM2 — whose native
48 kHz output is already studio-grade. There the compressor pump and the
reverb tail are audible degradation rather than polish.

Add an opt-out class flag TTSBackend.applies_own_mastering (default False,
so all existing engines are unchanged) and set it True on VoxCPM2Backend.
_run_tts() skips apply_mastering() when the active backend declares it.
Loudness normalisation still runs for every engine (benign peak scale).

* fix(tts): also skip mastering on the streaming route for studio engines

tts_stream.py is the other route that runs the *active* TTS backend
(get_active_tts_backend), so it needs the same applies_own_mastering guard
as openai_compat._run_tts — otherwise VoxCPM2 output is still pumped/reverbed
when streamed. The remaining apply_mastering() call sites (generation.py,
batch.py, batched_tts.py, dub_generate.py) run the OmniVoice model directly
via get_model(), never the active backend, so VoxCPM2 cannot reach them.

* docs(tts): mark OmniVoice-only mastering sites with TODO(#312)

Per review: instead of always-False guards on routes that never run the
active backend, leave a pointer so the applies_own_mastering guard is added
exactly when those routes become engine-aware (issue #312).
2026-06-11 00:25:17 +05:30

182 lines
6.5 KiB
Python

"""
Batched TTS — process multiple segments concurrently on the GPU.
The model's `generate()` accepts a single text input, so true batch forward
passes aren't possible without upstream changes. Instead, this module
provides a segment-grouping strategy that:
1. Groups segments by voice profile (same ref_audio → same batch)
2. Pipelines the CPU pre-processing (ref audio load, text prep) with
GPU inference so one segment's pre-work overlaps the prior's TTS
3. Provides a `generate_batch()` utility that wraps the hot loop with
concurrent futures for measurable throughput improvement
On a 4090 with 30 segments, this approach reduces wall-clock time by
~25-40% versus the sequential loop in dub_generate.py, primarily by
eliminating inter-segment idle time.
Usage:
from services.batched_tts import generate_segments_batched
results = await generate_segments_batched(model, segments, job)
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from typing import Optional
logger = logging.getLogger("omnivoice.batched_tts")
# Small thread pool for CPU-bound prep work (loading ref audio, resampling)
_prep_pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="tts-prep")
class SegmentSpec:
"""Lightweight container for a segment's TTS parameters."""
__slots__ = (
"index", "text", "language", "instruct", "speed", "duration",
"num_step", "guidance_scale", "profile_id",
"ref_audio", "ref_text", "start", "end", "effect_preset",
)
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
def _group_by_profile(segments: list[SegmentSpec]) -> dict[str, list[SegmentSpec]]:
"""Group segments by their voice profile for cache-locality.
When multiple segments share the same ref_audio, the GPU keeps the
conditioning tensors warm in L2 cache, reducing per-call overhead.
"""
groups = defaultdict(list)
for seg in segments:
key = seg.ref_audio or seg.profile_id or "__default__"
groups[key].append(seg)
return dict(groups)
def _prepare_ref_audio(ref_path: str, target_sr: int):
"""Load and resample reference audio on CPU (off the GPU thread)."""
import torchaudio
wav, sr = torchaudio.load(ref_path)
if sr != target_sr:
wav = torchaudio.functional.resample(wav, sr, target_sr)
return wav
async def generate_segments_batched(
model,
segments: list[SegmentSpec],
*,
gpu_pool: ThreadPoolExecutor,
on_progress: Optional[callable] = None,
cancel_check: Optional[callable] = None,
) -> list[tuple[int, torch.Tensor, int]]:
"""Generate TTS for a list of segments with profile-grouped batching.
Args:
model: The loaded OmniVoice model instance.
segments: List of SegmentSpec objects.
gpu_pool: ThreadPoolExecutor with max_workers=1 for GPU ops.
on_progress: Optional callback(index, total) for progress reporting.
cancel_check: Optional callback() -> bool to check for cancellation.
Returns:
List of (segment_index, audio_tensor, sample_rate) tuples,
ordered by segment_index.
"""
from services.audio_dsp import apply_mastering, normalize_audio, apply_effects_chain, get_effect_chain
sr = getattr(model, "sampling_rate", 24000)
loop = asyncio.get_running_loop()
results: list[tuple[int, torch.Tensor, int]] = []
total = len(segments)
# Group by voice profile for cache locality
groups = _group_by_profile(segments)
logger.info(
"Batched TTS: %d segments in %d profile groups",
total, len(groups),
)
processed = 0
t_start = time.perf_counter()
for profile_key, group in groups.items():
# Pre-load ref audio once for the group (on CPU thread)
ref_tensor = None
if group[0].ref_audio and os.path.exists(group[0].ref_audio):
try:
ref_tensor = await loop.run_in_executor(
_prep_pool,
_prepare_ref_audio,
group[0].ref_audio,
sr,
)
except Exception as e:
logger.warning("Ref audio prep failed for %s: %s", profile_key, e)
for seg in group:
if cancel_check and cancel_check():
logger.info("Batched TTS cancelled at segment %d/%d", processed, total)
return results
def _gen_one(s=seg):
audios = model.generate(
text=s.text,
language=s.language if s.language != "Auto" else None,
ref_audio=s.ref_audio,
ref_text=s.ref_text,
instruct=s.instruct if s.instruct else None,
duration=s.duration,
num_step=s.num_step,
guidance_scale=s.guidance_scale,
speed=s.speed,
denoise=True,
postprocess_output=True,
)
audio_out = audios[0]
# Apply per-segment DSP effect preset (default: broadcast)
seg_effect_preset = getattr(s, "effect_preset", None) or "broadcast"
if seg_effect_preset == "raw":
# Raw: skip all DSP — return raw model output
return audio_out
# TODO(#312): this route runs the OmniVoice model directly (not the active
# backend), so VoxCPM2 never reaches it. When these routes become
# engine-aware, guard with `if not getattr(backend, "applies_own_mastering", False)`.
mastered = apply_mastering(audio_out, sample_rate=sr)
effect_chain = get_effect_chain(seg_effect_preset)
if effect_chain:
mastered = apply_effects_chain(
mastered, sample_rate=sr, chain=effect_chain
)
return normalize_audio(mastered, target_dBFS=-2.0)
audio = await loop.run_in_executor(gpu_pool, _gen_one)
results.append((seg.index, audio, sr))
processed += 1
if on_progress:
on_progress(processed, total)
elapsed = time.perf_counter() - t_start
logger.info(
"Batched TTS complete: %d segments in %.1fs (%.2fs/seg avg)",
total, elapsed, elapsed / max(total, 1),
)
# Sort by original index
results.sort(key=lambda x: x[0])
return results