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:
co-authored by
mergetest
Claude Fable 5
parent
a99f1fdff9
commit
d3ec4ed371
@@ -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
@@ -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",
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user