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
+267 -10

No files matched your search

+157 -10
View File
@@ -1,12 +1,22 @@
"""Spectrum analyser. """Spectrum analyser.
The audio you hear is played by mpv. The *synced* mode captures the real 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 output of the sound card mpv is playing to, so the bars follow exactly what
bars follow exactly what leaves the speakers (status ``SYNC``). leaves the speakers (status ``SYNC``).
If that is unavailable we fall back to a muted second ffmpeg decode of the That capture is platform specific:
stream (status ``FFT``; it can drift relative to playback), and finally to a
synthesized show (``SIM``) so the UI stays alive. * **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 from __future__ import annotations
@@ -14,9 +24,11 @@ from __future__ import annotations
import contextlib import contextlib
import math import math
import os import os
import re
import select import select
import shutil import shutil
import subprocess import subprocess
import sys
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@@ -34,6 +46,19 @@ MIN_FREQ = 35.0
MAX_FREQ = 16000.0 MAX_FREQ = 16000.0
SOURCE_MODES = ("auto", "monitor", "stream", "sim") 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) @dataclass(frozen=True)
class Frame: class Frame:
@@ -76,6 +101,9 @@ class SpectrumEngine:
self._ffmpeg = ffmpeg_path if ffmpeg_path is not None else shutil.which("ffmpeg") self._ffmpeg = ffmpeg_path if ffmpeg_path is not None else shutil.which("ffmpeg")
self._parec = shutil.which("parec") self._parec = shutil.which("parec")
self._pactl = shutil.which("pactl") 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._window = np.hanning(fft_size).astype(np.float32)
self._groups = self._make_groups() self._groups = self._make_groups()
@@ -108,7 +136,7 @@ class SpectrumEngine:
@property @property
def monitor_available(self) -> bool: 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: def start(self) -> None:
if self._thread is not None: if self._thread is not None:
@@ -195,7 +223,15 @@ class SpectrumEngine:
return False return False
if self.source_mode == "stream": if self.source_mode == "stream":
return False 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: def _monitor_device(self) -> str:
override = os.environ.get("SOMAFM_SPECTRUM_DEVICE", "").strip() override = os.environ.get("SOMAFM_SPECTRUM_DEVICE", "").strip()
@@ -224,10 +260,10 @@ class SpectrumEngine:
return self._monitor_device_cache return self._monitor_device_cache
def _monitor_command(self) -> list[str] | None: def _monitor_command(self) -> list[str] | None:
if not self._parec: kind = self._monitor_kind()
return None if kind == "parec":
return [ return [
self._parec, self._parec or "parec",
f"--device={self._monitor_device()}", f"--device={self._monitor_device()}",
"--format=s16le", "--format=s16le",
"--rate=44100", "--rate=44100",
@@ -236,6 +272,108 @@ class SpectrumEngine:
"--latency-msec=30", "--latency-msec=30",
"--client-name=somafm-tui", "--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
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( def _spawn(
self, command: list[str], kind: str self, command: list[str], kind: str
@@ -375,6 +513,7 @@ class SpectrumEngine:
fd = stdout.fileno() fd = stdout.fileno()
got_data = False got_data = False
buffer = bytearray() buffer = bytearray()
silent_blocks = 0
while not self._stop.is_set(): while not self._stop.is_set():
current = self._read_url() current = self._read_url()
if current is None: if current is None:
@@ -405,6 +544,14 @@ class SpectrumEngine:
while len(buffer) >= READ_BYTES: while len(buffer) >= READ_BYTES:
block = bytes(buffer[:READ_BYTES]) block = bytes(buffer[:READ_BYTES])
del 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 got_data = True
self.feed_pcm(block, status) self.feed_pcm(block, status)
return got_data return got_data
+110
View File
@@ -138,3 +138,113 @@ def test_stream_tap_pipeline(tmp_path) -> None:
assert engine.snapshot().status == "idle" assert engine.snapshot().status == "idle"
finally: finally:
engine.stop() 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()