# Architecture (/docs/architecture)



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

## Runtime Shape [#runtime-shape]

```text
src/cli.tsx
  -> src/ui/App.tsx
      -> provider manager
      -> player controller
      -> JSON library store
      -> alarm TUI service
      -> Ink screens/components
  -> src/alarms/cli.ts
      -> native scheduler adapter
      -> alarm runner + local control session
      -> power inhibitor / Alarm Guard
```

The CLI has two modes:

* Interactive TUI: `radiocli`
* Command mode: `radiocli search "tokyo jazz"`, `radiocli check`,
  `radiocli import stations.m3u`, `radiocli alarm doctor`

Local agent control adds two adapters over one command model: `radiocli agent`
for shell-capable harnesses and a stdio MCP server for MCP-capable harnesses.
Both route mutations to the currently owning TUI or headless playback process
through an authenticated loopback session. That owner serializes commands, so
concurrent completions have deterministic arrival-order behavior. Discovery
queries and listening-stat reads do not start a player or open a terminal.

Agent playback and scheduled alarms intentionally have separate discovery
files, tokens, and owners. Explicit alarm MCP tools reuse the alarm scheduler
and authenticated active-occurrence controls; ordinary agent stop/pause tools
still cannot mutate alarm state. A due alarm publishes its starting state before
resolving audio, blocks new agent playback, and asks the current interactive or
headless owner to yield immediately before it tunes. This also stops an active
AirPlay stream before the alarm switches to local output. If that owner cannot
be reached, alarm delivery continues and runtime health records the degraded
takeover instead of suppressing the alarm.

## Alarm Runtime [#alarm-runtime]

Alarm definitions are portable library data, while delivery is rebuilt from
machine-local runtime components:

```text
alarm definition in JSON library
  -> next occurrence (absolute instant or IANA civil schedule)
  -> native per-user scheduler registration
  -> detached radiocli alarm internal-run process
  -> provider resolution + local PlayerController
  -> authenticated loopback ringing controls
  -> run outcome + next native registration
```

`SchedulerService` chooses launchd on macOS, Task Scheduler on native Windows,
or systemd user timers on Linux. Each native job names one exact occurrence.
The runner validates that occurrence against the latest alarm before playing,
uses an occurrence lock to suppress duplicate dispatch, records the result, and
then installs the next occurrence. A four-worker bound keeps bulk synchronization
from spawning unbounded native commands.

Recurring schedules store `HH:mm`, ISO weekdays, and an IANA timezone. Civil
time is resolved one minute at a time: a spring-forward gap advances to the
first valid minute on that civil date, while a fall-back overlap fires only the
first matching instant. One-time schedules are absolute ISO instants at minute
precision. The scheduler must be resynchronized after the host timezone changes
because native registrations contain the already-resolved next instant.

The runner attempts the primary station twice before an optional fallback,
enforces missed-run grace, inhibits sleep while audio is playing, and stops at
the configured deadline unless the user chooses Keep playing. Scheduled output
always resets to local playback; an AirPlay receiver is never selected
unattended. Fade-in is real volume automation only with `mpv`; fallback players
start at the configured target volume.

Alarm Guard is a second detached process, separate from native scheduling. It
holds the platform idle-sleep inhibitor until just after the next occurrence and
uses an authenticated loopback ownership challenge so stale PID files cannot
cause an unrelated process to be signaled. Guard state, scheduler health,
occurrence locks, and active-control discovery are operational machine state,
not portable library state.

## Provider Layer [#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 [#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 [#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 [#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 [#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
* Alarms list, editor, station picker, and ringing controls
* 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 [#persistence]

The store is local JSON. It keeps:

* recent stations
* favorites
* imported stations
* listening activity
* alarm definitions, next snooze override, and recent run outcome
* 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.

Full library backup/restore includes alarm definitions. Native scheduler files,
runtime health, Alarm Guard state, occurrence locks, and authenticated local
control discovery files live under OS-specific machine-local locations and are
excluded. Restored alarms therefore require `radiocli alarm sync` on the target
machine.

Related reading: [Reliability](./reliability.mdx), [Design](./design.mdx), and
[Privacy and Security](./privacy-security.mdx).
