summaryrefslogtreecommitdiff
path: root/stacks/crdop/lib/sound_proxy_oss.py
diff options
context:
space:
mode:
Diffstat (limited to 'stacks/crdop/lib/sound_proxy_oss.py')
-rw-r--r--stacks/crdop/lib/sound_proxy_oss.py151
1 files changed, 151 insertions, 0 deletions
diff --git a/stacks/crdop/lib/sound_proxy_oss.py b/stacks/crdop/lib/sound_proxy_oss.py
new file mode 100644
index 0000000..66cf765
--- /dev/null
+++ b/stacks/crdop/lib/sound_proxy_oss.py
@@ -0,0 +1,151 @@
+"""
+MAX25 sound-proxy — FreeBSD/OSS capture and playback.
+
+Uses `sox` with OSS devices when available; falls back to raw /dev/dsp read/write.
+"""
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+import threading
+from dataclasses import dataclass
+from typing import Callable, Optional
+
+
+@dataclass
+class OssSoundConfig:
+ capture: str = "/dev/dsp"
+ playback: str = "/dev/dsp"
+ sample_rate: int = 48000
+ channels: int = 1
+
+
+class OssSoundProxy:
+ def __init__(self, cfg: OssSoundConfig) -> None:
+ self.cfg = cfg
+ self._rec_proc: Optional[subprocess.Popen[bytes]] = None
+ self._dsp_fd: Optional[int] = None
+ self._stop = threading.Event()
+ self._use_sox = shutil.which("sox") is not None
+
+ def _sox_capture_cmd(self) -> list[str]:
+ dev = self.cfg.capture
+ return [
+ "sox",
+ "-q",
+ "-t",
+ "oss",
+ dev,
+ "-r",
+ str(self.cfg.sample_rate),
+ "-c",
+ str(self.cfg.channels),
+ "-b",
+ "16",
+ "-e",
+ "signed-integer",
+ "-t",
+ "raw",
+ "-",
+ ]
+
+ def _sox_play_cmd(self) -> list[str]:
+ dev = self.cfg.playback
+ return [
+ "sox",
+ "-q",
+ "-t",
+ "raw",
+ "-r",
+ str(self.cfg.sample_rate),
+ "-c",
+ str(self.cfg.channels),
+ "-b",
+ "16",
+ "-e",
+ "signed-integer",
+ "-",
+ "-t",
+ "oss",
+ dev,
+ ]
+
+ def start_capture(self) -> None:
+ if self._use_sox:
+ self._rec_proc = subprocess.Popen(
+ self._sox_capture_cmd(),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ )
+ return
+ flags = os.O_RDONLY
+ try:
+ self._dsp_fd = os.open(self.cfg.capture, flags)
+ except OSError as exc:
+ raise RuntimeError(f"OSS open {self.cfg.capture}: {exc}") from exc
+
+ def read_capture(self, nbytes: int) -> bytes:
+ if self._rec_proc is not None and self._rec_proc.stdout is not None:
+ return self._rec_proc.stdout.read(nbytes) or b""
+ if self._dsp_fd is not None:
+ try:
+ return os.read(self._dsp_fd, nbytes) or b""
+ except OSError:
+ return b""
+ return b""
+
+ def play_pcm(self, pcm: bytes) -> None:
+ if not pcm:
+ return
+ if self._use_sox:
+ subprocess.run(
+ self._sox_play_cmd(),
+ input=pcm,
+ check=False,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ return
+ try:
+ fd = os.open(self.cfg.playback, os.O_WRONLY)
+ except OSError:
+ return
+ try:
+ os.write(fd, pcm)
+ finally:
+ os.close(fd)
+
+ 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 = max(256, (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()
+ if self._rec_proc is not None and self._rec_proc.poll() is None:
+ self._rec_proc.terminate()
+ try:
+ self._rec_proc.wait(timeout=1.0)
+ except subprocess.TimeoutExpired:
+ self._rec_proc.kill()
+ self._rec_proc = None
+ if self._dsp_fd is not None:
+ try:
+ os.close(self._dsp_fd)
+ except OSError:
+ pass
+ self._dsp_fd = None
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com