summaryrefslogtreecommitdiff
path: root/stacks/crdop/lib/sound_proxy.py
diff options
context:
space:
mode:
authorinfo@mode42.com <info@mode42.com>2026-08-07 18:23:28 +0000
committerinfo@mode42.com <info@mode42.com>2026-08-07 18:23:28 +0000
commitfa05a5f8238e9e1417235711656e5062ccc843a7 (patch)
tree46fbbd18de54492b59307c16efcc7f3152723238 /stacks/crdop/lib/sound_proxy.py
Initial push
Diffstat (limited to 'stacks/crdop/lib/sound_proxy.py')
-rw-r--r--stacks/crdop/lib/sound_proxy.py185
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
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com