fix(tts): voxcpm2 — version floor, reference-clip prep, trailing-silence guard (#1055)

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 <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-07-11 00:21:54 +05:30
committed by GitHub
co-authored by mergetest Claude Fable 5
parent a99f1fdff9
commit d3ec4ed371
5 changed files with 499 additions and 16 deletions
+39
View File
@@ -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.
+183 -10
View File
@@ -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",
+3 -2
View File
@@ -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():
+7 -4
View File
@@ -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():
+267
View File
@@ -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))