Ctrl K
ComunidadGuías…

test_capture_quality.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Checks for the capture-device suitability warning (issue #34).

Three halves, if you will: the advertised-property verdicts, the spectral verdict
on captured audio, and the fact that a verdict reaches the operator at `--listen`
startup. That last one is what makes the feature real -- a correct classifier
nobody prints is worth nothing.

The spectral tests synthesize their own audio rather than shipping fixtures, so
the narrowband case is generated by band-limiting a voice-shaped signal the same
way a resampler does. That keeps the repo free of binary test assets and lets a
reader see exactly which property is being asserted.

Hardware-free by construction. capture_quality reads advertised fields and plain
sample arrays rather than a device, and the startup check stands a fake in for the
lazily-imported audio module, so everything here runs on a box with no PortAudio
and no microphone.

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

from __future__ import annotations

import contextlib
import io
import sys
import types

import numpy as np

import capture_quality
import pipeline
from capture_quality import (
    CaptureRisk,
    assess_capture_spectrum,
    assess_input_device,
)

# audio.py only needs sounddevice for hardware calls, while these tests replace
# Microphone before exercising its capture verdict. Keep the test hardware-free
# on hosts where PortAudio is absent.
_saved_sounddevice = sys.modules.get("sounddevice")
sys.modules["sounddevice"] = types.ModuleType("sounddevice")
try:
    import audio
finally:
    if _saved_sounddevice is None:
        sys.modules.pop("sounddevice", None)
    else:
        sys.modules["sounddevice"] = _saved_sounddevice

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)


def test_flags_bluetooth_hands_free_by_name() -> None:
    """The reported failure: a BT hands-free endpoint on Windows."""
    # The real device name from issue #34's box, in the Windows form.
    risk = assess_input_device("Headset (Anker PowerConf Hands-Free AG Audio)", 16000)
    check(
        "hands-free endpoint flagged",
        risk is not None and risk.kind == "hands-free-profile",
        f"kind={risk.kind if risk else None}",
    )
    check(
        "hands-free message names the fix",
        risk is not None and "USB" in risk.message,
        "message points at USB Audio Class" if risk else "no message",
    )


def test_hands_free_wins_over_rate() -> None:
    """Both signals present: report the profile, since that is the actionable one."""
    risk = assess_input_device("Hands-Free AG Audio", 8000)
    check(
        "profile reported ahead of rate",
        risk is not None and risk.kind == "hands-free-profile",
        f"kind={risk.kind if risk else None}",
    )


def test_flags_narrowband_rate() -> None:
    """A WASAPI capture format below the pipeline's 16 kHz."""
    risk = assess_input_device("Some Telephony Mic", 8000, host_api="Windows WASAPI")
    check(
        "8 kHz device flagged",
        risk is not None and risk.kind == "narrowband",
        f"kind={risk.kind if risk else None}",
    )
    check(
        "narrowband message states the measured rate",
        risk is not None and "8000" in risk.message,
        "message includes 8000 Hz" if risk else "no message",
    )


def test_default_rate_is_advisory_outside_wasapi() -> None:
    """ALSA/CoreAudio defaults do not prove the opened stream is narrowband."""
    for host_api in ("ALSA", "Core Audio", None):
        risk = assess_input_device(
            "Mic with a low preferred rate", 8000, host_api=host_api
        )
        check(
            f"silent for {host_api or 'unknown'} default rate",
            risk is None,
            "no warning" if risk is None else f"unexpected {risk.kind}",
        )


def test_microphone_passes_host_api_to_default_rate_check() -> None:
    """The live/test path preserves the host distinction from find_device."""

    class FakeStream:
        active = False

        def start(self):
            self.active = True

        def stop(self):
            self.active = False

        def close(self):
            pass

    saved_find_device = audio.find_device
    saved_open = audio.Microphone._open
    info = {
        "name": "Mic with a low preferred rate",
        "default_samplerate": 8000,
        "max_input_channels": 1,
    }
    audio.Microphone._open = lambda self, idx, host: (
        FakeStream(),
        self.target_sr,
        1,
    )
    try:
        for host_api, expected_kind in (
            ("ALSA", None),
            ("Windows WASAPI", "narrowband"),
        ):
            audio.find_device = lambda name, kind, host=host_api: (0, info, host)
            with audio.Microphone() as mic:
                kind = mic.capture_risk.kind if mic.capture_risk else None
                check(
                    f"microphone preserves {host_api}",
                    kind == expected_kind,
                    f"kind={kind}",
                )
    finally:
        audio.find_device = saved_find_device
        audio.Microphone._open = saved_open


def test_microphone_flags_an_actual_low_rate_fallback() -> None:
    """A concrete open below target is evidence even when the default was not."""

    class FakeStream:
        active = False

        def start(self):
            self.active = True

        def stop(self):
            self.active = False

        def close(self):
            pass

    saved_find_device = audio.find_device
    saved_open = audio.Microphone._open
    info = {
        "name": "Mic with an 8 kHz fallback",
        "default_samplerate": 8000,
        "max_input_channels": 1,
    }
    audio.find_device = lambda name, kind: (0, info, "ALSA")
    audio.Microphone._open = lambda self, idx, host: (FakeStream(), 8000, 1)
    try:
        with audio.Microphone() as mic:
            check(
                "actual low-rate fallback is flagged",
                mic.capture_risk is not None
                and mic.capture_risk.kind == "narrowband"
                and "8000" in mic.capture_risk.message,
                f"risk={mic.capture_risk}",
            )
    finally:
        audio.find_device = saved_find_device
        audio.Microphone._open = saved_open


def test_good_devices_are_silent() -> None:
    """No warning for the devices the project runs on.

    A warning here would be worse than no warning at all: the operator learns to
    skip the line, and the real one gets skipped with it.
    """
    cases = [
        # The Pi production path, native 16 kHz mono over USB Audio Class. This is
        # the boundary case -- 16 kHz must not read as narrowband.
        ("Anker PowerConf", 16000),
        ("Shure MV7", 48000),
        ("NVIDIA Broadcast", 48000),
        ("Microphone (USB Audio Device)", 44100),
        # A USB headset is a fine STT mic, so "headset" alone must not trip it.
        ("Headset Microphone (Logitech USB Headset)", 48000),
    ]
    for name, sr in cases:
        risk = assess_input_device(name, sr)
        check(
            f"silent for {name}",
            risk is None,
            "no warning" if risk is None else f"unexpected {risk.kind}",
        )


def test_unknown_rate_is_not_a_fault() -> None:
    """Absent rate information must not manufacture a warning."""
    for sr in (None, 0, 0.0, "", "not-a-number"):
        risk = assess_input_device("Mystery Device", sr)
        check(
            f"silent for default_sr={sr!r}",
            risk is None,
            "no warning" if risk is None else f"unexpected {risk.kind}",
        )


def test_survives_a_missing_name() -> None:
    """A device with no usable name must not raise."""
    try:
        risk = assess_input_device(None, 48000)
        check("no name does not raise", risk is None, "returned None")
    except Exception as e:  # noqa: BLE001 - any raise is the failure being checked
        check("no name does not raise", False, f"raised {type(e).__name__}: {e}")


def test_target_rate_is_honored() -> None:
    """The threshold follows the caller's target, not a baked-in 16 kHz.

    audio.py passes its TARGET_SR, so a pipeline retuned to a different frame rate
    keeps a correct warning instead of one pinned to the old rate.
    """
    risk = assess_input_device(
        "Some Mic", 16000, target_sr=48000, host_api="Windows WASAPI"
    )
    check(
        "16 kHz is narrowband against a 48 kHz target",
        risk is not None and risk.kind == "narrowband",
        f"kind={risk.kind if risk else None}",
    )
    check(
        "default target is the pipeline's 16 kHz",
        capture_quality.DEFAULT_TARGET_SR == 16000,
        f"DEFAULT_TARGET_SR={capture_quality.DEFAULT_TARGET_SR}",
    )


class _FakeMic:
    """audio.Microphone stand-in whose stream has already ended, so run_loop
    reaches the startup print and then breaks immediately."""

    device_label = "fake-mic"

    def __init__(self, capture_risk):
        self.capture_risk = capture_risk

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        return False

    def frames(self):
        return iter(())

    def pause(self):
        pass

    def resume(self):
        pass

    def flush(self):
        pass

    def active(self) -> bool:
        return False


def _run_loop_output(capture_risk) -> str:
    """Drive pipeline.run_loop with a fake mic carrying `capture_risk`, and return
    what it printed. run_loop imports `audio` lazily, so a fake module stands in
    and no device, PortAudio, or model is touched."""
    saved = {
        "load_config": pipeline.load_config,
        "warm_models": pipeline.warm_models,
        "run_turn": pipeline.run_turn,
    }
    saved_audio = sys.modules.get("audio")

    fake_audio = types.ModuleType("audio")
    fake_audio.Microphone = lambda name=None: _FakeMic(capture_risk)
    fake_audio.play_wav = lambda path, name=None: None

    pipeline.load_config = lambda: {"wake_word": "computah", "wake_chime": False}
    pipeline.warm_models = lambda cfg=None, wake_word=None: {}
    pipeline.run_turn = lambda frames, **kw: None
    sys.modules["audio"] = fake_audio
    buf = io.StringIO()
    try:
        with contextlib.redirect_stdout(buf):
            pipeline.run_loop()
    finally:
        pipeline.load_config = saved["load_config"]
        pipeline.warm_models = saved["warm_models"]
        pipeline.run_turn = saved["run_turn"]
        if saved_audio is None:
            sys.modules.pop("audio", None)
        else:
            sys.modules["audio"] = saved_audio
    return buf.getvalue()


def test_startup_warns_about_a_bad_device() -> None:
    """The verdict has to reach the operator, not just exist.

    This is the acceptance criterion from issue #34: warn at `--listen` startup.
    A classifier that is right and silent leaves the original failure intact --
    the loop looks healthy and only the transcript is wrong.
    """
    risk = CaptureRisk("hands-free-profile", "narrowband and frame-dropped, use USB")
    out = _run_loop_output(risk)
    check(
        "run_loop prints the warning at startup",
        risk.message in out,
        "message found" if risk.message in out else f"message absent from {out!r}",
    )
    check(
        "the warning is labelled so it reads as a problem",
        "WARNING" in out,
        "labelled WARNING" if "WARNING" in out else "no WARNING label",
    )
    check(
        "warned once, not per frame",
        out.count(risk.message) == 1,
        f"occurrences={out.count(risk.message)}",
    )
    check(
        "the loop still starts on a flagged device",
        "listening" in out,
        "reached listening" if "listening" in out else f"never listened: {out!r}",
    )


def test_startup_is_quiet_on_a_good_device() -> None:
    """No WARNING line when the device is fine, so the line stays meaningful."""
    out = _run_loop_output(None)
    check(
        "no warning for a clean device",
        "WARNING" not in out,
        "no WARNING printed" if "WARNING" not in out else f"unexpected: {out!r}",
    )


SR = 16000


def _speechlike(seconds: float = 3.0, tilt: float = 0.9, seed: int = 7):
    """Noise shaped like a voice: a natural spectral tilt plus formant bumps.

    Real speech is the hard case for a spectral check, because its energy falls
    off steeply with frequency all on its own. A flat signal would make any
    threshold look good.
    """
    rng = np.random.default_rng(seed)
    n = int(seconds * SR)
    spectrum = np.fft.rfft(rng.normal(0, 1, n))
    freqs = np.fft.rfftfreq(n, 1.0 / SR)
    shape = 1.0 / np.maximum(freqs, 50) ** tilt
    for center, gain in ((500, 6), (1500, 4), (2600, 3), (5200, 1.6), (6800, 1.3)):
        shape = shape * (1 + gain * np.exp(-((freqs - center) ** 2) / (2 * 320**2)))
    return np.fft.irfft(spectrum * shape, n=n)


def _bandlimited(
    signal, cutoff_hz: float, floor_db: float | None = None, seed: int = 3
):
    """Zero everything above cutoff, the way resampling from a narrower rate does.

    `floor_db` adds the noise a real resampler leaves in the emptied band; None is
    the ideal case that leaves it at exactly zero.
    """
    spectrum = np.fft.rfft(signal)
    freqs = np.fft.rfftfreq(len(signal), 1.0 / SR)
    spectrum[freqs >= cutoff_hz] = 0
    out = np.fft.irfft(spectrum, n=len(signal))
    if floor_db is not None:
        rng = np.random.default_rng(seed)
        rms = float(np.sqrt((out**2).mean()))
        out = out + rng.normal(0, rms * 10 ** (floor_db / 20), len(out))
    return out


def test_spectrum_flags_upsampled_narrowband() -> None:
    """The Linux hole: audio that arrived at 16 kHz but started life at 8 kHz.

    Neither the name nor the advertised rate can catch this -- PipeWire resamples
    and reports 16 kHz -- so the audio is the only evidence left.
    """
    voice = _speechlike()
    cases = [
        ("ideal resampler", _bandlimited(voice, 4000)),
        ("-60 dB resampler floor", _bandlimited(voice, 4000, floor_db=-60)),
        ("-40 dB sloppy resampler", _bandlimited(voice, 4000, floor_db=-40)),
        ("CVSD narrowband (3.4 kHz)", _bandlimited(voice, 3400, floor_db=-50)),
    ]
    for label, signal in cases:
        assessment = assess_capture_spectrum(signal, SR)
        risk = assessment.risk
        check(
            f"flags {label}",
            risk is not None and risk.kind == "upsampled-narrowband",
            f"kind={risk.kind if risk else None}",
        )


def test_spectrum_is_silent_on_genuine_full_band_audio() -> None:
    """A false positive here tells someone to replace a mic that works.

    The dull-voice case is the one that matters: it has very little energy up
    high on its own, which is exactly what a naive high-band-energy check would
    misread as narrowband.
    """
    rng = np.random.default_rng(11)
    cases = [
        ("speech-like full band", _speechlike()),
        ("very dull, heavily tilted voice", _speechlike(tilt=1.8)),
        ("white noise", rng.normal(0, 1, 3 * SR)),
        ("mild 7 kHz lowpass", _bandlimited(_speechlike(), 7000, floor_db=-50)),
    ]
    for label, signal in cases:
        assessment = assess_capture_spectrum(signal, SR)
        check(
            f"does not flag {label}",
            assessment.risk is None,
            (
                "no warning"
                if assessment.risk is None
                else f"unexpected {assessment.risk.kind}"
            ),
        )
        check(
            f"keeps {label} inconclusive",
            assessment.status == "inconclusive",
            f"status={assessment.status}",
        )


def test_spectrum_reports_no_information_rather_than_guessing() -> None:
    """Nothing to measure must read as nothing to say, not as a fault."""
    cases = [
        ("digital silence", np.zeros(3 * SR)),
        ("a constant offset", np.full(3 * SR, 1234.0)),
        ("too short to analyze", _speechlike(seconds=0.2)),
        ("empty buffer", np.zeros(0)),
    ]
    for label, signal in cases:
        assessment = assess_capture_spectrum(signal, SR)
        check(
            f"inconclusive verdict for {label}",
            getattr(assessment, "status", None) == "inconclusive",
            f"status={getattr(assessment, 'status', None)}",
        )


def test_full_band_spectrum_does_not_clear_wideband_hfp() -> None:
    """A 4 kHz cliff check cannot rule out wideband HFP codec/drop artifacts."""
    assessment = assess_capture_spectrum(_speechlike(), SR)
    check(
        "full-band spectrum remains inconclusive",
        getattr(assessment, "status", None) == "inconclusive",
        f"status={getattr(assessment, 'status', None)}",
    )
    message = getattr(assessment, "message", "")
    check(
        "inconclusive message names the undetected artifacts",
        "wideband HFP" in message and "frame drops" in message,
        f"message={message!r}",
    )


def _run_mic_test(signal: np.ndarray, seconds: float) -> tuple[int, str]:
    """Run audio._test_mic against deterministic frames from `signal`."""

    class FakeMicrophone:
        device_label = "Unmarked PipeWire source (ALSA)"
        device_default_sr = SR
        host_api = "ALSA"
        capture_risk = None
        _open_sr = SR
        _open_ch = 1

        def __enter__(self):
            return self

        def __exit__(self, *exc):
            return False

        def frames(self):
            usable = len(signal) - (len(signal) % audio.FRAME_SIZE)
            for offset in range(0, usable, audio.FRAME_SIZE):
                yield signal[offset : offset + audio.FRAME_SIZE].astype(np.int16)

    saved_microphone = audio.Microphone
    audio.Microphone = lambda name=None: FakeMicrophone()
    output = io.StringIO()
    try:
        with contextlib.redirect_stdout(output):
            exit_code = audio._test_mic("", seconds)
    finally:
        audio.Microphone = saved_microphone
    return exit_code, output.getvalue()


def test_mic_returns_inconclusive_for_unanalyzable_audio() -> None:
    """A short or stuck nonzero stream must not receive exit code 0."""
    cases = [
        ("too short", (_speechlike(seconds=0.2) * 8000), 0.2),
        ("constant nonzero", np.full(3 * SR, 1000), 3.0),
    ]
    for label, signal, seconds in cases:
        exit_code, output = _run_mic_test(signal.astype(np.int16), seconds)
        check(
            f"{label} capture exits inconclusive",
            exit_code == 4,
            f"exit_code={exit_code}",
        )
        check(
            f"{label} capture reports inconclusive",
            "inconclusive" in output.lower(),
            f"output={output!r}",
        )


def test_mic_does_not_pass_unmarked_wideband_hfp() -> None:
    """Full-band energy alone cannot make an unmarked PipeWire source usable."""
    signal = (_speechlike() * 8000).astype(np.int16)
    exit_code, output = _run_mic_test(signal, 3.0)
    check(
        "full-band unmarked source exits inconclusive",
        exit_code == 4,
        f"exit_code={exit_code}",
    )
    check(
        "full-band unmarked source explains wideband HFP gap",
        "wideband HFP" in output,
        f"output={output!r}",
    )


def test_spectrum_threshold_keeps_its_measured_margin() -> None:
    """Pin the separation the threshold was chosen from.

    The docstring claims genuine audio sits well above the cutoff and narrowband
    well below. If a future tweak narrows that gap, this fails while both verdict
    tests above still pass -- the margin is the thing worth protecting, not just
    the verdicts at today's values.
    """
    # The dull voice, not the ordinary one: it is the genuine case with the least
    # headroom, so it is the side of the threshold that gives out first. Measuring
    # the comfortable case instead would leave the binding one unpinned.
    genuine = capture_quality._cliff_ratio(
        _speechlike(tilt=1.8), SR, capture_quality.DEFAULT_TARGET_SR
    )
    narrow = capture_quality._cliff_ratio(
        _bandlimited(_speechlike(), 4000, floor_db=-40),
        SR,
        capture_quality.DEFAULT_TARGET_SR,
    )
    threshold = capture_quality._CLIFF_RATIO_THRESHOLD
    check(
        "genuine audio clears the threshold with margin",
        genuine > threshold * 3,
        f"ratio={genuine:.4f} vs threshold={threshold} (need >{threshold * 3:.3f})",
    )
    check(
        "narrowband audio sits under it with margin",
        narrow < threshold / 3,
        f"ratio={narrow:.4f} vs threshold={threshold} (need <{threshold / 3:.3f})",
    )


def test_spectrum_survives_int16_frames() -> None:
    """--test-mic hands it the concatenated int16 frames, not floats."""
    frames = (_bandlimited(_speechlike(), 4000, floor_db=-60) * 8000).astype(np.int16)
    risk = assess_capture_spectrum(frames, SR).risk
    check(
        "int16 input is judged the same as float",
        risk is not None and risk.kind == "upsampled-narrowband",
        f"kind={risk.kind if risk else None}",
    )


def main() -> int:
    print("capture-device suitability checks (issue #34)")
    test_flags_bluetooth_hands_free_by_name()
    test_hands_free_wins_over_rate()
    test_flags_narrowband_rate()
    test_default_rate_is_advisory_outside_wasapi()
    test_microphone_passes_host_api_to_default_rate_check()
    test_microphone_flags_an_actual_low_rate_fallback()
    test_good_devices_are_silent()
    test_unknown_rate_is_not_a_fault()
    test_survives_a_missing_name()
    test_target_rate_is_honored()
    test_startup_warns_about_a_bad_device()
    test_startup_is_quiet_on_a_good_device()
    test_spectrum_flags_upsampled_narrowband()
    test_spectrum_is_silent_on_genuine_full_band_audio()
    test_spectrum_reports_no_information_rather_than_guessing()
    test_full_band_spectrum_does_not_clear_wideband_hfp()
    test_mic_returns_inconclusive_for_unanalyzable_audio()
    test_mic_does_not_pass_unmarked_wideband_hfp()
    test_spectrum_threshold_keeps_its_measured_margin()
    test_spectrum_survives_int16_frames()
    failed = results.count(False)
    print(f"\n{len(results) - failed}/{len(results)} checks passed")
    return 1 if failed else 0


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