From d3ec4ed3714071e9ce07a5f017034656052599fc Mon Sep 17 00:00:00 2001 From: Palash Debnath Date: Sat, 11 Jul 2026 00:21:54 +0530 Subject: [PATCH] =?UTF-8?q?fix(tts):=20voxcpm2=20=E2=80=94=20version=20flo?= =?UTF-8?q?or,=20reference-clip=20prep,=20trailing-silence=20guard=20(#105?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three hardenings of the voxcpm2 engine path, all backward-compatible and platform-identical: - Version floor: every install hint now says pip install "voxcpm>=2.0.3" (2.0.3 fixed an Apple-Silicon/MPS audio-quality bug). Floor only — an already-installed older version stays available and working; it just surfaces an actionable upgrade hint in the is_available reason and a load-time warning. - Reference-clip prep: the voxcpm package no longer trims reference audio itself, so raw user clips reached the model unconditioned. The clone path now trims leading/trailing near-silence (-50 dBFS floor, 50 ms edge pad) and caps the reference at 30 s. Fail-open (any prep problem falls back to the raw clip) and a strict no-op for short clean clips. - Trailing-silence guard: generated output is trimmed to the last voiced sample + ~0.3 s natural tail via the new audio_dsp.trim_trailing_silence. Silence-trim only, no content analysis; a no-op on outputs without a silent tail and on all-silent (dead) renders. 22 new fake-module tests in tests/test_voxcpm2_guardrails.py; existing engine/hint tests strengthened to guard the floor. Co-authored-by: mergetest Co-authored-by: Claude Fable 5 --- backend/services/audio_dsp.py | 39 +++++ backend/services/tts_backend.py | 193 ++++++++++++++++++++-- tests/test_engines.py | 5 +- tests/test_issue_fixes.py | 11 +- tests/test_voxcpm2_guardrails.py | 267 +++++++++++++++++++++++++++++++ 5 files changed, 499 insertions(+), 16 deletions(-) create mode 100644 tests/test_voxcpm2_guardrails.py diff --git a/backend/services/audio_dsp.py b/backend/services/audio_dsp.py index 2bd2d32d..c3a521dd 100644 --- a/backend/services/audio_dsp.py +++ b/backend/services/audio_dsp.py @@ -147,6 +147,45 @@ def normalize_audio(audio_tensor, target_dBFS=-2.0): return audio_tensor +def trim_trailing_silence( + audio_tensor: torch.Tensor, + sample_rate: int, + keep_tail_s: float = 0.3, +) -> torch.Tensor: + """Trim trailing near-silence from a generated clip, keeping a short + natural tail of ``keep_tail_s`` seconds after the last voiced sample. + + Amplitude-based SILENCE trim only — no content analysis of any kind. + Uses the same -50 dBFS silence floor as :func:`normalize_audio`: the last + sample above that floor marks the end of speech, and everything more than + ``keep_tail_s`` past it is dropped. + + Guaranteed no-op cases (input returned as-is, same object): + • the trailing quiet span is already ≤ ``keep_tail_s`` (clean output); + • the entire clip sits below the floor (dead render — downstream + dead-render guards own that case, we must not shrink their evidence); + • empty input. + + Accepts ``(n,)`` or ``(channels, n)`` tensors; the returned tensor keeps + the input's shape convention. + """ + if audio_tensor.numel() == 0: + return audio_tensor + # -50 dBFS ≈ 0.00316 linear — matches normalize_audio's silence floor. + floor = 10 ** (-50.0 / 20.0) + envelope = torch.abs(audio_tensor) + if envelope.ndim > 1: + envelope = envelope.amax(dim=tuple(range(envelope.ndim - 1))) + voiced = torch.nonzero(envelope > floor) + if voiced.numel() == 0: + return audio_tensor + last_voiced = int(voiced[-1].item()) + end = last_voiced + 1 + int(keep_tail_s * sample_rate) + if end >= audio_tensor.shape[-1]: + return audio_tensor + return audio_tensor[..., :end] + + def apply_effects_chain(audio_tensor, sample_rate: int, chain: list[dict]) -> torch.Tensor: """Apply a chain of named effects to an audio tensor. diff --git a/backend/services/tts_backend.py b/backend/services/tts_backend.py index 9db44161..8f5a1380 100644 --- a/backend/services/tts_backend.py +++ b/backend/services/tts_backend.py @@ -6,7 +6,7 @@ A uniform protocol for every TTS engine. Today we ship: • OmniVoiceBackend — wraps the current k2-fsa/OmniVoice model. Zero behaviour change for existing callers. • VoxCPM2Backend — thin stub that raises with a clear install hint - until `pip install voxcpm` is present and enabled. + until `pip install "voxcpm>=2.0.3"` is present and enabled. Callers should use `get_active_tts_backend()` to pick the configured engine instead of importing a specific class. The selection is controlled by the @@ -407,9 +407,155 @@ class OmniVoiceBackend(TTSBackend): # ── VoxCPM2 adapter (optional, scaffolded) ────────────────────────────────── +#: Minimum recommended `voxcpm` package version. 2.0.3 fixed an audio-quality +#: bug on Apple Silicon (low-precision dtypes on the MPS device produced +#: degraded output). A floor, NOT a pin: newer versions are fine, and an +#: already-installed older version keeps working — we only surface an upgrade +#: hint (is_available reason + load-time warning), never force a reinstall. +_VOXCPM_MIN_VERSION = "2.0.3" + +#: Reference-clip cap for VoxCPM2 cloning (seconds). The `voxcpm` package no +#: longer trims reference audio internally, so an unbounded user clip would +#: condition the model on minutes of audio (slow, and past a point it stops +#: helping voice similarity). 30 s is a conservative upper bound. +_VOXCPM_REF_MAX_S = 30.0 + +#: Silence pad kept around the voiced region when trimming a reference clip — +#: a hard cut exactly at the first/last voiced sample clips consonant onsets. +_VOXCPM_REF_EDGE_PAD_S = 0.05 + + +def _version_tuple(v: str) -> Optional[tuple[int, ...]]: + """Parse the leading numeric components of a version string ("2.0.3" → + (2, 0, 3), "2.1rc1" → (2, 1)). Returns None when nothing numeric parses — + callers treat that as 'unknown, assume fine' rather than failing.""" + parts: list[int] = [] + for piece in v.split("."): + digits = "" + for ch in piece: + if not ch.isdigit(): + break + digits += ch + if not digits: + break + parts.append(int(digits)) + return tuple(parts) if parts else None + + +def _voxcpm_installed_version() -> Optional[str]: + """Installed `voxcpm` dist version, or None when undeterminable + (not installed, or importable without package metadata).""" + try: + from importlib.metadata import version + return version("voxcpm") + except Exception: + return None + + +def _voxcpm_upgrade_hint() -> Optional[str]: + """Actionable upgrade hint when the installed `voxcpm` is older than + :data:`_VOXCPM_MIN_VERSION`, else None. Never raises; an unparseable or + unknown version yields None (don't nag users we can't be sure about).""" + installed = _voxcpm_installed_version() + if installed is None: + return None + have = _version_tuple(installed) + want = _version_tuple(_VOXCPM_MIN_VERSION) + if have is None or want is None or have >= want: + return None + return ( + f"installed voxcpm {installed} is older than {_VOXCPM_MIN_VERSION}, " + "which fixed an audio-quality bug on Apple Silicon (low-precision " + "dtypes on MPS). The engine still works, but upgrading is " + 'recommended: pip install --upgrade "voxcpm>=2.0.3"' + ) + + +# Prepared-reference cache: (abspath, mtime_ns, size) → prepared path (which +# may be the original path itself when no trim/cap applied). Keeps repeat +# generations from re-reading + re-writing the same clip, and keeps the temp +# dir from filling with one copy per generate() call. +_VOXCPM_REF_PREP_CACHE: dict[tuple, str] = {} + + +def _prepare_voxcpm_ref(path: str) -> str: + """Prepare a cloning reference clip for VoxCPM2. + + The `voxcpm` package used to trim reference audio itself but no longer + does — raw user clips reach the model unconditioned. This applies the + minimal, conservative preparation the model expects: + + • trim leading/trailing near-silence (amplitude threshold at the same + -50 dBFS floor `audio_dsp.normalize_audio` uses, with a small + :data:`_VOXCPM_REF_EDGE_PAD_S` pad kept on each side), and + • cap the reference at :data:`_VOXCPM_REF_MAX_S` seconds from the + trimmed start. + + Returns a path to the prepared WAV. Deliberately non-destructive and + fail-open: the ORIGINAL path is returned unchanged when the clip needs no + meaningful trim/cap (short clean clips pass through untouched), when the + whole clip sits below the silence floor (nothing to anchor a trim on), or + when anything at all goes wrong — reference prep must never be the reason + a generation fails. + """ + try: + import numpy as np + import soundfile as sf + + abspath = os.path.abspath(path) + st = os.stat(abspath) + cache_key = (abspath, st.st_mtime_ns, st.st_size) + cached = _VOXCPM_REF_PREP_CACHE.get(cache_key) + if cached is not None and (cached == abspath or os.path.exists(cached)): + return cached + + audio, sr = sf.read(abspath, dtype="float32", always_2d=True) # (n, ch) + n = audio.shape[0] + if n == 0 or sr <= 0: + return path + + # Silence floor: -50 dBFS, matching audio_dsp.normalize_audio. A clip + # that never rises above it is left alone (fail-open, see docstring). + floor = 10 ** (-50.0 / 20.0) + envelope = np.abs(audio).max(axis=1) + voiced = np.flatnonzero(envelope > floor) + if voiced.size == 0: + _VOXCPM_REF_PREP_CACHE[cache_key] = abspath + return path + + pad = int(_VOXCPM_REF_EDGE_PAD_S * sr) + start = max(0, int(voiced[0]) - pad) + end = min(n, int(voiced[-1]) + 1 + pad) + cap = int(_VOXCPM_REF_MAX_S * sr) + end = min(end, start + cap) + + # No-op path: nothing meaningful to cut (>0.1 s total) — hand the + # original file to the model byte-identical. + if (start + (n - end)) <= int(0.1 * sr): + _VOXCPM_REF_PREP_CACHE[cache_key] = abspath + return path + + import tempfile + fd, prepared = tempfile.mkstemp(prefix="voxcpm_ref_", suffix=".wav") + os.close(fd) + sf.write(prepared, audio[start:end], sr) + _VOXCPM_REF_PREP_CACHE[cache_key] = prepared + logger.info( + "VoxCPM2: prepared reference clip %s → %s (%.2fs → %.2fs; " + "silence trimmed, cap %.0fs)", + path, prepared, n / sr, (end - start) / sr, _VOXCPM_REF_MAX_S, + ) + return prepared + except Exception as e: # noqa: BLE001 — prep is best-effort by contract + logger.warning( + "VoxCPM2: reference-clip preparation failed for %s — using the " + "raw clip: %s", path, e, + ) + return path + class VoxCPM2Backend(TTSBackend): - """OpenBMB VoxCPM2 wrapper — `pip install voxcpm` required. + """OpenBMB VoxCPM2 wrapper — `pip install "voxcpm>=2.0.3"` required. Ships as a scaffold: the class loads and reports unavailability cleanly when the dep isn't installed, so Settings UI can gate the engine selector @@ -437,10 +583,17 @@ class VoxCPM2Backend(TTSBackend): import voxcpm # noqa: F401 except ImportError: return False, ( - "voxcpm package not installed. Install with `pip install voxcpm` " + "voxcpm package not installed. Install with " + '`pip install "voxcpm>=2.0.3"` ' "(requires Python ≥3.10, PyTorch ≥2.5). CUDA ≥12 recommended " "for full speed; MPS (Apple Silicon) and CPU also supported." ) + # Version FLOOR, not pin: an older install still reports available + # (no forced reinstall), but the reason carries the upgrade hint and + # _ensure_loaded() logs it at load time. + hint = _voxcpm_upgrade_hint() + if hint: + return True, f"ready — {hint}" return True, "ready" @property @@ -462,6 +615,9 @@ class VoxCPM2Backend(TTSBackend): ok, msg = self.is_available() if not ok: raise RuntimeError(f"VoxCPM2 unavailable: {msg}") + hint = _voxcpm_upgrade_hint() + if hint: + logger.warning("VoxCPM2: %s", hint) from voxcpm import VoxCPM # type: ignore[import-not-found] checkpoint = os.environ.get("OMNIVOICE_VOXCPM_MODEL", "openbmb/VoxCPM2") logger.info("Loading VoxCPM2 from %s", checkpoint) @@ -491,14 +647,16 @@ class VoxCPM2Backend(TTSBackend): cfg_value=kw.get("guidance_scale", 2.0), inference_timesteps=kw.get("num_step", 10), ) - if isinstance(wav, np.ndarray): - wav = torch.from_numpy(wav).float() - if wav.ndim == 1: - wav = wav.unsqueeze(0) - return wav + return self._finalize(wav) # ── Standard clone / instruct mode ────────────────────────────── # Map our instruct prop onto VoxCPM2's inline "(instruct)prompt" prefix. + # The reference clip is prepared first (edge-silence trim + length + # cap) — the model no longer trims it internally, so a raw user clip + # would condition generation on dead air. Fail-open: on any prep + # problem the raw path is used, exactly as before. + if ref_audio: + ref_audio = _prepare_voxcpm_ref(ref_audio) prompt = text if instruct: prompt = f"({instruct}){text}" @@ -510,11 +668,26 @@ class VoxCPM2Backend(TTSBackend): prompt_wav_path=ref_audio if ref_text else None, prompt_text=ref_text, ) + return self._finalize(wav) + + def _finalize(self, wav) -> torch.Tensor: + """Normalize model output to a (1, n) float tensor and apply the + trailing-silence guard. + + The guard is a SILENCE trim only: generations often end with a long + near-silent tail, which this cuts (keeping a short ~0.3 s natural + tail). It deliberately does NOT attempt to detect or judge trailing + *content* — an output that ends in audible audio, wanted or not, + passes through unchanged, as does any output without a silent tail. + """ + import numpy as np + from services.audio_dsp import trim_trailing_silence + if isinstance(wav, np.ndarray): wav = torch.from_numpy(wav).float() if wav.ndim == 1: wav = wav.unsqueeze(0) - return wav + return trim_trailing_silence(wav, self.sample_rate) # ── MOSS-TTS-Nano adapter (tiny, CPU-friendly, 20 langs) ──────────────────── @@ -1459,7 +1632,7 @@ _INSTALL_HINTS: dict[str, str] = { "cosyvoice": "git clone --recursive FunAudioLLM/CosyVoice + pip install -r requirements.txt + SoX", "kittentts": "pip install kittentts (ONNX, CPU-only, ~80 MB)", "mlx-audio": "pip install mlx-audio (Apple Silicon only)", - "voxcpm2": "pip install voxcpm (CPU/MPS supported; CUDA recommended for speed)", + "voxcpm2": 'pip install "voxcpm>=2.0.3" (floor: 2.0.3 fixed Apple-Silicon audio quality; CPU/MPS supported, CUDA recommended for speed)', "moss-tts-nano": "git clone OpenMOSS/MOSS-TTS-Nano && pip install -e . (not on PyPI)", "indextts2": "git clone index-tts/index-tts && uv pip install -e . (NOT uv sync --all-extras)", "gpt-sovits": "External API server — start api_v2.py on port 9880", diff --git a/tests/test_engines.py b/tests/test_engines.py index 0649f93b..533b181f 100644 --- a/tests/test_engines.py +++ b/tests/test_engines.py @@ -24,9 +24,10 @@ def test_tts_registry_lists_all_backends(): def test_tts_voxcpm2_unavailable_message_is_actionable(): ok, msg = tts_backend.VoxCPM2Backend.is_available() - # On most CI boxes voxcpm isn't installed; message must tell the user how. + # On most CI boxes voxcpm isn't installed; message must tell the user how + # — including the >=2.0.3 version floor (Apple-Silicon audio quality). if not ok: - assert "pip install voxcpm" in msg or "CUDA" in msg + assert 'pip install "voxcpm>=2.0.3"' in msg def test_tts_moss_nano_unavailable_message_points_to_install(): diff --git a/tests/test_issue_fixes.py b/tests/test_issue_fixes.py index f07e03ad..2b2236a8 100644 --- a/tests/test_issue_fixes.py +++ b/tests/test_issue_fixes.py @@ -211,16 +211,19 @@ def test_indextts_install_hint_warns_about_sync(): def test_voxcpm_install_hint_uses_correct_package_name(): - """VoxCPM2 backend's hint must reference pip package 'voxcpm', not 'voxcpm2'.""" + """VoxCPM2 backend's hint must reference pip package 'voxcpm', not + 'voxcpm2' — and carry the >=2.0.3 version FLOOR (2.0.3 fixed an + Apple-Silicon audio-quality bug; floor only, never an exact pin).""" rows = tts_backend.list_backends() vox_row = next((r for r in rows if r["id"] == "voxcpm2"), None) assert vox_row is not None, "voxcpm2 not in registry" hint = vox_row["install_hint"] - # The pip package is 'voxcpm', NOT 'voxcpm2' - assert "pip install voxcpm" in hint + # The pip package is 'voxcpm' (with the version floor), NOT 'voxcpm2' + assert 'pip install "voxcpm>=2.0.3"' in hint assert "voxcpm2" not in hint.split("pip install ")[1].split()[0], ( - f"Hint should say 'pip install voxcpm' not 'pip install voxcpm2': {hint}" + f"Hint should say 'pip install voxcpm...' not 'pip install voxcpm2': {hint}" ) + assert "voxcpm==" not in hint, f"Floor only — never pin exact: {hint}" def test_list_backends_shape_unchanged(): diff --git a/tests/test_voxcpm2_guardrails.py b/tests/test_voxcpm2_guardrails.py new file mode 100644 index 00000000..4a53147f --- /dev/null +++ b/tests/test_voxcpm2_guardrails.py @@ -0,0 +1,267 @@ +"""VoxCPM2 engine guardrails — version floor, reference-clip prep, tail trim. + +Three independent hardenings of the voxcpm2 path, all backend-only and +platform-identical: + + 1. Version FLOOR (>=2.0.3): 2.0.3 fixed an audio-quality bug on Apple + Silicon (low-precision dtypes on MPS). Every install hint carries the + floor; an already-installed older version keeps working (available=True, + no forced reinstall) but surfaces an actionable upgrade hint. + 2. Reference-clip preparation: the `voxcpm` package no longer trims + reference audio internally, so raw user clips reached the model + unconditioned. The clone path now trims edge silence and caps the + reference at 30 s — fail-open, and a no-op for short clean clips. + 3. Trailing-silence guard: generations often end with a long near-silent + tail. Output is trimmed to the last voiced sample + ~0.3 s. Silence-trim + ONLY (no content analysis); a no-op on outputs without a silent tail. + +All tests use a fake `voxcpm` module / fake model (test_engines.py pattern) — +no real model, so they run on every CI box. +""" +import os +os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1") + +import logging +import sys +import types + +import numpy as np +import pytest +import soundfile as sf +import torch + +from services import tts_backend +from services.audio_dsp import trim_trailing_silence + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +SPEECH = 0.5 # well above the -50 dBFS (~0.00316) silence floor +SR = 16000 + + +def _write_wav(path, segments, sr=SR): + """Write a mono wav from (amplitude, seconds) segments; return its path.""" + audio = np.concatenate( + [np.full(int(s * sr), a, dtype=np.float32) for a, s in segments] + ) + sf.write(str(path), audio, sr) + return str(path) + + +@pytest.fixture() +def fresh_ref_cache(monkeypatch): + """Isolate the prepared-reference cache per test.""" + monkeypatch.setattr(tts_backend, "_VOXCPM_REF_PREP_CACHE", {}) + + +# ── 1. Version floor ───────────────────────────────────────────────────────── + + +def test_version_floor_hint_on_old_version(monkeypatch): + monkeypatch.setitem(sys.modules, "voxcpm", types.ModuleType("voxcpm")) + monkeypatch.setattr(tts_backend, "_voxcpm_installed_version", lambda: "2.0.1") + ok, msg = tts_backend.VoxCPM2Backend.is_available() + assert ok is True # floor, not pin — existing installs keep working + assert "2.0.1" in msg and "2.0.3" in msg + assert 'pip install --upgrade "voxcpm>=2.0.3"' in msg + + +def test_version_floor_no_hint_at_or_above_floor(monkeypatch): + monkeypatch.setitem(sys.modules, "voxcpm", types.ModuleType("voxcpm")) + for v in ("2.0.3", "2.0.10", "2.1.0", "3.0.0"): + monkeypatch.setattr(tts_backend, "_voxcpm_installed_version", lambda v=v: v) + assert tts_backend.VoxCPM2Backend.is_available() == (True, "ready") + + +def test_version_floor_unknown_version_does_not_nag(monkeypatch): + monkeypatch.setitem(sys.modules, "voxcpm", types.ModuleType("voxcpm")) + for v in (None, "unknown", ""): + monkeypatch.setattr(tts_backend, "_voxcpm_installed_version", lambda v=v: v) + assert tts_backend.VoxCPM2Backend.is_available() == (True, "ready") + + +def test_version_floor_in_not_installed_message(monkeypatch): + monkeypatch.delitem(sys.modules, "voxcpm", raising=False) + ok, msg = tts_backend.VoxCPM2Backend.is_available() + if not ok: # only asserts on boxes without a real voxcpm install + assert 'pip install "voxcpm>=2.0.3"' in msg + assert "voxcpm==" not in msg # floor only, never an exact pin + + +def test_version_floor_load_time_warning(monkeypatch, caplog): + fake = types.ModuleType("voxcpm") + + class _FakeVoxCPM: + @classmethod + def from_pretrained(cls, *a, **kw): + return types.SimpleNamespace() + + fake.VoxCPM = _FakeVoxCPM + monkeypatch.setitem(sys.modules, "voxcpm", fake) + monkeypatch.setattr(tts_backend, "_voxcpm_installed_version", lambda: "2.0.2") + backend = tts_backend.VoxCPM2Backend() + with caplog.at_level(logging.WARNING, logger="omnivoice.tts"): + backend._ensure_loaded() + assert any("voxcpm>=2.0.3" in r.getMessage() for r in caplog.records) + + +def test_version_tuple_parsing(): + vt = tts_backend._version_tuple + assert vt("2.0.3") == (2, 0, 3) + assert vt("2.0.10") > vt("2.0.3") + assert vt("2.1rc1") == (2, 1) + assert vt("garbage") is None + + +# ── 2. Reference-clip preparation ──────────────────────────────────────────── + + +def test_ref_prep_trims_edge_silence(tmp_path, fresh_ref_cache): + raw = _write_wav(tmp_path / "ref.wav", [(0.0, 1.0), (SPEECH, 0.5), (0.0, 1.0)]) + prepared = tts_backend._prepare_voxcpm_ref(raw) + assert prepared != raw + audio, sr = sf.read(prepared, dtype="float32") + # 0.5 s of speech + the 50 ms edge pad on each side. + assert abs(len(audio) / sr - 0.6) < 0.02 + # Leading silence really gone: speech starts within the edge pad. + first_voiced = np.flatnonzero(np.abs(audio) > 0.01)[0] + assert first_voiced / sr <= 0.06 + + +def test_ref_prep_caps_length(tmp_path, fresh_ref_cache): + raw = _write_wav(tmp_path / "long.wav", [(SPEECH, 40.0)], sr=8000) + prepared = tts_backend._prepare_voxcpm_ref(raw) + assert prepared != raw + audio, sr = sf.read(prepared, dtype="float32") + assert abs(len(audio) / sr - 30.0) < 0.01 # capped at 30 s + + +def test_ref_prep_noop_on_short_clean_clip(tmp_path, fresh_ref_cache): + raw = _write_wav(tmp_path / "clean.wav", [(SPEECH, 0.5)]) + # Untouched means: the ORIGINAL path comes back, file not rewritten. + assert tts_backend._prepare_voxcpm_ref(raw) == raw + + +def test_ref_prep_noop_on_all_silence_clip(tmp_path, fresh_ref_cache): + # Nothing above the floor to anchor a trim — fail-open, original path. + raw = _write_wav(tmp_path / "silent.wav", [(0.0, 2.0)]) + assert tts_backend._prepare_voxcpm_ref(raw) == raw + + +def test_ref_prep_fail_open_on_unreadable_path(fresh_ref_cache): + missing = "/nonexistent/dir/ref.wav" + assert tts_backend._prepare_voxcpm_ref(missing) == missing + + +def test_ref_prep_result_is_cached(tmp_path, fresh_ref_cache): + raw = _write_wav(tmp_path / "ref.wav", [(0.0, 1.0), (SPEECH, 0.5), (0.0, 1.0)]) + first = tts_backend._prepare_voxcpm_ref(raw) + assert tts_backend._prepare_voxcpm_ref(raw) == first # no second temp file + + +def test_generate_passes_prepared_ref_to_model(tmp_path, fresh_ref_cache): + raw = _write_wav(tmp_path / "ref.wav", [(0.0, 1.0), (SPEECH, 0.5), (0.0, 1.0)]) + backend = tts_backend.VoxCPM2Backend() + backend._ensure_loaded = lambda: None + captured = {} + + def _fake_generate(**kw): + captured.update(kw) + return np.full(4800, SPEECH, dtype=np.float32) + + backend._model = types.SimpleNamespace(generate=_fake_generate) + backend.generate("hello", ref_audio=raw, ref_text="the reference line") + + assert captured["reference_wav_path"] != raw # prepared copy + assert captured["prompt_wav_path"] == captured["reference_wav_path"] + assert os.path.exists(captured["reference_wav_path"]) + assert captured["prompt_text"] == "the reference line" + + +def test_generate_clean_ref_reaches_model_unchanged(tmp_path, fresh_ref_cache): + raw = _write_wav(tmp_path / "clean.wav", [(SPEECH, 0.5)]) + backend = tts_backend.VoxCPM2Backend() + backend._ensure_loaded = lambda: None + captured = {} + + def _fake_generate(**kw): + captured.update(kw) + return np.full(4800, SPEECH, dtype=np.float32) + + backend._model = types.SimpleNamespace(generate=_fake_generate) + backend.generate("hello", ref_audio=raw) + assert captured["reference_wav_path"] == raw # byte-identical original + + +# ── 3. Trailing-silence guard ──────────────────────────────────────────────── + + +def test_tail_trim_cuts_long_silent_tail(): + sr = 1000 + audio = torch.cat([torch.full((sr,), SPEECH), torch.zeros(2 * sr)]) + out = trim_trailing_silence(audio, sr) + assert out.shape[-1] == sr + int(0.3 * sr) # keeps the ~0.3 s natural tail + + +def test_tail_trim_noop_without_silent_tail(): + sr = 1000 + audio = torch.full((sr,), SPEECH) + assert trim_trailing_silence(audio, sr) is audio # same object, untouched + + +def test_tail_trim_noop_on_short_natural_tail(): + sr = 1000 + audio = torch.cat([torch.full((sr,), SPEECH), torch.zeros(int(0.2 * sr))]) + assert trim_trailing_silence(audio, sr) is audio + + +def test_tail_trim_noop_on_all_silence(): + # Dead renders must pass through so downstream dead-render guards see them. + sr = 1000 + audio = torch.zeros(2 * sr) + assert trim_trailing_silence(audio, sr) is audio + + +def test_tail_trim_preserves_channel_dim(): + sr = 1000 + audio = torch.cat([torch.full((sr,), SPEECH), torch.zeros(2 * sr)]).unsqueeze(0) + out = trim_trailing_silence(audio, sr) + assert out.ndim == 2 and out.shape[0] == 1 + assert out.shape[-1] == sr + int(0.3 * sr) + + +def test_generate_output_tail_is_trimmed(fresh_ref_cache): + backend = tts_backend.VoxCPM2Backend() + backend._ensure_loaded = lambda: None + sr = backend.sample_rate + tail = np.zeros(2 * sr, dtype=np.float32) + voiced = np.full(sr, SPEECH, dtype=np.float32) + + backend._model = types.SimpleNamespace( + generate=lambda **kw: np.concatenate([voiced, tail])) + out = backend.generate("hello") + assert out.shape == (1, sr + int(0.3 * sr)) + + +def test_generate_output_without_tail_is_unchanged(fresh_ref_cache): + backend = tts_backend.VoxCPM2Backend() + backend._ensure_loaded = lambda: None + backend._model = types.SimpleNamespace( + generate=lambda **kw: np.full(4800, SPEECH, dtype=np.float32)) + out = backend.generate("hello") + assert out.shape == (1, 4800) + + +def test_generate_voice_design_output_tail_is_trimmed(fresh_ref_cache): + # The guard covers the voxcpm2 design path too — same engine output. + backend = tts_backend.VoxCPM2Backend() + backend._ensure_loaded = lambda: None + sr = backend.sample_rate + backend._model = types.SimpleNamespace( + generate=lambda **kw: np.concatenate([ + np.full(sr, SPEECH, dtype=np.float32), + np.zeros(sr, dtype=np.float32), + ])) + out = backend.generate("hello", description="young female, warm tone") + assert out.shape == (1, sr + int(0.3 * sr))