diff --git a/somafm_tui/spectrum.py b/somafm_tui/spectrum.py index 5007401..7804f9f 100644 --- a/somafm_tui/spectrum.py +++ b/somafm_tui/spectrum.py @@ -1,12 +1,22 @@ """Spectrum analyser. The audio you hear is played by mpv. The *synced* mode captures the real -PulseAudio/PipeWire monitor of the sink mpv is playing to (``parec``), so the -bars follow exactly what leaves the speakers (status ``SYNC``). +output of the sound card mpv is playing to, so the bars follow exactly what +leaves the speakers (status ``SYNC``). -If that is unavailable we fall back to a muted second ffmpeg decode of the -stream (status ``FFT``; it can drift relative to playback), and finally to a -synthesized show (``SIM``) so the UI stays alive. +That capture is platform specific: + +* **Linux** — ``parec`` records the PulseAudio/PipeWire monitor of the default + sink (or ``SOMAFM_SPECTRUM_DEVICE``). +* **macOS** — ``ffmpeg -f avfoundation`` records a loopback input device. A + virtual loopback such as BlackHole/Loopback/Soundflower (used as part of a + Multi-Output Device) is auto-detected; ``SOMAFM_SPECTRUM_DEVICE`` picks one + by index or by name. Microphones are deliberately never auto-selected. + The first capture makes macOS ask for microphone permission. + +If no output capture is available we fall back to a muted second ffmpeg decode +of the stream (status ``FFT``; it can drift relative to playback), and finally +to a synthesized show (``SIM``) so the UI stays alive. """ from __future__ import annotations @@ -14,9 +24,11 @@ from __future__ import annotations import contextlib import math import os +import re import select import shutil import subprocess +import sys import threading import time from dataclasses import dataclass @@ -34,6 +46,19 @@ MIN_FREQ = 35.0 MAX_FREQ = 16000.0 SOURCE_MODES = ("auto", "monitor", "stream", "sim") +#: ~46 ms of all-zero audio per block; give a loopback ~6 s to prove itself. +SILENT_MONITOR_BLOCKS = 130 + +#: Device names (lower-case) that look like an output loopback on macOS. +LOOPBACK_HINTS = ( + "blackhole", + "loopback", + "soundflower", + "existential audio", + "multi-output", + "aggregate", +) + @dataclass(frozen=True) class Frame: @@ -76,6 +101,9 @@ class SpectrumEngine: self._ffmpeg = ffmpeg_path if ffmpeg_path is not None else shutil.which("ffmpeg") self._parec = shutil.which("parec") self._pactl = shutil.which("pactl") + self._darwin = sys.platform == "darwin" + self._avf_checked = False + self._avf_device: int | None = None self._window = np.hanning(fft_size).astype(np.float32) self._groups = self._make_groups() @@ -108,7 +136,7 @@ class SpectrumEngine: @property def monitor_available(self) -> bool: - return bool(self._parec) and not self._monitor_failed + return not self._monitor_failed and self._monitor_kind() is not None def start(self) -> None: if self._thread is not None: @@ -195,7 +223,15 @@ class SpectrumEngine: return False if self.source_mode == "stream": return False - return bool(self._parec) + return self._monitor_kind() is not None + + def _monitor_kind(self) -> str | None: + """Name of the output-capture backend available here, if any.""" + if self._parec: + return "parec" + if self._darwin and self._ffmpeg and self._avf_loopback() is not None: + return "avfoundation" + return None def _monitor_device(self) -> str: override = os.environ.get("SOMAFM_SPECTRUM_DEVICE", "").strip() @@ -224,18 +260,120 @@ class SpectrumEngine: return self._monitor_device_cache def _monitor_command(self) -> list[str] | None: - if not self._parec: + kind = self._monitor_kind() + if kind == "parec": + return [ + self._parec or "parec", + f"--device={self._monitor_device()}", + "--format=s16le", + "--rate=44100", + "--channels=1", + "--raw", + "--latency-msec=30", + "--client-name=somafm-tui", + ] + if kind == "avfoundation": + device = self._avf_loopback() + if device is None: + return None + return [ + self._ffmpeg or "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-nostdin", + "-f", + "avfoundation", + "-i", + # avfoundation wants "video:audio"; we only record audio. + f":{device}", + "-ac", + "1", + "-ar", + str(self.sample_rate), + "-f", + "s16le", + "-acodec", + "pcm_s16le", + "-", + ] + return None + + # ----- macOS: avfoundation loopback ---------------------------------- # + + def _avf_audio_devices(self) -> list[tuple[int, str]]: + """List ``(index, name)`` of the audio inputs ffmpeg can see. + + The first call may be slow (ffmpeg enumerates CoreAudio devices) and + macOS may show a microphone permission prompt. + """ + if not self._ffmpeg: + return [] + try: + result = subprocess.run( + [ + self._ffmpeg, + "-hide_banner", + "-f", + "avfoundation", + "-list_devices", + "true", + "-i", + "", + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=8.0, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return [] + devices: list[tuple[int, str]] = [] + in_audio = False + for line in result.stdout.splitlines(): + if "AVFoundation audio devices" in line: + in_audio = True + continue + if "AVFoundation video devices" in line: + in_audio = False + continue + if not in_audio: + continue + match = re.search(r"\[(\d+)\]\s+(.+?)\s*$", line) + if match: + devices.append((int(match.group(1)), match.group(2))) + return devices + + def _avf_loopback(self) -> int | None: + """Pick the avfoundation input device used for the sync capture. + + A virtual loopback (BlackHole, Loopback, Soundflower, …) is preferred. + Microphones are never chosen automatically -- recording the room is + not what the bars are supposed to show. + """ + if self._avf_checked: + return self._avf_device + self._avf_checked = True + devices = self._avf_audio_devices() + override = os.environ.get("SOMAFM_SPECTRUM_DEVICE", "").strip() + if override: + if override.isdigit(): + self._avf_device = int(override) + return self._avf_device + needle = override.casefold() + for index, name in devices: + if needle in name.casefold(): + self._avf_device = index + return index return None - return [ - self._parec, - f"--device={self._monitor_device()}", - "--format=s16le", - "--rate=44100", - "--channels=1", - "--raw", - "--latency-msec=30", - "--client-name=somafm-tui", - ] + for index, name in devices: + haystack = name.casefold() + if any(hint in haystack for hint in LOOPBACK_HINTS): + self._avf_device = index + return index + return None def _spawn( self, command: list[str], kind: str @@ -375,6 +513,7 @@ class SpectrumEngine: fd = stdout.fileno() got_data = False buffer = bytearray() + silent_blocks = 0 while not self._stop.is_set(): current = self._read_url() if current is None: @@ -405,6 +544,14 @@ class SpectrumEngine: while len(buffer) >= READ_BYTES: block = bytes(buffer[:READ_BYTES]) del buffer[:READ_BYTES] + # A loopback device that nobody routes into delivers endless + # digital silence. SYNC bars that never move are worse than + # the (slightly drifting) stream tap, so declare the monitor + # dead and let the caller fall back. + if status == "monitor": + silent_blocks = silent_blocks + 1 if block.count(0) == len(block) else 0 + if silent_blocks >= SILENT_MONITOR_BLOCKS: + return False got_data = True self.feed_pcm(block, status) return got_data diff --git a/tests/test_spectrum.py b/tests/test_spectrum.py index 9f68722..fb7590a 100644 --- a/tests/test_spectrum.py +++ b/tests/test_spectrum.py @@ -138,3 +138,113 @@ def test_stream_tap_pipeline(tmp_path) -> None: assert engine.snapshot().status == "idle" finally: engine.stop() + + +def test_avfoundation_loopback_is_preferred_on_macos(monkeypatch) -> None: + engine = SpectrumEngine(ffmpeg_path="/opt/homebrew/bin/ffmpeg") + engine._parec = None # pretend there is no PulseAudio on this host + engine._darwin = True + monkeypatch.delenv("SOMAFM_SPECTRUM_DEVICE", raising=False) + monkeypatch.setattr( + engine, + "_avf_audio_devices", + lambda: [(0, "MacBook Pro Microphone"), (1, "BlackHole 2ch")], + ) + engine._avf_checked = False + + assert engine._avf_loopback() == 1 + assert engine._monitor_kind() == "avfoundation" + assert engine.monitor_available is True + + command = engine._monitor_command() + assert command is not None + assert command[0] == "/opt/homebrew/bin/ffmpeg" + assert command[command.index("-i") + 1] == ":1" + assert "-f" in command and "avfoundation" in command + assert command[-1] == "-" + assert "pcm_s16le" in command + assert command[command.index("-ar") + 1] == "44100" + + +def test_avfoundation_never_autoselects_a_microphone(monkeypatch) -> None: + engine = SpectrumEngine(ffmpeg_path="/opt/homebrew/bin/ffmpeg") + engine._parec = None + engine._darwin = True + monkeypatch.delenv("SOMAFM_SPECTRUM_DEVICE", raising=False) + monkeypatch.setattr( + engine, "_avf_audio_devices", lambda: [(0, "MacBook Pro Microphone")] + ) + engine._avf_checked = False + assert engine._avf_loopback() is None + assert engine._monitor_kind() is None + assert engine._monitor_command() is None + + +def test_avfoundation_device_override_by_name_and_index(monkeypatch) -> None: + devices = [(0, "MacBook Pro Microphone"), (1, "BlackHole 2ch"), (2, "Loopback Audio")] + + engine = SpectrumEngine(ffmpeg_path="/opt/homebrew/bin/ffmpeg") + engine._parec = None + engine._darwin = True + monkeypatch.setattr(engine, "_avf_audio_devices", lambda: devices) + monkeypatch.setenv("SOMAFM_SPECTRUM_DEVICE", "loopback audio") + engine._avf_checked = False + assert engine._avf_loopback() == 2 + + engine = SpectrumEngine(ffmpeg_path="/opt/homebrew/bin/ffmpeg") + engine._parec = None + engine._darwin = True + monkeypatch.setattr(engine, "_avf_audio_devices", lambda: devices) + monkeypatch.setenv("SOMAFM_SPECTRUM_DEVICE", "0") + engine._avf_checked = False + assert engine._avf_loopback() == 0 + + +def test_avfoundation_listing_parser(monkeypatch) -> None: + listing = ( + "[AVFoundation indev @ 0x1] AVFoundation video devices:\n" + "[AVFoundation indev @ 0x1] [0] Capture screen 0\n" + "[AVFoundation indev @ 0x1] AVFoundation audio devices:\n" + "[AVFoundation indev @ 0x1] [0] MacBook Pro Microphone\n" + "[AVFoundation indev @ 0x1] [1] BlackHole 2ch\n" + ) + + class Result: + stdout = listing + returncode = 1 + + def fake_run(*args, **kwargs): + return Result() + + engine = SpectrumEngine(ffmpeg_path="/opt/homebrew/bin/ffmpeg") + monkeypatch.setattr(spectrum_module.subprocess, "run", fake_run) + assert engine._avf_audio_devices() == [ + (0, "MacBook Pro Microphone"), + (1, "BlackHole 2ch"), + ] + + +def test_dead_loopback_falls_back_instead_of_flat_bars(tmp_path) -> None: + script = tmp_path / "silent-parec" + script.write_text( + "#!/usr/bin/env python3\n" + "import sys\n" + "block = b'\\0' * 4096\n" + "for _ in range(1000):\n" + " sys.stdout.buffer.write(block)\n" + "sys.stdout.buffer.flush()\n" + ) + script.chmod(0o755) + + engine = SpectrumEngine(ffmpeg_path="") + engine.set_source("https://example.invalid/live") + proc = engine._spawn([str(script)], "monitor") + assert proc is not None + assert engine.snapshot().status == "monitor" + try: + assert engine._capture( + proc, "https://example.invalid/live", stop_on_url_change=False, status="monitor" + ) is False + finally: + engine._stop_proc(proc) + engine.stop()