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:
1 parent
7042707e84
commit
48e480d58c
8 files changed
+988
-282
No files matched your search
+181
-159
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user