HOA7 Spatial Field Decoder (hoa64 v0.5.0): 7th-order Ambisonics encode/decode, Wigner-D rotation, DOA analysis, vision fuse, diffusion conditioning
570b87b verified | """Live microphone / PulseAudio capture → HOA spatial report. | |
| Uses system tools (no sounddevice): | |
| * ffmpeg -f pulse -i <source> | |
| * arecord (ALSA fallback) | |
| Mono capture is encoded as a plane wave from a configured look direction | |
| (default: front). Multi-channel (2–4) maps L/R to ±az pseudo-Ambix order-1. | |
| """ | |
| from __future__ import annotations | |
| import shutil | |
| import subprocess | |
| import tempfile | |
| from pathlib import Path | |
| from typing import Optional, Sequence, Union | |
| import numpy as np | |
| from .audio_io import read_wav, write_wav | |
| from .encode import encode_plane_waves | |
| from .report import SpatialReport, report_from_hoa | |
| from .stream import encode_mono_plane_wave | |
| PathLike = Union[str, Path] | |
| def list_pulse_sources() -> list[str]: | |
| """Return Pulse/PipeWire source names via pactl.""" | |
| if not shutil.which("pactl"): | |
| return [] | |
| try: | |
| out = subprocess.check_output( | |
| ["pactl", "list", "short", "sources"], | |
| text=True, | |
| stderr=subprocess.DEVNULL, | |
| ) | |
| except subprocess.CalledProcessError: | |
| return [] | |
| names = [] | |
| for line in out.splitlines(): | |
| parts = line.split("\t") | |
| if len(parts) >= 2: | |
| names.append(parts[1]) | |
| return names | |
| def default_mic_source() -> str: | |
| """Prefer a non-monitor mic source.""" | |
| sources = list_pulse_sources() | |
| for s in sources: | |
| if "monitor" not in s.lower() and ("mic" in s.lower() or "input" in s.lower() or "Line" in s): | |
| return s | |
| for s in sources: | |
| if "monitor" not in s.lower(): | |
| return s | |
| return "default" | |
| def capture_wav( | |
| path: PathLike, | |
| *, | |
| duration_sec: float = 2.0, | |
| sample_rate: int = 48000, | |
| channels: int = 1, | |
| source: Optional[str] = None, | |
| backend: str = "auto", | |
| ) -> Path: | |
| """Record audio to WAV. backend: auto | ffmpeg | arecord.""" | |
| path = Path(path) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| source = source or default_mic_source() | |
| backend = backend.lower() | |
| def _ffmpeg() -> None: | |
| # pulse device | |
| dev = source if source != "default" else "default" | |
| cmd = [ | |
| "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", | |
| "-f", "pulse", "-i", dev, | |
| "-t", str(duration_sec), | |
| "-ar", str(sample_rate), | |
| "-ac", str(channels), | |
| str(path), | |
| ] | |
| subprocess.check_call(cmd) | |
| def _arecord() -> None: | |
| cmd = [ | |
| "arecord", | |
| "-d", str(int(max(1, round(duration_sec)))), | |
| "-f", "S16_LE", | |
| "-r", str(sample_rate), | |
| "-c", str(channels), | |
| str(path), | |
| ] | |
| subprocess.check_call(cmd) | |
| errors = [] | |
| if backend in ("auto", "ffmpeg") and shutil.which("ffmpeg"): | |
| try: | |
| _ffmpeg() | |
| return path | |
| except Exception as e: | |
| errors.append(f"ffmpeg: {e}") | |
| if backend == "ffmpeg": | |
| raise | |
| if backend in ("auto", "arecord") and shutil.which("arecord"): | |
| try: | |
| _arecord() | |
| return path | |
| except Exception as e: | |
| errors.append(f"arecord: {e}") | |
| if backend == "arecord": | |
| raise | |
| raise RuntimeError("capture failed: " + "; ".join(errors) if errors else "no capture backend") | |
| def stereo_to_order1_hoa( | |
| left: np.ndarray, | |
| right: np.ndarray, | |
| *, | |
| width_az_deg: float = 30.0, | |
| ) -> np.ndarray: | |
| """Pseudo order-1 Ambix from stereo: L at +width, R at -width, mid omni.""" | |
| left = np.asarray(left, dtype=np.float64).reshape(-1) | |
| right = np.asarray(right, dtype=np.float64).reshape(-1) | |
| n = min(left.shape[0], right.shape[0]) | |
| left, right = left[:n], right[:n] | |
| mid = 0.5 * (left + right) | |
| # plane waves | |
| L = encode_plane_waves([width_az_deg], [0.0], left[None, :], max_order=1) | |
| R = encode_plane_waves([-width_az_deg], [0.0], right[None, :], max_order=1) | |
| M = encode_plane_waves([0.0], [0.0], mid[None, :], max_order=1) | |
| hoa = L + R + 0.5 * M | |
| # pad to 64 | |
| out = np.zeros((64, n), dtype=np.float64) | |
| out[:4] = hoa[:4] | |
| return out | |
| def audio_to_hoa_stream( | |
| audio: np.ndarray, | |
| *, | |
| az_deg: float = 0.0, | |
| el_deg: float = 0.0, | |
| max_order: int = 3, | |
| stereo_width_az: float = 30.0, | |
| ) -> np.ndarray: | |
| """(C,T) HOA from captured audio array (C_in, T) or (T,).""" | |
| a = np.asarray(audio, dtype=np.float64) | |
| if a.ndim == 1: | |
| return encode_mono_plane_wave(a, az_deg, el_deg, max_order=max_order) | |
| if a.shape[0] == 1: | |
| return encode_mono_plane_wave(a[0], az_deg, el_deg, max_order=max_order) | |
| if a.shape[0] >= 4: | |
| # assume Ambix ACN already | |
| from .audio_io import ensure_hoa_channels | |
| return ensure_hoa_channels(a, max_order=max_order) | |
| if a.shape[0] == 2: | |
| hoa = stereo_to_order1_hoa(a[0], a[1], width_az_deg=stereo_width_az) | |
| if max_order < 1: | |
| return hoa[:1] | |
| return hoa | |
| # mixdown | |
| mono = np.mean(a, axis=0) | |
| return encode_mono_plane_wave(mono, az_deg, el_deg, max_order=max_order) | |
| def live_report( | |
| *, | |
| duration_sec: float = 2.0, | |
| sample_rate: int = 48000, | |
| channels: int = 1, | |
| source: Optional[str] = None, | |
| az_deg: float = 0.0, | |
| el_deg: float = 0.0, | |
| max_order: int = 3, | |
| keep_wav: Optional[PathLike] = None, | |
| ) -> SpatialReport: | |
| """Capture from mic and return a SpatialReport.""" | |
| if keep_wav: | |
| wav_path = Path(keep_wav) | |
| else: | |
| tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) | |
| wav_path = Path(tmp.name) | |
| tmp.close() | |
| try: | |
| capture_wav( | |
| wav_path, | |
| duration_sec=duration_sec, | |
| sample_rate=sample_rate, | |
| channels=channels, | |
| source=source, | |
| ) | |
| audio, sr = read_wav(wav_path) | |
| hoa = audio_to_hoa_stream( | |
| audio, az_deg=az_deg, el_deg=el_deg, max_order=max_order | |
| ) | |
| rep = report_from_hoa( | |
| hoa, | |
| sr, | |
| max_order=max_order, | |
| notes=[ | |
| f"live capture {duration_sec}s ch={audio.shape[0]} src={source or 'default'}", | |
| f"encode az={az_deg} el={el_deg}" if audio.shape[0] < 4 else "ambix/multi-ch path", | |
| ], | |
| meta={ | |
| "encode": "live_mic", | |
| "source": source or default_mic_source(), | |
| "wav": str(wav_path) if keep_wav else None, | |
| }, | |
| ) | |
| return rep | |
| finally: | |
| if not keep_wav: | |
| try: | |
| wav_path.unlink(missing_ok=True) | |
| except Exception: | |
| pass | |
| def live_report_from_file( | |
| path: PathLike, | |
| *, | |
| az_deg: float = 0.0, | |
| el_deg: float = 0.0, | |
| max_order: int = 3, | |
| ) -> SpatialReport: | |
| """Same pipeline as live_report but from an existing WAV (offline test).""" | |
| audio, sr = read_wav(path) | |
| hoa = audio_to_hoa_stream(audio, az_deg=az_deg, el_deg=el_deg, max_order=max_order) | |
| return report_from_hoa( | |
| hoa, | |
| sr, | |
| max_order=max_order, | |
| notes=[f"live pipeline on file {path}"], | |
| meta={"encode": "live_from_file", "path": str(path)}, | |
| ) | |