Files

448 lines
15 KiB
Python

from __future__ import annotations
import sys
import time
from pathlib import Path
import numpy as np
from beefy_player import spectrum as spectrum_module
from beefy_player.spectrum import Frame, SpectrumEngine
PCM_SCRIPT = f"#!{sys.executable}\n" + """import math, struct, sys, time
sr = 44100
samples = [int(0.6 * 32767 * math.sin(2 * math.pi * 1000 * i / sr)) for i in range(4096)]
chunk = struct.pack("<%dh" % len(samples), *samples)
out = sys.stdout.buffer
try:
while True:
out.write(chunk)
out.flush()
time.sleep(0.08)
except (BrokenPipeError, KeyboardInterrupt):
pass
"""
def write_pcm_script(path: Path) -> None:
path.write_text(PCM_SCRIPT)
path.chmod(0o755)
def tap_pcm(
freq: float = 1000,
frames: int = 4096,
sample_rate: int = 48000,
channels: int = 2,
) -> bytes:
"""Interleaved float32 stereo PCM, as the macOS tap delivers it."""
samples = np.arange(frames) / sample_rate
tone = (0.5 * np.sin(2 * np.pi * freq * samples)).astype("<f4")
return np.repeat(tone, channels).tobytes()
class FakeTap:
"""Stand-in for ``beefy_player.macos_tap.SystemAudioTap``."""
def __init__(
self,
*,
silent: bool = False,
idle: bool = False,
error: Exception | None = None,
sample_rate: int = 48000,
channels: int = 2,
) -> None:
self.sample_rate = sample_rate
self.channels = channels
self.bytes_per_frame = 4 * channels
self.stopped = False
self._idle = idle
self._error = error
self._chunk = (
b"\x00" * (4096 * self.bytes_per_frame)
if silent
else tap_pcm(1000, 4096, sample_rate, channels)
)
def start(self) -> None:
if self._error is not None:
raise self._error
def read(self, timeout: float | None = 1.0) -> bytes | None:
if self.stopped or self._idle:
return None
return self._chunk
def stop(self) -> None:
self.stopped = True
def wait_for_spectrum(engine: SpectrumEngine, status: str, timeout: float = 6.0):
deadline = time.monotonic() + timeout
snap = engine.snapshot()
while time.monotonic() < deadline:
snap = engine.snapshot()
if snap.status == status and sum(snap.bands) > 1.0:
break
time.sleep(0.05)
return snap
def test_fft_detects_a_1khz_tone() -> None:
engine = SpectrumEngine(ffmpeg_path="")
sample_rate = 44100
t = np.arange(8192) / sample_rate
tone = np.sin(2 * np.pi * 1000 * t) * 0.5
engine.feed_pcm((tone * 32767).astype("<i2").tobytes())
snap = engine.snapshot()
assert snap.status == "live"
assert snap.label == "FFT"
assert len(snap.bands) == 72
top = max(range(len(snap.bands)), key=lambda index: snap.bands[index])
expected = 72 * np.log(1000.0 / 35.0) / np.log(16000.0 / 35.0)
assert abs(top - expected) <= 2
assert snap.bands[top] > 0.8
assert snap.peaks[top] >= snap.bands[top] - 1e-6
assert any(abs(value) > 0.3 for value in snap.wave)
assert 0.0 < snap.level <= 1.0
def test_frame_labels() -> None:
assert Frame((), (), (), 0.0, "monitor").label == "SYNC"
assert Frame((), (), (), 0.0, "live").label == "FFT"
assert Frame((), (), (), 0.0, "sim").label == "SIM"
assert Frame((), (), (), 0.0, "idle").label == "IDLE"
assert Frame((), (), (), 0.0, "off").label == "OFF"
assert Frame((), (), (), 0.0, "monitor").is_live is True
def test_simulation_fallback() -> None:
engine = SpectrumEngine(ffmpeg_path="", source_mode="sim")
engine.start()
try:
engine.set_source("https://example.invalid/demo")
deadline = time.monotonic() + 5
snap = engine.snapshot()
while time.monotonic() < deadline:
snap = engine.snapshot()
if snap.status == "sim" and max(snap.bands) > 0.2:
break
time.sleep(0.05)
assert snap.status == "sim"
assert snap.label == "SIM"
assert max(snap.bands) > 0.2
assert 0.0 <= snap.level <= 1.0
finally:
engine.stop()
def test_monitor_capture_prefers_real_output(tmp_path, monkeypatch) -> None:
script = tmp_path / "parec"
write_pcm_script(script)
def fake_which(name: str) -> str | None:
return str(script) if name == "parec" else None
monkeypatch.setattr(spectrum_module.shutil, "which", fake_which)
engine = SpectrumEngine(source_mode="auto")
assert engine.monitor_available is True
engine.start()
try:
engine.set_source("https://example.invalid/live")
snap = wait_for_spectrum(engine, "monitor")
assert snap.status == "monitor", snap
assert snap.label == "SYNC"
top = max(range(len(snap.bands)), key=lambda index: snap.bands[index])
assert 36 <= top <= 42
assert snap.bands[top] > 0.5
engine.set_source(None)
time.sleep(0.3)
assert engine.snapshot().status == "idle"
finally:
engine.stop()
def test_stream_tap_pipeline(tmp_path) -> None:
script = tmp_path / "fake-ffmpeg"
write_pcm_script(script)
engine = SpectrumEngine(ffmpeg_path=str(script), source_mode="stream")
engine.start()
try:
engine.set_source("https://example.invalid/stream")
snap = wait_for_spectrum(engine, "live")
assert snap.status == "live", snap
assert snap.label == "FFT"
top = max(range(len(snap.bands)), key=lambda index: snap.bands[index])
assert 36 <= top <= 42
assert snap.bands[top] > 0.5
engine.set_source(None)
time.sleep(0.3)
assert engine.snapshot().status == "idle"
finally:
engine.stop()
def test_macos_tap_is_the_sync_monitor(monkeypatch) -> None:
engine = SpectrumEngine(ffmpeg_path="")
engine._parec = None
engine._darwin = True
monkeypatch.setattr(spectrum_module.macos_tap, "available", lambda: True)
tap = FakeTap()
monkeypatch.setattr(spectrum_module.macos_tap, "SystemAudioTap", lambda: tap)
assert engine.monitor_available is True
assert engine._monitor_kind() == "tap"
engine.start()
try:
engine.set_source("https://example.invalid/live")
snap = wait_for_spectrum(engine, "monitor")
assert snap.status == "monitor", snap
assert snap.label == "SYNC"
top = max(range(len(snap.bands)), key=lambda index: snap.bands[index])
assert 36 <= top <= 42
assert snap.bands[top] > 0.5
engine.set_source(None)
time.sleep(0.3)
assert engine.snapshot().status == "idle"
assert tap.stopped is True
finally:
engine.stop()
def test_silent_tap_falls_back_instead_of_flat_bars(monkeypatch) -> None:
# A tap that was never granted System Audio Recording permission is
# created happily but only ever hands out digital silence.
engine = SpectrumEngine(ffmpeg_path="")
engine._parec = None
engine._darwin = True
monkeypatch.setattr(spectrum_module.macos_tap, "available", lambda: True)
tap = FakeTap(silent=True)
monkeypatch.setattr(spectrum_module.macos_tap, "SystemAudioTap", lambda: tap)
engine.start()
try:
engine.set_source("https://example.invalid/live")
snap = wait_for_spectrum(engine, "sim")
assert snap.status == "sim", snap
assert engine.monitor_available is False
assert tap.stopped is True
notice = engine.take_notice()
assert notice is not None and "System Audio Recording" in notice
assert engine.take_notice() is None
finally:
engine.stop()
def test_tap_start_failure_falls_back_and_explains(monkeypatch) -> None:
engine = SpectrumEngine(ffmpeg_path="")
engine._parec = None
engine._darwin = True
monkeypatch.setattr(spectrum_module.macos_tap, "available", lambda: True)
monkeypatch.setattr(
spectrum_module.macos_tap,
"SystemAudioTap",
lambda: FakeTap(error=spectrum_module.macos_tap.TapError("boom")),
)
engine.start()
try:
engine.set_source("https://example.invalid/live")
snap = wait_for_spectrum(engine, "sim")
assert snap.status == "sim", snap
assert engine.monitor_available is False
notice = engine.take_notice()
assert notice is not None and "macOS audio capture failed" in notice
finally:
engine.stop()
def test_idle_tap_retries_instead_of_latching(monkeypatch) -> None:
# If the output device renders nothing yet (slow stream, or mpv pointed at
# another device) the stream tap covers us and the tap is retried later.
monkeypatch.setattr(spectrum_module, "TAP_IDLE_SECONDS", 0.05)
engine = SpectrumEngine(ffmpeg_path="")
engine._parec = None
engine._darwin = True
monkeypatch.setattr(spectrum_module.macos_tap, "available", lambda: True)
tap = FakeTap(idle=True)
monkeypatch.setattr(spectrum_module.macos_tap, "SystemAudioTap", lambda: tap)
engine.start()
try:
engine.set_source("/tmp/tone.wav")
snap = wait_for_spectrum(engine, "sim")
assert snap.status == "sim", snap
assert engine.monitor_available is True # not a permanent failure
assert engine._tap_retry_at > time.monotonic()
notice = engine.take_notice()
assert notice is not None and "default output device" in notice
finally:
engine.stop()
def test_tap_audio_is_downmixed_and_retuned() -> None:
engine = SpectrumEngine(ffmpeg_path="")
engine._retune(48000)
engine.feed_audio(tap_pcm(1000, sample_rate=48000), channels=2)
snap = engine.snapshot()
assert snap.status == "monitor"
top = max(range(len(snap.bands)), key=lambda index: snap.bands[index])
assert 36 <= top <= 42
assert snap.bands[top] > 0.8
def test_dead_monitor_falls_back_instead_of_flat_bars(tmp_path) -> None:
script = tmp_path / "silent-parec"
script.write_text(
f"#!{sys.executable}\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()
def test_monitor_survives_silence_after_audio(tmp_path, monkeypatch) -> None:
# Tuning a station leaves mpv's sink silent while the new stream buffers.
# That gap must not be mistaken for a dead monitor: only a capture that has
# never delivered audio is written off, otherwise SYNC would be lost (and
# replaced by the drifting stream tap) for the rest of the session.
script = tmp_path / "parec"
script.write_text(
f"#!{sys.executable}\n"
"import math, struct, sys, time\n"
"sr = 44100\n"
"n = 2048\n"
"tone = struct.pack('<%dh' % n, *["
"int(0.6 * 32767 * math.sin(2 * math.pi * 1000 * i / sr)) for i in range(n)])\n"
"silence = b'\\0' * (2 * n)\n"
"out = sys.stdout.buffer\n"
"for _ in range(40):\n"
" out.write(tone)\n"
"out.flush()\n"
"for _ in range(400):\n"
" out.write(silence)\n"
"out.flush()\n"
"time.sleep(30)\n"
)
script.chmod(0o755)
def fake_which(name: str) -> str | None:
return str(script) if name == "parec" else None
monkeypatch.setattr(spectrum_module.shutil, "which", fake_which)
engine = SpectrumEngine(ffmpeg_path="")
engine.start()
try:
engine.set_source("https://example.invalid/live")
assert wait_for_spectrum(engine, "monitor").status == "monitor"
# The script now feeds far more than SILENT_MONITOR_BLOCKS of digital
# silence. Give the analyser time to consume it, then make sure the
# capture is still alive and still SYNC.
time.sleep(1.0)
snap = engine.snapshot()
assert snap.status == "monitor", snap
assert snap.label == "SYNC"
assert engine.monitor_available is True
assert engine._monitor_failed is False
finally:
engine.stop()
class SequencedTap(FakeTap):
"""A tap that hands out a fixed list of buffers, then goes quiet."""
def __init__(self, chunks: list[bytes], **kwargs) -> None:
super().__init__(**kwargs)
self._chunks = list(chunks)
def read(self, timeout: float | None = 1.0) -> bytes | None:
if self.stopped:
return None
return self._chunks.pop(0) if self._chunks else None
def test_tap_survives_silence_after_audio(monkeypatch) -> None:
# A real tap that has already captured audio is allowed to go quiet while
# a new station buffers; only a tap that never produced audio means the
# System Audio Recording permission is missing.
engine = SpectrumEngine(ffmpeg_path="")
engine._parec = None
engine._darwin = True
monkeypatch.setattr(spectrum_module.macos_tap, "available", lambda: True)
chunks = [tap_pcm(1000, 4096)] + [b"\x00" * (4096 * 8)] * 200
tap = SequencedTap(chunks)
monkeypatch.setattr(spectrum_module.macos_tap, "SystemAudioTap", lambda: tap)
engine.start()
try:
engine.set_source("https://example.invalid/live")
assert wait_for_spectrum(engine, "monitor").status == "monitor"
time.sleep(0.5)
snap = engine.snapshot()
assert snap.status == "monitor", snap
assert engine.monitor_available is True
assert engine._monitor_failed is False
assert tap.stopped is False
finally:
engine.stop()
def test_tuning_clears_a_monitor_failure_and_retries_sync(tmp_path, monkeypatch) -> None:
# Once an output capture has failed (or been given up on after a tuning
# gap), a station change must clear that state and try SYNC again instead
# of staying on the drifting FFT stream for the rest of the session.
script = tmp_path / "parec"
write_pcm_script(script)
def fake_which(name: str) -> str | None:
return str(script) if name == "parec" else None
monkeypatch.setattr(spectrum_module.shutil, "which", fake_which)
engine = SpectrumEngine(ffmpeg_path="")
engine.start()
try:
engine.set_source("https://example.invalid/a")
assert wait_for_spectrum(engine, "monitor").status == "monitor"
# Simulate the latch that a silent retry used to leave behind.
engine._monitor_failed = True
engine._tap_retry_at = time.monotonic() + 60.0
for index in range(10):
engine.set_source(f"https://example.invalid/b{index}")
time.sleep(0.02)
snap = wait_for_spectrum(engine, "monitor")
assert snap.status == "monitor", snap
assert engine._monitor_failed is False
assert engine.monitor_available is True
assert engine._monitor_worked is True
finally:
engine.stop()