* 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>
792 lines
28 KiB
Python
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)]
|