Ctrl K
ComunidadGuías…

test_tts_voice_eval.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Fast, no-audio tests for the voice-comparison metrics (#38).

The scoring half of tts_voice_eval is a pure function over timing numbers, so this
exercises it with no piper install, no voice download, and no audio, like
test_wake_eval.py. It pins the ratio the whole comparison rests on, the failure
cases that must not read as a fast voice, and the fact that a voice that failed
to run never reaches the recommendation.

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

from __future__ import annotations

import math
import subprocess
import tempfile
from pathlib import Path
from unittest import mock

from tts_voice_eval import (
    LineResult,
    VoiceResult,
    config_path,
    download_error,
    download_voice,
    format_table,
    mean_rtf,
    real_time_factor,
    recommend,
    rtf_spread,
    voice_is_complete,
)

PASS, FAIL = "PASS", "FAIL"
results: list[tuple[str, str]] = []


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


def voice(name: str, rtf: float, **kwargs) -> VoiceResult:
    """A voice whose every line costs `rtf` synthesis seconds per audio second."""
    return VoiceResult(
        voice=name,
        lines=[LineResult(label="only", synth_s=rtf * 2.0, audio_s=2.0)],
        **kwargs,
    )


def test_real_time_factor() -> None:
    check(
        "rtf is synthesis seconds per audio second",
        real_time_factor(1.0, 2.0) == 0.5 and real_time_factor(4.0, 2.0) == 2.0,
        "1s/2s = 0.5, 4s/2s = 2.0",
    )


def test_silent_synthesis_is_not_free() -> None:
    """No audio is a failed voice, not an infinitely fast one.

    Reported as a division it would be the cheapest voice on the table and would
    win the recommendation outright, which is the wrong direction to fail in.
    """
    check(
        "zero-length audio costs infinity, not zero",
        real_time_factor(1.5, 0.0) == math.inf
        and real_time_factor(0.0, 0.0) == math.inf
        and mean_rtf([]) == math.inf,
        "a voice that produced nothing cannot be the cheapest row",
    )


def test_mean_is_over_lines_not_totals() -> None:
    """A long line must not outvote a short one just for being long."""
    lines = [
        LineResult(label="short", synth_s=0.1, audio_s=1.0),
        LineResult(label="long", synth_s=9.0, audio_s=9.0),
    ]
    check(
        "mean averages per-line ratios, not summed seconds",
        mean_rtf(lines) == 0.55,
        f"(0.1 + 1.0) / 2 = 0.55, got {mean_rtf(lines)}",
    )


def test_spread_shows_every_line() -> None:
    """The mean can hide the outlier the whole cross-machine argument rests on."""
    result = VoiceResult(
        voice="v",
        lines=[
            LineResult(label="fallback", synth_s=0.1, audio_s=1.0),
            LineResult(label="status", synth_s=0.9, audio_s=1.0),
        ],
    )
    spread = rtf_spread(result)
    check(
        "per-line spread names each line and its own rtf",
        "fallback 0.100" in spread and "status 0.900" in spread,
        spread,
    )
    check(
        "the spread reaches the printed table",
        "fallback 0.100" in format_table([result]),
        "a number collected and never shown is a number nobody can act on",
    )


def test_recommend_orders_by_cost_and_names_the_ratio() -> None:
    results_in = [voice("baseline", 0.10), voice("pricey", 0.65), voice("cheap", 0.05)]
    notes = recommend(results_in, rtf_budget=0.5)

    check(
        "notes run cheapest first",
        notes[0].startswith("cheap")
        and notes[1].startswith("baseline")
        and notes[2].startswith("pricey"),
        " | ".join(note.split(":")[0] for note in notes[:3]),
    )
    # The ratio is against the first voice measured, which is the shipped one in
    # the default set, not against whichever voice happened to be cheapest.
    check(
        "the ratio is against the first voice measured, not the cheapest",
        "6.5x the cost of baseline" in notes[2]
        and "0.5x the cost of baseline" in notes[0],
        "a ratio against the winner would always read 1.0x and say nothing",
    )


def test_recommend_flags_only_what_is_over_budget() -> None:
    notes = recommend([voice("fast", 0.1), voice("slow", 0.9)], rtf_budget=0.5)
    over = [note for note in notes if note.startswith("Over the")]

    check(
        "only the over-budget voice is flagged",
        len(over) == 1 and "slow" in over[0] and "fast" not in over[0],
        over[0] if over else "no budget note at all",
    )
    # A budget note that does not send the reader to the real device would end the
    # decision on the wrong machine's numbers.
    check(
        "the budget note sends the reader to the target device",
        over and "target device" in over[0],
        "this box is not the Pi, and the absolutes do not transfer",
    )


def test_failed_voice_is_excluded_but_still_shown() -> None:
    """A voice that would not run must not be scored, and must not vanish either."""
    results_in = [
        voice("works", 0.2),
        VoiceResult(voice="broken", error="no such model"),
    ]
    notes = recommend(results_in, rtf_budget=0.5)
    table = format_table(results_in)

    check(
        "a failed voice is not scored",
        not any("broken" in note for note in notes),
        "scoring it would rank a voice that never ran",
    )
    check(
        "a failed voice still appears, with its reason",
        "broken" in table and "no such model" in table,
        "silently dropping it would read as a voice nobody tried",
    )


def test_download_error_is_actionable() -> None:
    """A misspelled voice name should not read as a urllib traceback."""
    notfound = subprocess.CalledProcessError(
        1,
        "cmd",
        stderr=b"Traceback...\nurllib.error.HTTPError: HTTP Error 404: Not Found",
    )
    message = download_error(notfound)
    check(
        "a 404 names the fix, not the traceback",
        "no voice by that name" in message
        and "piper.download_voices" in message
        and "Traceback" not in message,
        message,
    )
    check(
        "any other failure keeps its own last line",
        download_error(
            subprocess.CalledProcessError(1, "cmd", stderr="boom: disk full")
        )
        == "boom: disk full"
        and download_error(subprocess.CalledProcessError(1, "cmd"))
        == "download failed",
        "a disk-full error must not be reported as a bad voice name",
    )


def test_half_downloaded_voice_is_not_treated_as_ready() -> None:
    """A model with no config is a download to finish, not a voice to load.

    PiperVoice.load derives the config path from the model path, so a voice that
    kept its 63 MB .onnx through an interrupted setup and lost its 5 KB .onnx.json
    loads as a failure. Returning early on the .onnx alone puts that failure on the
    table as a property of the voice, when one more download call fixes it.
    """
    with tempfile.TemporaryDirectory() as tmp:
        onnx = Path(tmp) / "en_US-lessac-medium.onnx"
        onnx.write_bytes(b"model")
        # The literal name piper writes and PiperVoice.load reads, spelled out
        # rather than taken from config_path, so a helper that derives the wrong
        # path cannot move the file and the assertion together and stay green.
        config = Path(tmp) / "en_US-lessac-medium.onnx.json"

        check(
            "the helper points at the file piper actually writes",
            config_path(onnx) == config,
            f"{config_path(onnx).name}, want {config.name}",
        )

        missing = voice_is_complete(onnx)
        config.write_bytes(b"")
        empty = voice_is_complete(onnx)
        config.write_bytes(b"{}")
        both = voice_is_complete(onnx)
        onnx.unlink()
        no_model = voice_is_complete(onnx)

    check(
        "a model without its config is not complete",
        not missing and not empty,
        "missing and empty both have to re-download, as piper's own check does",
    )
    check(
        "both files present and non-empty is complete",
        both and not no_model,
        "and a missing model is still incomplete however good the config is",
    )


def test_download_repairs_a_half_downloaded_voice_once() -> None:
    """The check above has to be the one the skip actually consults.

    Testing voice_is_complete alone would leave `if onnx.exists()` at the call site
    green, which is the whole bug. So this drives download_voice with the
    downloader stubbed out: it must call the downloader for a model with no config,
    and must not call it again once the config is there.
    """
    voice_name = "en_US-lessac-medium"
    with tempfile.TemporaryDirectory() as tmp:
        voices_dir = Path(tmp)
        onnx = voices_dir / f"{voice_name}.onnx"
        onnx.write_bytes(b"model")
        calls: list[list[str]] = []

        def fake_run(cmd, **kwargs):
            calls.append(cmd)
            (voices_dir / f"{voice_name}.onnx.json").write_bytes(b"{}")
            return subprocess.CompletedProcess(cmd, 0)

        with mock.patch("tts_voice_eval.subprocess.run", fake_run):
            download_voice(voice_name, voices_dir)
            repaired = len(calls)
            download_voice(voice_name, voices_dir)
            again = len(calls)

    check(
        "a model with no config sends the voice back to the downloader",
        repaired == 1,
        f"{repaired} download calls; 0 means the skip is still checking the .onnx alone",
    )
    check(
        "a complete voice downloads nothing",
        again == 1,
        f"{again} total calls; more than one means it re-downloads 63 MB every run",
    )


def test_recommend_says_so_when_nothing_ran() -> None:
    notes = recommend([VoiceResult(voice="broken", error="boom")], rtf_budget=0.5)
    check(
        "an all-failed run says so instead of printing an empty comparison",
        len(notes) == 1 and "nothing to compare" in notes[0],
        notes[0] if notes else "no note at all",
    )


def main() -> int:
    test_real_time_factor()
    test_silent_synthesis_is_not_free()
    test_mean_is_over_lines_not_totals()
    test_spread_shows_every_line()
    test_recommend_orders_by_cost_and_names_the_ratio()
    test_recommend_flags_only_what_is_over_budget()
    test_failed_voice_is_excluded_but_still_shown()
    test_download_error_is_actionable()
    test_half_downloaded_voice_is_not_treated_as_ready()
    test_download_repairs_a_half_downloaded_voice_once()
    test_recommend_says_so_when_nothing_ran()

    failed = [name for verdict, name in results if verdict == FAIL]
    total = len(results)
    print(f"\n{total - len(failed)}/{total} checks passed")
    if failed:
        print("FAILED: " + ", ".join(failed))
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())