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.
|
"""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,18 +260,120 @@ 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()
|
||||||
|
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 None
|
||||||
return [
|
for index, name in devices:
|
||||||
self._parec,
|
haystack = name.casefold()
|
||||||
f"--device={self._monitor_device()}",
|
if any(hint in haystack for hint in LOOPBACK_HINTS):
|
||||||
"--format=s16le",
|
self._avf_device = index
|
||||||
"--rate=44100",
|
return index
|
||||||
"--channels=1",
|
return None
|
||||||
"--raw",
|
|
||||||
"--latency-msec=30",
|
|
||||||
"--client-name=somafm-tui",
|
|
||||||
]
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user