docs(readme): document macOS support, Now Playing and loopback capture

Split the install instructions per platform, add the macOS microphone
permission and BlackHole/Multi-Output recipe for a synced spectrum,
describe the Now Playing integration and its limits, and note that config
and cache stay in the XDG locations. The in-app help text is now
platform-aware too, and the package metadata lists macOS.
This commit is contained in:
beefcarver committed 2026-10-30 18:43:26 -04:00
1 parent 06386d3e1e
commit 2570d9b854
3 files changed
+153 -42

No files matched your search

+129 -40
View File
@@ -3,13 +3,17 @@
![somafm-tui demo](assets/somafm-tui-demo.gif)
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).
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 MPRIS service are all implemented here from
the public SomaFM endpoints and open protocols.
audio control, spectrum visualiser and media-session integration are all
implemented here from the public SomaFM endpoints and open protocols.
## Features
@@ -17,15 +21,17 @@ the public SomaFM endpoints and open protocols.
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`.
actual audio output, so the bars follow what you hear (`SYNC`): the
PulseAudio/PipeWire monitor via `parec` on Linux, an `ffmpeg`/avfoundation
loopback input (BlackHole, Loopback, Soundflower, …) on macOS. 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
@@ -34,10 +40,13 @@ the public SomaFM endpoints and open protocols.
- **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
- **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`. Desktop media widgets and
media keys pick it up automatically.
`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.
@@ -48,17 +57,32 @@ the public SomaFM endpoints and open protocols.
## 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`.
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` | synced monitor capture for the spectrum (`pulseaudio-utils`; works with PipeWire) |
| D-Bus session bus | MPRIS desktop media integration (`playerctl` is handy for testing) |
| `ffmpeg` | muted stream decode for the fallback analyser, and the macOS output capture |
| `parec` / `pactl` *(Linux)* | synced monitor capture for the spectrum (`pulseaudio-utils`; works with PipeWire) |
| a loopback audio device *(macOS, optional)* | synced output capture for the spectrum: BlackHole, Loopback, Soundflower, … |
| D-Bus session bus *(Linux)* | MPRIS desktop media integration (`playerctl` is handy for testing) |
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:
@@ -80,14 +104,39 @@ 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:
### macOS
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
brew install mpv ffmpeg uv # ffplay ships with ffmpeg
```
`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`.
That is enough to run: playback, artwork in iTerm2/Kitty/WezTerm, the muted
`ffmpeg` spectrum tap and the macOS Now Playing integration all work out of the
box. The first capture attempt makes macOS ask for **microphone** access for
whatever terminal you use — that permission is what lets `ffmpeg` read the
loopback device.
For a *synced* spectrum (`SYNC` instead of `FFT`) macOS needs an output
loopback, because CoreAudio does not let an ordinary process record the
speakers directly:
```bash
brew install blackhole-2ch # or Loopback / Soundflower
```
Then either
1. create a **Multi-Output Device** in *Audio MIDI Setup* containing both your
speakers/headphones and *BlackHole 2ch*, and select it as the system output,
or
2. set *BlackHole 2ch* as the output and monitor the audio elsewhere.
The app auto-detects loopback-style inputs (BlackHole, Loopback, Soundflower,
“Multi-Output”, “Aggregate”) and never grabs a plain microphone by accident.
Pick one explicitly with `SOMAFM_SPECTRUM_DEVICE` (name or avfoundation index,
see `ffmpeg -f avfoundation -list_devices true -i ""`). If the loopback turns
out to be silent (nothing routed into it), the analyser notices after a few
seconds and falls back to `FFT` instead of showing dead bars.
Terminal image rendering uses [Pillow](https://python-pillow.org/) and is
implemented directly (Kitty TGP + iTerm2 OSC 1337) — no terminal probing.
@@ -126,7 +175,7 @@ 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 --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
@@ -158,9 +207,9 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty.
| `?` | help |
| `q` | quit |
## Desktop media widgets (MPRIS)
## Desktop media widgets (MPRIS / Now Playing)
While playing, the app exports
**Linux** — while playing, the app exports
`org.mpris.MediaPlayer2.somafmtui.instance<pid>` on the **session bus**. Check it:
```bash
@@ -173,6 +222,19 @@ playerctl play-pause
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:
@@ -183,14 +245,19 @@ containers, …) MPRIS is skipped and the status bar shows `mpris off`.
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`.
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` — 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.
the real output: `parec` (PulseAudio/PipeWire monitor) on Linux, or
`ffmpeg -f avfoundation` on a loopback input on macOS (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/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.
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.
@@ -208,14 +275,21 @@ containers, …) MPRIS is skipped and the status bar shows `mpris off`.
~/.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. 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`.
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
@@ -224,10 +298,11 @@ containers, …) MPRIS is skipped and the status bar shows `mpris off`.
- **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.
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=...'`).
`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`?** `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
@@ -240,6 +315,20 @@ containers, …) MPRIS is skipped and the status bar shows `mpris off`.
- **`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`?** Install a loopback (BlackHole/Loopback) and
route your speakers through a Multi-Output Device, then check
`ffmpeg -f avfoundation -list_devices true -i ""` lists it. The first capture
triggers a macOS microphone-permission prompt; grant it to your terminal
(System Settings → Privacy & Security → Microphone), otherwise ffmpeg cannot
open the device and the analyser stays on `FFT`.
- **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
+4 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "somafm-tui"
version = "0.1.0"
description = "A flashy terminal radio for SomaFM with a live spectrum analyser, MPRIS support and song history"
description = "A flashy terminal radio for SomaFM with a live spectrum analyser, MPRIS / macOS Now Playing support and song history"
readme = "README.md"
license = "WTFPL"
license-files = ["LICENSE"]
@@ -17,6 +17,9 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Multimedia :: Sound/Audio",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Environment :: MacOS X",
]
dependencies = [
"textual>=8.0,<9",
+20 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import sys
import textwrap
from rich.console import Group
@@ -31,11 +32,14 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
("q", "quit"),
)
ABOUT = (
ABOUT_COMMON = (
(
"Stations and song history come from somafm.com "
"(channels.json + songs/<station>.json)."
),
)
ABOUT_LINUX = (
(
"The spectrum captures your actual PulseAudio/PipeWire output with "
"parec (SYNC); otherwise it falls back to a muted ffmpeg stream tap "
@@ -47,6 +51,21 @@ ABOUT = (
),
)
ABOUT_MACOS = (
(
"The spectrum follows your audio output (SYNC) when a loopback device "
"such as BlackHole is available for ffmpeg/avfoundation; otherwise it "
"falls back to a muted ffmpeg stream tap (FFT, can drift) or a "
"synthesized display (SIM)."
),
(
"mpv publishes what is playing to the macOS Now Playing session, so "
"Control Center and the keyboard media keys control this app."
),
)
ABOUT = ABOUT_COMMON + (ABOUT_MACOS if sys.platform == "darwin" else ABOUT_LINUX)
class HelpScreen(ModalScreen[None]):
def compose(self) -> ComposeResult: