* 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).
182 lines
6.5 KiB
Python
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
|