Wake-word barge-in, Gitea auto-updater, hard_reset fix
This commit is contained in:
+155
-2
@@ -1,4 +1,5 @@
|
||||
"""Barge-in detection, driven by a fake mic stream (no audio hardware)."""
|
||||
"""Barge-in detection, driven by a fake mic stream and a fake wake model
|
||||
(no audio hardware, no ONNX runtime)."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -7,7 +8,7 @@ import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet.audio.barge_in import BargeInDetector
|
||||
from bolt_pet.audio.barge_in import BargeInDetector, WakeWordBargeIn, make_detector
|
||||
|
||||
|
||||
class FakeStream:
|
||||
@@ -63,3 +64,155 @@ def test_a_mic_error_mid_playback_is_not_fatal():
|
||||
|
||||
detector = BargeInDetector(BrokenStream(), threshold=1000, required_frames=1)
|
||||
assert detector.check() is False
|
||||
|
||||
|
||||
# ── wake-word mode ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FakeModel:
|
||||
"""Scores frames from a canned list, mimicking openWakeWord's
|
||||
{class_name: score} return. Records reset() calls."""
|
||||
|
||||
def __init__(self, scores):
|
||||
self._scores = list(scores)
|
||||
self.resets = 0
|
||||
|
||||
def predict(self, frame):
|
||||
score = self._scores.pop(0) if self._scores else 0.0
|
||||
return {"thunderbolt": score}
|
||||
|
||||
def reset(self):
|
||||
self.resets += 1
|
||||
|
||||
|
||||
def _wake_detector(scores, threshold=0.5, amplitudes=None):
|
||||
stream = FakeStream(amplitudes if amplitudes is not None else [500] * len(scores))
|
||||
return WakeWordBargeIn(stream, model=FakeModel(scores), threshold=threshold), stream
|
||||
|
||||
|
||||
def test_loud_noise_alone_does_not_interrupt_in_wake_mode():
|
||||
"""The whole point of wake mode: a slammed door is deafening and scores
|
||||
nothing, so the pet keeps talking."""
|
||||
detector, _ = _wake_detector([0.01] * 6, amplitudes=[30000] * 6)
|
||||
assert not any(detector.check() for _ in range(6))
|
||||
|
||||
|
||||
def test_the_wake_word_interrupts():
|
||||
detector, _ = _wake_detector([0.1, 0.2, 0.9])
|
||||
assert [detector.check() for _ in range(3)] == [False, False, True]
|
||||
|
||||
|
||||
def test_a_single_frame_is_enough_when_it_clears_the_threshold():
|
||||
detector, _ = _wake_detector([0.55])
|
||||
assert detector.check() is True
|
||||
|
||||
|
||||
def test_scores_just_under_the_threshold_do_not_fire():
|
||||
detector, _ = _wake_detector([0.49, 0.499], threshold=0.5)
|
||||
assert not any(detector.check() for _ in range(2))
|
||||
|
||||
|
||||
def test_detecting_resets_the_model_so_the_tail_is_not_reused():
|
||||
model = FakeModel([0.9])
|
||||
detector = WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5)
|
||||
assert detector.check() is True
|
||||
assert model.resets == 1
|
||||
|
||||
|
||||
def test_reset_clears_the_models_audio_window_not_just_predictions():
|
||||
"""The regression that made the pet interrupt itself a word into every
|
||||
reply: openwakeword's reset() clears only the prediction buffer, so the
|
||||
"thunderbolt" that started the turn was still in the preprocessor's
|
||||
rolling window when playback began, and the first frame fed to the model
|
||||
re-fired on it."""
|
||||
|
||||
class FakePreprocessor:
|
||||
def __init__(self):
|
||||
self.raw_data_buffer = [1, 2, 3]
|
||||
self.feature_buffer = np.ones((120, 96))
|
||||
self.melspectrogram_buffer = np.zeros((76, 32))
|
||||
self.accumulated_samples = 4096
|
||||
|
||||
def _get_embeddings(self, audio):
|
||||
return np.zeros((120, 96))
|
||||
|
||||
model = FakeModel([0.9])
|
||||
model.preprocessor = FakePreprocessor()
|
||||
|
||||
WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5).reset()
|
||||
|
||||
assert model.preprocessor.raw_data_buffer == []
|
||||
assert model.preprocessor.accumulated_samples == 0
|
||||
assert not model.preprocessor.feature_buffer.any() # blank, not the old audio
|
||||
assert model.preprocessor.melspectrogram_buffer.all() # restored to ones
|
||||
|
||||
|
||||
def test_the_lazy_wrapper_exposes_its_preprocessor():
|
||||
"""The pet holds _default_model — a lazy *wrapper* around openwakeword's
|
||||
Model. If the wrapper stops proxying .preprocessor, hard_reset() finds
|
||||
nothing to clear and silently degrades to the shallow reset that leaves
|
||||
the last detection in the audio window. That failure is invisible: no
|
||||
exception, no log, the pet just interrupts itself again."""
|
||||
from bolt_pet.audio.wake_word import _OpenWakeWordModel
|
||||
|
||||
wrapper = _OpenWakeWordModel()
|
||||
assert hasattr(wrapper, "preprocessor")
|
||||
assert wrapper.preprocessor is None # not loaded yet: a no-op, not a load
|
||||
|
||||
class FakeInner:
|
||||
preprocessor = object()
|
||||
|
||||
def reset(self):
|
||||
pass
|
||||
|
||||
wrapper._model = FakeInner()
|
||||
assert wrapper.preprocessor is FakeInner.preprocessor
|
||||
|
||||
|
||||
def test_a_model_without_a_preprocessor_still_resets():
|
||||
"""Fakes in tests, and any future openwakeword whose internals moved."""
|
||||
model = FakeModel([0.0])
|
||||
WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5).reset()
|
||||
assert model.resets == 1
|
||||
|
||||
|
||||
def test_a_callable_threshold_is_read_every_frame():
|
||||
"""The tray tuner's slider has to apply mid-playback, not just mid-idle."""
|
||||
threshold = {"value": 0.9}
|
||||
detector = WakeWordBargeIn(
|
||||
FakeStream([500] * 2), model=FakeModel([0.6, 0.6]),
|
||||
threshold=lambda: threshold["value"],
|
||||
)
|
||||
assert detector.check() is False
|
||||
threshold["value"] = 0.5
|
||||
assert detector.check() is True
|
||||
|
||||
|
||||
def test_a_model_that_blows_up_mid_playback_is_not_fatal():
|
||||
class BrokenModel:
|
||||
def predict(self, frame):
|
||||
raise RuntimeError("onnx session died")
|
||||
|
||||
def reset(self):
|
||||
raise RuntimeError("still dead")
|
||||
|
||||
detector = WakeWordBargeIn(FakeStream([500]), model=BrokenModel(), threshold=0.5)
|
||||
assert detector.check() is False
|
||||
detector.reset() # must not raise either
|
||||
|
||||
|
||||
def test_a_mic_error_is_not_fatal_in_wake_mode():
|
||||
class BrokenStream:
|
||||
def read(self, frames):
|
||||
raise OSError("device disappeared")
|
||||
|
||||
detector = WakeWordBargeIn(BrokenStream(), model=FakeModel([0.9]), threshold=0.5)
|
||||
assert detector.check() is False
|
||||
|
||||
|
||||
def test_make_detector_picks_the_mode():
|
||||
stream = FakeStream([0])
|
||||
assert isinstance(make_detector(stream, mode="wake"), WakeWordBargeIn)
|
||||
assert isinstance(make_detector(stream, mode="energy"), BargeInDetector)
|
||||
# A typo in .env shouldn't stop the pet from starting.
|
||||
assert isinstance(make_detector(stream, mode="waek"), BargeInDetector)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Auto-updater: version comparison, release parsing, and the apply/rollback
|
||||
dance driven by a fake git (no network, no real repo, nothing checked out)."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import updater
|
||||
|
||||
|
||||
# ── version comparison ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag,expected",
|
||||
[
|
||||
("v1.2.3", (1, 2, 3)),
|
||||
("1.2.3", (1, 2, 3)),
|
||||
("V0.1.0", (0, 1, 0)),
|
||||
("1.2", (1, 2)),
|
||||
("1.2.3-beta1", (1, 2, 3)), # suffix ends the parse
|
||||
("", ()),
|
||||
("nightly", ()),
|
||||
],
|
||||
)
|
||||
def test_parse_version(tag, expected):
|
||||
assert updater.parse_version(tag) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"candidate,current",
|
||||
[("0.2.0", "0.1.0"), ("1.0.0", "0.9.9"), ("0.1.1", "0.1"), ("v2.0", "1.9.9")],
|
||||
)
|
||||
def test_is_newer_accepts_newer_versions(candidate, current):
|
||||
assert updater.is_newer(candidate, current)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"candidate,current",
|
||||
[
|
||||
("0.1.0", "0.1.0"),
|
||||
("0.1.0", "0.2.0"),
|
||||
("0.1", "0.1.0"), # zero-padded: equal, not newer
|
||||
("", "0.1.0"),
|
||||
("nightly", "0.1.0"), # unparseable is never newer
|
||||
],
|
||||
)
|
||||
def test_is_newer_rejects_same_or_older(candidate, current):
|
||||
assert not updater.is_newer(candidate, current)
|
||||
|
||||
|
||||
def test_release_parsing_skips_drafts_and_untagged():
|
||||
assert updater.release_from_payload({"tag_name": "v1.0.0", "draft": True}) is None
|
||||
assert updater.release_from_payload({"name": "no tag"}) is None
|
||||
release = updater.release_from_payload({"tag_name": "v1.0.0", "name": "One", "prerelease": True})
|
||||
assert (release.tag, release.name, release.prerelease) == ("v1.0.0", "One", True)
|
||||
|
||||
|
||||
# ── fake git ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FakeGit:
|
||||
"""Records every git invocation and answers from a canned script.
|
||||
*failures* maps a leading-args tuple to the (code, output) it should
|
||||
return, so a test can make exactly one command fail."""
|
||||
|
||||
def __init__(self, ref="main", dirty=False, failures=None, requirements_changed=False):
|
||||
self.calls = []
|
||||
self._ref = ref
|
||||
self._dirty = dirty
|
||||
self._failures = failures or {}
|
||||
self._requirements_changed = requirements_changed
|
||||
|
||||
def __call__(self, args):
|
||||
self.calls.append(list(args))
|
||||
for prefix, result in self._failures.items():
|
||||
if tuple(args[: len(prefix)]) == prefix:
|
||||
return result
|
||||
head = args[0]
|
||||
if head == "rev-parse" and args[1] == "--git-dir":
|
||||
return 0, ".git"
|
||||
if head == "status":
|
||||
return 0, " M bolt_pet/config.py" if self._dirty else ""
|
||||
if head == "symbolic-ref":
|
||||
return (0, self._ref) if self._ref else (1, "")
|
||||
if head == "rev-parse":
|
||||
return 0, "abc1234"
|
||||
if head == "diff":
|
||||
return 0, "requirements.txt" if self._requirements_changed else ""
|
||||
return 0, ""
|
||||
|
||||
def commands(self):
|
||||
"""Just the verbs, for asserting on the sequence."""
|
||||
return [call[0] for call in self.calls]
|
||||
|
||||
|
||||
def test_apply_update_checks_out_the_tag(tmp_path):
|
||||
git = FakeGit()
|
||||
previous = updater.apply_update(
|
||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=lambda repo: None
|
||||
)
|
||||
assert previous == "main"
|
||||
assert ["fetch", "--tags", "--prune", "origin"] in git.calls
|
||||
assert ["checkout", "--force", "tags/v1.0.0"] in git.calls
|
||||
|
||||
|
||||
def test_a_dirty_working_tree_is_left_completely_alone(tmp_path):
|
||||
git = FakeGit(dirty=True)
|
||||
with pytest.raises(updater.UpdateError, match="local changes"):
|
||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
|
||||
assert "fetch" not in git.commands()
|
||||
assert "checkout" not in git.commands()
|
||||
|
||||
|
||||
def test_a_non_git_install_refuses_before_touching_anything(tmp_path):
|
||||
git = FakeGit(failures={("rev-parse", "--git-dir"): (128, "not a repository")})
|
||||
with pytest.raises(updater.UpdateError, match="not a git clone"):
|
||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
|
||||
assert "checkout" not in git.commands()
|
||||
|
||||
|
||||
def test_a_failed_smoke_test_rolls_back_to_the_previous_ref(tmp_path):
|
||||
git = FakeGit(ref="main")
|
||||
|
||||
def broken(repo):
|
||||
raise updater.UpdateError("the new version failed to import: boom")
|
||||
|
||||
with pytest.raises(updater.UpdateError, match="rolled back to main"):
|
||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=broken)
|
||||
|
||||
checkouts = [call for call in git.calls if call[0] == "checkout"]
|
||||
assert checkouts == [["checkout", "--force", "tags/v1.0.0"], ["checkout", "--force", "main"]]
|
||||
|
||||
|
||||
def test_rollback_targets_the_commit_when_head_is_detached(tmp_path):
|
||||
# No branch to go back to (symbolic-ref fails) — the SHA is the ref.
|
||||
git = FakeGit(ref="")
|
||||
|
||||
with pytest.raises(updater.UpdateError):
|
||||
updater.apply_update(
|
||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False,
|
||||
verify=lambda repo: (_ for _ in ()).throw(RuntimeError("nope")),
|
||||
)
|
||||
assert ["checkout", "--force", "abc1234"] in git.calls
|
||||
|
||||
|
||||
def test_a_verify_that_raises_something_unexpected_still_rolls_back(tmp_path):
|
||||
git = FakeGit()
|
||||
|
||||
def exploding(repo):
|
||||
raise ValueError("not even an UpdateError")
|
||||
|
||||
with pytest.raises(updater.UpdateError, match="rolled back"):
|
||||
updater.apply_update(
|
||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=exploding
|
||||
)
|
||||
assert ["checkout", "--force", "main"] in git.calls
|
||||
|
||||
|
||||
def test_a_failed_fetch_never_moves_the_checkout(tmp_path):
|
||||
git = FakeGit(failures={("fetch",): (1, "could not resolve host")})
|
||||
with pytest.raises(updater.UpdateError, match="git fetch failed"):
|
||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
|
||||
assert "checkout" not in git.commands()
|
||||
|
||||
|
||||
def test_a_stranded_checkout_is_logged_loudly(tmp_path):
|
||||
"""Rollback itself failing is the one case a human has to fix by hand."""
|
||||
git = FakeGit(failures={("checkout", "--force", "main"): (1, "index locked")})
|
||||
logs = []
|
||||
|
||||
with pytest.raises(updater.UpdateError):
|
||||
updater.apply_update(
|
||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False, on_log=logs.append,
|
||||
verify=lambda repo: (_ for _ in ()).throw(updater.UpdateError("bad build")),
|
||||
)
|
||||
assert any("ROLLBACK FAILED" in line and "git checkout main" in line for line in logs)
|
||||
|
||||
|
||||
def test_deps_are_only_reinstalled_when_requirements_actually_changed(tmp_path, monkeypatch):
|
||||
installs = []
|
||||
monkeypatch.setattr(updater, "_install_deps", lambda repo: installs.append(repo))
|
||||
|
||||
updater.apply_update(
|
||||
"v1.0.0", run=FakeGit(requirements_changed=False), repo=tmp_path,
|
||||
install_deps=True, verify=lambda repo: None,
|
||||
)
|
||||
assert installs == []
|
||||
|
||||
updater.apply_update(
|
||||
"v1.0.0", run=FakeGit(requirements_changed=True), repo=tmp_path,
|
||||
install_deps=True, verify=lambda repo: None,
|
||||
)
|
||||
assert installs == [tmp_path]
|
||||
|
||||
|
||||
# ── release checking ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_check_for_update_returns_nothing_when_current(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
updater, "fetch_latest_release",
|
||||
lambda **kwargs: updater.Release("v0.1.0", "0.1.0", "", False),
|
||||
)
|
||||
assert updater.check_for_update(current_version="0.1.0") is None
|
||||
assert updater.check_for_update(current_version="0.0.9").tag == "v0.1.0"
|
||||
|
||||
|
||||
def test_no_releases_yet_is_not_an_error(monkeypatch):
|
||||
monkeypatch.setattr(updater, "fetch_latest_release", lambda **kwargs: None)
|
||||
assert updater.check_for_update(current_version="0.1.0") is None
|
||||
Reference in New Issue
Block a user