Files
beefy-player/README.md
T

28 KiB
Raw Blame History

Beefy Player

beefy-player demo

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 SomaFM (≈50 curated channels), tilderadio (the tildeverse's single community stream) and Bassdrive (worldwide drum & bass), and the UI never talks to a station catalogue directly: it asks a provider 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 <name> 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.
  • 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).
  • 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<pid> 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.
  • 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 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):

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:

# 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

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

# 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/<station>.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.

Three catalogues ship today:

slug catalogue station list history streams
somafm SomaFM channels.json (≈50) songs/<station>.json .pls → icecast mirrors
tilderadio tilderadio /api/now/ (1) now_playing + history[] AzuraCast mounts (mp3/ogg, 320k)
bassdrive Bassdrive icecast status (1) rolling display-title three mounts + mirrors

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: their left pane is the broadcast schedule rather than a list containing one station, and they opt out of 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.

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:

# 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/<slug>/ with a RadioProvider subclass,
  2. register one instance in beefy_player/providers/__init__.py,
  3. run it with beefy-player --provider <slug>, 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:

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:

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:

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
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
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<pid> on the session bus. Check it:

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)
    tilderadio/       the tilderadio catalogue (one station, hero, schedule)
    bassdrive/        Bassdrive (Icecast status, show schedule, rolling history)
  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
    • <station>.pls → resolved direct icecast URLs (cached 8h)
    • songs/<station>.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 <local-time datetime> 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/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-messages 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-<id>.json last good list, other catalogues
~/.cache/beefy-player/schedule-<id>.json last good broadcast schedule
~/.cache/beefy-player/playlists.json     resolved stream URLs
~/.cache/beefy-player/songs/<id>.json    SomaFM song history snapshots
~/.cache/beefy-player/songs/<p>-<id>.json history for every other catalogue
~/.cache/beefy-player/artwork/<sha1>     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=<source> 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.
  • tilderadio is a volunteer-run community station: drop by, or show up on the schedule with a set.
  • Bassdrive is free and listener-supported too: 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:

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. SomaFM and its channel names are trademarks of SomaFM.com, LLC. tilderadio belongs to the tildeverse community.

Terminal image rendering uses Pillow.