# somafm-tui [![somafm-tui demo](assets/somafm-tui-demo.gif)](https://asciinema.org/a/VrDPrImiQvCwd2Yw) A flashy terminal radio for **[SomaFM](https://somafm.com)** — a live spectrum analyser, song history, and full desktop media integration over MPRIS, drawn with your terminal's own ANSI palette on the terminal's default background so it fits any theme (including transparent terminals). No existing SomaFM client is used: the station list, playlists, song history, audio control, spectrum visualiser and MPRIS service 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; or a full-height oscilloscope. Three modes (`v`). - **Synced spectrum, not a guess** — by default the analyser records your actual PulseAudio/PipeWire output monitor with `parec`, so the bars follow what you hear (`SYNC`). Without a monitor it falls back to a muted second `ffmpeg` decode of the stream (`FFT`, can drift), then to a synthesized display (`SIM`). - **Real artwork where supported** — Kitty uses the kitty graphics protocol; iTerm2 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. - **Last played** — the full recent history from `somafm.com/songs/.json`. - **Every station** — the whole `channels.json` roster (≈50 channels), live listener counts, genre search. - **Favourites** that float to the top, remembered between runs. - **MPRIS** — an `org.mpris.MediaPlayer2.somafmtui.instance` player exposes `PlaybackStatus`, `Metadata` (title/artist/album/artUrl), `Volume`, and `Play`/`Pause`/`PlayPause`/`Stop`/`Next`/`Previous`. Desktop media widgets and media keys pick it up automatically. - **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 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` | synced monitor capture for the spectrum (`pulseaudio-utils`; works with PipeWire) | | D-Bus session bus | MPRIS desktop media integration (`playerctl` is handy for testing) | 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`. Install `uv` once with the official installer: ```bash curl -LsSf https://astral.sh/uv/install.sh | sh ``` `pipx install uv`, `brew install uv`, or a distro package also work. If your distro's Python is older than 3.10, let uv manage one: `uv python install 3.12`. 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 # don't announce over D-Bus 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: mirrored bars → bars → wave | | `r` | refresh channels + song history | | `g` / `G` | top / bottom | | `?` | help | | `q` | quit | ## Desktop media widgets (MPRIS) While playing, the app exports `org.mpris.MediaPlayer2.somafmtui.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 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`. ## How it works - `somafm_tui/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. - `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`. - `somafm_tui/spectrum.py` — 44.1 kHz mono s16le analyser. Prefers capturing the real sink monitor with `parec` (SYNC); 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/mpris.py` — `dbus-next` MPRIS service in its own thread with a thread-safe command queue back into the UI. - `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/.json song history snapshots ~/.cache/somafm-tui/artwork/ cached album/channel artwork ``` ## 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. 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. - **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=...'`). - **Spectrum says `FFT` instead of `SYNC`?** `parec`/`pactl` are unavailable, the monitor device 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=` to pick a specific monitor/source. `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`. - **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/).