Ctrl K
ComunidadGuías…

test_speak_resident.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Resident-Piper checks for speak() (#12).

speak() must load the Piper voice once and reuse it across calls in one process
(the latency win), keep the missing-voice error path with its download hint, and
fall back to the CLI when the in-process synth fails instead of crashing the loop.
These run hardware-free: a fake `piper` module is injected so no ONNX voice loads
and no audio backend is touched.

Run:  .venv/bin/python test_speak_resident.py
Exit code is 0 only if every check passes.
"""

from __future__ import annotations

import sys
import tempfile
import types
import warnings
from pathlib import Path

import pipeline

PASS, FAIL = "PASS", "FAIL"
results: list[bool] = []


def check(name: str, ok: bool, detail: str) -> bool:
    results.append(bool(ok))
    print(f"  [{PASS if ok else FAIL}] {name}: {detail}")
    return bool(ok)


class _FakeVoice:
    """Writes a tiny valid WAV so speak()'s wave context manager succeeds."""

    def synthesize_wav(self, text, wav_file, **kwargs):
        wav_file.setnchannels(1)
        wav_file.setsampwidth(2)
        wav_file.setframerate(22050)
        wav_file.writeframes(b"\x00\x00" * 16)


def _install_fake_piper() -> tuple[types.ModuleType, list[str]]:
    """Build a fake `piper` module whose PiperVoice.load records each call."""
    calls: list[str] = []
    mod = types.ModuleType("piper")

    class _FakePiperVoice:
        @classmethod
        def load(cls, model_path, config_path=None, **kwargs):
            calls.append(str(model_path))
            return _FakeVoice()

    mod.PiperVoice = _FakePiperVoice  # type: ignore[attr-defined]
    return mod, calls


def _voices_dir(tmp: Path, stem: str = "testvoice") -> Path:
    tmp.mkdir(parents=True, exist_ok=True)
    (tmp / f"{stem}.onnx").write_bytes(b"")  # only existence is checked
    return tmp


def test_voice_loads_once_across_calls() -> None:
    tmp = Path(tempfile.mkdtemp())
    voices = _voices_dir(tmp)
    fake_piper, calls = _install_fake_piper()

    saved_mod = sys.modules.get("piper")
    saved_dir = pipeline.VOICES_DIR
    pipeline.VOICES_DIR = voices
    pipeline._piper_cache.clear()
    sys.modules["piper"] = fake_piper
    try:
        outs = [str(tmp / f"out{i}.wav") for i in range(3)]
        for out in outs:
            pipeline.speak("hello there", out, voice_model="testvoice")
    finally:
        pipeline.VOICES_DIR = saved_dir
        pipeline._piper_cache.clear()
        if saved_mod is not None:
            sys.modules["piper"] = saved_mod
        else:
            sys.modules.pop("piper", None)

    check(
        "piper voice loaded exactly once across 3 speak() calls",
        len(calls) == 1,
        f"load calls={len(calls)}",
    )
    check(
        "every speak() call wrote a non-empty WAV",
        all(Path(o).exists() and Path(o).stat().st_size > 0 for o in outs),
        f"sizes={[Path(o).stat().st_size for o in outs if Path(o).exists()]}",
    )


def test_missing_voice_raises_with_hint() -> None:
    tmp = Path(tempfile.mkdtemp())  # empty: no onnx present
    saved_dir = pipeline.VOICES_DIR
    pipeline.VOICES_DIR = tmp
    raised: Exception | None = None
    try:
        pipeline.speak("hi", str(tmp / "o.wav"), voice_model="nope")
    except FileNotFoundError as e:
        raised = e
    finally:
        pipeline.VOICES_DIR = saved_dir

    check(
        "missing voice raises FileNotFoundError",
        isinstance(raised, FileNotFoundError),
        f"raised={type(raised).__name__ if raised else None}",
    )
    check(
        "missing-voice error keeps the download hint",
        raised is not None and "download" in str(raised),
        f"msg={str(raised)[:70]!r}",
    )


def test_falls_back_to_cli_when_inprocess_fails() -> None:
    tmp = Path(tempfile.mkdtemp())
    voices = _voices_dir(tmp)
    saved_dir = pipeline.VOICES_DIR
    saved_get = pipeline._get_piper
    saved_sub = pipeline._speak_subprocess
    fell_back: list[tuple] = []

    def boom(model_path):
        raise RuntimeError("simulated bad piper build")

    def fake_sub(text, onnx, out_wav_path):
        fell_back.append((text, str(onnx), out_wav_path))
        Path(out_wav_path).write_bytes(b"RIFF")  # pretend the CLI wrote a wav

    pipeline.VOICES_DIR = voices
    pipeline._get_piper = boom  # type: ignore[assignment]
    pipeline._speak_subprocess = fake_sub  # type: ignore[assignment]
    pipeline._piper_cache.clear()
    out = str(tmp / "fb.wav")
    err: Exception | None = None
    try:
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            pipeline.speak("hello", out, voice_model="testvoice")
    except Exception as e:  # noqa: BLE001 - the point is that it should NOT raise
        err = e
    finally:
        pipeline.VOICES_DIR = saved_dir
        pipeline._get_piper = saved_get  # type: ignore[assignment]
        pipeline._speak_subprocess = saved_sub  # type: ignore[assignment]

    check("in-process failure does not crash speak()", err is None, f"err={err!r}")
    check(
        "speak() fell back to the CLI path exactly once",
        len(fell_back) == 1,
        f"fallback calls={len(fell_back)}",
    )
    check(
        "fallback produced the output wav",
        Path(out).exists(),
        f"exists={Path(out).exists()}",
    )


def test_evicts_and_falls_back_when_synth_fails() -> None:
    """Load succeeds and caches the voice, then synth raises: speak() evicts the
    entry and falls back to the CLI. Unlike the boom-on-load test above, this runs
    the real _get_piper cache and the _piper_cache.pop eviction line, which is the
    exact seam residency introduces."""
    tmp = Path(tempfile.mkdtemp())
    voices = _voices_dir(tmp)
    onnx_key = str(voices / "testvoice.onnx")

    calls: list[str] = []
    mod = types.ModuleType("piper")

    class _BoomVoice:
        def synthesize_wav(self, text, wav_file, **kwargs):
            raise RuntimeError("simulated synth failure after a good load")

    class _FakePiperVoice:
        @classmethod
        def load(cls, model_path, config_path=None, **kwargs):
            calls.append(str(model_path))
            return _BoomVoice()

    mod.PiperVoice = _FakePiperVoice  # type: ignore[attr-defined]

    saved_mod = sys.modules.get("piper")
    saved_dir = pipeline.VOICES_DIR
    saved_sub = pipeline._speak_subprocess
    fell_back: list[tuple] = []

    def fake_sub(text, onnx, out_wav_path):
        fell_back.append((text, str(onnx), out_wav_path))
        Path(out_wav_path).write_bytes(b"RIFF")  # pretend the CLI wrote a wav

    pipeline.VOICES_DIR = voices
    pipeline._speak_subprocess = fake_sub  # type: ignore[assignment]
    pipeline._piper_cache.clear()
    sys.modules["piper"] = mod
    out = str(tmp / "boom.wav")
    err: Exception | None = None
    cached_after = True
    try:
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            pipeline.speak("hello", out, voice_model="testvoice")
        cached_after = onnx_key in pipeline._piper_cache
    except Exception as e:  # noqa: BLE001 - it must NOT raise
        err = e
    finally:
        pipeline.VOICES_DIR = saved_dir
        pipeline._speak_subprocess = saved_sub  # type: ignore[assignment]
        pipeline._piper_cache.clear()
        if saved_mod is not None:
            sys.modules["piper"] = saved_mod
        else:
            sys.modules.pop("piper", None)

    check(
        "the voice loaded once via the real cache before synth failed",
        len(calls) == 1,
        f"load calls={len(calls)}",
    )
    check(
        "synth failure after load does not crash speak()", err is None, f"err={err!r}"
    )
    check(
        "the failed voice entry was evicted from the cache",
        cached_after is False,
        f"cached_after={cached_after}",
    )
    check(
        "speak() fell back to the CLI after the synth failure",
        len(fell_back) == 1,
        f"fallback calls={len(fell_back)}",
    )
    check(
        "fallback produced the output wav",
        Path(out).exists(),
        f"exists={Path(out).exists()}",
    )


def main() -> int:
    print("=== resident-piper speak() checks ===")
    for test in (
        test_voice_loads_once_across_calls,
        test_missing_voice_raises_with_hint,
        test_falls_back_to_cli_when_inprocess_fails,
        test_evicts_and_falls_back_when_synth_fails,
    ):
        try:
            test()
        except Exception as e:  # noqa: BLE001 - report, don't abort the suite
            check(
                f"{test.__name__} ran without error", False, f"{type(e).__name__}: {e}"
            )
    n_pass = sum(1 for r in results if r)
    print(f"\n=== SUMMARY: {n_pass}/{len(results)} checks passed ===")
    return 0 if n_pass == len(results) else 1


if __name__ == "__main__":
    sys.exit(main())