feat(audio): add PipeWire capture path with system-audio sources (#889)
* feat(audio): add PipeWire capture path with system-audio sources Enumerate PipeWire sinks via pw-dump and expose their monitors as "System audio: <name>" capture devices at negative device indices (-2..), alongside the existing PortAudio microphones. Recording, device test, and stream-reconnection paths route to a pw-record-backed stream shaped like a PyAudio stream, so the VAD and silence-detection loop is unchanged. Playback ducking is skipped when the selected source is a monitor, since ducking the default sink would also duck the captured audio. Closes #751 * test(audio): restore stashed numpy instead of re-importing it test_audio_input imports numpy lazily; deleting the MagicMock that earlier test modules leave in sys.modules re-imported a second numpy module object whose lazy submodule attributes (linalg, testing) were unpopulated while sys.modules kept the first object's entries. The next test file to reach for numpy.testing hit a __getattr__ import loop (RecursionError in TestDownmixToMono on CI). Restore the conftest-stashed _vocalinux_real_numpy instead, matching the pattern tests/test_recognition_manager_device_config.py already uses. * fix(pipewire): drop PyAudio requirement for PipeWire sources, rebind source after reconnect * docs(pipewire): restore PipeWireCaptureSource class docstring * fix(audio): initialize PyAudio for mic reconnection after PipeWire session A session started on a PipeWire sink never creates a PyAudio instance; when capture later reconnects onto a PortAudio device the source needs one to reopen, and a failed open dereferenced None calling terminate. * fix(audio): harden PipeWire source resolution, reads, and cleanup - resolve stored names by node.name/description; a stored name that no longer resolves never falls back to a positional index - require pw-record before listing system-audio sources in the picker - bound pw-record chunk reads with a stall watchdog so stopping cannot block on a wedged stream - close the reconnection probe stream when its verification read fails - log cleanup failures instead of swallowing them; narrow exception handlers in enumeration and PyAudio reconnection init - annotate test helpers and signatures per repo style --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
co-authored by
Jatin K Malik
parent
9898bb8dd9
commit
047b379bd2
@@ -1 +1 @@
|
||||
"""Playback helpers that are not the dictation microphone."""
|
||||
"""Audio helpers — playback ducking and PipeWire system-audio capture."""
|
||||
|
||||
@@ -13,9 +13,17 @@ without inheriting dictation behavior.
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from .pipewire import (
|
||||
PIPEWIRE_INDEX_BASE,
|
||||
_test_pipewire_input,
|
||||
get_system_audio_sources,
|
||||
is_pipewire_device_index,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy as np
|
||||
|
||||
@@ -209,6 +217,22 @@ def get_audio_input_devices() -> list:
|
||||
except OSError as e:
|
||||
logger.error(f"Error enumerating audio devices: {e}")
|
||||
|
||||
# PipeWire sinks exposed as system-audio sources. Indices count down from
|
||||
# PIPEWIRE_INDEX_BASE so they can never collide with a PortAudio index.
|
||||
# Kept outside the PortAudio block so a missing PyAudio still lists them.
|
||||
try:
|
||||
for position, source in enumerate(get_system_audio_sources()):
|
||||
devices.append((PIPEWIRE_INDEX_BASE - position, source.display_name, source.is_default))
|
||||
except (
|
||||
OSError,
|
||||
subprocess.SubprocessError,
|
||||
ValueError,
|
||||
TypeError,
|
||||
KeyError,
|
||||
AttributeError,
|
||||
):
|
||||
logger.warning("PipeWire source enumeration failed", exc_info=True)
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
@@ -620,6 +644,9 @@ def test_audio_input(device_index: Optional[int] = None, duration: float = 1.0)
|
||||
"error": None,
|
||||
}
|
||||
|
||||
if is_pipewire_device_index(device_index):
|
||||
return _test_pipewire_input(device_index, duration)
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
import pyaudio
|
||||
@@ -739,6 +766,9 @@ class PortAudioCaptureSource:
|
||||
self.channels = 1
|
||||
self.downmix_channel: Optional[int] = None
|
||||
|
||||
#: Reads through a PyAudio instance — the caller must supply one.
|
||||
requires_pyaudio: bool = True
|
||||
|
||||
def open(self, audio: Any = None) -> None:
|
||||
"""Resolve the input device and open the negotiated capture stream.
|
||||
|
||||
@@ -884,6 +914,7 @@ class PortAudioCaptureSource:
|
||||
FORMAT = pyaudio.paInt16
|
||||
self.audio = audio_instance
|
||||
|
||||
new_stream: Any = None
|
||||
try:
|
||||
# Close existing stream if it exists
|
||||
if self.stream:
|
||||
@@ -952,9 +983,11 @@ class PortAudioCaptureSource:
|
||||
|
||||
except (IOError, OSError) as e:
|
||||
logger.error(f"Audio reconnection failed: {e}")
|
||||
_safe_close_stream(new_stream)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during audio reconnection: {e}")
|
||||
_safe_close_stream(new_stream)
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
"""PipeWire capture: monitor/system-audio sources through ``pw-record``.
|
||||
|
||||
PortAudio enumerates ALSA and PulseAudio devices, not PipeWire nodes, so a
|
||||
sink's monitor (everything playing through it) is unreachable through the
|
||||
microphone path. ``pw-record`` is the native PipeWire recorder: pointed at an
|
||||
``Audio/Sink`` node it captures that sink's monitor, and PipeWire converts to
|
||||
the requested format in the graph, so no PortAudio-style format negotiation is
|
||||
needed. With ``--raw`` and ``-`` it streams raw s16 PCM on stdout, which this
|
||||
module wraps in the same ``read``/``stop_stream``/``close`` shape the capture
|
||||
loop uses for PortAudio streams.
|
||||
|
||||
Sources are enumerated with ``pw-dump`` (one JSON document describing every
|
||||
object in the daemon). Each ``Audio/Sink`` — physical or virtual — becomes a
|
||||
selectable "System audio" device. Per-application stream capture is a
|
||||
different problem and deliberately out of scope here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, List, Optional, Tuple
|
||||
|
||||
from ..utils.host_process import host_env
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Display-name prefix marking a PipeWire system-audio entry. Kept free of
|
||||
#: the substrings ``_is_virtual_device`` filters on ("pipewire", "monitor"),
|
||||
#: which only apply to PortAudio pseudo-devices.
|
||||
SYSTEM_AUDIO_PREFIX = "System audio: "
|
||||
|
||||
#: Device index assigned to the first PipeWire source. PortAudio indices are
|
||||
#: non-negative and -1 already means "system default", so PipeWire sources
|
||||
#: count down from -2.
|
||||
PIPEWIRE_INDEX_BASE = -2
|
||||
|
||||
#: pw-dump media classes whose monitor a user can meaningfully record.
|
||||
_MONITOR_CLASSES = frozenset({"Audio/Sink", "Audio/Sink/Virtual"})
|
||||
|
||||
#: Metadata keys carrying the effective and configured default sink.
|
||||
_DEFAULT_SINK_KEYS = ("default.audio.sink", "default.configured.audio.sink")
|
||||
|
||||
_COMMAND_TIMEOUT_SECONDS = 3.0
|
||||
|
||||
CommandResult = Tuple[int, str, str]
|
||||
CommandRunner = Callable[[List[str]], CommandResult]
|
||||
Which = Callable[[str], Optional[str]]
|
||||
Popen = Callable[..., "subprocess.Popen[bytes]"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PipeWireSource:
|
||||
"""An ``Audio/Sink`` node whose monitor can be recorded.
|
||||
|
||||
Attributes:
|
||||
node_name: ``node.name`` — the stable identifier ``pw-record
|
||||
--target`` accepts (e.g. ``alsa_output.pci-0000_00_1f.3.analog-stereo``).
|
||||
description: Human-readable ``node.description``.
|
||||
serial: ``object.serial``, used for deterministic ordering.
|
||||
is_default: True when the daemon's default sink metadata names this node.
|
||||
label: Display label with collisions already disambiguated.
|
||||
"""
|
||||
|
||||
node_name: str
|
||||
description: str
|
||||
serial: int
|
||||
is_default: bool = False
|
||||
label: str = ""
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Device-list name: prefix plus the (disambiguated) label."""
|
||||
return f"{SYSTEM_AUDIO_PREFIX}{self.label or self.description or self.node_name}"
|
||||
|
||||
|
||||
def _run_host_command(args: List[str]) -> CommandResult:
|
||||
"""Run a host PipeWire tool. The caller handles a non-zero status."""
|
||||
completed = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_COMMAND_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
env=host_env(),
|
||||
)
|
||||
return completed.returncode, completed.stdout or "", completed.stderr or ""
|
||||
|
||||
|
||||
def _parse_pw_dump(objects: object) -> List[PipeWireSource]:
|
||||
"""Extract monitor-able sink nodes and default-sink names from ``pw-dump``."""
|
||||
if not isinstance(objects, list):
|
||||
return []
|
||||
sinks: List[PipeWireSource] = []
|
||||
default_names = set()
|
||||
for obj in objects:
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
info = obj.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
if obj.get("type") == "PipeWire:Interface:Node":
|
||||
props = info.get("props") or {}
|
||||
if not isinstance(props, dict):
|
||||
continue
|
||||
if props.get("media.class") not in _MONITOR_CLASSES:
|
||||
continue
|
||||
node_name = props.get("node.name")
|
||||
if not node_name:
|
||||
continue
|
||||
try:
|
||||
serial = int(props.get("object.serial") or obj.get("id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
serial = 0
|
||||
sinks.append(
|
||||
PipeWireSource(
|
||||
node_name=str(node_name),
|
||||
description=str(props.get("node.description") or node_name),
|
||||
serial=serial,
|
||||
)
|
||||
)
|
||||
elif obj.get("type") == "PipeWire:Interface:Metadata":
|
||||
metadata = info.get("metadata") or []
|
||||
if not isinstance(metadata, list):
|
||||
continue
|
||||
for entry in metadata:
|
||||
if not isinstance(entry, dict) or entry.get("key") not in _DEFAULT_SINK_KEYS:
|
||||
continue
|
||||
value = entry.get("value")
|
||||
if isinstance(value, dict) and value.get("name"):
|
||||
default_names.add(str(value["name"]))
|
||||
|
||||
sinks.sort(key=lambda source: source.serial)
|
||||
|
||||
# Two sinks can share a description; include the node name so entries and
|
||||
# stored device names stay resolvable.
|
||||
description_counts: dict = {}
|
||||
for source in sinks:
|
||||
description_counts[source.description] = description_counts.get(source.description, 0) + 1
|
||||
labelled: List[PipeWireSource] = []
|
||||
for source in sinks:
|
||||
label = source.description
|
||||
if description_counts.get(source.description, 0) > 1:
|
||||
label = f"{source.description} ({source.node_name})"
|
||||
labelled.append(
|
||||
PipeWireSource(
|
||||
node_name=source.node_name,
|
||||
description=source.description,
|
||||
serial=source.serial,
|
||||
is_default=source.node_name in default_names,
|
||||
label=label,
|
||||
)
|
||||
)
|
||||
return labelled
|
||||
|
||||
|
||||
def get_system_audio_sources(
|
||||
runner: Optional[CommandRunner] = None,
|
||||
which: Optional[Which] = None,
|
||||
) -> List[PipeWireSource]:
|
||||
"""List PipeWire sinks whose monitor can be captured, in stable order.
|
||||
|
||||
Returns an empty list when ``pw-dump`` is missing, the daemon is not
|
||||
running, or the output cannot be parsed — PipeWire absent must never break
|
||||
microphone enumeration.
|
||||
"""
|
||||
which = which or shutil.which
|
||||
# Both tools are required: pw-dump enumerates, pw-record captures. A
|
||||
# system missing pw-record would otherwise list sources that can never
|
||||
# be opened.
|
||||
if which("pw-dump") is None or which("pw-record") is None:
|
||||
return []
|
||||
runner = runner or _run_host_command
|
||||
try:
|
||||
code, stdout, _stderr = runner(["pw-dump"])
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
logger.debug("pw-dump failed: %s", exc)
|
||||
return []
|
||||
if code != 0 or not stdout.strip():
|
||||
return []
|
||||
try:
|
||||
objects = json.loads(stdout)
|
||||
except ValueError:
|
||||
logger.debug("Could not parse pw-dump output as JSON")
|
||||
return []
|
||||
return _parse_pw_dump(objects)
|
||||
|
||||
|
||||
def pipewire_available(which: Optional[Which] = None) -> bool:
|
||||
"""Whether the host has the PipeWire command-line tools needed to capture."""
|
||||
which = which or shutil.which
|
||||
return which("pw-dump") is not None and which("pw-record") is not None
|
||||
|
||||
|
||||
def is_pipewire_device_index(device_index: Optional[int]) -> bool:
|
||||
"""Whether a device index falls in the PipeWire (system-audio) range."""
|
||||
return (
|
||||
isinstance(device_index, int)
|
||||
and not isinstance(device_index, bool)
|
||||
and device_index <= PIPEWIRE_INDEX_BASE
|
||||
)
|
||||
|
||||
|
||||
def is_pipewire_device(device_index: Optional[int], device_name: Optional[str]) -> bool:
|
||||
"""Whether a stored device selection refers to a PipeWire source."""
|
||||
if is_pipewire_device_index(device_index):
|
||||
return True
|
||||
return bool(device_name) and str(device_name).startswith(SYSTEM_AUDIO_PREFIX)
|
||||
|
||||
|
||||
def _source_at_position(
|
||||
device_index: Optional[int], sources: List[PipeWireSource]
|
||||
) -> Optional[PipeWireSource]:
|
||||
"""Return the source at a stored negative index position, if in range."""
|
||||
if device_index is None or not is_pipewire_device_index(device_index):
|
||||
return None
|
||||
position = PIPEWIRE_INDEX_BASE - device_index
|
||||
if 0 <= position < len(sources):
|
||||
return sources[position]
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_by_stored_name(
|
||||
device_name: str,
|
||||
device_index: Optional[int],
|
||||
sources: List[PipeWireSource],
|
||||
) -> Optional[PipeWireSource]:
|
||||
"""Match a stored display name to a live source by stable identifiers.
|
||||
|
||||
Match order, strongest evidence first:
|
||||
|
||||
1. Exact ``display_name`` equality.
|
||||
2. ``node.description`` equality on the stored remainder. When several
|
||||
sources share the description (dedup labels changed since the name was
|
||||
stored), the stored index position disambiguates: a sink added later
|
||||
gets a higher ``object.serial`` and sorts after the original.
|
||||
3. A trailing ``(node.name)`` — the dedup format written when two sinks
|
||||
shared a description. ``node.name`` is stable across description
|
||||
changes, so a renamed sink still resolves. Description matching runs
|
||||
first so a description that itself ends in parentheses is not
|
||||
mistaken for the suffix.
|
||||
"""
|
||||
for source in sources:
|
||||
if source.display_name == device_name:
|
||||
return source
|
||||
remainder = device_name
|
||||
if remainder.startswith(SYSTEM_AUDIO_PREFIX):
|
||||
remainder = remainder[len(SYSTEM_AUDIO_PREFIX) :]
|
||||
matches = [s for s in sources if s.description == remainder]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
positioned = _source_at_position(device_index, sources)
|
||||
if positioned in matches:
|
||||
return positioned
|
||||
return None # Genuinely ambiguous — refuse rather than guess.
|
||||
if remainder.endswith(")") and " (" in remainder:
|
||||
node_part = remainder.rpartition(" (")[2][:-1]
|
||||
node_matches = [s for s in sources if s.node_name == node_part]
|
||||
if len(node_matches) == 1:
|
||||
return node_matches[0]
|
||||
return None
|
||||
|
||||
|
||||
def resolve_pipewire_source(
|
||||
device_index: Optional[int],
|
||||
device_name: Optional[str],
|
||||
sources: Optional[List[PipeWireSource]] = None,
|
||||
) -> Optional[PipeWireSource]:
|
||||
"""Resolve a stored selection to a live PipeWire source.
|
||||
|
||||
A stored name that no longer resolves returns ``None`` rather than
|
||||
falling back to a positional index: index positions shift as sinks come
|
||||
and go, and guessing wrong would capture a different device. The index
|
||||
fallback only applies when no name was stored (legacy selections).
|
||||
"""
|
||||
if sources is None:
|
||||
sources = get_system_audio_sources()
|
||||
if device_name:
|
||||
return _resolve_by_stored_name(device_name, device_index, sources)
|
||||
return _source_at_position(device_index, sources)
|
||||
|
||||
|
||||
class PipeWireCaptureSource:
|
||||
"""``CaptureSource`` over a PipeWire sink monitor via ``pw-record --raw``.
|
||||
|
||||
``pw-record --raw`` emits raw s16 PCM on stdout at the rate/channels the
|
||||
dictation loop already expects (mono 16 kHz by default), so no downmixing
|
||||
or resampling is needed. ``read_chunk`` raises ``IOError`` when the
|
||||
process dies — the same class of event as a PortAudio device loss, so the
|
||||
existing reconnection path applies.
|
||||
|
||||
Attributes:
|
||||
device_index: Stored negative device index (see PIPEWIRE_INDEX_BASE).
|
||||
device_name: Stored display name, re-resolved first on open.
|
||||
audio: Unused — kept None; present for parity with PortAudioCaptureSource.
|
||||
stream: The capture process while open, else None.
|
||||
sample_rate: Capture rate in Hz.
|
||||
channels: Channel count (pw-record is opened mono by default).
|
||||
downmix_channel: Always None — the stream is already mono.
|
||||
"""
|
||||
|
||||
#: Spawns ``pw-record`` itself — the PortAudio instance can be absent.
|
||||
requires_pyaudio: bool = False
|
||||
|
||||
_CHUNK_FRAMES = 1024
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_index: Optional[int] = None,
|
||||
device_name: Optional[str] = None,
|
||||
*,
|
||||
sample_rate: int = 16000,
|
||||
channels: int = 1,
|
||||
popen: Optional[Popen] = None,
|
||||
which: Optional[Which] = None,
|
||||
) -> None:
|
||||
self.device_index = device_index
|
||||
self.device_name = device_name
|
||||
self.audio: Any = None
|
||||
self.stream: Optional["subprocess.Popen[bytes]"] = None
|
||||
self.sample_rate = sample_rate
|
||||
self.channels = channels
|
||||
self.downmix_channel: Optional[int] = None
|
||||
self.source: Optional[PipeWireSource] = None
|
||||
self._popen: Popen = popen or subprocess.Popen
|
||||
self._which = which or shutil.which
|
||||
|
||||
def open(self, audio: Any = None) -> None:
|
||||
"""Resolve the PipeWire source and spawn ``pw-record``.
|
||||
|
||||
Args:
|
||||
audio: Ignored — present for ``CaptureSource`` parity.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: when ``pw-record`` is not installed.
|
||||
IOError: when the stored selection no longer resolves to a source.
|
||||
"""
|
||||
if self.stream is not None:
|
||||
return
|
||||
if self._which("pw-record") is None:
|
||||
raise FileNotFoundError("pw-record is not installed")
|
||||
source = resolve_pipewire_source(self.device_index, self.device_name)
|
||||
if source is None:
|
||||
# Deliberately no PortAudio fallback: silently switching to a
|
||||
# microphone would capture the wrong audio.
|
||||
raise IOError("PipeWire source is no longer available")
|
||||
self.source = source
|
||||
self.stream = self._popen(
|
||||
[
|
||||
"pw-record",
|
||||
"--raw",
|
||||
"--format",
|
||||
"s16",
|
||||
"--rate",
|
||||
str(self.sample_rate),
|
||||
"--channels",
|
||||
str(self.channels),
|
||||
"--target",
|
||||
source.node_name,
|
||||
"-",
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=host_env(),
|
||||
)
|
||||
|
||||
def reopen(self, audio_instance: Any) -> bool:
|
||||
"""Close the current process and spawn a fresh one. True on success."""
|
||||
self.close()
|
||||
try:
|
||||
self.open()
|
||||
except (IOError, OSError) as exc:
|
||||
logger.error("PipeWire reconnection failed: %s", exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
#: Seconds a chunk read may wait for samples before the stream is
|
||||
#: declared stalled. pw-record streams continuously — even a silent sink
|
||||
#: emits zeros — so a long gap means the process is wedged, not quiet.
|
||||
_STALL_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
def read_chunk(self) -> bytes:
|
||||
"""Return one 1024-frame chunk of mono int16 PCM.
|
||||
|
||||
Raises IOError when the process dies or when no samples arrive within
|
||||
``_STALL_TIMEOUT_SECONDS`` — a blocking ``read`` would keep the
|
||||
capture thread (and ``_buffer_lock``) held forever on a wedged
|
||||
pw-record, preventing a clean stop.
|
||||
"""
|
||||
process = self.stream
|
||||
if process is None or process.stdout is None:
|
||||
raise IOError("PipeWire capture stream is not open")
|
||||
need = self._CHUNK_FRAMES * self.channels * 2
|
||||
try:
|
||||
fd = process.stdout.fileno()
|
||||
except (OSError, ValueError, AttributeError):
|
||||
fd = None
|
||||
if not isinstance(fd, int) or fd < 0:
|
||||
fd = None
|
||||
deadline = time.monotonic() + self._STALL_TIMEOUT_SECONDS
|
||||
parts: List[bytes] = []
|
||||
remaining = need
|
||||
while remaining > 0:
|
||||
left = deadline - time.monotonic()
|
||||
if left <= 0:
|
||||
code = process.poll()
|
||||
raise IOError(
|
||||
"pw-record stalled: no audio for "
|
||||
f"{self._STALL_TIMEOUT_SECONDS:.0f}s (exit status {code})"
|
||||
)
|
||||
if fd is not None:
|
||||
try:
|
||||
ready, _, _ = select.select([fd], [], [], left)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise IOError(f"PipeWire capture read failed: {exc}") from exc
|
||||
if not ready:
|
||||
continue
|
||||
try:
|
||||
chunk = os.read(fd, remaining)
|
||||
except OSError as exc:
|
||||
raise IOError(f"PipeWire capture read failed: {exc}") from exc
|
||||
else:
|
||||
# File-like substitutes without a descriptor (tests) cannot
|
||||
# block, so they read directly.
|
||||
try:
|
||||
chunk = process.stdout.read(remaining)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise IOError(f"PipeWire capture read failed: {exc}") from exc
|
||||
if not chunk:
|
||||
code = process.poll()
|
||||
raise IOError(f"pw-record stopped streaming (exit status {code})")
|
||||
parts.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
return b"".join(parts)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Terminate the process, reap it (killing if needed), release the pipe."""
|
||||
process = self.stream
|
||||
if process is None:
|
||||
return
|
||||
try:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=1.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
process.kill()
|
||||
except OSError as exc:
|
||||
logger.warning("Could not kill stalled pw-record: %s", exc)
|
||||
try:
|
||||
process.wait(timeout=1.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("pw-record did not exit after kill")
|
||||
except OSError as exc:
|
||||
logger.warning("Error waiting for pw-record after kill: %s", exc)
|
||||
except OSError as exc:
|
||||
logger.warning("Error waiting for pw-record to exit: %s", exc)
|
||||
if process.stdout is not None:
|
||||
try:
|
||||
process.stdout.close()
|
||||
except OSError as exc:
|
||||
logger.debug("Error closing pw-record stdout: %s", exc)
|
||||
self.stream = None
|
||||
|
||||
|
||||
def _test_pipewire_input(device_index: int, duration: float = 1.0) -> dict:
|
||||
"""
|
||||
Test capture from a PipeWire system-audio source.
|
||||
|
||||
Args:
|
||||
device_index: Negative PipeWire source index (see PIPEWIRE_INDEX_BASE)
|
||||
duration: How long to record in seconds
|
||||
|
||||
Returns:
|
||||
Dictionary in the same shape as :func:`test_audio_input`.
|
||||
"""
|
||||
result = {
|
||||
"success": False,
|
||||
"device_name": "Unknown",
|
||||
"device_index": device_index,
|
||||
"sample_count": 0,
|
||||
"max_amplitude": 0.0,
|
||||
"mean_amplitude": 0.0,
|
||||
"has_signal": False,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
if resolve_pipewire_source(device_index, None) is None:
|
||||
result["error"] = "PipeWire system-audio source is not available"
|
||||
return result
|
||||
|
||||
source = PipeWireCaptureSource(device_index=device_index)
|
||||
try:
|
||||
import numpy as np
|
||||
|
||||
source.open()
|
||||
result["device_name"] = source.source.display_name if source.source else "Unknown"
|
||||
result["sample_rate"] = source.sample_rate
|
||||
all_amplitudes = []
|
||||
chunks_to_read = max(1, int(source.sample_rate * duration / source._CHUNK_FRAMES))
|
||||
for _ in range(chunks_to_read):
|
||||
data = source.read_chunk()
|
||||
audio_data = np.frombuffer(data, dtype=np.int16)
|
||||
all_amplitudes.extend(np.abs(audio_data))
|
||||
|
||||
if all_amplitudes:
|
||||
all_amplitudes = np.array(all_amplitudes)
|
||||
max_amplitude = float(np.max(all_amplitudes))
|
||||
result["success"] = True
|
||||
result["sample_count"] = len(all_amplitudes)
|
||||
result["max_amplitude"] = max_amplitude
|
||||
result["mean_amplitude"] = float(np.mean(all_amplitudes))
|
||||
result["has_signal"] = max_amplitude > 200
|
||||
except (IOError, OSError) as e:
|
||||
result["error"] = f"PipeWire capture failed: {e}"
|
||||
except ImportError as e:
|
||||
result["error"] = f"Missing dependency: {e}"
|
||||
finally:
|
||||
source.close()
|
||||
|
||||
return result
|
||||
@@ -18,7 +18,7 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable, Optional
|
||||
from typing import TYPE_CHECKING, Callable, Optional, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy as np
|
||||
@@ -42,6 +42,7 @@ from ..audio.capture import ( # noqa: F401 (re-exported for compatibility)
|
||||
get_audio_input_devices,
|
||||
test_audio_input,
|
||||
)
|
||||
from ..audio.pipewire import PipeWireCaptureSource, is_pipewire_device
|
||||
from ..audio.playback_ducker import default_dictation_duck_session, duck_delay_seconds
|
||||
from ..common_types import RecognitionState
|
||||
from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_sound
|
||||
@@ -517,7 +518,7 @@ class SpeechRecognitionManager:
|
||||
self._capture_channels = 1 # Default, updated when device is opened
|
||||
self._capture_downmix_channel = None # Speech-gated sticky N>=3 channel for open stream
|
||||
# The active capture source while a recording session is open.
|
||||
self._capture_source: Optional[PortAudioCaptureSource] = None
|
||||
self._capture_source: Optional[Union[PortAudioCaptureSource, PipeWireCaptureSource]] = None
|
||||
|
||||
# Recover a sink left quiet by a crash before doing anything slow.
|
||||
# Tests inject a session so this never touches a real audio server.
|
||||
@@ -2978,6 +2979,10 @@ class SpeechRecognitionManager:
|
||||
try:
|
||||
if not self._playback_duck.enabled():
|
||||
return
|
||||
if is_pipewire_device(self.audio_device_index, self.audio_device_name):
|
||||
# Ducking lowers the default sink — the very audio a
|
||||
# system-audio source is capturing.
|
||||
return
|
||||
# The capture thread may already have failed and released the duck;
|
||||
# arming now would lower playback in the error state with nothing
|
||||
# left to put it back.
|
||||
@@ -3289,7 +3294,6 @@ class SpeechRecognitionManager:
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
import pyaudio
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import required audio libraries: {e}")
|
||||
logger.error("Please install required dependencies: pip install pyaudio numpy")
|
||||
@@ -3305,17 +3309,29 @@ class SpeechRecognitionManager:
|
||||
# PyAudio configuration
|
||||
CHUNK = 1024
|
||||
|
||||
# Initialize PyAudio with reconnection support
|
||||
self._pyaudio_instance = pyaudio.PyAudio()
|
||||
audio = self._pyaudio_instance
|
||||
|
||||
# The capture source owns the device: resolution, format
|
||||
# negotiation, downmixing, and resampling. This loop only consumes
|
||||
# mono 16 kHz chunks and applies dictation segmentation policy.
|
||||
source = PortAudioCaptureSource(
|
||||
device_index=self.audio_device_index,
|
||||
device_name=self.audio_device_name,
|
||||
)
|
||||
# negotiation, downmixing, and resampling. PipeWire sources spawn
|
||||
# pw-record themselves and need no PyAudio instance; PortAudio
|
||||
# sources get one passed to open()/reopen(). This loop only
|
||||
# consumes mono 16 kHz chunks and applies dictation segmentation
|
||||
# policy.
|
||||
source = self._new_capture_source()
|
||||
audio = None
|
||||
if getattr(source, "requires_pyaudio", True):
|
||||
try:
|
||||
import pyaudio
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import required audio libraries: {e}")
|
||||
logger.error("Please install required dependencies: pip install pyaudio numpy")
|
||||
self.should_record = False
|
||||
self.release_playback_duck()
|
||||
play_error_sound()
|
||||
self._buffered_capture_failed = True
|
||||
self._update_state(RecognitionState.ERROR)
|
||||
self._signal_buffered_capture_done()
|
||||
return
|
||||
self._pyaudio_instance = pyaudio.PyAudio()
|
||||
audio = self._pyaudio_instance
|
||||
self._capture_source = source
|
||||
# The attempt count belongs to this session — a previous thread
|
||||
# may still be finishing and must not leave its retries here.
|
||||
@@ -3338,7 +3354,8 @@ class SpeechRecognitionManager:
|
||||
self.should_record = False
|
||||
self.release_playback_duck()
|
||||
play_error_sound()
|
||||
audio.terminate()
|
||||
if audio is not None:
|
||||
audio.terminate()
|
||||
self._buffered_capture_failed = True
|
||||
self._update_state(RecognitionState.ERROR)
|
||||
return
|
||||
@@ -3504,6 +3521,10 @@ class SpeechRecognitionManager:
|
||||
|
||||
if self._attempt_audio_reconnection(audio):
|
||||
logger.info("Audio reconnection successful, continuing recording")
|
||||
# The session source may have been rebuilt (e.g.
|
||||
# the configured device switched between a mic and
|
||||
# a PipeWire sink) — keep reading the new source.
|
||||
source = self._capture_source
|
||||
continue # Continue recording with the reopened source
|
||||
else:
|
||||
logger.error("Audio reconnection failed, stopping recording")
|
||||
@@ -3961,18 +3982,36 @@ class SpeechRecognitionManager:
|
||||
# If only VOSK params changed, just log it
|
||||
logger.info("Applied VAD/silence timeout changes.")
|
||||
|
||||
def _capture_source_for_session(self) -> PortAudioCaptureSource:
|
||||
def _new_capture_source(self) -> Union[PortAudioCaptureSource, PipeWireCaptureSource]:
|
||||
"""Build the capture source matching the configured device."""
|
||||
if is_pipewire_device(self.audio_device_index, self.audio_device_name):
|
||||
return PipeWireCaptureSource(
|
||||
device_index=self.audio_device_index,
|
||||
device_name=self.audio_device_name,
|
||||
)
|
||||
return PortAudioCaptureSource(
|
||||
device_index=self.audio_device_index,
|
||||
device_name=self.audio_device_name,
|
||||
)
|
||||
|
||||
def _capture_source_for_session(self) -> Union[PortAudioCaptureSource, PipeWireCaptureSource]:
|
||||
"""Return the session's capture source, creating one when absent.
|
||||
|
||||
Device selection is refreshed from the manager's configured device so
|
||||
reconnections track the current setting.
|
||||
reconnections track the current setting. The source is rebuilt when
|
||||
the configured device switched families (PortAudio <-> PipeWire).
|
||||
"""
|
||||
want_pipewire = is_pipewire_device(
|
||||
getattr(self, "audio_device_index", None),
|
||||
getattr(self, "audio_device_name", None),
|
||||
)
|
||||
source = getattr(self, "_capture_source", None)
|
||||
if source is None:
|
||||
source = PortAudioCaptureSource()
|
||||
if source is None or isinstance(source, PipeWireCaptureSource) != want_pipewire:
|
||||
source = self._new_capture_source()
|
||||
self._capture_source = source
|
||||
source.device_index = getattr(self, "audio_device_index", None)
|
||||
source.device_name = getattr(self, "audio_device_name", None)
|
||||
else:
|
||||
source.device_index = getattr(self, "audio_device_index", None)
|
||||
source.device_name = getattr(self, "audio_device_name", None)
|
||||
return source
|
||||
|
||||
def _sync_capture_state(self) -> None:
|
||||
@@ -4018,7 +4057,21 @@ class SpeechRecognitionManager:
|
||||
# The source may not know about the stream it is asked to replace
|
||||
# (e.g. when tests drive this method directly).
|
||||
source.stream = getattr(self, "_audio_stream", None)
|
||||
ok = source.reopen(audio_instance)
|
||||
audio = audio_instance
|
||||
if getattr(source, "requires_pyaudio", True) and audio is None:
|
||||
# A session that started on a PipeWire sink never built a PyAudio
|
||||
# instance; a PortAudio source still needs one to reopen.
|
||||
audio = getattr(self, "_pyaudio_instance", None)
|
||||
if audio is None:
|
||||
try:
|
||||
import pyaudio
|
||||
|
||||
audio = pyaudio.PyAudio()
|
||||
self._pyaudio_instance = audio
|
||||
except (ImportError, OSError, AttributeError) as e:
|
||||
logger.error(f"Failed to initialize PyAudio for reconnection: {e}")
|
||||
return False
|
||||
ok = source.reopen(audio)
|
||||
self._sync_capture_state()
|
||||
return ok
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -151,6 +152,23 @@ def _no_remote_desktop_portal(monkeypatch):
|
||||
monkeypatch.setattr("vocalinux.text_injection.remote_desktop_portal.PORTAL_AVAILABLE", False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_pipewire_enumeration() -> Generator[None, None, None]:
|
||||
"""Keep host PipeWire enumeration out of unit tests.
|
||||
|
||||
get_audio_input_devices appends the machine's real PipeWire sinks when
|
||||
pw-dump is present and a daemon answers; tests exercising PipeWire
|
||||
sources patch the seam with the sources they want.
|
||||
"""
|
||||
try:
|
||||
from vocalinux.audio import capture as _capture
|
||||
except ImportError:
|
||||
yield
|
||||
return
|
||||
with patch.object(_capture, "get_system_audio_sources", return_value=[]):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_shared_config_manager():
|
||||
"""Drop the process-wide ConfigManager between tests.
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
"""
|
||||
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.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_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_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() -> 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, "_vocalinux_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()
|
||||
Reference in New Issue
Block a user