RADIOCLI

Architecture

Runtime shape, provider adapters, playback control, TUI state, and persistence.

RadioCLI is a terminal-first product built around four seams: TUI state, provider adapters, playback control, and local persistence.

Runtime Shape

src/cli.tsx
  -> src/ui/App.tsx
      -> provider manager
      -> player controller
      -> JSON library store
      -> Ink screens/components

The CLI has two modes:

  • Interactive TUI: radiocli
  • Command mode: radiocli search "tokyo jazz", radiocli check, radiocli import stations.m3u

Provider Layer

ProviderManager coordinates public station sources.

  • RadioBrowserProvider is the primary source for countries, station search, geo metadata, station popularity, and tune resolution.
  • RadioGardenProvider is experimental because terminal clients can be blocked by edge protection.
  • Playlist imports are modeled as first-class playlist stations.

Radio Browser calls use mirror fallback and a durable cache. If a live request fails and stale cached data exists, the provider returns stale data rather than blanking the interface.

Explore and Nearby share the same geospatial path: RadioCLI caches the full available Radio Browser geotagged station atlas, filters out invalid coordinates, computes haversine distance locally, and sorts by distance first. Popularity, votes, bitrate, and last-check status only break near-identical coordinate ties, so map exploration is not capped to globally popular stations.

Playback Layer

PlayerController owns playback process lifecycle.

  • Default local output: mpv
  • Fallback backend: ffplay for playback only
  • Experimental macOS output: AirPlay/RAOP through Bonjour discovery, FFmpeg, and RadioCLI's bundled sender bridge
  • mpv is controlled over JSON IPC for pause, mute, volume, readiness checks, and metadata polling.

The controller waits for backend readiness before reporting playback as active. Tune attempts time out according to settings.tuneTimeoutSeconds.

Audio Pipeline

Station tuning starts with provider resolution. Radio Browser stations go through the provider's /json/url/:id click/resolve endpoint when possible; playlist and other non-Radio-Browser stations use their exposed stream URL.

Once a URL is resolved, PlayerController starts mpv with --no-video, --force-window=no, a local JSON IPC endpoint, a forced media title, and the configured volume. That endpoint is a Unix socket on macOS/Linux and a named pipe on native Windows. ffplay is available as a playback-only fallback, but it does not provide a reliable IPC surface for pause, mute, volume, play/pause media-key handling, metadata, or readiness checks. RadioCLI labels that mode as limited instead of updating UI state optimistically.

On macOS, the optional AirPlay backend discovers RAOP receivers with Bonjour and streams through a child worker that feeds decoded PCM from ffmpeg into a bundled sender bridge. The player requires an explicit receiver selection from Settings before it starts an AirPlay worker. AirPlay is a current-session output instead of an automatic startup default, so missing receivers do not trigger station-skip fallback.

With mpv, RadioCLI waits for the IPC socket to accept commands before marking the stream as ready. It then polls playback state every 500ms and ICY metadata every 2.5s. Metadata is cleaned before it is shown in Now Playing, the footer, and the terminal/window media title.

FFT And Signal Model

RadioCLI does not currently decode audio in Node, tap PCM frames, or run a live FFT. The native playback backend owns decoding and output. The receiver visualizers are deterministic terminal signal models driven by playback state, style, theme, dimensions, and the shared pulse counter.

Spectrum-like styles use generated samples such as spectrumSample() rather than measurement-grade frequency bins. This is an intentional CPU and complexity trade: the TUI gets lively receiver motion without duplicating stream decoding or adding a cross-platform audio capture layer.

TUI Layer

The TUI is an Ink app with explicit screens:

  • Home
  • Now Playing
  • Library
  • Explore
  • Search
  • Countries
  • Country station results
  • World map
  • Nearby
  • Stats
  • Settings

computeTerminalLayout() converts terminal dimensions into list sizes, receiver width, compact mode, and map mode. Screens consume those layout values instead of hard-coding visible rows.

Pure screen model helpers, tab definitions, filters, and text-editing helpers live in src/ui/app-state.ts. The Now Playing screen owns receiver layout while the receiver visualizer builders live under src/ui/visualizers.

Explore cursor movement is intentionally split between fine WASD nudges and Shift+WASD jumps. Fine movement keeps the map from leaping over local station clusters; jump movement preserves fast cross-continent travel.

Receiver animation has a single state boundary: shouldAnimateReceiver() allows the shared pulse timer to advance only on the Now Playing screen while playback is playing and backend-ready. The visualizer builders also guard inactive playback states and return zero-signal frames, so UI cadence and rendered signal agree.

The live receiver pulse runs every 80ms, about 12.5 frames per second. Ambient animation uses 140ms, and the loading spinner uses 120ms. Outside active Now Playing, the pulse timer is not scheduled, so library, search, map, nearby, stats, and settings screens do not pay visualizer animation cost.

The rendering strategy is string-first: visualizer builders return fixed-width text rows or colored text segments, and screens compose those rows into stable Ink boxes. computeTerminalLayout() keeps panel dimensions, list lengths, map density, and footer space stable while the terminal resizes.

Persistence

The store is local JSON. It keeps:

  • recent stations
  • favorites
  • imported stations
  • listening activity
  • settings

Corrupt store/cache files are renamed with a .bad-* suffix before defaults are used, so user data is not silently overwritten.

Writes use a temp-file-and-rename flow so interrupted writes are less likely to leave partial JSON behind.

Related reading: Reliability, Design, and Privacy and Security.

On this page