Architecture
Runtime shape, provider adapters, playback control, TUI state, and persistence.
RadioCLI separates TUI state, provider adapters, playback control, and local persistence. A shared platform layer describes the host and selects native integrations for those components.
Runtime Shape
src/cli.tsx
-> src/ui/App.tsx
-> provider manager
-> TuiSession -> shared playback owner
-> RadioEngine -> player controller
-> LibraryRepository -> transactional JSON store worker
-> alarm TUI service
-> Ink screens/components
-> src/alarms/cli.ts
-> native scheduler adapter
-> alarm runner + local control session
-> power inhibitor / Alarm Guard
-> headless playback owner
-> RadioEngine + LibraryRepositoryThe CLI has two modes:
- Interactive TUI:
radiocli - Command mode:
radiocli search "tokyo jazz",radiocli check,radiocli import https://example.com/live,radiocli import stations.m3u,radiocli alarm doctor
The listener CLI (src/cli) handles station-name resolution, inline selection,
terminal/JSON/plain formatting, shell completion, and named presets. Playback
commands use the shared radio service with local CLI access, which does not
enable agent integrations. The service reuses an owning TUI or starts one
background host; now --watch reads that owner's live metadata and cancels
pending status requests when the view exits. Presets participate in the library's
concurrent write merge and JSON backup/restore.
Help, version, and basic shell completion load only the CLI argument and output
helpers. Providers, persistence, playback, React/Ink, Doctor, and MCP load when
their command needs them. Setup rehearses a local WAV through installed players
with null or dummy output and a bounded process deadline. Executable discovery,
successful decoding, and physical speaker or receiver output are separate facts;
radiocli doctor --playback repeats the decoder rehearsal.
Opt-in local agent control adds two adapters over the same 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. The owner orders preference changes
and desktop transport intent. Status and Stop remain available during slow
station resolution; Stop and alarm takeover cancel active and queued transport
work, while a replacement tune invalidates the earlier tune's generation. A new
command waits for an accepted Stop to finish. Discovery
queries and listening-stat reads do not start a player or open a terminal.
The loopback boundary validates command shapes, stations, settings, queue sizes, and numeric limits before dispatch. Version 2 requests carry a UUID, and a bounded five-minute outcome cache lets clients retry that UUID without replaying a mutation. Reusing an ID with a different command fails. Legacy command requests remain supported. Request and response bodies are bounded, and control tokens always use direct loopback connections regardless of proxy settings.
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.
Platform Boundaries
The modules in src/platform keep host detection separate from runtime
readiness and the project's support policy:
| Modules | Responsibility |
|---|---|
runtime.ts, support.ts | Describe the OS, CPU, libc, WSL, and Termux environment; assess runtime eligibility and verified support evidence separately |
omarchy.ts, desktop-media.ts, mpris.ts | Identify packaged Quattro independently of the OS, select desktop-media policy, and publish the ordinary playback owner's state on a local Unix user bus |
capabilities.ts | Report each feature as available, unavailable, or unverified from the evidence gathered in this session |
paths.ts, storage.ts | Preserve library and runtime namespaces; inspect storage access without creating a write probe |
executables.ts, packages.ts, shell.ts | Resolve commands and construct installation or launch arguments for the host |
desktop.ts, terminals.ts, terminal.ts | Select optional desktop helpers and describe terminal capabilities |
network.ts, loopback.ts | Bound public HTTP requests and keep authenticated local control independent of public-network proxy settings |
Finding a player, scheduler command, or terminal executable does not establish
that its audio device, user service, or desktop session works. Native operations
check their results, and radiocli doctor reports the available evidence. BSD,
Termux, and other experimental targets do not inherit Linux alarm adapters or a
support claim from a successful platform mock. See Platform support.
Alarm Runtime
Alarm definitions are portable library data, while delivery is rebuilt from machine-local runtime components:
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 registrationSchedulerService 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.
An early native dispatch releases its occurrence lock without marking the occurrence complete, so the real due dispatch can still run. If removal of a native job fails, the adapter accepts the result only when a query confirms that specific job is absent. An ambiguous failure keeps the definition and repair artifacts available.
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. Bounded caches reuse civil-time resolution and the next
occurrence until the occurrence passes, the schedule changes, or the clock moves
backward; timezone formatters are also bounded. 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. Unreadable ownership records and records for live processes that fail the challenge are retained for repair; the unverified PID is not signaled. An inhibitor lease reports an unexpected helper exit, allowing runtime health to show that sleep protection was lost.
System-volume leases snapshot the prior mixer state. A partial change triggers a rollback attempt, and a failed restore leaves the lease retryable. Guard state, scheduler health, occurrence locks, and active-control discovery are operational machine state, not portable library state.
Provider Layer
ProviderManager coordinates public station sources.
RadioBrowserProvideris the primary source for countries, station search, geo metadata, station popularity, and tune resolution.RadioGardenProvideris experimental because terminal clients can be blocked by edge protection.- Playlist imports are modeled as first-class
playliststations.
Radio Browser calls use the canonical endpoint first, discover current HTTPS servers from the directory's DNS service record when fallback is needed, and keep a durable cache. If a live request fails and stale cached data exists, the provider returns stale data rather than blanking the interface. A cache write failure does not discard a successful directory response. Request deadlines remain active while response bodies are consumed, and unused bodies are canceled where possible.
Search and country browsing return explicit pages with a continuation cursor, exhaustion flag, and per-source status. Each source advances by raw rows it actually consumed; post-query filters do not pretend that rejected rows were never fetched. Overflow candidates and seen identities stay in a bounded, expiring discovery session. Radio Browser and Radio Garden have independent progress, so mixed pages cannot skip one source's stations. Radio Garden's finite snapshot is exhausted once consumed. A failed or canceled page does not commit its progress, and replaying the last cursor returns that same page. The older array APIs remain available for existing callers.
Provider memory caches are bounded by entry count and estimated bytes. Durable shards share a disk budget, preserve per-key write order, and keep reloadable entries after memory eviction. Public JSON responses count bytes as they are consumed, with the request deadline and cancellation retained through body reading and parsing. Large JSON parsing uses a bounded worker queue instead of blocking terminal input.
RADIOCLI_OFFLINE=1 blocks public requests made by RadioCLI's Node HTTP client.
Cached directory results and saved direct stream URLs remain usable.
RADIOCLI_LOW_BANDWIDTH=1 makes the geotagged atlas cache-only; smaller directory
queries remain available. Both flags skip automatic AirPlay discovery. Offline
mode also blocks manual discovery; low-bandwidth mode allows an explicit
refresh. Authenticated local controls remain available. These policies do not
block the external player's network access when a user requests a direct
stream. Proxy configuration and the exact network boundaries are described in
Reliability.
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
RadioEngine owns station tuning, cancellation, queue auto-skip, bounded
reconnection, metadata identity, and optional listening history for both TUI and
headless playback. Screens retain navigation and list selection. The engine
sets the candidate station before backend startup so early metadata is recorded
against the correct station. Backend exit, pause, and buffering close audible
listening intervals; an ended process cannot continue accumulating time.
Diagnosed transient transport failures can reconnect up to three times with
backoff. Normal EOF, unknown failures, authentication, and output initialization
errors remain visible instead of entering a retry loop.
Production TUI playback attaches to the same detached owner used by shell
commands. TuiSession, TuiRadioEngine, and TuiPlayer adapt its authenticated
session to the existing screens. The owner retains the engine, stream control,
queue, listening checkpoints, desktop controls, and sleep timer when a terminal
dies. Reopening the TUI claims a new view only after the previous view's PID is
dead, then restores Now Playing without retuning. A normal quit explicitly
detaches, stops playback, and shuts down the owner. Alarms can preempt this owner
even while the TUI is absent. Inline playback remains available for restricted
runtime/loopback environments and injected test adapters.
PlayerController owns playback process lifecycle within the playback owner.
- Default local output:
mpv - Playback-only fallbacks:
ffplayand VLC - Experimental macOS/native Linux output: AirPlay/RAOP through Bonjour/Avahi, FFmpeg, and RadioCLI's optional sender bridge
mpvis controlled over JSON IPC for pause, mute, volume, readiness checks, property events, and fallback polling.
The controller waits for backend readiness before reporting playback as active.
Backend startup uses settings.tuneTimeoutSeconds; the engine also bounds the
whole resolution and auto-skip operation to 90 seconds.
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 named pipe on native Windows and a Unix
socket on Unix-like targets whose runtime supports it. ffplay and VLC are
playback-only fallbacks; they do 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.
The optional AirPlay backend discovers RAOP receivers with Bonjour on macOS or
bounded Avahi queries on native Linux, 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.
Linux AirPlay also requires the per-library linuxAirPlayEnabled preference,
set explicitly through setup and disabled by default. macOS ignores this Linux
preference. The player, TUI, headless owner, and agent service enforce the same
policy. Platform eligibility, opt-in, and dependency/service readiness are
separate: an opted-in installation can still need setup, while Doctor can inspect
prerequisites without enabling playback or starting services. Hidden settings
and command suggestions use the same filtered items as keyboard navigation.
The Linux desktop-media adapter shares the ordinary owner's command queue with terminal and authenticated local controls. It resolves transport commands when dequeued, publishes station and playback changes through MPRIS, and releases its service during alarm takeover. Names are scoped to the RadioCLI data directory; the bridge never steals another owner's name. It accepts only local Unix bus addresses and backs off between reconnection attempts. Selective per-process mpv script filtering avoids duplicate packaged MPRIS players while retaining user scripts.
With mpv, RadioCLI waits for usable audio and IPC readiness before marking the
stream as ready. It subscribes to pause, metadata, and buffering properties;
observations are restored after an IPC reconnect. Elapsed-time and metadata
polling remain as fallbacks. Pause polling runs every 500ms while subscriptions
are unavailable and falls back to a five-second check after acceptance. Delayed
replies from an earlier player instance cannot overwrite the current state.
Metadata is cleaned before it is shown in Now Playing, the footer, and the
terminal/window media title. A ready AirPlay connection can retune in place;
the owner does not tear down the receiver before every station change.
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
- 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.
Settings intents are ordered separately from rendering. Relative controls read the latest committed settings when their turn begins, so rapid repeated keys apply every change. Save confirmations follow durable acknowledgement. A new station waits for earlier accepted settings before choosing its output; Stop can cancel that wait and any pending retune.
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
- alarm definitions, next snooze override, and recent run outcome
- settings
The library distinguishes missing data, malformed JSON or schema data, and an
unreadable file. A missing library starts with defaults. Corrupt
library bytes must first be preserved under a .bad-* name; a failed backup
leaves the original file in place and reports an error. Permission and I/O
failures are never treated as corruption or as a missing current library that
allows fallback to an older legacy file. Provider caches use separate best-effort
recovery.
Library writes take a lock, merge concurrent changes, write a private temporary file beside the destination, and atomically rename it into place. POSIX file permissions are set before replacement. A failed write preserves the previous disk and in-memory state, and cleanup cannot mask the original error. Windows uses the user directory's ACLs rather than POSIX modes.
Long-lived TUI, headless, and MCP processes use LibraryRepository. It submits
ordered semantic operations to a worker that performs locking, validation,
merging, serialization, and replacement. A successful mutation acknowledgement
follows persistence. Filesystem events and a revision fallback refresh cached
snapshots after another process writes the library, including changes to agent
consent. Short-lived commands retain the synchronous transactional store.
Restricted hosts that cannot start a worker fall back before any operation is
sent; an ambiguous failure after startup is reported without replaying a toggle
that might already have committed. Shutdown drains accepted writes.
Imported URL identities use SHA-256 rather than the legacy 32-bit hash. Known legacy URL references migrate together across favorites, recents, presets, alarms, and activity, retaining legacy IDs as aliases. Track keys migrate only when their old identity maps unambiguously to one URL. Arbitrary imported IDs retain their meaning; migration cannot recover an entry already overwritten by a collision in an older version.
A readable library remains usable when its file or directory is read-only. The
TUI catches optional recent, listening, track, search, and update-history save
failures separately from playback and navigation. Stop, sleep-timer expiry,
alarm takeover, and quit still stop the player. Explicit settings and favorite
changes report a failed save without claiming success. Error messages suggest
a writable RADIOCLI_HOME without exposing the private path.
The library and backup format remain JSON, with optional station aliases for
identity migration. Existing RADIOCLI_HOME, RADIO_ATLAS_HOME, and legacy Radio Atlas locations retain
their meaning, including the existing macOS runtime namespaces.
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, Design, and Privacy and Security.