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:
1 parent
9c722c5f61
commit
69b46b9035
2 files changed
+275
-18
No files matched your search
+165
-18
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user