Files
beefy-player/somafm_tui/spectrum.py
T
beefcarver 48e480d58c 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.
2026-10-30 22:53:29 -04:00

704 lines
25 KiB
Python

"""Spectrum analyser.
The audio you hear is played by mpv. The *synced* mode captures the real
output of the sound card mpv is playing to, so the bars follow exactly what
leaves the speakers (status ``SYNC``).
That capture is platform specific:
* **Linux** — ``parec`` records the PulseAudio/PipeWire monitor of the default
sink (or ``SOMAFM_SPECTRUM_DEVICE``).
* **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
to a synthesized show (``SIM``) so the UI stays alive.
"""
from __future__ import annotations
import contextlib
import math
import os
import select
import shutil
import subprocess
import sys
import threading
import time
from dataclasses import dataclass
import numpy as np
from . import macos_tap
from .api import USER_AGENT
SAMPLE_RATE = 44100
FFT_SIZE = 2048
READ_BYTES = 4096 # 2048 mono s16le samples
BAND_COUNT = 72
WAVE_POINTS = 192
MIN_FREQ = 35.0
MAX_FREQ = 16000.0
SOURCE_MODES = ("auto", "monitor", "stream", "sim")
#: ~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
#: 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)
class Frame:
bands: tuple[float, ...]
peaks: tuple[float, ...]
wave: tuple[float, ...]
level: float
status: str # "monitor" | "live" | "sim" | "idle" | "off"
@property
def is_live(self) -> bool:
return self.status in ("monitor", "live")
@property
def label(self) -> str:
return {
"monitor": "SYNC",
"live": "FFT",
"sim": "SIM",
"idle": "IDLE",
"off": "OFF",
}.get(self.status, self.status.upper())
class SpectrumEngine:
def __init__(
self,
bands: int = BAND_COUNT,
sample_rate: int = SAMPLE_RATE,
fft_size: int = FFT_SIZE,
wave_points: int = WAVE_POINTS,
ffmpeg_path: str | None = None,
source_mode: str = "auto",
) -> None:
self.band_count = bands
self.sample_rate = sample_rate
self.fft_size = fft_size
self.wave_points = wave_points
self.source_mode = source_mode if source_mode in SOURCE_MODES else "auto"
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._window = np.hanning(fft_size).astype(np.float32)
self._groups = self._make_groups()
self._history = np.zeros(fft_size, dtype=np.float32)
self._levels = np.zeros(bands, dtype=np.float32)
self._peaks = np.zeros(bands, dtype=np.float32)
self._wave = np.zeros(wave_points, dtype=np.float32)
self._level = 0.0
self._gain = -24.0
self._status = "idle"
self._url: str | None = None
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
self._rng = np.random.default_rng()
self._sim_phase = 0.0
# ------------------------------------------------------------------ #
# public API
# ------------------------------------------------------------------ #
@property
def ffmpeg_available(self) -> bool:
return bool(self._ffmpeg)
@property
def monitor_available(self) -> bool:
return not self._monitor_failed and self._monitor_kind() is not None
def start(self) -> None:
if self._thread is not None:
return
self._thread = threading.Thread(
target=self._run, name="somafm-spectrum", daemon=True
)
self._thread.start()
def set_source(self, url: str | None) -> None:
"""Start analysing playback, or idle when ``url`` is ``None``.
The URL is only a fallback source for the stream tap. A monitor
capture is tied to the actual audio output, so it deliberately keeps
running when the station changes underneath it.
"""
url = url or None
with self._lock:
changed = url != self._url
self._url = url
kind = self._capture_kind
if url is None:
self._status = "idle"
if changed and (url is None or kind != "monitor"):
self._kill_proc()
def snapshot(self) -> Frame:
with self._lock:
return Frame(
bands=tuple(float(v) for v in self._levels),
peaks=tuple(float(v) for v in self._peaks),
wave=tuple(float(v) for v in self._wave),
level=float(self._level),
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()
thread = self._thread
if thread is not None and thread.is_alive():
thread.join(timeout=2.0)
self._thread = None
with self._lock:
self._status = "off"
# ------------------------------------------------------------------ #
# internals
# ------------------------------------------------------------------ #
def _make_groups(self) -> list[tuple[int, int]]:
freqs = np.fft.rfftfreq(self.fft_size, 1.0 / self.sample_rate)
top = min(MAX_FREQ, self.sample_rate / 2 - 100)
edges = np.geomspace(MIN_FREQ, top, self.band_count + 1)
groups: list[tuple[int, int]] = []
last = len(freqs) - 1
for index in range(self.band_count):
lo = int(np.searchsorted(freqs, edges[index], side="left"))
hi = int(np.searchsorted(freqs, edges[index + 1], side="left"))
lo = min(max(lo, 0), last)
hi = min(max(hi, lo + 1), len(freqs))
groups.append((lo, hi))
return groups
def _read_url(self) -> str | None:
with self._lock:
return self._url
def _kill_proc(self) -> None:
with self._lock:
proc = self._proc
self._proc = None
self._capture_kind = None
if proc is not None:
with contextlib.suppress(OSError):
proc.kill()
with contextlib.suppress(Exception):
proc.wait(timeout=1)
def _use_monitor(self) -> bool:
if self._monitor_failed or self.source_mode in ("sim", "stream"):
return False
kind = self._monitor_kind()
if kind is None:
return False
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 macos_tap.available():
return "tap"
return None
def _monitor_device(self) -> str:
override = os.environ.get("SOMAFM_SPECTRUM_DEVICE", "").strip()
if override:
return override
if self._monitor_device_cache is not None:
return self._monitor_device_cache
if self._pactl:
try:
result = subprocess.run(
[self._pactl, "get-default-sink"],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=1.0,
check=False,
)
sink = result.stdout.strip()
if result.returncode == 0 and sink:
self._monitor_device_cache = f"{sink}.monitor"
return self._monitor_device_cache
except (OSError, subprocess.SubprocessError):
pass
self._monitor_device_cache = "@DEFAULT_MONITOR@"
return self._monitor_device_cache
def _monitor_command(self) -> list[str] | None:
if self._monitor_kind() != "parec":
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
) -> subprocess.Popen[bytes] | None:
try:
proc = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=0,
start_new_session=True,
)
except (OSError, ValueError):
return None
with self._lock:
self._proc = proc
self._capture_kind = kind
self._status = "monitor" if kind == "monitor" else "live"
return proc
def _stop_proc(self, proc: subprocess.Popen[bytes]) -> None:
with self._lock:
if self._proc is proc:
self._proc = None
self._capture_kind = None
with contextlib.suppress(OSError):
proc.kill()
with contextlib.suppress(Exception):
proc.wait(timeout=1)
def _run(self) -> None:
while not self._stop.is_set():
url = self._read_url()
if not url:
self._idle_frame()
self._stop.wait(0.05)
continue
if self.source_mode == "sim":
self._simulate_for(url, 0.05)
continue
if self._use_monitor():
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
if not self._ffmpeg:
self._simulate_for(url, 0.05)
continue
proc = self._spawn(self._command(url), "stream")
if proc is None:
with self._lock:
self._status = "sim"
self._simulate_for(url, 0.06, duration=5.0)
continue
self._capture(proc, url, stop_on_url_change=True, status="live")
self._stop_proc(proc)
if self._stop.is_set():
break
if self._read_url() == url:
with self._lock:
self._status = "sim"
self._simulate_for(url, 0.06, duration=12.0)
def _command(self, url: str) -> list[str]:
return [
self._ffmpeg or "ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-reconnect",
"1",
"-reconnect_streamed",
"1",
"-reconnect_at_eof",
"1",
"-reconnect_delay_max",
"5",
"-fflags",
"nobuffer",
"-flags",
"low_delay",
"-probesize",
"32768",
"-analyzeduration",
"0",
"-user_agent",
USER_AGENT,
"-i",
url,
"-map",
"0:a:0",
"-vn",
"-ac",
"1",
"-ar",
str(self.sample_rate),
"-f",
"s16le",
"-acodec",
"pcm_s16le",
"-",
]
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],
url: str,
*,
stop_on_url_change: bool,
status: str,
) -> bool:
"""Read PCM until playback stops, the source changes, or the process dies."""
stdout = proc.stdout
if stdout is None:
return False
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:
break
if stop_on_url_change and current != url:
break
# A monitor can go quiet if the device disappears; don't block the
# analyser thread forever. os.read returns whatever is available
# (instead of waiting for a full 4 KiB), which keeps latency low.
if status == "monitor" and os.name == "posix":
try:
ready, _, _ = select.select([fd], [], [], 2.0)
except (OSError, ValueError):
ready = [fd]
if not ready:
break
try:
data = os.read(fd, READ_BYTES)
except OSError:
break
if not data:
break
buffer.extend(data)
# Process fixed-size blocks so attack/decay smoothing is tied to
# time (~46 ms per block), regardless of how the pipe fragments.
while len(buffer) >= READ_BYTES:
block = bytes(buffer[:READ_BYTES])
del buffer[:READ_BYTES]
# 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:
return False
got_data = True
self.feed_pcm(block, status)
return got_data
# ------------------------------------------------------------------ #
# signal processing
# ------------------------------------------------------------------ #
def feed_pcm(self, data: bytes, status: str = "live") -> None:
"""Feed raw little-endian mono s16 PCM (also used by the tests)."""
if not data or len(data) < 2:
return
usable = len(data) - (len(data) % 2)
try:
samples = np.frombuffer(data[:usable], dtype="<i2")
except ValueError:
return
if samples.size == 0:
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:
self._history[:] = samples[-size:]
else:
count = samples.size
self._history[:-count] = self._history[count:]
self._history[-count:] = samples
chunk = self._history
spectrum = np.abs(np.fft.rfft(chunk * self._window))
spectrum /= size * 0.5
db = 20.0 * np.log10(spectrum + 1e-9)
values = np.array(
[db[lo:hi].max() for lo, hi in self._groups], dtype=np.float32
)
peak_db = float(values.max())
self._gain = max(peak_db, self._gain - 0.35)
floor = self._gain - 42.0
norm = np.clip((values - floor) / 42.0, 0.0, 1.0) ** 1.4
rms = float(np.sqrt(np.mean(np.square(chunk)) + 1e-12))
level = float(np.clip((20.0 * math.log10(rms + 1e-9) + 55.0) / 45.0, 0.0, 1.0))
wave = self._downsample(chunk)
self._apply(norm.astype(np.float32), wave, level, status)
def _downsample(self, samples: np.ndarray) -> np.ndarray:
target = self.wave_points
if samples.size < target:
padded = np.zeros(target, dtype=np.float32)
padded[-samples.size :] = samples
samples = padded
step = max(1, samples.size // target)
trimmed = samples[samples.size - step * target :]
try:
folded = trimmed.reshape(target, step).mean(axis=1)
except ValueError:
return np.zeros(target, dtype=np.float32)
peak = float(np.max(np.abs(folded)))
if peak > 1e-4:
folded = folded / peak
return np.clip(folded, -1.0, 1.0).astype(np.float32)
def _apply(
self, values: np.ndarray, wave: np.ndarray, level: float, status: str
) -> None:
with self._lock:
self._levels = np.maximum(values, self._levels * 0.65).astype(np.float32)
self._peaks = np.maximum(values, self._peaks - 0.04).astype(np.float32)
self._wave = (self._wave * 0.55 + wave * 0.45).astype(np.float32)
self._level = float(self._level * 0.5 + level * 0.5)
self._status = status
def _idle_frame(self) -> None:
with self._lock:
self._levels = (self._levels * 0.86).astype(np.float32)
self._peaks = np.maximum(self._peaks - 0.02, 0.0).astype(np.float32)
self._wave = (self._wave * 0.8).astype(np.float32)
self._level *= 0.9
self._status = "idle"
# ------------------------------------------------------------------ #
# simulation
# ------------------------------------------------------------------ #
def _simulate_for(
self, url: str, delay: float, duration: float | None = None
) -> None:
deadline = None if duration is None else time.monotonic() + duration
while not self._stop.is_set():
if self._read_url() != url:
return
self._simulate_step()
if self._stop.wait(delay):
return
if deadline is not None and time.monotonic() >= deadline:
return
def _simulate_step(self) -> None:
self._sim_phase += 0.16
t = self._sim_phase
index = np.linspace(0.0, 1.0, self.band_count, dtype=np.float32)
humps = np.exp(-((index - 0.16) ** 2) / 0.020) * 0.58
humps += np.exp(-((index - 0.46) ** 2) / 0.055) * 0.34
humps += np.exp(-((index - 0.80) ** 2) / 0.130) * 0.20
motion = 0.55 + 0.45 * np.sin(t * 1.9 + index * 11.0)
noise = self._rng.random(self.band_count, dtype=np.float32) * 0.22
beat_phase = (t * 0.55) % 1.0
beat = math.exp(-(beat_phase / 0.16)) * 0.28 * np.exp(-(index**2) / 0.03)
values = np.clip((humps * motion + noise + beat), 0.02, 1.0)
values = np.clip(values * (1.15 - 0.25 * index), 0.0, 1.0).astype(np.float32)
points = np.linspace(0.0, 2.0 * math.pi * 7.0, self.wave_points)
wave = 0.42 * np.sin(points + t * 3.0) * (0.6 + 0.4 * math.sin(t * 0.7))
wave += 0.20 * np.sin(points * 1.9 + t * 4.3)
wave += self._rng.normal(0.0, 0.05, self.wave_points)
wave = np.clip(wave, -1.0, 1.0).astype(np.float32)
level = float(
min(1.0, 0.32 + 0.14 * math.sin(t * 1.7) + 0.25 * math.exp(-(beat_phase / 0.2)))
)
self._apply(values, wave, level, "sim")