refactor!: rename somafm-tui to beefy-player
The app is now a general internet-radio player rather than a SomaFM client, so the package, console script, MPRIS identity, mpv namespaces, macOS tap names, XDG directories and docs all move to beefy-player. Environment variables are now BEEFY_* (the old SOMAFM_* spellings are still honoured) and an existing ~/.config/somafm-tui/config.json is migrated on first run, so favourites and volume survive the rename. The visible interface is untouched: same layout, same logo, same keys.
This commit is contained in:
1 parent
4bed90b489
commit
e30ca09627
44 files changed
+329
-192
No files matched your search
@@ -0,0 +1,879 @@
|
||||
"""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="<i2")
|
||||
except ValueError:
|
||||
return
|
||||
if samples.size == 0:
|
||||
return
|
||||
self._process(samples.astype(np.float32) / 32768.0, status)
|
||||
|
||||
def feed_audio(self, data: bytes, channels: int = 1, status: str = "monitor") -> 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="<f4")
|
||||
except ValueError:
|
||||
return
|
||||
if samples.size == 0:
|
||||
return
|
||||
channels = max(1, int(channels))
|
||||
if channels > 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")
|
||||
Reference in new issue
Block a user