* feat(audio): lower other audio while dictating Opt-in setting lowers the default PipeWire or PulseAudio sink while the microphone is open, then restores that exact level. It stays off by default. * fix(test): keep pytest.approx working when numpy is mocked Other test modules replace sys.modules["numpy"] with a MagicMock at import and leave it there. pytest.approx then crashes on isinstance(..., numpy.bool_). * fix(audio): restore every channel and do not duck if it cannot be undone A failed restore-point write no longer lowers the sink. PulseAudio puts each channel back, and a dead microphone restores playback instead of waiting for an explicit stop. The recognition test patches the real audio module when an older test left a mock behind. * fix(test): put the audio-feedback mock back after the ducking test The recognition test has to patch the real module, but later tests still expect the mock that test_text_injector installs. Restore that mock when the test ends, and annotate the new helpers. * fix(test): annotate the playback-duck tests AGENTS.md requires a type hint on every new function. These tests were the ones still missing them. * fix(audio): skip arming playback duck after recording already failed * chore: retrigger review after rebase
748 lines
26 KiB
Python
748 lines
26 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_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()
|
|
assert manager.start_recognition() is True
|
|
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)]
|