Ctrl K
ComunidadGuías…

test_chime.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Fast, no-model tests for the wake-acknowledgment chime (issue #41).

Two concerns, both mic-free and model-free:
  1. The cue generator (chime.py) -- a 16 kHz mono int16 two-tone rising cue with
     edge fades. Checks the format, that the edges fade to near silence (no click),
     and that the pitch actually rises (the "two-tone rising" spec).
  2. The wiring -- both live loops fire the chime at the wake->capture boundary, the
     instant the wake fires and before the request is captured, and never when no
     wake fires. The detection/capture/transcribe/brain/speak/playback stages are
     stubbed, so this runs with no microphone, model, or audio device.

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

from __future__ import annotations

import os
import sys
import tempfile

import numpy as np
import soundfile as sf

import chime
import live_driver
import pipeline
from pipeline import FRAME_SIZE, Transcript

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


class FakeMic:
    """Records flush-to-now calls. The post-cue drop is now mic.flush() (issue #56):
    a cue that played flushes once; a failed or suppressed cue never flushes."""

    def __init__(self) -> None:
        self.flushed = 0

    def flush(self) -> None:
        self.flushed += 1


def _dom_freq(seg: np.ndarray) -> float:
    """Dominant frequency (Hz) of an int16 segment, windowed to limit edge leakage."""
    x = seg.astype(np.float64)
    if x.size < 16:
        return 0.0
    spec = np.abs(np.fft.rfft(x * np.hanning(x.size)))
    freqs = np.fft.rfftfreq(x.size, 1.0 / chime.SR)
    return float(freqs[int(np.argmax(spec))])


def test_generator() -> None:
    print("=== chime cue generator ===")
    pcm = chime.wake_cue_pcm()

    check(
        "cue is 1-D int16 PCM",
        isinstance(pcm, np.ndarray) and pcm.dtype == np.int16 and pcm.ndim == 1,
        f"dtype={pcm.dtype} ndim={pcm.ndim}",
    )

    # A short cue: long enough to be heard, short enough not to delay the turn.
    ms = pcm.size / chime.SR * 1000.0
    check(
        "cue length is in a sane 80-600 ms range",
        80.0 <= ms <= 600.0,
        f"{ms:.0f} ms ({pcm.size} samples)",
    )

    peak = int(np.max(np.abs(pcm))) if pcm.size else 0
    check("cue is audible but not clipped", 1000 < peak < 32768, f"peak={peak}")

    # Raised-cosine edge fades ramp through zero, so the waveform is continuous at
    # both ends -- no step discontinuity, hence no click.
    edge = max(abs(int(pcm[0])), abs(int(pcm[-1]))) if pcm.size else 0
    check(
        "edges fade to near silence (no click)", edge < 500, f"max edge sample={edge}"
    )

    # "Two-tone rising": the back of the cue is higher in pitch than the front.
    n = pcm.size
    lo = _dom_freq(pcm[: int(0.4 * n)])
    hi = _dom_freq(pcm[int(0.6 * n) :])
    check(
        "pitch rises (second tone higher than first)",
        hi > lo + 50,
        f"front~{lo:.0f}Hz back~{hi:.0f}Hz",
    )

    # The WAV helper writes a readable 16 kHz mono file and is idempotent.
    d = tempfile.mkdtemp()
    path = os.path.join(d, "cue.wav")
    p1 = chime.wake_cue_wav(path)
    data, sr = sf.read(p1, dtype="int16", always_2d=False)
    check(
        "wake_cue_wav writes a 16 kHz mono WAV at the given path",
        p1 == path and os.path.exists(path) and sr == chime.SR and data.ndim == 1,
        f"path={p1!r} sr={sr} ndim={data.ndim}",
    )
    mtime = os.path.getmtime(path)
    p2 = chime.wake_cue_wav(path)
    check(
        "wake_cue_wav is idempotent (same path, file intact)",
        p2 == path and os.path.getmtime(path) == mtime,
        f"p2={p2!r}",
    )


# --- wiring: pipeline.run_turn fires on_wake between detection and capture --------


def _run_turn_order(
    cfg_wake: float, fire: bool, use_hook: bool = True, hook_boundary: bool = True
):
    """Drive pipeline.run_turn with every stage stubbed, recording the call order of
    detection, the on_wake hook, and capture, plus the pre-roll capture_request saw.

    `fire` toggles whether a wake fires; `use_hook` toggles wiring the on_wake chime
    hook, so a caller can compare the pre-roll cleared (chime) vs preserved (no chime).
    `hook_boundary` is what the hook returns: True models a cue that played and flushed
    (a fresh capture boundary), False a cue that failed (no boundary, buffer kept) --
    so a caller can check the pre-roll is dropped only on the boundary case.
    Returns (order, seen_preroll)."""
    order: list[str] = []
    seen_preroll: list[list] = []
    real = (
        pipeline._resolve_wake_path,
        pipeline._get_oww_model,
        pipeline.stream_detect_wake,
        pipeline.capture_request,
        pipeline.transcribe_detailed,
        pipeline.guard_transcript,
        pipeline.brain,
        pipeline.speak,
    )

    pipeline._resolve_wake_path = lambda name: "dummy"
    pipeline._get_oww_model = lambda path: object()

    def fake_detect(frames, model, threshold, preroll=None):
        order.append("detect")
        if fire and preroll is not None:  # a real detection leaves wake frames here
            preroll.append(np.full(FRAME_SIZE, 7, dtype=np.int16))
        return 0.9 if fire else None

    def fake_capture(frames, preroll=None, vad_threshold=None, **_):
        order.append("capture")
        seen_preroll.append(list(preroll) if preroll is not None else [])
        return np.full(8 * FRAME_SIZE, 4000, dtype=np.int16)

    pipeline.stream_detect_wake = fake_detect
    pipeline.capture_request = fake_capture
    pipeline.transcribe_detailed = lambda p: Transcript("file an issue", -0.2, 0.05)
    pipeline.guard_transcript = lambda heard, cfg: (True, "")
    pipeline.brain = lambda t, **_: "ok"
    pipeline.speak = lambda text, out, **_: out

    def on_wake() -> bool:
        order.append("wake")
        return hook_boundary  # True = cue played and flushed (a fresh boundary)

    try:
        pipeline.run_turn(
            iter([]),
            model_name="x",
            threshold=cfg_wake,
            out_wav_path=os.devnull,
            on_wake=on_wake if use_hook else None,
        )
    finally:
        (
            pipeline._resolve_wake_path,
            pipeline._get_oww_model,
            pipeline.stream_detect_wake,
            pipeline.capture_request,
            pipeline.transcribe_detailed,
            pipeline.guard_transcript,
            pipeline.brain,
            pipeline.speak,
        ) = real
    return order, seen_preroll


def test_run_turn_hook() -> None:
    print("\n=== pipeline.run_turn on_wake hook ===")
    order, pr = _run_turn_order(0.5, fire=True, use_hook=True, hook_boundary=True)
    check(
        "on_wake fires once, after detection and before capture",
        order == ["detect", "wake", "capture"],
        f"order={order}",
    )
    check(
        "a cue that played (hook returns True) drops the pre-roll so the wake-word "
        "tail is not prepended",
        pr == [[]],
        f"preroll lengths seen by capture={[len(p) for p in pr]}",
    )

    order, pr = _run_turn_order(0.5, fire=True, use_hook=True, hook_boundary=False)
    check(
        "a cue that failed (hook returns falsy, no boundary) keeps the pre-roll so "
        "a no-pause command is still recovered (issue #30)",
        order == ["detect", "wake", "capture"] and len(pr) == 1 and len(pr[0]) == 1,
        f"order={order} preroll lengths={[len(p) for p in pr]}",
    )

    order, pr = _run_turn_order(0.5, fire=True, use_hook=False)
    check(
        "without the chime hook the pre-roll is preserved (issue #30 recovery)",
        order == ["detect", "capture"] and len(pr) == 1 and len(pr[0]) == 1,
        f"order={order} preroll lengths={[len(p) for p in pr]}",
    )

    order, _ = _run_turn_order(0.5, fire=False, use_hook=True)
    check(
        "on_wake does not fire when no wake fires",
        order == ["detect"] and "wake" not in order,
        f"order={order}",
    )


# --- wiring: live_driver plays the chime between wake and capture ----------------


def test_live_driver_chime() -> None:
    print("\n=== live_driver fires the chime at the wake->capture boundary ===")
    order: list[str] = []
    real = (
        live_driver.listen_for_wake,
        pipeline.capture_request,
        pipeline.transcribe_detailed,
        pipeline.brain,
        pipeline.speak,
        live_driver._play_wav,
        chime.wake_cue_wav,
    )

    def fake_listen(fr, m, t, d, preroll=None):
        if preroll is not None:  # detection leaves the wake-word tail in the pre-roll
            preroll.append(np.full(FRAME_SIZE, 7, dtype=np.int16))
        return 0.9

    live_driver.listen_for_wake = fake_listen
    chime.wake_cue_wav = lambda path=None: "CUE.wav"

    seen_preroll: list[list] = []

    def fake_capture(fr, preroll=None, vad_threshold=None, **_):
        order.append("capture")
        seen_preroll.append(list(preroll) if preroll is not None else [])
        return np.full(8 * FRAME_SIZE, 4000, dtype=np.int16)

    def fake_play(path, dev):
        order.append(f"play:{'cue' if path == 'CUE.wav' else 'reply'}")

    pipeline.capture_request = fake_capture
    pipeline.transcribe_detailed = lambda p: Transcript("file an issue", -0.2, 0.05)
    pipeline.brain = lambda t, **_: "Brain answer."
    pipeline.speak = lambda text, out, **_: out
    live_driver._play_wav = fake_play
    mic = FakeMic()

    cfg = {
        "stt_confidence_guard": True,
        "stt_min_avg_logprob": -1.0,
        "stt_max_no_speech_prob": 0.6,
        "wake_chime": True,
        "capture_vad_threshold": 0.5,
        "endpoint_silence_ms": 800,
        "max_request_ms": 8000,
    }
    try:
        ran = live_driver.run_turn(
            iter([]), mic, object(), 0.5, os.devnull, None, cfg, False
        )
    finally:
        (
            live_driver.listen_for_wake,
            pipeline.capture_request,
            pipeline.transcribe_detailed,
            pipeline.brain,
            pipeline.speak,
            live_driver._play_wav,
            chime.wake_cue_wav,
        ) = real

    check(
        "chime plays before the request is captured",
        "play:cue" in order
        and "capture" in order
        and order.index("play:cue") < order.index("capture"),
        f"ran={ran} order={order}",
    )
    check(
        "reply still plays after capture",
        "play:reply" in order and order.index("play:reply") > order.index("capture"),
        f"order={order}",
    )
    check(
        "a cue that played flushes-to-now once (drops the post-cue mic bleed)",
        mic.flushed == 1,
        f"flushed={mic.flushed}",
    )
    check(
        "the chime drops the pre-roll so the wake-word tail is not prepended",
        seen_preroll == [[]],
        f"preroll lengths seen by capture={[len(p) for p in seen_preroll]}",
    )


# --- wiring: a failed cue keeps the pre-roll (no false boundary) ------------------


def test_live_driver_chime_failure_keeps_preroll() -> None:
    print("\n=== live_driver: a failed cue keeps the pre-roll (issue #30 recovery) ===")
    order: list[str] = []
    real = (
        live_driver.listen_for_wake,
        pipeline.capture_request,
        pipeline.transcribe_detailed,
        pipeline.brain,
        pipeline.speak,
        live_driver._play_wav,
        chime.wake_cue_wav,
    )

    def fake_listen(fr, m, t, d, preroll=None):
        if preroll is not None:
            preroll.append(np.full(FRAME_SIZE, 7, dtype=np.int16))
        return 0.9

    live_driver.listen_for_wake = fake_listen
    chime.wake_cue_wav = lambda path=None: "CUE.wav"

    seen_preroll: list[list] = []

    def fake_capture(fr, preroll=None, vad_threshold=None, **_):
        order.append("capture")
        seen_preroll.append(list(preroll) if preroll is not None else [])
        return np.full(8 * FRAME_SIZE, 4000, dtype=np.int16)

    def failing_play(path, dev):
        # Both the cue and the reply route through _play_wav; the cue must fail so the
        # turn exercises the chime's failure branch. The reply's own try/except logs
        # and continues, so failing it too does not abort the turn.
        raise RuntimeError("aplay unavailable")

    pipeline.capture_request = fake_capture
    pipeline.transcribe_detailed = lambda p: Transcript("file an issue", -0.2, 0.05)
    pipeline.brain = lambda t, **_: "Brain answer."
    pipeline.speak = lambda text, out, **_: out
    live_driver._play_wav = failing_play
    mic = FakeMic()

    cfg = {
        "stt_confidence_guard": True,
        "stt_min_avg_logprob": -1.0,
        "stt_max_no_speech_prob": 0.6,
        "wake_chime": True,
        "capture_vad_threshold": 0.5,
        "endpoint_silence_ms": 800,
        "max_request_ms": 8000,
    }
    try:
        ran = live_driver.run_turn(
            iter([]), mic, object(), 0.5, os.devnull, None, cfg, False
        )
    finally:
        (
            live_driver.listen_for_wake,
            pipeline.capture_request,
            pipeline.transcribe_detailed,
            pipeline.brain,
            pipeline.speak,
            live_driver._play_wav,
            chime.wake_cue_wav,
        ) = real

    check(
        "a failed cue does not flush without a clean playback boundary",
        mic.flushed == 0,
        f"flushed={mic.flushed}",
    )
    check(
        "a failed cue keeps the pre-roll so a no-pause command is still recovered",
        len(seen_preroll) == 1 and len(seen_preroll[0]) == 1,
        f"preroll lengths seen by capture={[len(p) for p in seen_preroll]}",
    )
    check(
        "the turn still completes after a failed cue",
        ran is True and "capture" in order,
        f"ran={ran} order={order}",
    )


# --- wiring: the chime is opt-in (default off) -----------------------------------


def test_chime_opt_in_default_off() -> None:
    print("\n=== chime is opt-in: no cue when wake_chime is off or unset ===")
    # The cue regresses the no-pause case on a half-duplex device, so it ships off by
    # default (issue #41). Both an absent key and an explicit false must suppress it,
    # while the turn itself still runs normally (capture -> reply, just no cue/flush).
    for label, cfg_extra in (
        ("wake_chime unset", {}),
        ("wake_chime false", {"wake_chime": False}),
    ):
        order: list[str] = []
        real = (
            live_driver.listen_for_wake,
            pipeline.capture_request,
            pipeline.transcribe_detailed,
            pipeline.brain,
            pipeline.speak,
            live_driver._play_wav,
            chime.wake_cue_wav,
        )

        def fake_listen(fr, m, t, d, preroll=None):
            if preroll is not None:
                preroll.append(np.full(FRAME_SIZE, 7, dtype=np.int16))
            return 0.9

        live_driver.listen_for_wake = fake_listen
        chime.wake_cue_wav = lambda path=None: "CUE.wav"

        seen_preroll: list[list] = []

        def fake_capture(fr, preroll=None, vad_threshold=None, **_):
            order.append("capture")
            seen_preroll.append(list(preroll) if preroll is not None else [])
            return np.full(8 * FRAME_SIZE, 4000, dtype=np.int16)

        def fake_play(path, dev):
            order.append(f"play:{'cue' if path == 'CUE.wav' else 'reply'}")

        pipeline.capture_request = fake_capture
        pipeline.transcribe_detailed = lambda p: Transcript("file an issue", -0.2, 0.05)
        pipeline.brain = lambda t, **_: "Brain answer."
        pipeline.speak = lambda text, out, **_: out
        live_driver._play_wav = fake_play
        mic = FakeMic()

        cfg = {
            "stt_confidence_guard": True,
            "stt_min_avg_logprob": -1.0,
            "stt_max_no_speech_prob": 0.6,
            "capture_vad_threshold": 0.5,
            "endpoint_silence_ms": 800,
            "max_request_ms": 8000,
            **cfg_extra,
        }
        try:
            ran = live_driver.run_turn(
                iter([]), mic, object(), 0.5, os.devnull, None, cfg, False
            )
        finally:
            (
                live_driver.listen_for_wake,
                pipeline.capture_request,
                pipeline.transcribe_detailed,
                pipeline.brain,
                pipeline.speak,
                live_driver._play_wav,
                chime.wake_cue_wav,
            ) = real

        check(
            f"no cue and no flush when {label}",
            "play:cue" not in order and mic.flushed == 0,
            f"ran={ran} order={order} flushed={mic.flushed}",
        )
        check(
            f"the turn still runs (capture then reply) when {label}",
            "capture" in order and "play:reply" in order,
            f"order={order}",
        )
        check(
            f"the pre-roll is preserved (not chime-cleared) when {label}",
            len(seen_preroll) == 1 and len(seen_preroll[0]) == 1,
            f"preroll lengths seen by capture={[len(p) for p in seen_preroll]}",
        )


def main() -> int:
    test_generator()
    test_run_turn_hook()
    test_live_driver_chime()
    test_live_driver_chime_failure_keeps_preroll()
    test_chime_opt_in_default_off()
    n_pass = sum(1 for r in results if r[0] == PASS)
    n_total = len(results)
    print(f"\n=== {n_pass}/{n_total} checks passed ===")
    return 0 if n_pass == n_total else 1


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