From 2570d9b8543fb2b9358891038bdec660f0ba0d83 Mon Sep 17 00:00:00 2001 From: beefcarver Date: Fri, 30 Oct 2026 18:43:26 -0400 Subject: [PATCH] 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. --- README.md | 169 ++++++++++++++++++++++++++++--------- pyproject.toml | 5 +- somafm_tui/widgets/help.py | 21 ++++- 3 files changed, 153 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index b4460a4..02d1aa0 100644 --- a/README.md +++ b/README.md @@ -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` player exposes +- **Desktop media integration** — on Linux 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. + `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` 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/ 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 diff --git a/pyproject.toml b/pyproject.toml index a6adb57..b8b0642 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/somafm_tui/widgets/help.py b/somafm_tui/widgets/help.py index 88dbaf8..7884f1a 100644 --- a/somafm_tui/widgets/help.py +++ b/somafm_tui/widgets/help.py @@ -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/.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: