diff options
Diffstat (limited to 'stacks/crdop/lib/sound_proxy.py')
| -rw-r--r-- | stacks/crdop/lib/sound_proxy.py | 185 |
1 files changed, 185 insertions, 0 deletions
diff --git a/stacks/crdop/lib/sound_proxy.py b/stacks/crdop/lib/sound_proxy.py new file mode 100644 index 0000000..de9b71d --- /dev/null +++ b/stacks/crdop/lib/sound_proxy.py @@ -0,0 +1,185 @@ +""" +MAX25 sound-proxy — host audio capture/playback. + +Linux/KLinux: ALSA (arecord/aplay). +FreeBSD: OSS via sound_proxy_oss (sox or /dev/dsp). +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import threading +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional, Protocol, runtime_checkable + + +@dataclass +class SoundConfig: + capture: str = "default" + playback: str = "default" + sample_rate: int = 48000 + channels: int = 1 + period_frames: int = 256 + forbid_pulse: bool = True + backend: str = "" # alsa | oss — empty = auto from platform + + +@runtime_checkable +class SoundProxyProto(Protocol): + def start_capture(self) -> None: ... + def read_capture(self, nbytes: int) -> bytes: ... + def play_pcm(self, pcm: bytes) -> None: ... + def sniff_loop( + self, + chunk_symbols: int, + on_pcm: Callable[[bytes], None], + stop: Optional[threading.Event] = None, + ) -> None: ... + def close(self) -> None: ... + + +def _detect_backend(cfg: SoundConfig) -> str: + explicit = (cfg.backend or os.environ.get("MAX25_AUDIO_BACKEND", "")).strip().lower() + if explicit in ("alsa", "oss"): + return explicit + if sys.platform.startswith("freebsd"): + return "oss" + return "alsa" + + +def create_sound_proxy(cfg: SoundConfig) -> SoundProxyProto: + backend = _detect_backend(cfg) + if backend == "oss": + from sound_proxy_oss import OssSoundConfig, OssSoundProxy + + cap = cfg.capture if cfg.capture not in ("", "default") else "/dev/dsp" + pb = cfg.playback if cfg.playback not in ("", "default") else cap + return OssSoundProxy( + OssSoundConfig( + capture=cap, + playback=pb, + sample_rate=cfg.sample_rate, + channels=cfg.channels, + ) + ) + return AlsaSoundProxy(cfg) + + +class AlsaSoundProxy: + def __init__(self, cfg: SoundConfig) -> None: + self.cfg = cfg + self._rec_proc: Optional[subprocess.Popen[bytes]] = None + self._play_proc: Optional[subprocess.Popen[bytes]] = None + self._stop = threading.Event() + + def _alsa_env(self) -> dict[str, str]: + env = os.environ.copy() + if self.cfg.forbid_pulse: + env.pop("PULSE_SERVER", None) + env.pop("PIPEWIRE_RUNTIME_DIR", None) + env["PULSE_SERVER"] = "" + return env + + @staticmethod + def _check_device(name: str) -> None: + low = name.lower() + if low in ("default", "pulse", "pipewire") or "pulse" in low or "pipewire" in low: + raise ValueError( + f"audio device {name!r} not allowed — use hw: or plughw: (kernel ALSA)" + ) + + def start_capture(self) -> None: + if not shutil.which("arecord"): + raise RuntimeError("arecord not found — install alsa-utils") + self._check_device(self.cfg.capture) + cmd = [ + "arecord", + "-q", + "-D", + self.cfg.capture, + "-f", + "S16_LE", + "-r", + str(self.cfg.sample_rate), + "-c", + str(self.cfg.channels), + "-t", + "raw", + ] + self._rec_proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=self._alsa_env(), + ) + + def read_capture(self, nbytes: int) -> bytes: + if self._rec_proc is None or self._rec_proc.stdout is None: + return b"" + return self._rec_proc.stdout.read(nbytes) or b"" + + def play_pcm(self, pcm: bytes) -> None: + if not pcm: + return + if not shutil.which("aplay"): + raise RuntimeError("aplay not found — install alsa-utils") + self._check_device(self.cfg.playback) + cmd = [ + "aplay", + "-q", + "-D", + self.cfg.playback, + "-f", + "S16_LE", + "-r", + str(self.cfg.sample_rate), + "-c", + str(self.cfg.channels), + "-t", + "raw", + ] + subprocess.run( + cmd, + input=pcm, + check=False, + env=self._alsa_env(), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + def sniff_loop( + self, + chunk_symbols: int, + on_pcm: Callable[[bytes], None], + stop: Optional[threading.Event] = None, + ) -> None: + stop_ev = stop or self._stop + frame_bytes = (self.cfg.sample_rate // 1200) * 2 * chunk_symbols + self.start_capture() + try: + while not stop_ev.is_set(): + chunk = self.read_capture(frame_bytes) + if not chunk: + break + on_pcm(chunk) + finally: + self.close() + + def close(self) -> None: + self._stop.set() + for proc in (self._rec_proc, self._play_proc): + if proc is not None and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=1.0) + except subprocess.TimeoutExpired: + proc.kill() + self._rec_proc = None + self._play_proc = None + + +# Backward-compatible alias +SoundProxy = AlsaSoundProxy |
