# Beefy Player ![beefy-player demo](assets/beefy-player-demo.gif) A flashy terminal player for **internet radio** — a live spectrum analyser, song history, and full desktop media integration, drawn with your terminal's own ANSI palette on the terminal's default background so it fits any theme (including transparent terminals). The station catalogue is pluggable. Beefy Player ships with thirteen catalogues — **[SomaFM](https://somafm.com)** (≈50 curated channels), **[Radio Paradise](https://radioparadise.com)** (five mixes), the community stations **[tilderadio](https://tilderadio.org)**, **[Bassdrive](https://bassdrive.com)**, **[dublab](https://dublab.com)**, **[Rinse FM](https://rinse.fm)**, **[Refuge Worldwide](https://refugeworldwide.com)**, **[LYL Radio](https://lyl.live)**, **[The Lot Radio](https://thelotradio.com)**, **[Cashmere Radio](https://cashmereradio.com)**, **[Datafruits](https://datafruits.fm)** and **[aNONradio](https://anonradio.net)**, plus the **[Radio Browser](https://www.radio-browser.info)** directory (300 stations) — and the UI never talks to a station catalogue directly: it asks a [provider](#providers) for stations, song history and stream URLs. Press `p` to switch catalogue without restarting. Adding another internet-radio catalogue is a self-contained module, not a rewrite. It runs the same way on **Linux** (PulseAudio/PipeWire) and **macOS** (CoreAudio, iTerm2/Kitty/WezTerm): MPRIS on the desktop bus where that exists, macOS Now Playing (Control Center and the media keys) where it does not. No existing radio client is used: the station list, playlists, song history, audio control, spectrum visualiser and media-session integration are all implemented here from public endpoints and open protocols. ## Features - **Live spectrum analyser** — 72 log-spaced bands with peak caps, a moving gradient taken from your terminal's ANSI palette and a symmetric mirrored spectrum. Six modes (`v`): mirrored bars, plain bars, oscilloscope, scrolling waterfall spectrogram, a radial spectrum wrapped around the centre, and a high-resolution braille oscilloscope (2×4 dots per cell). - **Synced spectrum, not a guess** — by default the analyser records your actual audio output, so the bars follow what you hear (`SYNC`): the PulseAudio/PipeWire monitor via `parec` on Linux, and a native Core Audio process tap on macOS (no driver, no BlackHole, nothing to install). Without an output capture it falls back to a muted second `ffmpeg` decode of the stream (`FFT`, can drift), then to a synthesized display (`SIM`). - **Real artwork on iTerm2, Kitty and WezTerm** — Kitty uses the kitty graphics protocol; iTerm2 (including on macOS) and WezTerm use their native iTerm2 inline-image protocol. Images occupy exactly the ON AIR art cell box and fall back to the procedural ANSI mosaic for other terminals, failed loads, or blocked graphics passthrough. Images are cached in `~/.cache/beefy-player/artwork`, letterboxed rather than cropped, and an image that carries an alpha channel is composited onto black — a transparent PNG drawn over the mosaic reads as noise behind a station logo. - **Now playing** — artist, title, album, DJ, listeners, format/bitrate and "played N minutes ago", with procedural ANSI-shaded album art that changes per track. When the current track can be found on Bandcamp, a clickable **Bandcamp ↗** link appears below it; otherwise the line stays hidden. Catalogues can opt out with `bandcamp = False` (tilderadio does), and a provider can fill the `Station.presenter` line instead — tilderadio shows `auto dj · library rotation` between live shows and `dj ` during one. - **Last played** — the full recent history, newest first. - **Every station** — with SomaFM that is the whole `channels.json` roster (≈50 channels), live listener counts and genre search; tilderadio is a single-station catalogue, and the list simply holds one. Long lists get a grey thumb down the right edge (no rail) and a `12/51` position in the pane title; `pgup`/`pgdn` move a screen at a time. - **Favourites** that float to the top, remembered between runs. - **Pluggable catalogues** — one `providers` module per radio network, with a `p` picker in the UI, `--provider` on the command line, and the choice remembered per catalogue (including the last station you tuned). Switching to a single-station catalogue tunes it straight away; a multi-station one waits for `enter`. - **A hero per catalogue** — the top-left art is the catalogue's own name in a shared half-block font: SomaFM in the spectrum gradient, tilderadio in the terminal's default colour, Bassdrive in its two blues. Every hero is three rows tall, so the header keeps one height. - **Schedule pane** — a single-stream catalogue gets a broadcast schedule instead of a list of one station: who is on air now, since/until when, and the upcoming shows grouped by day. - **Desktop media integration** — on Linux an `org.mpris.MediaPlayer2.beefyplayer.instance` player exposes `PlaybackStatus`, `Metadata` (title/artist/album/artUrl), `Volume`, and `Play`/`Pause`/`PlayPause`/`Stop`/`Next`/`Previous`, so GNOME/KDE widgets, lock screens, `playerctl` and the media keys pick it up. On macOS the audio engine (mpv) publishes to the system **Now Playing** session, so Control Center, the menu bar and the keyboard media keys drive the app the same way. - **Sixteen palettes** — every colour comes from one ladder of ANSI names per theme: the spectrum analyser, the album-art mosaic, the provider wordmarks and the pane corners all draw from it. `c` cycles spectrum, rainbow, ocean, ember, mint, sunset, violet, acid, neon, sand, blood, forest, ice, steel, candy and mono; the choice is remembered. - **Theme-aware** — every accent is emitted as native ANSI (not RGB), and the background is the terminal default (`SGR 49`), so transparent terminals stay transparent and the TUI adopts your shell/terminal colour scheme. - **Graceful degradation** — works with cached data offline, falls back to a bundled station list, reconnects across ICE mirrors automatically, and uses `ffplay` if `mpv` is missing. ## Requirements `beefy-player` is a normal Python application: there is nothing to compile and no distro-specific packaging. It runs on any Linux distribution or macOS with Python **3.10+** and a few command-line audio tools on `$PATH`. | tool | why | | ---- | --- | | `python3` | runtime (3.10 or newer; no exact version pin) | | [`uv`](https://docs.astral.sh/uv/) | creates the venv and installs every Python dependency from `uv.lock` | | `mpv` | playback and JSON IPC control | | `ffmpeg` | muted stream decode for the fallback analyser | | `parec` / `pactl` *(Linux)* | synced monitor capture for the spectrum (`pulseaudio-utils`; works with PipeWire) | | D-Bus session bus *(Linux)* | MPRIS desktop media integration (`playerctl` is handy for testing) | On macOS the spectrum capture is part of the app: it uses the Core Audio process-tap API (macOS 14.2 or newer), so there is no loopback driver to install. On macOS the media integration needs nothing extra: mpv is what the system's Now Playing session (Control Center, media keys) talks to. Install `uv` once (skip if it is already there): ```bash curl -LsSf https://astral.sh/uv/install.sh | sh ``` `brew install uv`, `pipx install uv`, or a distro package also work. If your system Python is older than 3.10, let uv manage one: `uv python install 3.12`. ### Linux Install these with your package manager. Names are usually: ```bash # Debian / Ubuntu sudo apt install python3 mpv ffmpeg pulseaudio-utils dbus playerctl # Fedora (enable RPM Fusion for ffmpeg if needed) sudo dnf install python3 mpv ffmpeg pulseaudio-utils dbus-tools playerctl # Arch / Manjaro sudo pacman -S python mpv ffmpeg libpulse dbus playerctl # openSUSE sudo zypper install python3 mpv ffmpeg pulseaudio-utils dbus-1-tools playerctl ``` For other distributions, install the equivalents. On a headless machine you can skip the audio/D-Bus tools and use `--no-audio` for a UI-only demo, though the synced spectrum needs `parec`/`pactl`. ### macOS ```bash brew install mpv ffmpeg uv # ffplay ships with ffmpeg ``` That is enough to run: playback, artwork in iTerm2/Kitty/WezTerm, the synced Core Audio spectrum tap and the macOS Now Playing integration all work out of the box. The first time the analyser starts it asks macOS for **System Audio Recording** permission — a one-off prompt for your terminal (iTerm2, Kitty, WezTerm, …). Approve it under *System Settings → Privacy & Security → System Audio Recording*; no loopback driver, Multi-Output Device or audio routing is needed. If the permission is denied the tap only ever hands out silence, the analyser notices and falls back to `FFT` instead of showing dead bars. The tap records the **default output device**, which is also what mpv uses; if you point mpv elsewhere with `BEEFY_MPV_ARGS='--audio-device=...'` the analyser falls back to `FFT`. Terminal image rendering uses [Pillow](https://python-pillow.org/) and is implemented directly (Kitty TGP + iTerm2 OSC 1337) — no terminal probing. ## Providers A *provider* is one station catalogue. It knows how to fetch the station list, how to fetch a station's recently played songs, and how to turn a station into a stream URL. Everything else — the UI, the player, the spectrum, the media session — is provider-neutral. ```python # beefy_player/providers/base.py class RadioProvider(ABC): id, name, homepage, brand, tagline, station_source, history_source, user_agent hero, hero_gradient, hero_lines # the header art and its caption has_schedule # schedule pane instead of a list bandcamp # look tracks up on Bandcamp? async def fetch_stations(self, client) -> list[Station]: ... def load_cached_stations(self) -> list[Station]: ... def fallback_stations(self) -> list[Station]: ... async def fetch_history(self, client, station_id) -> list[Song]: ... def load_cached_history(self, station_id) -> list[Song]: ... async def fetch_schedule(self, client) -> list[Broadcast]: ... def load_cached_schedule(self) -> list[Broadcast]: ... def fallback_urls(self, station) -> list[str]: ... async def stream_candidates(self, client, station) -> list[str]: ... ``` `hero` rows are rich markup (half blocks); with `hero_gradient` the header paints plain glyphs with the app's spectrum palette instead, which is how the SomaFM wordmark stays theme-aware. `has_schedule` swaps the left pane for the schedule view — the pane asks the provider for `Broadcast` objects (name, DJ, start, end, live) and never learns where they came from. SomaFM is the reference implementation, and it is quarantined in its own package: the `channels.json` schema, the `songs/.json` history, the PLS layout and the guessed icecast mirrors all live in `beefy_player/providers/somafm/`. Nothing outside that package imports it; the app only ever holds a `RadioProvider`. Thirteen catalogues ship today: | slug | catalogue | stations | what is on | relays | | --- | --- | --- | --- | --- | | `somafm` | [SomaFM](https://somafm.com) | `channels.json` (≈50) | `songs/.json` | `.pls` → icecast mirrors | | `radioparadise` | [Radio Paradise](https://radioparadise.com) | five mixes | `api/now_playing` (artist, album, cover) | FLAC / AAC / MP3 icecast | | `tilderadio` | [tilderadio](https://tilderadio.org) | 1 + schedule | `/api/now/` | AzuraCast mounts | | `bassdrive` | [Bassdrive](https://bassdrive.com) | 1 + schedule | icecast `display-title` (the show) | three mounts + mirrors | | `dublab` | [dublab](https://dublab.com) | 1 + schedule | dublab's calendar | Airtime mp3 | | `rinse` | [Rinse FM](https://rinse.fm) | 3 | — | proxy mounts (aac+) | | `refuge` | [Refuge Worldwide](https://refugeworldwide.com) | 1 | `public.radio.co` status | radio.co mp3 | | `lyl` | [LYL Radio](https://lyl.live) | 1 | — | icecast mp3 | | `lot` | [The Lot Radio](https://thelotradio.com) | 1 | — | HLS | | `cashmere` | [Cashmere Radio](https://cashmereradio.com) | 1 | — | Airtime ogg/mp3 | | `datafruits` | [Datafruits](https://datafruits.fm) | 1 | streampusher icecast | relay mp3 | | `anonradio` | [aNONradio](https://anonradio.net) | 1 | icecast title | icecast mp3 | | `radiobrowser` | [Radio Browser](https://www.radio-browser.info) | 300 (directory) | — | whatever each station publishes | They are deliberately different shapes: fifty channels with PLS playlists, a single AzuraCast stream that carries its own history, and an Icecast relay whose "now playing" is the *show* rather than the track. That is the seam working. The single-stream catalogues also use the optional capabilities: where a schedule exists (`tilderadio`, `bassdrive`, `dublab`) the left pane is that schedule rather than a list containing one station, and none of them do Bandcamp lookups. Bassdrive is the extreme case — Icecast reports one title and keeps no history, so the provider maps the title to `Station.dj`/`presenter` and keeps a rolling record of the shows it has heard. The community stations share one base class, `providers/community/base.py`, which knows how to read an Icecast `status-json.xsl`, a `public.radio.co` status, a scheduled show, or nothing at all; each station is then ten lines of data (relays, wordmark, tagline). Where a relay publishes no metadata (Rinse, LYL, The Lot, Cashmere) the app simply plays it and says so, rather than inventing a track. `radiobrowser` is the directory case: three hundred of the most-clicked working stations from the community database, keyless and public-domain, with no per-station history at all — breadth rather than depth, and the one catalogue where ON AIR legitimately has nothing to say. ### Header wordmarks All three heroes come from one font, `beefy_player/providers/wordmark.py`: four-column capitals six pixels tall, emitted as three rows of half blocks (`▀`/`▄`/`█`) — the shape of the original SomaFM logo. A provider's `hero.py` is one line of data: ```python # beefy_player/providers/bassdrive/hero.py HERO = render("BASSDRIVE", ["bright_cyan"] * 4 + ["cyan"] * 5) ``` `render()` takes one optional style per character and groups equal neighbours into markup spans; with no styles the wordmark inherits the terminal's default foreground (tilderadio). Add a glyph to `GLYPHS` when a catalogue needs a letter the font does not have yet — `▀█▀` is the I, one pixel thick on purpose. Image logos are a different problem: `tools/ansi_art.py` converts a PNG into half-block art (`--key-white` for logos on white, `--preview out.png` to see the result). No shipped catalogue needs it today, but it is the tool to reach for when a station's identity is a picture rather than a word. Adding a catalogue: 1. create `beefy_player/providers//` with a `RadioProvider` subclass, 2. register one instance in `beefy_player/providers/__init__.py`, 3. run it with `beefy-player --provider `, press `p` in the UI, or set `"provider"` in `~/.config/beefy-player/config.json`. `RadioProvider.stream_candidates()` already implements the common strategy — resolve the station's playlists (with an on-disk cache), then try `fallback_urls()` — so most providers only have to supply the catalogue, history and mirror URLs. ## Run From the repository root, sync the environment and start the app: ```bash uv sync # create .venv and install exactly what uv.lock pins uv run beefy-player # run inside that environment ``` You do **not** need to activate the venv. `uv run` uses `.venv` automatically, and re-syncs it first if `pyproject.toml` or `uv.lock` changed. If you prefer a traditional shell, run `source .venv/bin/activate` and then `beefy-player`. ### Keeping the uv environment in sync `uv sync` is the command to remember. It is incremental, safe to re-run, and should be run after pulling changes, switching branches, or editing `pyproject.toml`: ```bash uv sync # make .venv match uv.lock uv sync --frozen # strict: fail if uv.lock is out of date, never rewrite it uv lock --upgrade # refresh uv.lock to the newest allowed versions uv sync --upgrade # upgrade dependencies and sync .venv in one step rm -rf .venv && uv sync # last resort if the venv was built by an older Python ``` Useful flags: ```bash uv run beefy-player --provider somafm # pick a station catalogue uv run beefy-player --provider tilderadio # one community stream, tildeverse uv run beefy-player --play # start the last station immediately uv run beefy-player --station defcon # pick a station uv run beefy-player --volume 55 uv run beefy-player --offline # no network; cached/bundled stations uv run beefy-player --no-mpris # no MPRIS / Now Playing, no media keys uv run beefy-player --list # print station ids uv run beefy-player --no-audio # UI demo without mpv/ffmpeg uv run beefy-player --spectrum-source monitor # force synced output capture uv run beefy-player --spectrum-source stream # force the muted ffmpeg tap BEEFY_IMAGES=0 uv run beefy-player # force the ANSI artwork mosaic BEEFY_IMAGES=tgp uv run beefy-player # force Kitty/TGP artwork BEEFY_IMAGES=iterm2 uv run beefy-player # force iTerm2/WezTerm artwork ``` Environment variables are prefixed `BEEFY_`; the pre-rename `SOMAFM_` spellings (`SOMAFM_IMAGES`, `SOMAFM_MPV_ARGS`, `SOMAFM_SPECTRUM_DEVICE`) are still honoured, so an existing shell profile keeps working. The UI wants roughly **84×24**; it also works smaller, just less pretty. ## Keys | key | action | | -------------- | --------------------------------------------- | | `↑` / `↓`, `j` / `k` | move through stations | | `pgup` / `pgdn` | page through stations | | `enter` | tune in to the selected station | | `space` | play / pause | | `s` | stop | | `]` / `[` | next / previous station (tunes immediately) | | `+` / `-` | volume | | `m` | mute | | `f` | favourite (favourites sort to the top) | | `/` | filter by title, genre, description | | `esc` | clear filter / close search | | `v` | visualiser: mirror → bars → wave → waterfall → radial → braille | | `c` | colour palette (16 of them, remembered) | | `r` | refresh stations, schedule + song history | | `p` | switch station catalogue (SomaFM / tilderadio)| | `g` / `G` | top / bottom | | `?` | help | | `q` | quit | ## Desktop media widgets (MPRIS / Now Playing) **Linux** — while playing, the app exports `org.mpris.MediaPlayer2.beefyplayer.instance` on the **session bus**. Check it: ```bash playerctl -l playerctl metadata --format '{{artist}} — {{title}} ({{album}})' playerctl play-pause ``` `mpris:artUrl` is the track's album art when the provider supplies it, otherwise the channel logo. If there is no session bus (SSH without `dbus-run-session`, containers, …) MPRIS is skipped and the status bar shows `mpris off`. **macOS** — mpv owns the system Now Playing session, so Control Center, the menu-bar widget and the keyboard media keys (▶︎/⏸, ⏭, ⏮) all target this app while it is playing. mpv publishes the live stream title (the same “Artist - Title” that the station sends with the audio), and the media keys are routed back into the TUI, so `⏭`/`⏮` really change station and the panel, the spectrum and the station list stay in sync. The status bar shows `nowplaying on`. There is no D-Bus and no extra dependency involved, and `--no-mpris` (also spelled `--no-media`) turns the media keys off again. > macOS shows one Now Playing app at a time. If Music/Safari/another mpv is > playing, the media keys go there instead — quit it or pause it to hand the > keys back. ## How it works ``` beefy_player/ app.py Textual app: layout, keys, orchestration models.py Song + Station, provider-neutral config.py XDG paths, settings, BEEFY_* env helpers providers/ base.py RadioProvider interface + HTTP/playlist toolkit wordmark.py the half-block font every hero is drawn with somafm/ the SomaFM catalogue (API, hero, bundled fallback list) radiobrowser/ the community directory (300 stations, no metadata) radioparadise/ five curated mixes with real track metadata tilderadio/ the tilderadio catalogue (one station, hero, schedule) bassdrive/ Bassdrive (Icecast status, show schedule, rolling history) community/ one module per community station, on a shared base player.py mpv over a Unix-socket JSON IPC channel spectrum.py analyser (parec / Core Audio tap / ffmpeg / sim) macos_tap.py macOS output capture (ctypes + Core Audio) mediasession.py platform-neutral desktop media face mpris.py dbus-next MPRIS service artwork.py Kitty TGP / iTerm2 images with an ANSI mosaic fallback bandcamp.py "is this track on Bandcamp?" lookups widgets/ the Textual widgets (schedule pane, catalogue picker, …) tools/ ansi_art.py logo PNG -> half-block ANSI hero (regenerates tilderadio's) ``` - `beefy_player/providers/base.py` — the provider interface and the shared toolkit: `httpx` client factory plus `.pls`/`.m3u` parsing and resolution (cached for 8 hours), so providers only describe their own catalogue. - `beefy_player/providers/somafm/api.py` — async `httpx` client for: - `channels.json` → stations, genres, listeners, `.pls` playlist URLs - `.pls` → resolved direct icecast URLs (cached 8h) - `songs/.json` → last played tracks (cached on disk) Playlist and channel caching means the UI opens instantly and still works offline. - `beefy_player/providers/tilderadio/api.py` — the same idea for a single-station catalogue: `/api/now/` carries the station, the live DJ, the listener count and both `now_playing` and `history[]`, while AzuraCast's mount list contributes the bitrates (320k mp3, 192k mp3/ogg). Station and history caches are per catalogue, so the two providers never overwrite each other's files. - `beefy_player/app.py` — holds one provider and one `Player`, ticks at 20 Hz, and hands the widgets plain `Station`/`Song`/`Broadcast` objects. `p` swaps the provider in place: the old stream is stopped, workers for the previous catalogue are cancelled, the hero height and left pane follow the new provider, and the station list (or schedule) reloads from cache first. - `beefy_player/providers/bassdrive/api.py` — Icecast's keyless `status-json.xsl` (mounts, bitrates, listeners, `display-title`) plus the homepage schedule grid, whose slots carry absolute `` values. A memoised fetch and a lock keep a cold start to one homepage download; the display-title's show is matched against the running slot for "live from ". - `beefy_player/providers/community/` — the community stations: `base.py` reads Icecast or radio.co status, keeps the rolling history and turns a running schedule slot into "what is on"; each station module is data. `rinse.py` shows the multi-station case and `dublab.py` the schedule case. - `beefy_player/providers/wordmark.py` — the half-block capitals behind every hero, with per-character style spans. - `beefy_player/widgets/schedule.py` — the left pane for single-stream catalogues: the live slot with its `since`/`until` window, then upcoming shows grouped by local day, with `… +N more` when the pane runs out. - `beefy_player/player.py` — mpv over a Unix-socket JSON IPC channel. Observes `pause`/`volume`, handles mirror failover on `end-file`, auto-restarts a dead engine, and uses `SIGSTOP`/`SIGCONT` when falling back to `ffplay`. On macOS it also asks mpv to publish Now Playing and maps the media keys onto `script-message`s that come back over the same IPC socket. - `beefy_player/spectrum.py` — analyser (44.1 kHz mono s16le from `parec`, or the device's native float32 rate from the macOS tap). Prefers capturing the real output: `parec` (PulseAudio/PipeWire monitor) on Linux, or a native Core Audio process tap on macOS (SYNC, see `macos_tap.py`). Otherwise a muted low-latency `ffmpeg` decode of the stream (FFT). Hann-windowed 2048-point FFT, log band grouping, auto-gain, fast attack/decay smoothing, peak hold and waveform downsampling. - `beefy_player/macos_tap.py` — the macOS output capture, in pure `ctypes` against Core Audio: `CATapDescription` → `AudioHardwareCreateProcessTap` → private aggregate device → IOProc. No driver, no helper binary, no ffmpeg. - `beefy_player/mediasession.py` — picks the desktop media backend (MPRIS on Linux, Now Playing on macOS) so the app has one platform-neutral interface. - `beefy_player/mpris.py` — `dbus-next` MPRIS service in its own thread with a thread-safe command queue back into the UI (only loaded on D-Bus platforms). - `beefy_player/bandcamp.py` — searches Bandcamp's public autocomplete API for the now-playing track (artist + track first, then artist + album, then the album page) and returns a track URL when there is a match. - `beefy_player/widgets/` — Textual widgets rendered with native ANSI colours; backgrounds are `ansi_default` (`SGR 49`), so transparent terminals stay transparent. ### Files ``` ~/.config/beefy-player/config.json favourites, volume, provider, last stations ~/.cache/beefy-player/channels.json last good SomaFM station list ~/.cache/beefy-player/channels-.json last good list, other catalogues ~/.cache/beefy-player/schedule-.json last good broadcast schedule ~/.cache/beefy-player/playlists.json resolved stream URLs ~/.cache/beefy-player/songs/.json SomaFM song history snapshots ~/.cache/beefy-player/songs/

-.json history for every other catalogue ~/.cache/beefy-player/artwork/ cached album/channel artwork ``` `last_stations` remembers which station each catalogue was tuned to, so switching back with `p` lands on the same channel. These stay in the XDG locations on every platform (macOS included), so the same dotfiles/config work everywhere; `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and `XDG_RUNTIME_DIR` are honoured when set. The app started life as `somafm-tui`: on first run a `~/.config/somafm-tui/config.json` is copied over so favourites and volume survive the rename. ## Notes / troubleshooting - **Artwork shows the mosaic?** Real images are auto-enabled for Kitty, iTerm2 and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty/TGP) or `BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the ANSI mosaic. iTerm2 on macOS is detected from `TERM_PROGRAM`/`LC_TERMINAL`, and empty or `0` values in `TMUX`/`ZELLIJ`/`STY` (exported by some wrappers) are ignored so they cannot downgrade it. Kitty inside tmux is attempted through tmux passthrough (enable `set -g allow-passthrough on`); iTerm2/WezTerm inside tmux default to the mosaic. Images are cached under `~/.cache/beefy-player/artwork`. - **Clock shows UTC?** The clock and song history use the system zone from `/etc/localtime`; a non-UTC `TZ` overrides it. A container or dev shell can inherit a build-time `TZ=UTC`, which the app deliberately ignores; bind the host's `/etc/localtime` or set e.g. `TZ=Europe/Berlin` in the shell that launches the app. - **MPRIS says off/error?** No session bus. Run from your desktop session (the shell profile picks up `$XDG_RUNTIME_DIR/bus`). `dbus-run-session -- uv run beefy-player` starts a private bus for testing, but desktop widgets won't see it. macOS does not use MPRIS at all — see “Now Playing” above. - **No audio?** `beefy-player` needs `mpv` (preferred) or `ffplay`. Without either, the UI still runs and tells you. Extra mpv args can be injected with `BEEFY_MPV_ARGS` (e.g. `BEEFY_MPV_ARGS='--audio-device=...'`, which is the macOS way to choose an output device). - **Spectrum says `FFT` instead of `SYNC`?** The monitor is unavailable or failed, or you forced the stream tap. The bars are still real audio, but the second stream can drift relative to playback. Use `--spectrum-source monitor` to force sync, or `BEEFY_SPECTRUM_DEVICE=` on Linux to pick a specific monitor. `SIM` means the bars are synthesized. - **Diagnose artwork:** `uv run beefy-player --diagnose-images` prints the detected protocol, widget class, terminal env, dependency versions and cache path. If `protocol` is `None`, force it with `BEEFY_IMAGES=tgp` (Kitty) or `BEEFY_IMAGES=iterm2` (iTerm2/WezTerm). - **`SYNC` but the bars are flat?** mpv may be playing to a different sink (or directly to ALSA). Point `BEEFY_SPECTRUM_DEVICE` at that sink's monitor, or use `--spectrum-source stream`. - **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio Recording** permission (*System Settings → Privacy & Security → System Audio Recording*), then restart the app; the status bar explains when the tap is silent because of this. Also check that mpv is playing to the default output device, since that is what the tap records. - **macOS: media keys do nothing?** Another app currently owns the Now Playing session, or the app was started with `--no-mpris`, or the audio backend fell back to `ffplay` (mpv is required for Now Playing). The status bar shows `nowplaying on` when the integration is armed. - **macOS: Control Center shows only a title?** mpv publishes the stream's live “Artist - Title” as the Now Playing title; artist/album fields and artwork are not filled in by macOS for plain audio streams. The TUI itself always shows the full metadata and album art. - **Bandwidth** is unchanged in SYNC mode (the monitor capture creates no second stream). The FFT fallback opens one extra stream; if you are on a metered link, use `--spectrum-source monitor` (or `--no-audio` for a UI-only demo). - SomaFM is listener-supported: if you enjoy it, [donate](https://somafm.com/support/). - tilderadio is a volunteer-run community station: [drop by](https://tilderadio.org), or show up on the [schedule](https://tilderadio.org/schedule/) with a set. - Bassdrive is free and listener-supported too: [bassdrive.com](https://bassdrive.com) has the shows, archives and the shop that keeps the relays running. ## NixOS (optional aside) None of the above needs Nix. If you *are* on NixOS, `shell.nix` wraps the same `uv sync` / `uv run` workflow in an FHS environment, because uv installs manylinux wheels such as NumPy that expect `libstdc++.so.6` in the usual `/usr/lib` paths: ```bash nix-shell # enter the FHS dev shell uv sync uv run beefy-player ``` - If you see `libstdc++.so.6: cannot open shared object file`, make sure you are inside `nix-shell`, then recreate the venv once with `rm -rf .venv && uv sync`. - `nix-shell --run "..."` does not work with FHS `shell.nix` files; enter the shell interactively and run commands there. - FHS shells need unprivileged user namespaces. `bwrap: No permissions to create a new namespace` means they are disabled (for example in a locked-down container); run the app on the host instead. ## Licence WTFPL — Do What The Fuck You Want To Public License, Version 2. See [LICENSE](LICENSE). SomaFM and its channel names are trademarks of SomaFM.com, LLC. tilderadio belongs to the tildeverse community. Terminal image rendering uses [Pillow](https://python-pillow.org/).