Files
voculinux/tests/test_playback_ducker.py
T
e66bd218d9 fix(audio): prevent dictation hangs on stalled microphones (#974)
* fix(audio): bound capture reads and support cancellation

* fix(recognition): bound and serialize capture shutdown

* fix(recognition): cancel retries and report stalled microphones

* fix(audio): prefer pulse when the ALSA default is unavailable

* fix(recognition): guard late callbacks and finalize capture tests

* docs(audio): explain input selection and stall recovery

* fix(speech): enforce auto-pause across buffered reload timeouts

* fix(speech): recover auto-resume after reload timeout

* test(speech): fix auto-pause follow-up test regressions

* fix(speech): retain auto-resume after reconfigure timeout

* fix(tray): preserve auto-pause recovery across suspend

* test(capture-shutdown): give the bare tray the suspend path's shortcut managers

_on_system_suspend now releases the main and per-language hotkey
listeners before sleep (#1025); the fixture builds TrayIndicator without
__init__, so it needs the two attributes present.

* fix(tray): release hotkey listeners on suspend even while auto-paused

Merging #1025's suspend cleanup with this branch's auto-pause handoff
left an early return that skipped shortcut_manager.stop() whenever a
configured game was active, keeping EVIOCGRABs held through sleep. The
paused branch now only owns the model-lifecycle work; listener release
and stopping active recognition run for every suspend.

* fix(tray): hold the apply lock only around the download's verify and save

Wrapping the whole recommended-model download in _apply_settings_lock
blocked every GTK-thread settings apply (e.g. Test Dictation) for the
length of the transfer. The engine already serializes reconfigures, so
the lock only needs to cover verifying the download still owns the
receipt and saving it; an apply that lands mid-download now proceeds
instead of freezing the app.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-26 21:25:50 -07:00

792 lines
28 KiB
Python

"""Playback ducking: lower the default sink while dictating, then restore it.
These tests never spawn wpctl or pactl. The system backend is driven with a
fake command runner, and the recognition hook with a fake clock.
"""
import json
import sys
from pathlib import Path
from typing import Any, Callable, Iterator, Optional, Union
from unittest.mock import MagicMock, patch
import pytest
from vocalinux.audio import playback_ducker as duck
from vocalinux.audio.playback_ducker import (
DictationDuckSession,
PlaybackDucker,
SystemSinkVolume,
duck_delay_seconds,
)
from vocalinux.ui.config_manager import (
DEFAULT_CONFIG,
DEFAULT_PLAYBACK_DUCK_PERCENT,
ConfigManager,
clamp_playback_duck_percent,
)
@pytest.fixture(autouse=True)
def _numpy_is_real_while_comparing() -> Iterator[None]:
"""Keep pytest.approx working in the full suite.
Older tests assign ``sys.modules["numpy"] = MagicMock()`` at import and
never put the real module back. ``pytest.approx`` then does
``isinstance(value, numpy.bool_)`` and raises TypeError.
"""
current = sys.modules.get("numpy")
real = getattr(sys, "_vocalinux_real_numpy", None)
if isinstance(current, MagicMock) and real is not None:
sys.modules["numpy"] = real
try:
yield
finally:
sys.modules["numpy"] = current
else:
yield
class FakeSink:
"""In-memory default sink. ``present`` False means that sink is gone."""
def __init__(
self,
sink_id: str = "sink-a",
volume: float = 0.5,
channels: Optional[tuple[float, ...]] = None,
) -> None:
self.sink_id = sink_id
self._channels = channels if channels is not None else (float(volume),)
self.present = True
self.fail_read = False
self.sets: list[tuple[str, Union[float, tuple[float, ...]]]] = []
@property
def volume(self) -> float:
return self._channels[0]
@volume.setter
def volume(self, value: float) -> None:
# A single knob moves every channel together. Tests that assign
# ``sink.volume`` are describing that.
self._channels = tuple(float(value) for _ in self._channels)
def default_sink(self) -> Optional[tuple[str, tuple[float, ...]]]:
if not self.present or self.fail_read:
return None
return self.sink_id, self._channels
def volume_of(self, sink_id: str) -> Optional[tuple[float, ...]]:
if sink_id != self.sink_id or not self.present or self.fail_read:
return None
return self._channels
def sink_exists(self, sink_id: str) -> bool:
return sink_id == self.sink_id and self.present
def set_volume(self, sink_id: str, linear: tuple[float, ...]) -> bool:
recorded: Union[float, tuple[float, ...]] = linear[0] if len(linear) == 1 else linear
self.sets.append((sink_id, recorded))
if sink_id != self.sink_id or not self.present:
return False
self._channels = tuple(linear)
return True
def _ducker(
tmp_path: Path,
sink: FakeSink,
*,
enabled: bool = True,
percent: int = 20,
recover: bool = True,
) -> PlaybackDucker:
return PlaybackDucker(
sink,
str(tmp_path),
enabled=lambda: enabled,
percent=lambda: percent,
recover=recover,
)
def _record(tmp_path: Path) -> dict:
return json.loads((tmp_path / duck.PENDING_RECORD_NAME).read_text(encoding="utf-8"))
def _real_audio_feedback(monkeypatch: pytest.MonkeyPatch) -> Any:
"""Module object the recognition manager's ``import audio_feedback`` returns.
``test_audio_feedback`` leaves a MagicMock in ``sys.modules``. The package
attribute can still be the real module, and that is the one a later import
uses. Patching the mock measures the real cue file instead of the stub.
"""
import importlib
import vocalinux.ui as ui_pkg
key = "vocalinux.ui.audio_feedback"
bound = getattr(ui_pkg, "audio_feedback", None)
leaked = sys.modules.get(key)
if isinstance(bound, MagicMock) or isinstance(leaked, MagicMock):
# Import the real module, then put the leak back so monkeypatch
# restores it. Leaving the real module in place breaks later tests
# that expect the mock (test_text_injector).
if key in sys.modules:
del sys.modules[key]
if isinstance(bound, MagicMock):
delattr(ui_pkg, "audio_feedback")
real = importlib.import_module(key)
if leaked is None:
sys.modules.pop(key, None)
else:
sys.modules[key] = leaked
if isinstance(bound, MagicMock):
setattr(ui_pkg, "audio_feedback", bound)
elif bound is not None:
real = bound
else:
real = importlib.import_module(key)
monkeypatch.setattr(ui_pkg, "audio_feedback", real, raising=False)
monkeypatch.setitem(sys.modules, key, real)
return real
class _Timer:
def __init__(self, delay: float, callback: Callable[[], None]) -> None:
self.delay = delay
self.callback = callback
self.cancelled = False
def cancel(self) -> None:
self.cancelled = True
def fire(self) -> None:
# Invoke even after cancel so the test proves the generation guard,
# not merely that cancel dropped the callback.
self.callback()
class _Clock:
def __init__(self) -> None:
self.timers: list[_Timer] = []
def __call__(self, delay: float, callback: Callable[[], None]) -> "_Timer":
timer = _Timer(delay, callback)
self.timers.append(timer)
return timer
class _FakeDucker:
def __init__(self) -> None:
self.ducks = 0
self.restores = 0
def duck(self) -> None:
self.ducks += 1
def restore(self) -> None:
self.restores += 1
class _RecordingSession:
def __init__(self, enabled: bool = True) -> None:
self._enabled = enabled
self.events: list = []
def enabled(self) -> bool:
return self._enabled
def start(self, delay_seconds: float) -> None:
self.events.append(("start", delay_seconds))
def cancel(self) -> None:
self.events.append("cancel")
def restore(self) -> None:
self.events.append("restore")
def test_duck_scales_saved_volume_and_does_not_stack(tmp_path: Path) -> None:
sink = FakeSink(volume=0.5)
ducker = _ducker(tmp_path, sink, percent=20)
ducker.duck()
assert sink.volume == pytest.approx(0.1)
assert sink.sets == [("sink-a", pytest.approx(0.1))]
saved = _record(tmp_path)
assert saved["sink_id"] == "sink-a"
assert saved["original_volume"] == pytest.approx(0.5)
assert saved["ducked_volume"] == pytest.approx(0.1)
assert saved["original_channels"] == pytest.approx((0.5,))
assert saved["ducked_channels"] == pytest.approx((0.1,))
sink.volume = 0.9
ducker.duck()
assert sink.volume == pytest.approx(0.9)
assert len(sink.sets) == 1
def test_zero_percent_silences_and_disabled_does_nothing(tmp_path: Path) -> None:
sink = FakeSink(volume=0.8)
silent = _ducker(tmp_path, sink, percent=0)
silent.duck()
assert sink.volume == 0.0
assert sink.sets == [("sink-a", 0.0)]
untouched = FakeSink(volume=0.8)
disabled = _ducker(tmp_path / "off", untouched, enabled=False, percent=0)
(tmp_path / "off").mkdir()
disabled.duck()
assert untouched.volume == 0.8
assert untouched.sets == []
assert not (tmp_path / "off" / duck.PENDING_RECORD_NAME).exists()
def test_unwritable_restore_point_does_not_lower_the_sink(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
sink = FakeSink(volume=0.5)
ducker = _ducker(tmp_path, sink, percent=20)
def refuse(_record: duck.PendingDuck) -> None:
raise OSError("read-only config")
monkeypatch.setattr(ducker, "_write_pending", refuse)
ducker.duck()
assert sink.volume == pytest.approx(0.5)
assert sink.sets == []
assert ducker._pending is None
assert not (tmp_path / duck.PENDING_RECORD_NAME).exists()
def test_failed_set_that_applied_keeps_the_restore_point(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A set that reports failure can still have applied; keep the way back."""
sink = FakeSink(volume=0.5)
ducker = _ducker(tmp_path, sink, percent=20)
def apply_but_report_false(sink_id: str, linear: tuple[float, ...]) -> bool:
recorded: Union[float, tuple[float, ...]] = linear[0] if len(linear) == 1 else linear
sink.sets.append((sink_id, recorded))
sink._channels = tuple(linear)
return False
monkeypatch.setattr(sink, "set_volume", apply_but_report_false)
ducker.duck()
assert sink.volume == pytest.approx(0.1)
assert (tmp_path / duck.PENDING_RECORD_NAME).exists()
ducker.restore()
assert sink.volume == pytest.approx(0.5)
def test_failed_set_confirmed_unchanged_drops_the_record(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
sink = FakeSink(volume=0.5)
ducker = _ducker(tmp_path, sink, percent=20)
def reject(sink_id: str, linear: tuple[float, ...]) -> bool:
recorded: Union[float, tuple[float, ...]] = linear[0] if len(linear) == 1 else linear
sink.sets.append((sink_id, recorded))
return False
monkeypatch.setattr(sink, "set_volume", reject)
ducker.duck()
assert sink.volume == pytest.approx(0.5)
assert not (tmp_path / duck.PENDING_RECORD_NAME).exists()
def test_percent_100_does_not_change_the_sink(tmp_path: Path) -> None:
sink = FakeSink(volume=0.5)
ducker = _ducker(tmp_path, sink, percent=100)
ducker.duck()
assert sink.volume == pytest.approx(0.5)
assert sink.sets == []
assert _record(tmp_path)["ducked_volume"] == pytest.approx(0.5)
ducker.duck()
assert sink.sets == []
def test_restore_writes_saved_volume_unless_the_user_moved_it(tmp_path: Path) -> None:
sink = FakeSink(volume=0.5)
ducker = _ducker(tmp_path, sink, percent=20)
ducker.duck()
sink.volume = 0.1 + 0.01 # readout noise, still our ducked level
ducker.restore()
assert sink.volume == pytest.approx(0.5)
assert not (tmp_path / duck.PENDING_RECORD_NAME).exists()
ducker.duck()
sink.volume = 0.42 # user moved it while dictating
sets_before = len(sink.sets)
ducker.restore()
assert sink.volume == pytest.approx(0.42)
assert len(sink.sets) == sets_before
assert not (tmp_path / duck.PENDING_RECORD_NAME).exists()
def test_unreadable_volume_keeps_the_record_and_a_missing_sink_drops_it(tmp_path: Path) -> None:
sink = FakeSink(volume=0.5)
ducker = _ducker(tmp_path, sink, percent=20)
ducker.duck()
sink.fail_read = True
ducker.restore()
assert (tmp_path / duck.PENDING_RECORD_NAME).exists()
assert sink.sets == [("sink-a", pytest.approx(0.1))]
sink.fail_read = False
sink.present = False
ducker.restore()
assert sink.volume == pytest.approx(0.1)
assert [target for target, _level in sink.sets] == ["sink-a"]
assert len(sink.sets) == 1
assert not (tmp_path / duck.PENDING_RECORD_NAME).exists()
def test_new_instance_restores_pending_record_once(tmp_path: Path) -> None:
sink = FakeSink(volume=0.8)
first = _ducker(tmp_path, sink, percent=25)
first.duck()
assert sink.volume == pytest.approx(0.2)
assert (tmp_path / duck.PENDING_RECORD_NAME).is_file()
# Setting off must not skip crash recovery: the previous process already ducked.
second = _ducker(tmp_path, sink, enabled=False, percent=25)
assert sink.volume == pytest.approx(0.8)
assert not (tmp_path / duck.PENDING_RECORD_NAME).exists()
sets_after_restore = len(sink.sets)
third = _ducker(tmp_path, sink, percent=25)
assert sink.volume == pytest.approx(0.8)
assert len(sink.sets) == sets_after_restore
assert third is not None
def test_corrupt_record_is_dropped_without_touching_volume(tmp_path: Path) -> None:
sink = FakeSink(volume=0.5)
path = tmp_path / duck.PENDING_RECORD_NAME
path.write_text(
json.dumps({"sink_id": "-rf", "original_volume": 0.5, "ducked_volume": 0.1}),
encoding="utf-8",
)
_ducker(tmp_path, sink)
assert sink.sets == []
assert sink.volume == pytest.approx(0.5)
assert not path.exists()
def test_duck_delay_waits_for_the_cue_and_caps() -> None:
assert (
duck_delay_seconds(sound_effects_enabled=False, tone="voca", cue_duration_seconds=0.4)
== 0.0
)
assert (
duck_delay_seconds(sound_effects_enabled=True, tone="off", cue_duration_seconds=0.4) == 0.0
)
assert duck_delay_seconds(
sound_effects_enabled=True, tone="voca", cue_duration_seconds=0.4
) == pytest.approx(0.45)
assert (
duck_delay_seconds(sound_effects_enabled=True, tone="voca", cue_duration_seconds=10) == 1.5
)
assert duck_delay_seconds(
sound_effects_enabled=True, tone="voca", cue_duration_seconds=-1
) == pytest.approx(0.05)
def test_stop_before_the_timer_does_not_duck_and_stop_after_restores() -> None:
clock = _Clock()
backend = _FakeDucker()
session = DictationDuckSession(backend, enabled=lambda: True, schedule=clock)
session.start(0.45)
assert len(clock.timers) == 1
assert clock.timers[0].delay == pytest.approx(0.45)
assert backend.ducks == 0
session.cancel()
session.restore()
clock.timers[0].fire()
assert backend.ducks == 0
assert backend.restores == 1
clock = _Clock()
backend = _FakeDucker()
session = DictationDuckSession(backend, enabled=lambda: True, schedule=clock)
session.start(0.2)
clock.timers[0].fire()
assert backend.ducks == 1
session.cancel()
session.restore()
clock.timers[0].fire()
assert backend.ducks == 1
assert backend.restores == 1
def test_disabled_session_does_not_schedule() -> None:
clock = _Clock()
session = DictationDuckSession(_FakeDucker(), enabled=lambda: False, schedule=clock)
session.start(0.4)
assert clock.timers == []
def test_daemon_timer_is_a_daemon_and_can_be_cancelled(monkeypatch: pytest.MonkeyPatch) -> None:
created = {}
class FakeTimer:
def __init__(self, delay: float, callback: Callable[[], None]) -> None:
created["delay"] = delay
created["callback"] = callback
self.daemon = False
def start(self) -> None:
created["started"] = True
def cancel(self) -> None:
created["cancelled"] = True
monkeypatch.setattr(duck.threading, "Timer", FakeTimer)
timer = duck._daemon_timer(0.2, lambda: None)
assert created["delay"] == pytest.approx(0.2)
assert created["started"] is True
assert timer.daemon is True
timer.cancel()
assert created["cancelled"] is True
def test_wpctl_parse_and_commands_do_not_set_on_parse_failure() -> None:
assert duck.parse_wpctl_volume("Volume: 0.40\n") == pytest.approx(0.40)
assert duck.parse_wpctl_volume("Volume: 0.40 [MUTED]\n") == pytest.approx(0.40)
assert duck.parse_wpctl_volume("Volume: 1.25\n") == pytest.approx(1.25)
assert duck.parse_wpctl_volume("not a volume") is None
assert duck.parse_wpctl_sink_id("id 48, type PipeWire:Interface:Node\n") == "48"
pactl = (
"Volume: front-left: 32768 / 50% / -18.06 dB, "
"front-right: 32768 / 40% / -23.88 dB\n"
" balance 0.00\n"
"Base Volume: 65536 / 100% / 0.00 dB\n"
)
assert duck.parse_pactl_volume(pactl) == pytest.approx(0.50)
def test_wpctl_is_preferred_and_parse_failure_does_not_set(tmp_path: Path) -> None:
calls = []
def runner(args: list[str]) -> tuple[int, str, str]:
calls.append(list(args))
if args[:3] == ["wpctl", "inspect", "@DEFAULT_AUDIO_SINK@"]:
return 0, "id 7, type PipeWire:Interface:Node\n", ""
if args[:3] == ["wpctl", "get-volume", "7"]:
return 0, "nope\n", ""
if args[1] == "set-volume":
raise AssertionError(f"set-volume must not run after a parse failure: {args}")
return 1, "", "unexpected"
control = SystemSinkVolume(
runner=runner,
which=lambda name: "/usr/bin/wpctl" if name == "wpctl" else "/usr/bin/pactl",
)
PlaybackDucker(control, str(tmp_path), enabled=lambda: True, percent=lambda: 20).duck()
assert calls[0][0] == "wpctl"
assert not any(args[1] == "set-volume" for args in calls)
assert not (tmp_path / duck.PENDING_RECORD_NAME).exists()
def test_wpctl_sets_the_resolved_sink_and_a_missing_sink_is_not_replaced(tmp_path: Path) -> None:
calls = []
def runner(args: list[str]) -> tuple[int, str, str]:
calls.append(list(args))
if args[:3] == ["wpctl", "inspect", "@DEFAULT_AUDIO_SINK@"]:
return 0, "id 7, type PipeWire:Interface:Node\n", ""
if args[:3] == ["wpctl", "get-volume", "7"]:
return 0, "Volume: 0.50\n", ""
if args[:3] == ["wpctl", "set-volume", "7"]:
return 0, "", ""
return 1, "", "unexpected"
control = SystemSinkVolume(runner=runner, which=lambda name: "/usr/bin/" + name)
PlaybackDucker(control, str(tmp_path), enabled=lambda: True, percent=lambda: 20).duck()
assert ["wpctl", "set-volume", "7", "0.100000"] in calls
def missing(args: list[str]) -> tuple[int, str, str]:
if args[:3] == ["wpctl", "inspect", "7"]:
return 3, "", "Object '7' not found\n"
if args[1] == "set-volume":
raise AssertionError(args)
return 1, "", "unexpected"
SystemSink = SystemSinkVolume(runner=missing, which=lambda name: "/usr/bin/" + name)
PlaybackDucker(SystemSink, str(tmp_path), enabled=lambda: True, percent=lambda: 20)
assert not (tmp_path / duck.PENDING_RECORD_NAME).exists()
def test_pactl_is_used_when_wpctl_is_absent(tmp_path: Path) -> None:
calls = []
state = {"volume": 0.50}
sink_name = "alsa_output.pci-0000_00_1f.3.analog-stereo"
def runner(args: list[str]) -> tuple[int, str, str]:
calls.append(list(args))
if args[:2] == ["pactl", "get-default-sink"]:
return 0, sink_name + "\n", ""
if args[:2] == ["pactl", "get-sink-volume"]:
percent = state["volume"] * 100.0
text = (
f"Volume: front-left: 1 / {percent:.0f}% / 0 dB, "
f"front-right: 1 / {percent:.0f}% / 0 dB\n"
"Base Volume: 65536 / 100% / 0.00 dB\n"
)
return 0, text, ""
if args[:2] == ["pactl", "set-sink-volume"]:
state["volume"] = float(args[3].rstrip("%")) / 100.0
return 0, "", ""
if args[:3] == ["pactl", "list", "short"]:
return 0, f"7\t{sink_name}\tPipeWire\n", ""
return 1, "", "unexpected"
control = SystemSinkVolume(
runner=runner,
which=lambda name: None if name == "wpctl" else "/usr/bin/pactl",
)
ducker = PlaybackDucker(
control, str(tmp_path), enabled=lambda: True, percent=lambda: 20, recover=False
)
ducker.duck()
assert ["pactl", "set-sink-volume", sink_name, "10.0000%", "10.0000%"] in calls
ducker.restore()
assert ["pactl", "set-sink-volume", sink_name, "50.0000%", "50.0000%"] in calls
assert state["volume"] == pytest.approx(0.50)
def test_pactl_restores_each_channel(tmp_path: Path) -> None:
calls = []
channels = [0.50, 0.40]
def runner(args: list[str]) -> tuple[int, str, str]:
calls.append(list(args))
if args[:2] == ["pactl", "get-default-sink"]:
return 0, "analog-stereo\n", ""
if args[:2] == ["pactl", "get-sink-volume"]:
text = (
"Volume: front-left: 1 / "
f"{channels[0] * 100:.0f}% / 0 dB, front-right: 1 / "
f"{channels[1] * 100:.0f}% / 0 dB\n"
"Base Volume: 65536 / 100% / 0.00 dB\n"
)
return 0, text, ""
if args[:2] == ["pactl", "set-sink-volume"]:
channels[0] = float(args[3].rstrip("%")) / 100.0
channels[1] = float(args[4].rstrip("%")) / 100.0
return 0, "", ""
if args[:3] == ["pactl", "list", "short"]:
return 0, "7\tanalog-stereo\tPipeWire\n", ""
return 1, "", "unexpected"
control = SystemSinkVolume(
runner=runner,
which=lambda name: None if name == "wpctl" else "/usr/bin/pactl",
)
ducker = PlaybackDucker(
control, str(tmp_path), enabled=lambda: True, percent=lambda: 20, recover=False
)
ducker.duck()
assert ["pactl", "set-sink-volume", "analog-stereo", "10.0000%", "8.0000%"] in calls
ducker.restore()
assert ["pactl", "set-sink-volume", "analog-stereo", "50.0000%", "40.0000%"] in calls
assert channels == pytest.approx([0.50, 0.40])
def test_playback_duck_percent_clamps_and_round_trips(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_dir = tmp_path / "vocalinux"
config_dir.mkdir()
config_file = config_dir / "config.json"
monkeypatch.setattr("vocalinux.ui.config_manager.CONFIG_DIR", str(config_dir))
monkeypatch.setattr("vocalinux.ui.config_manager.CONFIG_FILE", str(config_file))
monkeypatch.setattr(
"vocalinux.utils.system_language.detect_system_language", lambda *_a, **_k: None
)
assert DEFAULT_CONFIG["playback_duck"] == {"enabled": False, "percent": 20}
assert DEFAULT_PLAYBACK_DUCK_PERCENT == 20
assert clamp_playback_duck_percent(True) == 20
assert clamp_playback_duck_percent(None) == 20
assert clamp_playback_duck_percent("nope") == 20
assert clamp_playback_duck_percent(-4) == 0
assert clamp_playback_duck_percent(150) == 100
assert clamp_playback_duck_percent(20.4) == 20
fresh = ConfigManager()
assert fresh.is_playback_duck_enabled() is False
assert fresh.get_playback_duck_percent() == 20
config_file.write_text(
json.dumps({"playback_duck": {"enabled": True, "percent": 250}}),
encoding="utf-8",
)
loaded = ConfigManager()
assert loaded.is_playback_duck_enabled() is True
assert loaded.get_playback_duck_percent() == 100
loaded.set_playback_duck_percent(-10)
assert loaded.config["playback_duck"]["percent"] == 0
loaded.set_playback_duck_percent(101)
assert loaded.config["playback_duck"]["percent"] == 100
loaded.set_playback_duck_enabled(True)
loaded.set_playback_duck_percent(35)
loaded.save_config()
again = ConfigManager()
assert again.is_playback_duck_enabled() is True
assert again.get_playback_duck_percent() == 35
assert again.config["playback_duck"]["percent"] == 35
def test_recognition_hook_schedules_after_start_and_restores_before_stop_cue(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Dictation that never starts does not duck; stop restores before the cue."""
from vocalinux.common_types import RecognitionState
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
timeline = []
session = _RecordingSession(enabled=True)
session.start = lambda delay: timeline.append(("start", delay))
session.cancel = lambda: timeline.append("cancel")
session.restore = lambda: timeline.append("restore")
feedback = _real_audio_feedback(monkeypatch)
monkeypatch.setattr(feedback, "_is_sound_effects_enabled", lambda: True)
monkeypatch.setattr(feedback, "_resolved_tone", lambda: "voca")
monkeypatch.setattr(feedback, "tone_sound_path", lambda _tone, _kind: "/cue.wav")
monkeypatch.setattr(feedback, "_wav_duration_seconds", lambda _path: 0.4)
model_dir = tmp_path / "vosk-model"
model_dir.mkdir()
with patch.dict(sys.modules, {"vosk": MagicMock()}):
with patch.object(
SpeechRecognitionManager, "_get_vosk_model_path", return_value=str(model_dir)
):
manager = SpeechRecognitionManager(engine="vosk", playback_duck=session)
assert manager._playback_duck_delay_seconds() == pytest.approx(0.45)
monkeypatch.setattr(feedback, "_resolved_tone", lambda: "off")
assert manager._playback_duck_delay_seconds() == 0.0
monkeypatch.setattr(feedback, "_is_sound_effects_enabled", lambda: False)
assert manager._playback_duck_delay_seconds() == 0.0
monkeypatch.setattr(feedback, "_is_sound_effects_enabled", lambda: True)
monkeypatch.setattr(feedback, "_resolved_tone", lambda: "voca")
monkeypatch.setattr(feedback, "_wav_duration_seconds", lambda _path: 2.0)
assert manager._playback_duck_delay_seconds() == 1.5
monkeypatch.setattr(feedback, "_wav_duration_seconds", lambda _path: 0.4)
manager.stop_recognition()
assert timeline == []
manager.state = RecognitionState.LISTENING
assert manager.start_recognition() is False
assert timeline == []
manager._model_initialized = False
manager.model = None
manager.state = RecognitionState.IDLE
assert manager.start_recognition() is False
assert timeline == []
manager._model_initialized = True
manager.model = object()
with (
patch(
"vocalinux.speech_recognition.recognition_manager.play_start_sound",
side_effect=lambda: timeline.append("start_sound"),
),
patch(
"vocalinux.speech_recognition.recognition_manager.play_stop_sound",
side_effect=lambda: timeline.append("stop_sound"),
),
patch("vocalinux.speech_recognition.recognition_manager.threading.Thread") as thread_cls,
):
thread_cls.return_value = MagicMock()
thread_cls.return_value.is_alive.return_value = False
assert manager.start_recognition() is True
assert manager._worker_exited is not None
manager._worker_exited.set()
manager.stop_recognition()
start_at = timeline.index("start_sound")
scheduled = next(item for item in timeline if isinstance(item, tuple))
assert scheduled[0] == "start"
assert scheduled[1] == pytest.approx(0.45)
assert start_at < timeline.index(scheduled)
assert timeline.index("restore") < timeline.index("stop_sound")
assert "cancel" in timeline[: timeline.index("restore")]
session._enabled = False
timeline.clear()
with patch("vocalinux.speech_recognition.recognition_manager.threading.Thread") as thread_cls:
thread_cls.return_value = MagicMock()
assert manager.start_recognition() is True
assert not any(isinstance(item, tuple) for item in timeline)
def test_arm_after_capture_failure_does_not_schedule(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A capture thread that already failed must not be followed by a duck."""
from vocalinux.common_types import RecognitionState
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
session = _RecordingSession(enabled=True)
feedback = _real_audio_feedback(monkeypatch)
monkeypatch.setattr(feedback, "_is_sound_effects_enabled", lambda: False)
monkeypatch.setattr(feedback, "_resolved_tone", lambda: "voca")
monkeypatch.setattr(feedback, "tone_sound_path", lambda _tone, _kind: "/cue.wav")
monkeypatch.setattr(feedback, "_wav_duration_seconds", lambda _path: 0.4)
model_dir = tmp_path / "vosk-model"
model_dir.mkdir()
with patch.dict(sys.modules, {"vosk": MagicMock()}):
with patch.object(
SpeechRecognitionManager, "_get_vosk_model_path", return_value=str(model_dir)
):
manager = SpeechRecognitionManager(engine="vosk", playback_duck=session)
# The capture thread failed before start_recognition reached the arm: it
# released the session and ended the dictation. The late arm must not
# schedule a duck that nothing will restore.
manager.should_record = False
manager.release_playback_duck()
manager._arm_playback_duck()
assert session.events == ["cancel", "restore"]
manager.state = RecognitionState.ERROR
manager.should_record = True
session.events.clear()
manager._arm_playback_duck()
assert session.events == []
manager.state = RecognitionState.LISTENING
manager._arm_playback_duck()
assert session.events == [("start", 0.0)]