362 lines
18 KiB
Markdown
362 lines
18 KiB
Markdown
# somafm-tui
|
||
|
||

|
||
|
||
A flashy terminal radio for **[SomaFM](https://somafm.com)** — 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).
|
||
|
||
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 SomaFM client is used: the station list, playlists, song history,
|
||
audio control, spectrum visualiser and media-session integration are all
|
||
implemented here from the public SomaFM 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/somafm-tui/artwork`.
|
||
- **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.
|
||
- **Last played** — the full recent history from `somafm.com/songs/<id>.json`.
|
||
- **Every station** — the whole `channels.json` roster (≈50 channels), live
|
||
listener counts, genre search.
|
||
- **Favourites** that float to the top, remembered between runs.
|
||
- **Desktop media integration** — on Linux an
|
||
`org.mpris.MediaPlayer2.somafmtui.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
|
||
|
||
`somafm-tui` 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 `SOMAFM_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.
|
||
|
||
## 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 somafm-tui # 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 `somafm-tui`.
|
||
|
||
### 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 somafm-tui --play # start the last station immediately
|
||
uv run somafm-tui --station defcon # pick a station
|
||
uv run somafm-tui --volume 55
|
||
uv run somafm-tui --offline # no network; cached/bundled stations
|
||
uv run somafm-tui --no-mpris # no MPRIS / Now Playing, no media keys
|
||
uv run somafm-tui --list # print station ids
|
||
uv run somafm-tui --no-audio # UI demo without mpv/ffmpeg
|
||
uv run somafm-tui --spectrum-source monitor # force synced output capture
|
||
uv run somafm-tui --spectrum-source stream # force the muted ffmpeg tap
|
||
SOMAFM_IMAGES=0 uv run somafm-tui # force the ANSI artwork mosaic
|
||
SOMAFM_IMAGES=tgp uv run somafm-tui # force Kitty/TGP artwork
|
||
SOMAFM_IMAGES=iterm2 uv run somafm-tui # force iTerm2/WezTerm artwork
|
||
```
|
||
|
||
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 channels + song history |
|
||
| `g` / `G` | top / bottom |
|
||
| `?` | help |
|
||
| `q` | quit |
|
||
|
||
## Desktop media widgets (MPRIS / Now Playing)
|
||
|
||
**Linux** — while playing, the app exports
|
||
`org.mpris.MediaPlayer2.somafmtui.instance<pid>` 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 SomaFM provides 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 SomaFM 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
|
||
|
||
- `somafm_tui/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.
|
||
- `somafm_tui/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.
|
||
- `somafm_tui/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.
|
||
- `somafm_tui/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.
|
||
- `somafm_tui/mediasession.py` — picks the desktop media backend (MPRIS on
|
||
Linux, Now Playing on macOS) so the app has one platform-neutral interface.
|
||
- `somafm_tui/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).
|
||
- `somafm_tui/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.
|
||
- `somafm_tui/widgets/` — Textual widgets rendered with native ANSI colours;
|
||
backgrounds are `ansi_default` (`SGR 49`), so transparent terminals stay
|
||
transparent.
|
||
|
||
### Files
|
||
|
||
```
|
||
~/.config/somafm-tui/config.json favourites, volume, last station, mode
|
||
~/.cache/somafm-tui/channels.json last good station list
|
||
~/.cache/somafm-tui/playlists.json resolved stream URLs
|
||
~/.cache/somafm-tui/songs/<id>.json song history snapshots
|
||
~/.cache/somafm-tui/artwork/<sha1> cached album/channel artwork
|
||
```
|
||
|
||
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.
|
||
|
||
## Notes / troubleshooting
|
||
|
||
- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty,
|
||
iTerm2 and WezTerm. Force a protocol with `SOMAFM_IMAGES=tgp` (Kitty/TGP) or
|
||
`SOMAFM_IMAGES=iterm2` (iTerm2/WezTerm); `SOMAFM_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/somafm-tui/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 somafm-tui` 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?** `somafm-tui` needs `mpv` (preferred) or `ffplay`. Without
|
||
either, the UI still runs and tells you. Extra mpv args can be injected with
|
||
`SOMAFM_MPV_ARGS` (e.g. `SOMAFM_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 `SOMAFM_SPECTRUM_DEVICE=<source>` on Linux to pick
|
||
a specific monitor. `SIM` means the bars are synthesized.
|
||
- **Diagnose artwork:** `uv run somafm-tui --diagnose-images` prints the
|
||
detected protocol, widget class, terminal env, dependency versions and cache
|
||
path. If `protocol` is `None`, force it with `SOMAFM_IMAGES=tgp` (Kitty) or
|
||
`SOMAFM_IMAGES=iterm2` (iTerm2/WezTerm).
|
||
- **`SYNC` but the bars are flat?** mpv may be playing to a different sink (or
|
||
directly to ALSA). Point `SOMAFM_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/).
|
||
|
||
## 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 somafm-tui
|
||
```
|
||
|
||
- 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.
|
||
|
||
Terminal image rendering uses [Pillow](https://python-pillow.org/).
|