System-audio capture sets stream.capture.sink on the spawned pw-record, so PipeWire links only the sink monitor. An explicit microphone that is missing, renamed, virtual or output-only now fails with an Audio Input Unavailable notification instead of recording from the default input. Fixes #41 Fixes #42
733 lines
28 KiB
Python
733 lines
28 KiB
Python
"""
|
|
Tests for PipeWire system-audio capture (audio/pipewire.py) and its
|
|
integration into recognition_manager (issue #751).
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
import unittest
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from vocalinux.audio.errors import AudioDeviceUnavailableError
|
|
from vocalinux.audio.pipewire import (
|
|
PIPEWIRE_INDEX_BASE,
|
|
SYSTEM_AUDIO_PREFIX,
|
|
PipeWireCaptureSource,
|
|
PipeWireSource,
|
|
_parse_pw_dump,
|
|
_test_pipewire_input,
|
|
get_system_audio_sources,
|
|
is_pipewire_device,
|
|
is_pipewire_device_index,
|
|
pipewire_available,
|
|
resolve_pipewire_source,
|
|
)
|
|
from vocalinux.common_types import RecognitionState
|
|
from vocalinux.speech_recognition.recognition_manager import (
|
|
SpeechRecognitionManager,
|
|
get_audio_input_devices,
|
|
)
|
|
from vocalinux.speech_recognition.recognition_manager import (
|
|
test_audio_input as _run_test_audio_input,
|
|
)
|
|
|
|
|
|
def _node(
|
|
object_id: int, serial: int, media_class: str, node_name: str, description: str
|
|
) -> Dict[str, Any]:
|
|
return {
|
|
"id": object_id,
|
|
"type": "PipeWire:Interface:Node",
|
|
"info": {
|
|
"props": {
|
|
"media.class": media_class,
|
|
"node.name": node_name,
|
|
"node.description": description,
|
|
"object.serial": serial,
|
|
}
|
|
},
|
|
}
|
|
|
|
|
|
PW_DUMP = [
|
|
_node(30, 30, "Audio/Source", "alsa_input.mic", "USB Microphone"),
|
|
_node(41, 41, "Audio/Sink", "alsa_output.dac", "Built-in Audio Analog Stereo"),
|
|
_node(55, 55, "Audio/Sink/Virtual", "obs_sink", "OBS Output"),
|
|
_node(60, 60, "Stream/Output/Audio", "Firefox", "Firefox"),
|
|
{
|
|
"id": 10,
|
|
"type": "PipeWire:Interface:Metadata",
|
|
"info": {
|
|
"metadata": [
|
|
{
|
|
"subject": 0,
|
|
"key": "default.audio.sink",
|
|
"value": {"name": "alsa_output.dac"},
|
|
},
|
|
{
|
|
"subject": 0,
|
|
"key": "default.audio.source",
|
|
"value": {"name": "alsa_input.mic"},
|
|
},
|
|
]
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def _source(
|
|
node_name: str = "alsa_output.dac",
|
|
description: str = "Built-in Audio Analog Stereo",
|
|
**kw: Any,
|
|
) -> PipeWireSource:
|
|
return PipeWireSource(
|
|
node_name=node_name,
|
|
description=description,
|
|
serial=kw.pop("serial", 41),
|
|
**kw,
|
|
)
|
|
|
|
|
|
class _FakeProcess:
|
|
"""Minimal Popen stand-in for PipeWireCaptureSource tests."""
|
|
|
|
def __init__(self, payload: bytes = b"", exit_code: Optional[int] = None) -> None:
|
|
self.stdout = io.BytesIO(payload)
|
|
self._exit_code = exit_code
|
|
self.terminated = False
|
|
self.killed = False
|
|
self.waited = False
|
|
|
|
def poll(self) -> Optional[int]:
|
|
return self._exit_code
|
|
|
|
def terminate(self) -> None:
|
|
self.terminated = True
|
|
self._exit_code = 0
|
|
|
|
def kill(self) -> None:
|
|
self.killed = True
|
|
self._exit_code = -9
|
|
|
|
def wait(self, timeout: Optional[float] = None) -> int:
|
|
self.waited = True
|
|
return self._exit_code if self._exit_code is not None else 0
|
|
|
|
|
|
def _popen_factory(
|
|
process: _FakeProcess, calls: List[Tuple[List[str], Dict[str, Any]]]
|
|
) -> Callable[..., _FakeProcess]:
|
|
def _fake_popen(args: List[str], **kwargs: Any) -> _FakeProcess:
|
|
calls.append((args, kwargs))
|
|
return process
|
|
|
|
return _fake_popen
|
|
|
|
|
|
def _make_manager(**kw: Any) -> SpeechRecognitionManager:
|
|
"""Create a manager with engine init patched out."""
|
|
with patch.object(SpeechRecognitionManager, "_init_vosk"):
|
|
with patch.object(SpeechRecognitionManager, "_init_whisper"):
|
|
with patch.object(SpeechRecognitionManager, "_init_whispercpp"):
|
|
return SpeechRecognitionManager(
|
|
engine="whisper_cpp",
|
|
model_size="small",
|
|
language="en-us",
|
|
defer_download=True,
|
|
**kw,
|
|
)
|
|
|
|
|
|
class TestParsePwDump(unittest.TestCase):
|
|
def test_sinks_only_sorted_by_serial(self) -> None:
|
|
sources = _parse_pw_dump(PW_DUMP)
|
|
names = [s.node_name for s in sources]
|
|
# Mic sources and app streams are excluded; order follows serials.
|
|
assert names == ["alsa_output.dac", "obs_sink"]
|
|
|
|
def test_default_sink_flagged(self) -> None:
|
|
sources = _parse_pw_dump(PW_DUMP)
|
|
by_name = {s.node_name: s for s in sources}
|
|
assert by_name["alsa_output.dac"].is_default is True
|
|
assert by_name["obs_sink"].is_default is False
|
|
|
|
def test_display_name_uses_prefix(self) -> None:
|
|
sources = _parse_pw_dump(PW_DUMP)
|
|
assert sources[0].display_name == f"{SYSTEM_AUDIO_PREFIX}Built-in Audio Analog Stereo"
|
|
|
|
def test_duplicate_descriptions_get_disambiguated(self) -> None:
|
|
objects = [
|
|
_node(1, 1, "Audio/Sink", "sink_a", "Speakers"),
|
|
_node(2, 2, "Audio/Sink", "sink_b", "Speakers"),
|
|
]
|
|
sources = _parse_pw_dump(objects)
|
|
labels = sorted(s.label for s in sources)
|
|
assert labels == ["Speakers (sink_a)", "Speakers (sink_b)"]
|
|
|
|
def test_missing_description_falls_back_to_node_name(self) -> None:
|
|
objects = [
|
|
{
|
|
"id": 1,
|
|
"type": "PipeWire:Interface:Node",
|
|
"info": {
|
|
"props": {
|
|
"media.class": "Audio/Sink",
|
|
"node.name": "sink_x",
|
|
"object.serial": 1,
|
|
}
|
|
},
|
|
}
|
|
]
|
|
sources = _parse_pw_dump(objects)
|
|
assert sources[0].display_name == f"{SYSTEM_AUDIO_PREFIX}sink_x"
|
|
|
|
def test_non_list_and_malformed_entries(self) -> None:
|
|
assert _parse_pw_dump("not a list") == []
|
|
assert _parse_pw_dump([None, "x", {"type": "PipeWire:Interface:Node"}]) == []
|
|
|
|
|
|
class TestGetSystemAudioSources(unittest.TestCase):
|
|
def test_missing_pw_dump_returns_empty(self) -> None:
|
|
assert get_system_audio_sources(which=lambda name: None) == []
|
|
|
|
def test_missing_pw_record_returns_empty(self) -> None:
|
|
# pw-dump alone cannot capture — without pw-record the picker must
|
|
# not offer sources that always fail to open.
|
|
def _which(name: str) -> Optional[str]:
|
|
return "/usr/bin/pw-dump" if name == "pw-dump" else None
|
|
|
|
assert get_system_audio_sources(which=_which) == []
|
|
|
|
def test_nonzero_exit_returns_empty(self) -> None:
|
|
sources = get_system_audio_sources(
|
|
runner=lambda args: (1, "", "boom"), which=lambda name: "/usr/bin/pw-dump"
|
|
)
|
|
assert sources == []
|
|
|
|
def test_invalid_json_returns_empty(self) -> None:
|
|
sources = get_system_audio_sources(
|
|
runner=lambda args: (0, "{nope", ""), which=lambda name: "/usr/bin/pw-dump"
|
|
)
|
|
assert sources == []
|
|
|
|
def test_runner_exception_returns_empty(self) -> None:
|
|
def _boom(args: List[str]) -> Tuple[int, str, str]:
|
|
raise OSError("spawn failed")
|
|
|
|
sources = get_system_audio_sources(runner=_boom, which=lambda name: "/usr/bin/pw-dump")
|
|
assert sources == []
|
|
|
|
def test_happy_path_parses_dump(self) -> None:
|
|
sources = get_system_audio_sources(
|
|
runner=lambda args: (0, json.dumps(PW_DUMP), ""),
|
|
which=lambda name: "/usr/bin/pw-dump",
|
|
)
|
|
assert [s.node_name for s in sources] == ["alsa_output.dac", "obs_sink"]
|
|
|
|
|
|
class TestDevicePredicates(unittest.TestCase):
|
|
def test_is_pipewire_device_index(self) -> None:
|
|
assert is_pipewire_device_index(PIPEWIRE_INDEX_BASE)
|
|
assert is_pipewire_device_index(-3)
|
|
assert not is_pipewire_device_index(-1) # -1 is PortAudio "system default"
|
|
assert not is_pipewire_device_index(0)
|
|
assert not is_pipewire_device_index(None)
|
|
assert not is_pipewire_device_index(True)
|
|
|
|
def test_is_pipewire_device(self) -> None:
|
|
assert is_pipewire_device(-2, None)
|
|
assert is_pipewire_device(None, f"{SYSTEM_AUDIO_PREFIX}Speakers")
|
|
assert is_pipewire_device(3, f"{SYSTEM_AUDIO_PREFIX}Speakers")
|
|
assert not is_pipewire_device(3, "USB Microphone")
|
|
assert not is_pipewire_device(None, None)
|
|
|
|
|
|
class TestResolvePipewireSource(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.sources = [
|
|
_source("alsa_output.dac", "DAC", serial=41),
|
|
_source("obs_sink", "OBS Output", serial=55),
|
|
]
|
|
|
|
def test_resolves_by_display_name(self) -> None:
|
|
name = self.sources[1].display_name
|
|
assert resolve_pipewire_source(None, name, self.sources) is self.sources[1]
|
|
|
|
def test_resolves_by_index_position(self) -> None:
|
|
assert resolve_pipewire_source(PIPEWIRE_INDEX_BASE, None, self.sources) is self.sources[0]
|
|
assert (
|
|
resolve_pipewire_source(PIPEWIRE_INDEX_BASE - 1, None, self.sources) is self.sources[1]
|
|
)
|
|
|
|
def test_name_wins_over_index(self) -> None:
|
|
name = self.sources[1].display_name
|
|
# Stale index pointing at position 0, name pointing at OBS.
|
|
assert resolve_pipewire_source(PIPEWIRE_INDEX_BASE, name, self.sources) is self.sources[1]
|
|
|
|
def test_out_of_range_and_missing(self) -> None:
|
|
assert resolve_pipewire_source(PIPEWIRE_INDEX_BASE - 9, None, self.sources) is None
|
|
assert resolve_pipewire_source(PIPEWIRE_INDEX_BASE, "System audio: Gone", []) is None
|
|
assert resolve_pipewire_source(None, None, self.sources) is None
|
|
|
|
def test_unresolved_name_never_falls_back_to_index(self) -> None:
|
|
# The named sink is gone; another sink now sits at the stored index
|
|
# position. Index fallback would silently capture the wrong device.
|
|
assert (
|
|
resolve_pipewire_source(PIPEWIRE_INDEX_BASE, "System audio: Gone", self.sources) is None
|
|
)
|
|
|
|
def test_duplicate_description_disambiguated_by_position(self) -> None:
|
|
# A second sink with the same description was added after the name
|
|
# was stored; its higher serial sorts it last, so the stored index
|
|
# position still points at the originally selected sink.
|
|
dup_a = _source("sink_a", "Speakers", serial=10, label="Speakers (sink_a)")
|
|
dup_b = _source("sink_b", "Speakers", serial=20, label="Speakers (sink_b)")
|
|
sources = [dup_a, dup_b]
|
|
assert (
|
|
resolve_pipewire_source(PIPEWIRE_INDEX_BASE, "System audio: Speakers", sources) is dup_a
|
|
)
|
|
assert (
|
|
resolve_pipewire_source(PIPEWIRE_INDEX_BASE - 1, "System audio: Speakers", sources)
|
|
is dup_b
|
|
)
|
|
|
|
def test_ambiguous_description_without_position_returns_none(self) -> None:
|
|
# Labels as _parse_pw_dump emits them once descriptions collide.
|
|
dup_a = _source("sink_a", "Speakers", serial=10, label="Speakers (sink_a)")
|
|
dup_b = _source("sink_b", "Speakers", serial=20, label="Speakers (sink_b)")
|
|
sources = [dup_a, dup_b]
|
|
assert resolve_pipewire_source(None, "System audio: Speakers", sources) is None
|
|
# Index pointing at a non-candidate must not pick one anyway.
|
|
assert (
|
|
resolve_pipewire_source(PIPEWIRE_INDEX_BASE - 5, "System audio: Speakers", sources)
|
|
is None
|
|
)
|
|
|
|
def test_node_name_suffix_survives_description_change(self) -> None:
|
|
renamed = _source("alsa_output.dac", "New DAC", serial=41, label="New DAC")
|
|
assert (
|
|
resolve_pipewire_source(None, "System audio: DAC (alsa_output.dac)", [renamed])
|
|
is renamed
|
|
)
|
|
|
|
def test_description_parens_not_parsed_as_node_name(self) -> None:
|
|
intended = _source("alsa_output.a", "Speakers (HDMI)", serial=10)
|
|
other = _source("HDMI", "Different sink", serial=20)
|
|
sources = [intended, other]
|
|
assert resolve_pipewire_source(None, "System audio: Speakers (HDMI)", sources) is intended
|
|
|
|
|
|
class TestPipewireAvailable(unittest.TestCase):
|
|
def test_requires_both_tools(self) -> None:
|
|
tools = {"pw-dump": "/usr/bin/pw-dump"}
|
|
assert not pipewire_available(which=tools.get)
|
|
tools["pw-record"] = "/usr/bin/pw-record"
|
|
assert pipewire_available(which=tools.get)
|
|
|
|
|
|
class TestPipeWireCaptureSource(unittest.TestCase):
|
|
def test_open_builds_pw_record_command(self) -> None:
|
|
process = _FakeProcess()
|
|
calls = []
|
|
with patch("vocalinux.audio.pipewire.resolve_pipewire_source", return_value=_source()):
|
|
capture = PipeWireCaptureSource(
|
|
device_index=PIPEWIRE_INDEX_BASE,
|
|
popen=_popen_factory(process, calls),
|
|
which=lambda n: "/usr/bin/pw-record",
|
|
)
|
|
capture.open()
|
|
args, kwargs = calls[0]
|
|
assert args[:2] == ["pw-record", "--raw"]
|
|
assert "--target" in args
|
|
assert args[args.index("--target") + 1] == "alsa_output.dac"
|
|
assert args[-1] == "-" # raw PCM on stdout
|
|
assert kwargs["stdout"] is not None
|
|
assert "env" in kwargs # host binaries get the de-bundled environment
|
|
assert capture.stream is process
|
|
|
|
def test_open_pins_capture_to_the_sink_monitor(self) -> None:
|
|
# Without stream.capture.sink, pw-record --target <sink> also links the
|
|
# default microphone (issue #41). Set it through PIPEWIRE_PROPS, the
|
|
# form verified on the real machine.
|
|
calls: List[Tuple[List[str], Dict[str, Any]]] = []
|
|
with (
|
|
patch("vocalinux.audio.pipewire.resolve_pipewire_source", return_value=_source()),
|
|
patch.dict("os.environ", {"PATH": "/usr/bin"}, clear=True),
|
|
):
|
|
capture = PipeWireCaptureSource(
|
|
device_index=PIPEWIRE_INDEX_BASE,
|
|
popen=_popen_factory(_FakeProcess(), calls),
|
|
which=lambda n: "/usr/bin/pw-record",
|
|
)
|
|
capture.open()
|
|
args, kwargs = calls[0]
|
|
assert args[args.index("--target") + 1] == "alsa_output.dac"
|
|
assert args[-1] == "-"
|
|
assert "stream.capture.sink = true" in kwargs["env"]["PIPEWIRE_PROPS"]
|
|
assert kwargs["env"]["PATH"] == "/usr/bin"
|
|
|
|
def test_open_keeps_user_pipewire_props_when_pinning_the_monitor(self) -> None:
|
|
calls: List[Tuple[List[str], Dict[str, Any]]] = []
|
|
user_props = "{ node.latency = 1024/48000 }"
|
|
with (
|
|
patch("vocalinux.audio.pipewire.resolve_pipewire_source", return_value=_source()),
|
|
patch.dict("os.environ", {"PIPEWIRE_PROPS": user_props}, clear=True),
|
|
):
|
|
capture = PipeWireCaptureSource(
|
|
device_index=PIPEWIRE_INDEX_BASE,
|
|
popen=_popen_factory(_FakeProcess(), calls),
|
|
which=lambda n: "/usr/bin/pw-record",
|
|
)
|
|
capture.open()
|
|
props = calls[0][1]["env"]["PIPEWIRE_PROPS"]
|
|
assert props == "{ node.latency = 1024/48000 stream.capture.sink = true }"
|
|
|
|
def test_open_requires_pw_record(self) -> None:
|
|
capture = PipeWireCaptureSource(device_index=PIPEWIRE_INDEX_BASE, which=lambda name: None)
|
|
with self.assertRaises(FileNotFoundError):
|
|
capture.open()
|
|
|
|
def test_open_raises_when_source_gone(self) -> None:
|
|
with patch("vocalinux.audio.pipewire.resolve_pipewire_source", return_value=None):
|
|
capture = PipeWireCaptureSource(device_index=PIPEWIRE_INDEX_BASE, which=lambda n: "x")
|
|
with self.assertRaises(IOError):
|
|
capture.open()
|
|
|
|
def test_open_reports_a_gone_source_as_unavailable(self) -> None:
|
|
# No PortAudio fallback: the typed error lets the manager tell the user.
|
|
with patch("vocalinux.audio.pipewire.resolve_pipewire_source", return_value=None):
|
|
capture = PipeWireCaptureSource(device_index=PIPEWIRE_INDEX_BASE, which=lambda n: "x")
|
|
with self.assertRaises(AudioDeviceUnavailableError) as ctx:
|
|
capture.open()
|
|
assert "no longer available" in str(ctx.exception)
|
|
|
|
def test_read_chunk_returns_exact_frame_bytes(self) -> None:
|
|
payload = b"\x01\x02" * 4096
|
|
process = _FakeProcess(payload)
|
|
with patch("vocalinux.audio.pipewire.resolve_pipewire_source", return_value=_source()):
|
|
capture = PipeWireCaptureSource(
|
|
device_index=PIPEWIRE_INDEX_BASE,
|
|
popen=_popen_factory(process, []),
|
|
which=lambda n: "x",
|
|
)
|
|
capture.open()
|
|
data = capture.read_chunk()
|
|
assert data == payload[:2048]
|
|
assert capture.read_chunk() == payload[2048:4096]
|
|
|
|
def test_read_chunk_raises_ioerror_at_eof(self) -> None:
|
|
process = _FakeProcess(b"\x01\x02" * 512, exit_code=0)
|
|
with patch("vocalinux.audio.pipewire.resolve_pipewire_source", return_value=_source()):
|
|
capture = PipeWireCaptureSource(
|
|
device_index=PIPEWIRE_INDEX_BASE,
|
|
popen=_popen_factory(process, []),
|
|
which=lambda n: "x",
|
|
)
|
|
capture.open()
|
|
with self.assertRaises(IOError):
|
|
capture.read_chunk()
|
|
|
|
def test_read_chunk_raises_when_not_open(self) -> None:
|
|
capture = PipeWireCaptureSource(device_index=PIPEWIRE_INDEX_BASE, which=lambda n: "x")
|
|
with self.assertRaises(IOError):
|
|
capture.read_chunk()
|
|
|
|
def test_read_chunk_raises_when_process_died(self) -> None:
|
|
process = _FakeProcess(exit_code=1)
|
|
process.stdout = io.BytesIO(b"") # dead process yields EOF
|
|
with patch("vocalinux.audio.pipewire.resolve_pipewire_source", return_value=_source()):
|
|
capture = PipeWireCaptureSource(
|
|
device_index=PIPEWIRE_INDEX_BASE,
|
|
popen=_popen_factory(process, []),
|
|
which=lambda n: "x",
|
|
)
|
|
capture.open()
|
|
with self.assertRaises(IOError) as ctx:
|
|
capture.read_chunk()
|
|
assert "exit status" in str(ctx.exception)
|
|
|
|
def test_read_chunk_raises_when_stream_stalls(self) -> None:
|
|
# A wedged pw-record that stays alive but emits nothing must not
|
|
# block the read forever — stop_recognition needs the lock the
|
|
# capture thread is holding.
|
|
read_fd, write_fd = os.pipe()
|
|
process = _FakeProcess()
|
|
process.stdout = os.fdopen(read_fd, "rb")
|
|
try:
|
|
with patch("vocalinux.audio.pipewire.resolve_pipewire_source", return_value=_source()):
|
|
capture = PipeWireCaptureSource(
|
|
device_index=PIPEWIRE_INDEX_BASE,
|
|
popen=_popen_factory(process, []),
|
|
which=lambda n: "x",
|
|
)
|
|
capture._STALL_TIMEOUT_SECONDS = 0.05
|
|
capture.open()
|
|
with self.assertRaises(IOError) as ctx:
|
|
capture.read_chunk()
|
|
assert "stalled" in str(ctx.exception)
|
|
finally:
|
|
os.close(write_fd)
|
|
process.stdout.close()
|
|
|
|
def test_close_terminates_process(self) -> None:
|
|
process = _FakeProcess()
|
|
with patch("vocalinux.audio.pipewire.resolve_pipewire_source", return_value=_source()):
|
|
capture = PipeWireCaptureSource(
|
|
device_index=PIPEWIRE_INDEX_BASE,
|
|
popen=_popen_factory(process, []),
|
|
which=lambda n: "x",
|
|
)
|
|
capture.open()
|
|
capture.close()
|
|
assert process.terminated
|
|
assert process.waited
|
|
assert capture.stream is None
|
|
# close is idempotent
|
|
capture.close()
|
|
|
|
def test_reopen_respawns_process(self) -> None:
|
|
calls = []
|
|
first, second = _FakeProcess(), _FakeProcess()
|
|
processes = iter([first, second])
|
|
|
|
def _factory(args: List[str], **kwargs: Any) -> _FakeProcess:
|
|
calls.append(args)
|
|
return next(processes)
|
|
|
|
with patch("vocalinux.audio.pipewire.resolve_pipewire_source", return_value=_source()):
|
|
capture = PipeWireCaptureSource(
|
|
device_index=PIPEWIRE_INDEX_BASE, popen=_factory, which=lambda n: "x"
|
|
)
|
|
capture.open()
|
|
assert capture.reopen(MagicMock()) is True
|
|
assert len(calls) == 2
|
|
assert first.terminated
|
|
assert capture.stream is second
|
|
|
|
def test_reopen_returns_false_when_source_gone(self) -> None:
|
|
capture = PipeWireCaptureSource(
|
|
device_index=PIPEWIRE_INDEX_BASE,
|
|
popen=_popen_factory(_FakeProcess(), []),
|
|
which=lambda n: "x",
|
|
)
|
|
with patch("vocalinux.audio.pipewire.resolve_pipewire_source", return_value=None):
|
|
assert capture.reopen(MagicMock()) is False
|
|
|
|
|
|
class TestManagerPipeWireIntegration(unittest.TestCase):
|
|
def test_get_audio_input_devices_appends_system_audio(self) -> None:
|
|
sources = [
|
|
_source("alsa_output.dac", "DAC", serial=41, is_default=True),
|
|
_source("obs_sink", "OBS Output", serial=55),
|
|
]
|
|
mock_audio = MagicMock()
|
|
mock_audio.get_device_count.return_value = 1
|
|
mock_audio.get_default_input_device_info.return_value = {"index": 0}
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"index": 0,
|
|
"name": "USB Microphone",
|
|
"maxInputChannels": 1,
|
|
}
|
|
mock_pyaudio = MagicMock(PyAudio=MagicMock(return_value=mock_audio))
|
|
|
|
with (
|
|
patch.dict("sys.modules", {"pyaudio": mock_pyaudio}),
|
|
patch(
|
|
"vocalinux.audio.capture.get_system_audio_sources",
|
|
return_value=sources,
|
|
),
|
|
):
|
|
devices = get_audio_input_devices()
|
|
|
|
assert devices[0] == (0, "USB Microphone", True)
|
|
assert devices[1] == (PIPEWIRE_INDEX_BASE, "System audio: DAC", True)
|
|
assert devices[2] == (PIPEWIRE_INDEX_BASE - 1, "System audio: OBS Output", False)
|
|
|
|
def test_record_audio_uses_pipewire_source_for_system_audio(self) -> None:
|
|
manager = _make_manager(
|
|
audio_device_index=PIPEWIRE_INDEX_BASE,
|
|
audio_device_name="System audio: DAC",
|
|
)
|
|
manager.should_record = True
|
|
manager.state = RecognitionState.LISTENING
|
|
manager.silence_timeout = 0.05
|
|
manager._silero_vad = None
|
|
|
|
fake_source = MagicMock()
|
|
fake_source.channels = 1
|
|
fake_source.sample_rate = 16000
|
|
fake_source.downmix_channel = None
|
|
fake_source.stream = MagicMock()
|
|
fake_source.audio = None
|
|
|
|
def _read_once(**_kwargs: Any) -> bytes:
|
|
manager.should_record = False
|
|
return b"\x00" * 2048
|
|
|
|
fake_source.read_chunk.side_effect = _read_once
|
|
mock_audio = MagicMock()
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
mock_pyaudio.PyAudio.return_value = mock_audio
|
|
|
|
with (
|
|
patch.dict("sys.modules", {"pyaudio": mock_pyaudio}),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager.PipeWireCaptureSource",
|
|
return_value=fake_source,
|
|
) as mock_source_cls,
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager.PortAudioCaptureSource"
|
|
) as mock_portaudio_cls,
|
|
patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"),
|
|
):
|
|
manager._record_audio()
|
|
|
|
mock_source_cls.assert_called_once_with(
|
|
device_index=PIPEWIRE_INDEX_BASE, device_name="System audio: DAC"
|
|
)
|
|
fake_source.open.assert_called_once_with(mock_audio)
|
|
fake_source.read_chunk.assert_called()
|
|
fake_source.close.assert_called_once_with()
|
|
mock_portaudio_cls.assert_not_called()
|
|
mock_audio.open.assert_not_called()
|
|
|
|
def test_reconnect_reopens_pipewire_source(self) -> None:
|
|
manager = _make_manager(
|
|
audio_device_index=PIPEWIRE_INDEX_BASE,
|
|
audio_device_name="System audio: DAC",
|
|
)
|
|
manager._reconnection_delay = 0
|
|
manager._max_reconnection_attempts = 5
|
|
|
|
fake_source = MagicMock()
|
|
fake_source.sample_rate = 16000
|
|
fake_source.channels = 1
|
|
fake_source.downmix_channel = None
|
|
fake_source.stream = MagicMock()
|
|
fake_source.audio = None
|
|
fake_source.reopen.return_value = True
|
|
|
|
with (
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager.PipeWireCaptureSource",
|
|
return_value=fake_source,
|
|
) as mock_source_cls,
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager.PortAudioCaptureSource"
|
|
) as mock_portaudio_cls,
|
|
):
|
|
assert manager._attempt_audio_reconnection(MagicMock()) is True
|
|
|
|
mock_source_cls.assert_called_once_with(
|
|
device_index=PIPEWIRE_INDEX_BASE, device_name="System audio: DAC"
|
|
)
|
|
fake_source.reopen.assert_called_once()
|
|
mock_portaudio_cls.assert_not_called()
|
|
assert manager._audio_stream is fake_source.stream
|
|
assert manager._capture_sample_rate == 16000
|
|
assert manager._capture_channels == 1
|
|
|
|
def test_reconnect_fails_when_pipewire_source_gone(self) -> None:
|
|
manager = _make_manager(
|
|
audio_device_index=PIPEWIRE_INDEX_BASE,
|
|
audio_device_name="System audio: DAC",
|
|
)
|
|
manager._reconnection_delay = 0
|
|
manager._max_reconnection_attempts = 5
|
|
|
|
fake_source = MagicMock()
|
|
fake_source.reopen.return_value = False
|
|
fake_source.sample_rate = 16000
|
|
fake_source.channels = 1
|
|
fake_source.downmix_channel = None
|
|
fake_source.stream = None
|
|
fake_source.audio = None
|
|
|
|
with (
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager.PipeWireCaptureSource",
|
|
return_value=fake_source,
|
|
),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager.PortAudioCaptureSource"
|
|
) as mock_portaudio_cls,
|
|
):
|
|
# A vanished sink must not silently reopen a microphone.
|
|
assert manager._attempt_audio_reconnection(MagicMock()) is False
|
|
mock_portaudio_cls.assert_not_called()
|
|
|
|
def test_audio_input_routes_negative_index_to_pipewire(self) -> None:
|
|
source = _source()
|
|
fake_capture = MagicMock()
|
|
fake_capture.sample_rate = 16000
|
|
fake_capture.channels = 1
|
|
fake_capture._CHUNK_FRAMES = 1024
|
|
fake_capture.source = source
|
|
fake_capture.read_chunk.return_value = b"\xff\x7f" * 1024 # int16 max amplitude
|
|
|
|
with (
|
|
patch(
|
|
"vocalinux.audio.pipewire.resolve_pipewire_source",
|
|
return_value=source,
|
|
),
|
|
patch(
|
|
"vocalinux.audio.pipewire.PipeWireCaptureSource",
|
|
return_value=fake_capture,
|
|
),
|
|
):
|
|
if isinstance(sys.modules.get("numpy"), MagicMock):
|
|
# Restore the stashed real module — re-importing it would
|
|
# leave sys.modules["numpy.*"] bound to an orphan.
|
|
real_numpy = getattr(sys, "_voculinux_real_numpy", None)
|
|
if real_numpy is not None:
|
|
sys.modules["numpy"] = real_numpy
|
|
else:
|
|
del sys.modules["numpy"]
|
|
result = _run_test_audio_input(device_index=PIPEWIRE_INDEX_BASE, duration=0.1)
|
|
|
|
assert result["success"] is True
|
|
assert result["device_name"] == "System audio: Built-in Audio Analog Stereo"
|
|
assert result["has_signal"] is True
|
|
assert result["error"] is None
|
|
|
|
def test_pipewire_input_reports_missing_source(self) -> None:
|
|
with patch(
|
|
"vocalinux.audio.pipewire.resolve_pipewire_source",
|
|
return_value=None,
|
|
):
|
|
result = _test_pipewire_input(PIPEWIRE_INDEX_BASE, duration=0.1)
|
|
assert result["success"] is False
|
|
assert result["error"]
|
|
|
|
def test_playback_duck_skipped_for_system_audio(self) -> None:
|
|
manager = _make_manager(
|
|
audio_device_index=PIPEWIRE_INDEX_BASE,
|
|
audio_device_name="System audio: DAC",
|
|
)
|
|
manager.should_record = True
|
|
manager.state = RecognitionState.LISTENING
|
|
manager._playback_duck = MagicMock()
|
|
manager._playback_duck.enabled.return_value = True
|
|
|
|
manager._arm_playback_duck()
|
|
|
|
manager._playback_duck.start.assert_not_called()
|
|
|
|
def test_playback_duck_still_arms_for_microphone(self) -> None:
|
|
manager = _make_manager(audio_device_index=0, audio_device_name="USB Microphone")
|
|
manager.should_record = True
|
|
manager.state = RecognitionState.LISTENING
|
|
manager._playback_duck = MagicMock()
|
|
manager._playback_duck.enabled.return_value = True
|
|
|
|
manager._arm_playback_duck()
|
|
|
|
manager._playback_duck.start.assert_called_once()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|