reachy_conv_app / tests /test_qwentts_cpp_backend.py
ArtFix0's picture
v0.5.25: card list of cloned voices + turbo knob disabling + chatterbox dtype fix
05a44d4 verified
Raw
History Blame Contribute Delete
12.8 kB
"""Tests for the qwentts.cpp in-process backend.
The C library is mocked via a fake QwenttsModel; we don't load any
real models. Voice library reads use the per-test isolated voices
dir set by the autouse fixture.
"""
from __future__ import annotations
import asyncio
import io
import os
import wave
from pathlib import Path
from typing import Any
from unittest import mock
import numpy as np
import pytest
from reachy_conv_app.tts import qwentts_cpp_backend # noqa: E402
from reachy_conv_app.tts import voice_library # noqa: E402
from reachy_conv_app.tts.base import TTSBackend, VoiceSpec # noqa: E402
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _stub_cpu_preflight(monkeypatch):
"""The CPU pre-flight checks /proc/cpuinfo for AMX. This dev box
doesn't have AMX (it would segfault on the real C library too),
so we patch the check to return supported. Tests that *want* the
pre-flight to fail can re-patch inside the test body."""
from reachy_conv_app.tts import qwentts_variant
monkeypatch.setattr(
qwentts_variant, "cpu_supports_qwentts",
lambda: (True, ""),
)
class _FakeSynthResult:
def __init__(self, samples: np.ndarray, sample_rate: int):
self.samples = samples
self.sample_rate = sample_rate
class _FakeModel:
"""A drop-in replacement for QwenttsModel."""
def __init__(self):
self.is_loaded = True
self._loaded_voice_mode = "preset"
self._loaded_model_size = "0.6B"
self._status = mock.MagicMock()
self._status.state = "ready"
# `status` is the property the real model exposes
self.status = self._status
self._speakers = ["Vivian", "Serena", "Uncle_Fu"]
self._synth_log: list[dict] = []
self._speakers_should_raise = False
self._force_unloaded = False
def get_speakers(self):
if self._speakers_should_raise:
return []
return list(self._speakers)
def load(self, voice_mode, model_size, *, progress=None):
self._loaded_voice_mode = voice_mode
self._loaded_model_size = model_size
self.is_loaded = True
self._status.state = "ready"
return self._status
def close(self):
self.is_loaded = False
self._status.state = "unloaded"
def synth(self, req):
self._synth_log.append({"text": req.text, "voice_mode": req.voice_mode,
"speaker": req.speaker, "voice_name": req.voice_name,
"voice_description": req.voice_description,
"language": req.language, "instruct": req.instruct,
"seed": req.seed, "temperature": req.temperature})
n = 2400 # 0.1s at 24 kHz
return _FakeSynthResult(np.zeros(n, dtype=np.float32), 24000)
async def synth_async(self, req):
return self.synth(req)
@pytest.fixture(autouse=True)
def isolated_voices_dir(tmp_path, monkeypatch):
# v0.5.25: also isolate chatterbox_voices_root so a stray
# chatterbox voice dir under the real ~/.cache/... doesn't
# pollute the qwentts list_voices assertions.
monkeypatch.setenv("REACHY_CONV_APP_VOICES_DIR", str(tmp_path / "voices"))
monkeypatch.setenv(
"REACHY_CONV_APP_CHATTERBOX_VOICES_DIR", str(tmp_path / "cb_voices")
)
yield
def _make_backend(model: _FakeModel | None = None) -> qwentts_cpp_backend.QwenTtsCpp:
return qwentts_cpp_backend.QwenTtsCpp(model=model or _FakeModel())
# ---------------------------------------------------------------------------
# Basic plumbing
# ---------------------------------------------------------------------------
def test_backend_name():
b = _make_backend()
assert b.name == "qwentts_cpp"
def test_backend_is_subclass_of_tts_backend():
assert issubclass(qwentts_cpp_backend.QwenTtsCpp, TTSBackend)
def test_is_available_when_loaded():
model = _FakeModel()
model.is_loaded = True
model._status.state = "ready"
b = qwentts_cpp_backend.QwenTtsCpp(model=model)
assert b.is_available() is True
def test_is_available_false_when_wheel_missing(monkeypatch):
"""is_available() should return False if the qwentts_cpp wheel can't import."""
import builtins
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "qwentts_cpp" or name.startswith("qwentts_cpp."):
raise ImportError("simulated wheel not installed")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(model=model)
assert b.is_available() is False
# An availability_error should be set so the dashboard can show why
assert b.availability_error is not None
assert "qwentts_cpp" in b.availability_error
def test_is_available_true_when_wheel_present(monkeypatch):
"""is_available() should return True if qwentts_cpp imports cleanly."""
# The real wheel IS importable in this venv, so just call it.
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(model=model)
# Don't call _ensure_loaded — the constructor doesn't load any more.
assert b.is_available() is True
# ---------------------------------------------------------------------------
# list_voices
# ---------------------------------------------------------------------------
def test_list_voices_preset_returns_speakers():
model = _FakeModel()
model._speakers = ["Vivian", "Serena"]
b = qwentts_cpp_backend.QwenTtsCpp(model=model, voice_mode="preset")
voices = b.list_voices()
names = [v.id for v in voices]
assert names == ["Vivian", "Serena"]
assert all(isinstance(v, VoiceSpec) for v in voices)
def test_list_voices_cloned_returns_library_entries():
spk = np.zeros(64, dtype=np.float32)
codes = np.zeros((4, 10), dtype=np.int32)
voice_library.add_voice(
name="alice", ref_text="x",
spk_embedding=spk, rvq_codes=codes,
source_wav=b"RIFF" + b"\x00" * 100,
model_id="Qwen/Qwen3-TTS-12Hz-0.6B-Base",
)
voice_library.add_voice(
name="bob", ref_text="y",
spk_embedding=spk, rvq_codes=codes,
source_wav=b"RIFF" + b"\x00" * 100,
model_id="Qwen/Qwen3-TTS-12Hz-0.6B-Base",
)
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(model=model, voice_mode="cloned")
voices = b.list_voices()
assert sorted(v.id for v in voices) == ["alice", "bob"]
def test_list_voices_design_returns_empty():
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(model=model, voice_mode="design")
assert b.list_voices() == []
# ---------------------------------------------------------------------------
# synth — preset
# ---------------------------------------------------------------------------
def test_synth_preset_uses_speaker_from_voice_arg():
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(model=model, voice_mode="preset")
pcm, sr = asyncio.run(b.synth("Hello.", "Serena"))
assert sr == 24000
# PCM int16 array, non-empty
assert isinstance(pcm, np.ndarray)
assert pcm.dtype == np.int16
assert pcm.size > 0
# The synth call recorded the right speaker
assert model._synth_log[-1]["speaker"] == "Serena"
assert model._synth_log[-1]["voice_mode"] == "preset"
def test_synth_preset_defaults_to_vivian_when_voice_empty():
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(model=model, voice_mode="preset")
asyncio.run(b.synth("Hi", ""))
assert model._synth_log[-1]["speaker"] == "Vivian"
# ---------------------------------------------------------------------------
# synth — cloned
# ---------------------------------------------------------------------------
def test_synth_cloned_uses_voice_name():
spk = np.zeros(64, dtype=np.float32)
codes = np.zeros((16, 50), dtype=np.int32)
voice_library.add_voice(
name="alice", ref_text="x",
spk_embedding=spk, rvq_codes=codes,
source_wav=b"RIFF" + b"\x00" * 100,
model_id="Qwen/Qwen3-TTS-12Hz-0.6B-Base",
)
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(model=model, voice_mode="cloned")
asyncio.run(b.synth("Hi", "alice"))
assert model._synth_log[-1]["voice_name"] == "alice"
assert model._synth_log[-1]["voice_mode"] == "cloned"
# ---------------------------------------------------------------------------
# synth — design
# ---------------------------------------------------------------------------
def test_synth_design_uses_voice_as_description():
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(model=model, voice_mode="design")
asyncio.run(b.synth("Hi", "a deep British voice"))
assert model._synth_log[-1]["voice_description"] == "a deep British voice"
# ---------------------------------------------------------------------------
# advanced controls
# ---------------------------------------------------------------------------
def test_synth_passes_instruct():
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(
model=model, voice_mode="preset", instruct="speak slowly and warmly",
)
asyncio.run(b.synth("Hi", "Vivian"))
assert model._synth_log[-1]["instruct"] == "speak slowly and warmly"
def test_synth_passes_seed_and_temperature():
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(
model=model, voice_mode="preset", seed=42, temperature=0.3,
)
asyncio.run(b.synth("Hi", "Vivian"))
assert model._synth_log[-1]["seed"] == 42
assert model._synth_log[-1]["temperature"] == 0.3
def test_synth_passes_language():
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(
model=model, voice_mode="preset", language="japanese",
)
asyncio.run(b.synth("Hi", "Vivian"))
assert model._synth_log[-1]["language"] == "japanese"
def test_set_advanced_updates_knobs():
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(model=model, voice_mode="preset", seed=1)
b.set_advanced(seed=99, temperature=0.0, top_p=0.5)
asyncio.run(b.synth("Hi", "Vivian"))
log = model._synth_log[-1]
assert log["seed"] == 99
assert log["temperature"] == 0.0
# ---------------------------------------------------------------------------
# empty text
# ---------------------------------------------------------------------------
def test_synth_empty_text_returns_short_silence_pcm():
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(model=model, voice_mode="preset")
pcm, sr = asyncio.run(b.synth("", "Vivian"))
assert sr == 24000
# Should be a short silence buffer (no model call)
assert isinstance(pcm, np.ndarray)
assert pcm.dtype == np.int16
assert pcm.size <= 240 # <= 10ms at 24kHz
# No synth call should have been made
assert model._synth_log == []
def test_synth_whitespace_only_text_returns_silence():
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(model=model, voice_mode="preset")
wav, sr = asyncio.run(b.synth(" \n ", "Vivian"))
assert sr == 24000
# No synth call should have been made
assert model._synth_log == []
# ---------------------------------------------------------------------------
# voice mode / size switching
# ---------------------------------------------------------------------------
def test_set_voice_mode_reloads_model():
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(model=model, voice_mode="preset", model_size="0.6B")
assert model._loaded_voice_mode == "preset"
b.set_voice_mode("cloned", "0.6B")
assert model._loaded_voice_mode == "cloned"
b.set_voice_mode("design", "1.7B")
assert model._loaded_voice_mode == "design"
assert model._loaded_model_size == "1.7B"
def test_set_voice_mode_same_mode_no_reload(monkeypatch):
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(model=model, voice_mode="preset", model_size="0.6B")
load_calls = []
orig = model.load
def tracker(*a, **kw):
load_calls.append((a, kw))
return orig(*a, **kw)
model.load = tracker
b.set_voice_mode("preset", "0.6B") # same
assert load_calls == []
# ---------------------------------------------------------------------------
# close
# ---------------------------------------------------------------------------
def test_close_frees_model():
model = _FakeModel()
b = qwentts_cpp_backend.QwenTtsCpp(model=model)
b.close()
assert model.is_loaded is False