Beefy Player
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 viaparecon 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 secondffmpegdecode 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 theStation.presenterline instead — tilderadio showsauto dj · library rotationbetween live shows anddj <name>during one. - Last played — the full recent history, newest first.
- Every station — with SomaFM that is the whole
channels.jsonroster (≈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
providersmodule per radio network, with appicker in the UI,--provideron 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 exposesPlaybackStatus,Metadata(title/artist/album/artUrl),Volume, andPlay/Pause/PlayPause/Stop/Next/Previous, so GNOME/KDE widgets, lock screens,playerctland 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
ffplayifmpvis 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:
- create
beefy_player/providers/<slug>/with aRadioProvidersubclass, - register one instance in
beefy_player/providers/__init__.py, - run it with
beefy-player --provider <slug>, presspin 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:httpxclient factory plus.pls/.m3uparsing and resolution (cached for 8 hours), so providers only describe their own catalogue.beefy_player/providers/somafm/api.py— asynchttpxclient for:channels.json→ stations, genres, listeners,.plsplaylist 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 bothnow_playingandhistory[], 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 onePlayer, ticks at 20 Hz, and hands the widgets plainStation/Song/Broadcastobjects.pswaps 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 keylessstatus-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 itssince/untilwindow, then upcoming shows grouped by local day, with… +N morewhen the pane runs out.beefy_player/player.py— mpv over a Unix-socket JSON IPC channel. Observespause/volume, handles mirror failover onend-file, auto-restarts a dead engine, and usesSIGSTOP/SIGCONTwhen falling back toffplay. On macOS it also asks mpv to publish Now Playing and maps the media keys ontoscript-messages that come back over the same IPC socket.beefy_player/spectrum.py— analyser (44.1 kHz mono s16le fromparec, 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, seemacos_tap.py). Otherwise a muted low-latencyffmpegdecode 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 purectypesagainst 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-nextMPRIS 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 areansi_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) orBEEFY_IMAGES=iterm2(iTerm2/WezTerm);BEEFY_IMAGES=0forces the ANSI mosaic. iTerm2 on macOS is detected fromTERM_PROGRAM/LC_TERMINAL, and empty or0values inTMUX/ZELLIJ/STY(exported by some wrappers) are ignored so they cannot downgrade it. Kitty inside tmux is attempted through tmux passthrough (enableset -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-UTCTZoverrides it. A container or dev shell can inherit a build-timeTZ=UTC, which the app deliberately ignores; bind the host's/etc/localtimeor set e.g.TZ=Europe/Berlinin 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-playerstarts 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-playerneedsmpv(preferred) orffplay. Without either, the UI still runs and tells you. Extra mpv args can be injected withBEEFY_MPV_ARGS(e.g.BEEFY_MPV_ARGS='--audio-device=...', which is the macOS way to choose an output device). - Spectrum says
FFTinstead ofSYNC? 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 monitorto force sync, orBEEFY_SPECTRUM_DEVICE=<source>on Linux to pick a specific monitor.SIMmeans the bars are synthesized. - Diagnose artwork:
uv run beefy-player --diagnose-imagesprints the detected protocol, widget class, terminal env, dependency versions and cache path. IfprotocolisNone, force it withBEEFY_IMAGES=tgp(Kitty) orBEEFY_IMAGES=iterm2(iTerm2/WezTerm). SYNCbut the bars are flat? mpv may be playing to a different sink (or directly to ALSA). PointBEEFY_SPECTRUM_DEVICEat 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 toffplay(mpv is required for Now Playing). The status bar showsnowplaying onwhen 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-audiofor 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 insidenix-shell, then recreate the venv once withrm -rf .venv && uv sync. nix-shell --run "..."does not work with FHSshell.nixfiles; enter the shell interactively and run commands there.- FHS shells need unprivileged user namespaces.
bwrap: No permissions to create a new namespacemeans 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.
