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
@@ -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