"""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 ``BEEFY_SPECTRUM_DEVICE``). * **macOS** — a native Core Audio *process tap* records the default output device (see :mod:`beefy_player.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 .config import USER_AGENT, env_var 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. This only applies before the #: capture has ever delivered real audio. Once a capture has worked, silence #: means the output really is silent (or mpv is rebuffering), so SYNC is kept #: and the bars simply fall instead of switching to the drifting stream tap. SILENT_MONITOR_BLOCKS = 130 #: How long a monitor may deliver no data at all before the stream tap takes #: over. Kept longer than a normal station change. MONITOR_IDLE_SECONDS = 10.0 #: How often a quiet monitor pipe is polled so stop/URL changes stay responsive. MONITOR_POLL_SECONDS = 0.5 #: 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 an output capture is retried. 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 #: Incremented whenever the source changes; captures from an older #: generation must not affect the new one (or latch a failure). self._generation = 0 self._reset_pending = False #: True once any output capture has ever delivered real audio. A later #: silent capture is then a gap, not a misconfigured device. self._monitor_worked = False self._monitor_failed = False #: Next time an output capture may be tried (shared by parec and tap). self._tap_retry_at = 0.0 #: Why ``_capture`` last returned ("eof", "idle", "silent", ...). self._capture_reason = "eof" 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="beefy-player-spectrum", daemon=True ) self._thread.start() def set_source(self, url: str | None) -> None: """Start analysing playback, or idle when ``url`` is ``None``. A station change is a clean slate: any capture belonging to the old station is retired, a previous monitor failure is forgotten, and the analyser's display state is reset. That way every tune gets a fresh chance to lock onto the real output (SYNC) instead of inheriting the drifting stream tap (FFT) or the synthesized fallback (SIM). """ url = url or None with self._lock: changed = url != self._url self._url = url if changed: self._request_reset() if url is None: self._status = "idle" if changed: # Unblock a stream read and retire a subprocess monitor. A tap has # no process; its generation check makes it stop and restart. self._kill_proc() def reset(self) -> None: """Give the output capture a clean slate (called when tuning). Forgets a previous monitor failure - including a hard one that set ``_monitor_failed`` - and clears the displayed state, so the next capture may use the real output (SYNC) again. Unlike :meth:`set_source` this does not change the source, so re-tuning the same station works. """ with self._lock: self._request_reset() 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 _request_reset(self) -> None: """Arm a clean restart; the caller must hold ``self._lock``.""" self._generation += 1 self._reset_pending = True self._monitor_failed = False self._tap_retry_at = 0.0 self._capture_reason = "eof" # The default sink may have changed along with the stream; resolve it # again for the next monitor capture. self._monitor_device_cache = None def _read_generation(self) -> int: with self._lock: return self._generation def _take_reset(self) -> bool: with self._lock: pending, self._reset_pending = self._reset_pending, False return pending def _reset_analysis(self) -> None: """Drop the previous station's samples and display state. Only called from the analyser thread (or before it starts), so the history buffer needs no lock; the values read by ``snapshot`` do. """ self._history[:] = 0.0 self._sim_phase = 0.0 with self._lock: self._levels[:] = 0.0 self._peaks[:] = 0.0 self._wave[:] = 0.0 self._level = 0.0 self._gain = -24.0 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 if self._monitor_kind() is None: return False # A capture that had nothing to give us (a station change leaves the # sink quiet while mpv buffers, a tap is idle before playback starts) # is retried later instead of being written off for the session. 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 = env_var("SPECTRUM_DEVICE") 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=beefy-player", ] 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(): if self._take_reset(): self._reset_analysis() 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(): generation = self._read_generation() outcome = self._capture_monitor(url) if generation != self._read_generation(): # The station changed while this capture was running; its # result belongs to the old source. Start over cleanly. continue if outcome == "done": continue if outcome == "retry": # Nothing was rendered (yet), or the sink went quiet while # the station changed: try the output capture again later. self._tap_retry_at = time.monotonic() + TAP_RETRY_SECONDS else: # Hard failure (the capture never produced audio): do not # retry until the source changes (which resets this). self._monitor_failed = True if self._stop.is_set(): break if self._read_url() is None: continue if not self._ffmpeg: # Keep re-checking: a pending output-capture retry may become # due while we are showing the synthesized fallback. self._simulate_for(url, 0.05, duration=5.0) continue generation = self._read_generation() 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", generation=generation, ) self._stop_proc(proc) if self._stop.is_set(): break if generation != self._read_generation(): continue # The stream tap only covers periods where no output capture is # available. Once the retry deadline has passed, go back around # and try the real thing instead of simulating on top of the tap. if self._use_monitor(): continue 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 capture never produced audio (the device is gone or the sink is not routed into it), and ``"retry"`` when it is temporarily unavailable. """ generation = self._read_generation() if self._monitor_kind() == "tap": outcome = self._capture_tap() if generation != self._read_generation(): return "done" return outcome 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", generation=generation, ) finally: self._stop_proc(proc) if generation != self._read_generation(): # Deliberately stopped/restarted between stations; not a failure. return "done" if self._stop.is_set() or self._read_url() is None: return "done" # A sink that is quiet for a moment (mpv rebuffers a new station, a # device is briefly suspended) is not broken: let the stream tap cover # us until the next retry instead of permanently writing SYNC off. if self._capture_reason == "idle": return "retry" return "failed" def _capture_tap(self) -> str: """Capture the native macOS output tap.""" generation = self._read_generation() 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, generation) finally: tap.stop() with self._lock: if self._capture_kind == "monitor": self._capture_kind = None def _pump_tap( self, tap: macos_tap.SystemAudioTap, generation: int | None = None ) -> str: """Feed tap audio to the analyser until playback stops or it fails.""" if generation is None: generation = self._read_generation() channels = max(1, tap.channels) block_bytes = TAP_BLOCK_FRAMES * max(4, tap.bytes_per_frame) buffer = bytearray() silent_blocks = 0 ever_worked = self._monitor_worked last_audio = time.monotonic() while not self._stop.is_set(): if self._read_generation() != generation: return "done" 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] if not block.strip(b"\x00"): silent_blocks += 1 # A tap that was never granted permission is created # happily but only ever hands out digital silence; detect # that here. Silence after real audio, however, is just a # station change or an actually silent output, so keep # monitoring (the bars fall by themselves). if not ever_worked and 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" else: silent_blocks = 0 ever_worked = True self._monitor_worked = True self.feed_audio(block, channels, "monitor") return "done" def _capture( self, proc: subprocess.Popen[bytes], url: str, *, stop_on_url_change: bool, status: str, generation: int | None = None, ) -> bool: """Read PCM until playback stops, the source changes, or the process dies. ``self._capture_reason`` records why the loop ended so the caller can tell a capture that has gone for good (``"eof"``/``"dead"``) from one that is merely quiet while a station is being tuned (``"idle"`` / ``"restart"``). """ stdout = proc.stdout if stdout is None: self._capture_reason = "eof" return False if generation is None: generation = self._read_generation() fd = stdout.fileno() got_data = False buffer = bytearray() silent_blocks = 0 ever_worked = self._monitor_worked last_data = time.monotonic() reason = "stop" while not self._stop.is_set(): if self._read_generation() != generation: reason = "restart" break current = self._read_url() if current is None: reason = "idle" break if stop_on_url_change and current != url: reason = "url" break # The stream tap yields once an output capture is due for a retry, # so a monitor with a momentary lapse does not keep us on the # (drifting) stream tap for the rest of the session. if status == "live" and self._use_monitor(): reason = "monitor" break # A monitor can go quiet while mpv rebuffers a newly tuned station, # or when the device disappears. Don't block the analyser thread # forever, but give silence a fair chance to turn back into audio. # 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], [], [], MONITOR_POLL_SECONDS ) except (OSError, ValueError): ready = [fd] if not ready: if proc.poll() is not None: reason = "eof" break if time.monotonic() - last_data >= MONITOR_IDLE_SECONDS: reason = "idle" break continue try: data = os.read(fd, READ_BYTES) except OSError: reason = "eof" break if not data: reason = "eof" break now = time.monotonic() last_data = now 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] if status == "monitor": if block.count(0) == len(block): silent_blocks += 1 else: silent_blocks = 0 ever_worked = True self._monitor_worked = True # A capture 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 on a # backend that has never worked. Once it has worked, # silence is real (or mpv rebuffering): keep SYNC and let # the bars fall. if not ever_worked and silent_blocks >= SILENT_MONITOR_BLOCKS: self._capture_reason = "dead" return False got_data = True self.feed_pcm(block, status) self._capture_reason = reason 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=" 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=" 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 # Stop pretending as soon as a real output capture is available # again (e.g. the retry deadline expired during the fallback). if self._use_monitor(): 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")