feat(spectrum): capture macOS output with an avfoundation loopback

SYNC mode was Linux-only: it shelled out to parec. On macOS there is no
PulseAudio, so the analyser always fell back to the drifting stream tap.

Capture with 'ffmpeg -f avfoundation' from a loopback input instead
(BlackHole, Loopback, Soundflower, Multi-Output/Aggregate devices are
auto-detected; SOMAFM_SPECTRUM_DEVICE accepts a name or index). Plain
microphones are never auto-selected.

A loopback that nothing is routed into delivers endless digital silence,
so treat ~6 s of all-zero blocks as a dead monitor and fall back to the
stream tap rather than showing frozen bars. parec handling is unchanged.
This commit is contained in:
beefcarver committed 2026-10-30 18:43:10 -04:00
1 parent 9c722c5f61
commit 69b46b9035
2 files changed
+275 -18

No files matched your search

+165 -18
View File
@@ -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
+110
View File
@@ -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()