feat(spectrum): capture macOS output with a Core Audio process tap

SYNC mode on macOS needed a virtual loopback driver (BlackHole, Loopback,
Soundflower) routed through a Multi-Output Device, and shelled out to
'ffmpeg -f avfoundation' to read it. macOS 14.2 lets a process tap the
system mix directly, so do that instead: CATapDescription ->
AudioHardwareCreateProcessTap -> private aggregate device -> IOProc,
straight through ctypes. No driver, no helper binary, no ffmpeg.

The tap hands out native-rate interleaved float32, so the analyser can
now retune its window and band edges when the capture rate differs from
44.1 kHz, and mono-downmixes the frames before the FFT.

A tap that was never granted System Audio Recording permission is
created happily but only ever delivers digital silence: treat that as a
failed capture instead of flat bars, explain it in the status bar and
fall back to the stream tap. A tap that renders nothing *yet* (playback
still starting, or mpv pointed at another device) is not a permanent
failure either - the stream tap covers for a minute, then the tap is
retried.

Drop the avfoundation device enumeration, the loopback-name heuristics
and the microphone safeguards' tests, and document the new permission
and its diagnostics instead.
This commit is contained in:
beefcarver committed 2026-10-30 22:53:29 -04:00
1 parent 7042707e84
commit 48e480d58c
8 files changed
+988 -282

No files matched your search

+181 -159
View File
@@ -8,11 +8,11 @@ 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.
* **macOS** — a native Core Audio *process tap* records the default output
device (see :mod:`somafm_tui.macos_tap`). Nothing to install: no virtual
loopback, no Multi-Output Device, no helper binary. macOS asks for
**System Audio Recording** permission the first time; while it is missing
the tap delivers silence, which is treated as a failed capture.
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
@@ -24,7 +24,6 @@ from __future__ import annotations
import contextlib
import math
import os
import re
import select
import shutil
import subprocess
@@ -35,6 +34,7 @@ from dataclasses import dataclass
import numpy as np
from . import macos_tap
from .api import USER_AGENT
SAMPLE_RATE = 44100
@@ -46,18 +46,20 @@ 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.
#: ~46 ms of all-zero audio per block; give an output capture ~6 s to prove
#: itself before falling back to the stream tap.
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",
)
#: Frames per analysis block for the macOS tap (float32, ~43 ms at 48 kHz).
TAP_BLOCK_FRAMES = 2048
#: Give up (for now) on the tap when the output renders nothing for this long.
#: macOS only drives a tap while the output device is playing, so this is also
#: the window playback gets to start before the stream tap takes over.
TAP_IDLE_SECONDS = 10.0
#: How long the stream tap covers for us before the tap is tried again.
TAP_RETRY_SECONDS = 60.0
@dataclass(frozen=True)
@@ -102,8 +104,6 @@ class SpectrumEngine:
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()
@@ -119,7 +119,9 @@ class SpectrumEngine:
self._proc: subprocess.Popen[bytes] | None = None
self._capture_kind: str | None = None
self._monitor_failed = False
self._tap_retry_at = 0.0
self._monitor_device_cache: str | None = None
self._notice: str | None = None
self._lock = threading.Lock()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
@@ -173,6 +175,12 @@ class SpectrumEngine:
status=self._status,
)
def take_notice(self) -> str | None:
"""One-shot message for the UI (e.g. macOS audio-capture permission)."""
with self._lock:
notice, self._notice = self._notice, None
return notice
def stop(self) -> None:
self._stop.set()
self._kill_proc()
@@ -217,20 +225,23 @@ class SpectrumEngine:
proc.wait(timeout=1)
def _use_monitor(self) -> bool:
if self._monitor_failed:
if self._monitor_failed or self.source_mode in ("sim", "stream"):
return False
if self.source_mode == "sim":
kind = self._monitor_kind()
if kind is None:
return False
if self.source_mode == "stream":
return False
return self._monitor_kind() is not None
if kind != "tap":
return True
# The tap needs the default output to actually render something; when
# it does not, the stream tap covers us until the retry deadline.
return time.monotonic() >= self._tap_retry_at
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"
if self._darwin and macos_tap.available():
return "tap"
return None
def _monitor_device(self) -> str:
@@ -260,120 +271,18 @@ class SpectrumEngine:
return self._monitor_device_cache
def _monitor_command(self) -> list[str] | None:
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
if self._monitor_kind() != "parec":
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
return [
self._parec or "parec",
f"--device={self._monitor_device()}",
"--format=s16le",
"--rate=44100",
"--channels=1",
"--raw",
"--latency-msec=30",
"--client-name=somafm-tui",
]
def _spawn(
self, command: list[str], kind: str
@@ -418,24 +327,20 @@ class SpectrumEngine:
continue
if self._use_monitor():
command = self._monitor_command()
proc = self._spawn(command, "monitor") if command else None
if proc is not None:
self._capture(
proc, url, stop_on_url_change=False, status="monitor"
)
self._stop_proc(proc)
if self._stop.is_set():
break
if self._read_url() is None:
continue
# The monitor device failed (or vanished). Use the stream
# tap for the rest of the session.
self._monitor_failed = True
with self._lock:
self._status = "sim"
outcome = self._capture_monitor(url)
if outcome == "done":
continue
if outcome == "retry":
# Nothing was rendered (yet): try the tap again later.
self._tap_retry_at = time.monotonic() + TAP_RETRY_SECONDS
else:
# Device gone, or the macOS audio-capture permission was
# never granted: do not retry in this session.
self._monitor_failed = True
if self._stop.is_set():
break
if self._read_url() is None:
continue
self._monitor_failed = True
if not self._ffmpeg:
self._simulate_for(url, 0.05)
@@ -498,6 +403,92 @@ class SpectrumEngine:
"-",
]
def _capture_monitor(self, url: str) -> str:
"""Capture the real audio output.
Returns ``"done"`` when playback stopped, ``"failed"`` when the
monitor is unusable, and ``"retry"`` when it merely had nothing to
capture yet.
"""
if self._monitor_kind() == "tap":
return self._capture_tap()
command = self._monitor_command()
proc = self._spawn(command, "monitor") if command else None
if proc is None:
return "failed"
try:
self._capture(proc, url, stop_on_url_change=False, status="monitor")
finally:
self._stop_proc(proc)
if self._stop.is_set() or self._read_url() is None:
return "done"
return "failed"
def _capture_tap(self) -> str:
"""Capture the native macOS output tap."""
tap = macos_tap.SystemAudioTap()
try:
tap.start()
except macos_tap.TapError as exc:
self._notify(f"macOS audio capture failed ({exc}) — using FFT")
return "failed"
with self._lock:
self._capture_kind = "monitor"
self._status = "monitor"
try:
self._retune(tap.sample_rate)
return self._pump_tap(tap)
finally:
tap.stop()
with self._lock:
if self._capture_kind == "monitor":
self._capture_kind = None
def _pump_tap(self, tap: macos_tap.SystemAudioTap) -> str:
"""Feed tap audio to the analyser until playback stops or it fails."""
channels = max(1, tap.channels)
block_bytes = TAP_BLOCK_FRAMES * max(4, tap.bytes_per_frame)
buffer = bytearray()
silent_blocks = 0
last_audio = time.monotonic()
while not self._stop.is_set():
if self._read_url() is None:
return "done"
chunk = tap.read(0.5)
now = time.monotonic()
if chunk is None:
# A tap only ticks while the output device renders something;
# give playback a moment to start before giving up on it.
if now - last_audio >= TAP_IDLE_SECONDS:
self._notify(
"nothing is playing on the default output device — "
"using the stream tap (FFT)"
)
return "retry"
continue
last_audio = now
buffer.extend(chunk)
# Fixed-size blocks keep attack/decay smoothing tied to time.
while len(buffer) >= block_bytes:
block = bytes(buffer[:block_bytes])
del buffer[:block_bytes]
# A tap that was never granted permission is created happily
# but only ever hands out digital silence; detect that here.
if not block.strip(b"\x00"):
silent_blocks += 1
if silent_blocks >= SILENT_MONITOR_BLOCKS:
self._notify(
"macOS is not letting the app record audio — allow "
"'System Audio Recording' for your terminal in "
"System Settings, then restart (using FFT)"
)
return "failed"
continue
silent_blocks = 0
self.feed_audio(block, channels, "monitor")
return "done"
def _capture(
self,
proc: subprocess.Popen[bytes],
@@ -544,10 +535,11 @@ 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.
# A monitor that was never granted permission (macOS) or that
# nobody routes into (PulseAudio) delivers endless digital
# silence. SYNC bars that never move are worse than the
# (slightly drifting) stream tap, so give up 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:
@@ -573,6 +565,36 @@ class SpectrumEngine:
return
self._process(samples.astype(np.float32) / 32768.0, status)
def feed_audio(self, data: bytes, channels: int = 1, status: str = "monitor") -> None:
"""Feed interleaved little-endian ``float32`` PCM (the macOS tap)."""
usable = len(data) - (len(data) % 4)
if usable <= 0:
return
try:
samples = np.frombuffer(data[:usable], dtype="<f4")
except ValueError:
return
if samples.size == 0:
return
channels = max(1, int(channels))
if channels > 1:
frames = samples.size - (samples.size % channels)
samples = samples[:frames].reshape(-1, channels).mean(axis=1)
self._process(np.ascontiguousarray(samples, dtype=np.float32), status)
def _retune(self, sample_rate: int) -> None:
"""Rebuild the FFT window and band edges for a different capture rate."""
rate = int(sample_rate) or self.sample_rate
if rate == self.sample_rate:
return
self.sample_rate = rate
self._window = np.hanning(self.fft_size).astype(np.float32)
self._groups = self._make_groups()
def _notify(self, message: str) -> None:
with self._lock:
self._notice = message
def _process(self, samples: np.ndarray, status: str = "live") -> None:
size = self.fft_size
if samples.size >= size: