247 lines
14 KiB
Markdown
247 lines
14 KiB
Markdown
# somafm-tui
|
||||
|
|
|
|||
|
|
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.
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄ SOMA FM RADIO 14:32:07
|
|||
|
|
▀▀▀▄ █ █ █ █ █ █▀▀█ LISTENER SUPPORTED Sat 15 Nov
|
|||
|
|
▄▄▄▀ ▀▄▄▀ █ █ █ █ SAN FRANCISCO vol 70%
|
|||
|
|
● PLAYING Groove Salad · Jens Buchert — Rauschebart
|
|||
|
|
┌ STATIONS ───────────────┐┌ ON AIR ────────────────────────────────────┐
|
|||
|
|
│ ★ ▶ Groove Salad 1.9k ││ ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄ GROOVE SALAD ● PLAYING│
|
|||
|
|
│ ▶ Drone Zone 812 ││ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ A nicely chilled plate│
|
|||
|
|
│ Secret Agent 420 ││ │
|
|||
|
|
│ Indie Pop Rocks 901 ││ NOW PLAYING 12s ago │
|
|||
|
|
│ … ││ Jens Buchert │
|
|||
|
|
│ ││ Rauschebart │
|
|||
|
|
│ ││ Gravity dj Rusty Hodge │
|
|||
|
|
│ ││ ♪ 1.9k listeners · mp3 · 128k │
|
|||
|
|
│ ││ │
|
|||
|
|
│ ││ LAST PLAYED │
|
|||
|
|
│ ││ 14:29 Alex Cortiz — Room 505 │
|
|||
|
|
│ ││ 14:24 Tosca — Amber November │
|
|||
|
|
└─────────────────────────┘└────────────────────────────────────────────┘
|
|||
|
|
┌ SPECTRUM · MIRROR · SYNC ─────────────────────────────────────────────┐
|
|||
|
|
│ ▁▃▅▇█▇▅▃▁ ▂▄▆█▆▄▂ │
|
|||
|
|
│ ▁▂▄▆██████████████████▆▄▂▁▂▄▆█████████▆▄▂▁ │
|
|||
|
|
│ ▀▄▂▁▂▄▆██████████████████▆▄▂▁▂▄▆█████████▆▄▂▁ │
|
|||
|
|
└───────────────────────────────────────────────────────────────────────┘
|
|||
|
|
space pause · ↑↓ station · enter tune · +/- vol · f ★ · / filter · ? help
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 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/<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.
|
|||
|
|
- **MPRIS** — an `org.mpris.MediaPlayer2.somafmtui.instance<pid>` 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
|
|||
|
|
|
|||
|
|
Python **3.10+**, plus the tools below. This is a minimum, not a pin: there
|
|||
|
|
is no `.python-version`, and `uv` can install a compatible interpreter if your
|
|||
|
|
distro ships something older. `shell.nix` provides everything inside an
|
|||
|
|
**FHS environment** (needed because uv installs manylinux wheels such as
|
|||
|
|
NumPy, which expect `libstdc++.so.6` in the standard FHS paths):
|
|||
|
|
|
|||
|
|
| tool | why |
|
|||
|
|
| -------- | ------------------------------------------------------- |
|
|||
|
|
| python3 | runtime (CPython 3.10 or newer; no exact version pin) |
|
|||
|
|
| uv | installs **all** Python dependencies (see `pyproject.toml`) |
|
|||
|
|
| mpv | playback + JSON IPC control |
|
|||
|
|
| ffmpeg | muted stream decode (fallback analyser) |
|
|||
|
|
| pulseaudio | `parec`/`pactl` for the synced monitor capture (works with PipeWire) |
|
|||
|
|
| dbus | session bus for MPRIS (`playerctl` included for testing) |
|
|||
|
|
|
|||
|
|
Terminal image rendering uses [Pillow](https://python-pillow.org/) and is
|
|||
|
|
implemented directly (Kitty TGP + iTerm2 OSC 1337) — no terminal probing.
|
|||
|
|
|
|||
|
|
## Run
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
nix-shell # enters the FHS dev shell
|
|||
|
|
uv sync
|
|||
|
|
uv run somafm-tui
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
If you used an older `shell.nix` before, recreate the venv once so it picks up
|
|||
|
|
the Nix-provided Python:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
rm -rf .venv && uv sync
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> FHS shell.nix files do not support `nix-shell --run "..."`. Enter the shell
|
|||
|
|
> interactively and run commands there.
|
|||
|
|
|
|||
|
|
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<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`.
|
|||
|
|
|
|||
|
|
## 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`.
|
|||
|
|
- `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/<id>.json song history snapshots
|
|||
|
|
~/.cache/somafm-tui/artwork/<sha1> cached album/channel artwork
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Notes / troubleshooting
|
|||
|
|
|
|||
|
|
- **NixOS: `libstdc++.so.6: cannot open shared object file`** — this is NumPy's
|
|||
|
|
manylinux wheel looking for the C++ runtime. `shell.nix` now uses
|
|||
|
|
`pkgs.buildFHSEnv` and installs `stdenv.cc.cc.lib`, so `nix-shell` puts you in
|
|||
|
|
an FHS root where the wheel loads. If the error persists, your `.venv` was
|
|||
|
|
built against a different Python: `rm -rf .venv && uv sync`.
|
|||
|
|
- **`bwrap: No permissions to create a new namespace`** — FHS shells need
|
|||
|
|
unprivileged user namespaces. They are enabled by default on NixOS; inside a
|
|||
|
|
locked-down container you may need to run the app on the host instead.
|
|||
|
|
- **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. `nix-shell` can inherit a
|
|||
|
|
build-time `TZ=UTC`, which the app deliberately ignores. In a container, bind
|
|||
|
|
the host's `/etc/localtime` or set e.g. `TZ=Europe/Berlin`.
|
|||
|
|
- **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=<source>`
|
|||
|
|
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/).
|
|||
|
|
|
|||
|
|
## 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/).
|