Design doc covers: custom raw audio pipeline (OkHttp → IcyParser → Mp3FrameSync → MediaCodec → AudioTrack), foreground service with aggressive reconnection, Room DB with future-proofed schema, Compose UI, and ICY metadata with album art fallback chain. Made-with: Cursor
12 KiB
Android 24/7 Radio — Design Document
Personal-use Android app for 24/7 internet radio streaming with absolute minimum latency, aggressive reconnection, and Icecast/Shoutcast metadata support.
Target: Android 9.0 (API 28) minimum
Language: Kotlin
UI: Jetpack Compose, Material Design 3
Approach: Custom raw audio pipeline (no ExoPlayer)
Architecture Overview
Four layers:
┌─────────────────────────────────────────┐
│ UI Layer │
│ Compose screens, ViewModel, StateFlow │
├─────────────────────────────────────────┤
│ Service Layer │
│ RadioPlaybackService (foreground) │
│ MediaSession, notification, wake lock │
├─────────────────────────────────────────┤
│ Audio Engine │
│ StreamConnection → IcyParser → │
│ Mp3FrameSync → MediaCodec → AudioTrack │
├─────────────────────────────────────────┤
│ Data Layer │
│ Room DB, PLS/M3U import/export, │
│ DataStore preferences │
└─────────────────────────────────────────┘
Playback Data Flow
- User taps a station in the UI.
- ViewModel tells
RadioPlaybackServiceto play a URL. - Service starts foreground, acquires wifi + wake locks, launches the audio engine.
- Audio engine opens HTTP connection with
Icy-MetaData: 1header. - Engine reads raw bytes, splits stream data from ICY metadata blocks.
- MP3 frame synchronizer finds frame boundaries, feeds complete frames to
MediaCodec. MediaCodecdecodes MP3 → PCM, output goes directly toAudioTrack.- ICY metadata (track title) is emitted as events back up to the service → UI.
- On disconnect: engine signals the service, service decides whether to reconnect based on mode.
Key Constraints
- The audio engine is a standalone component with no Android framework dependencies (testable in isolation).
- The service owns lifecycle, reconnection policy, and notification.
- The UI observes state via
StateFlow— never directly touches the engine.
Audio Engine
Five pipeline stages running on a single dedicated thread.
Stage 1: StreamConnection
Opens an HTTP connection via OkHttp. Sends Icy-MetaData: 1 request header. Reads the icy-metaint response header to learn the metadata interval. Exposes the raw InputStream to the next stage.
On connection failure or stream EOF, emits a Disconnected event with the cause. Does not retry — retry logic lives in the service layer.
Stage 2: IcyParser
Wraps the raw input stream. Reads exactly metaint bytes of audio data, then reads the metadata block (1-byte length prefix × 16 = metadata size). Separates concerns:
- Audio bytes forward to Stage 3.
- Metadata strings (e.g.,
StreamTitle='Artist - Song';) are parsed and emitted as events.
If icy-metaint is absent, this stage is a passthrough.
Stage 3: Mp3FrameSync
Scans audio bytes for MP3 frame boundaries (sync word 0xFF + frame header). Parses the 4-byte header to determine frame size from bitrate, sample rate, and padding. Reads exactly that many bytes to form a complete frame. Passes complete frames to Stage 4.
On corrupted data, discards bytes and re-syncs. This is where glitchy audio manifests on bad connections — we lose a frame or two and pick back up.
Stage 4: MediaCodec (MP3 → PCM)
Android's hardware-accelerated MediaCodec configured for audio/mpeg. Synchronous operation: dequeue input buffer, fill with MP3 frame, queue, dequeue output buffer with decoded PCM (16-bit signed, typically 44.1kHz stereo), pass to Stage 5.
Stage 5: AudioTrack (PCM → Speaker)
AudioTrack in MODE_STREAM with the smallest buffer size the device supports (AudioTrack.getMinBufferSize()). Writes decoded PCM chunks directly. Final latency contributor: ~20-40ms from Android's hardware buffer minimum.
Configurable Buffer
A ring buffer between Stage 3 and Stage 4. In "as live as possible" mode, capacity is 0 frames — frames go straight through. For smoother listening, user sets 0-500ms via a slider (0-~19 frames at 26ms each).
Threading
The entire pipeline runs on a single dedicated thread: read from socket, parse ICY, sync frames, decode, write to AudioTrack. No inter-thread synchronization overhead. The thread blocks on socket reads when waiting for data.
Error Model
Errors propagate as sealed classes:
ConnectionFailed(cause)— HTTP/socket errorStreamEnded— server closed the connectionDecoderError(cause)— MediaCodec failure (restart codec)AudioOutputError(cause)— AudioTrack write failure
The engine surfaces these to the service layer for handling.
Foreground Service & Stay Connected
RadioPlaybackService
A Service subclass running as a foreground service with a persistent notification.
Lifecycle:
- UI sends "play station X" command.
- Service starts foreground with notification showing station name.
- Acquires
PARTIAL_WAKE_LOCKandWifiLock. - Creates and starts the audio engine on its dedicated thread.
- Listens for engine events (metadata, errors, disconnects).
- On stop: releases locks, tears down engine, stops foreground.
MediaSession
Provides lockscreen/notification playback controls (play/stop), Bluetooth headset button support, and metadata display on lockscreen. The service updates MediaSession metadata when ICY updates arrive.
Stay Connected Mode
When enabled, aggressive reconnection on disconnect:
- Engine emits
DisconnectedorConnectionFailed. - Service checks Stay Connected flag. If off, stop. If on, continue.
- Show "Reconnecting..." in the notification.
- Retry immediately (first attempt).
- If that fails, backoff: 1s, 2s, 4s, 8s... capped at 30s.
- Monitor
ConnectivityManager— when network returns, retry immediately (skip backoff timer). - Never give up. Retry indefinitely until the user hits disconnect.
Notification
Single persistent notification:
- Station name (always)
- Track title (when metadata available)
- Album art (when available, fallbacks as defined below)
- Stop button (always)
- "Reconnecting..." state indicator (when disconnected)
Built with NotificationCompat, MEDIA notification channel.
Data Model
Room Database Entities
Station
id: Long(auto-generated PK)name: Stringurl: String(stream URL)playlistId: Long?(nullable FK → Playlist)sortOrder: Intstarred: Boolean(default false)defaultArtworkUrl: String?(from#EXTIMGor manually set)
Playlist
id: Long(auto-generated PK)name: StringsortOrder: Intstarred: Boolean(default false)
MetadataSnapshot (append-only, logs every track change)
id: LongstationId: Long(FK → Station)title: String?artist: String?(parsed from StreamTitle if "Artist - Title" format)artworkUrl: String?(resolved album art URL)timestamp: Long(epoch millis)
ListeningSession (one per play-to-stop session)
id: LongstationId: Long(FK → Station)startedAt: Long(epoch millis)endedAt: Long?(null while active)
ConnectionSpan (one per TCP connection within a session)
id: LongsessionId: Long(FK → ListeningSession)startedAt: Long(epoch millis)endedAt: Long?(null while active)
Future-Proofing
The schema supports features not built in V1:
- Recording: Tap the pipeline between Stage 3 (raw MP3 frames) and Stage 4 to write frames to a file.
MetadataSnapshottimestamps enable splitting by track. - Clips: Reference
MetadataSnapshottimestamp ranges within a recording. - Analytics:
ListeningSession+ConnectionSpanenable total listening time, reconnect frequency, per-station stats.
PLS/M3U Import/Export
Import: User picks a .pls or .m3u file via ACTION_OPEN_DOCUMENT. Format detected by extension/content. Stations created and added to a user-selected playlist.
M3U extensions supported:
#EXTINF:— station display name#EXTIMG:— station default artwork URL
PLS parsing: INI-style, reads File1=, Title1=, NumberOfEntries=.
Export: Write stations from a playlist to PLS or M3U via ACTION_CREATE_DOCUMENT. Includes #EXTIMG for stations with defaultArtworkUrl.
Preferences (DataStore)
- Stay Connected: on/off (default off)
- Live Mode buffer: 0-500ms (default 0)
- Last played station ID
UI
Kotlin/Jetpack Compose. Three screens. Single-activity, state-driven navigation via a Screen sealed class.
Station List (Home)
Playlists as expandable groups, stations as rows within them. Starred items sort to the top.
Station row: station name, star toggle, "now playing" indicator if active. Tap to play, long-press for edit/delete.
Drag-to-reorder for both stations within playlists and playlists themselves.
Top bar: Import (file picker), Add Station (name + URL), Add Playlist, Settings.
Ungrouped stations appear in an "Unsorted" section at the top.
Mini-player bar at the bottom when audio is active: station name, current track title (marquee scroll), stop button, tap to open Now Playing.
Now Playing
- Station name
- Track title + artist (or "No track info" fallback)
- Album art (large, with fallback chain)
- Stop button
- Stay Connected toggle (inline)
- Live Mode buffer indicator (e.g., "Live: 0ms")
- Connection status ("Connected", "Reconnecting...")
- Session timer — total elapsed time since play, not reset on reconnect
- Connection timer — elapsed time since current TCP connection established, resets on reconnect
- Latency indicator — estimated ms from buffer frames + AudioTrack pending position
Settings
- Stay Connected toggle
- Live Mode buffer slider (0-500ms)
- Export playlist (pick playlist, choose format, save)
- Recently Played (stations with timestamps)
- Track History (searchable "Artist - Title" list with station and timestamp)
Metadata & Album Art
ICY Metadata Flow
- IcyParser extracts
StreamTitlefrom the raw metadata string. - Attempt split on
-into artist + title. If no separator, treat whole string as title. - Emit
MetadataUpdate(artist, title, raw)event. - Service persists as
MetadataSnapshot, updatesMediaSession, UI observes via StateFlow.
Album Art Priority
- MusicBrainz / Cover Art Archive — free, no API key. Query by artist + title. Skip if metadata lacks
-separator (likely spoken word). - ICY StreamUrl — use if the station provides a valid image URL in the metadata.
- Station
defaultArtworkUrl— from#EXTIMGin M3U or manually set. - Station favicon/logo — scraped from station website.
- Generic placeholder — radio icon.
Art is cached on disk, keyed on "$artist-$title", bounded to ~50MB with LRU eviction.
Graceful Degradation
| Available Data | Display |
|---|---|
| Artist + Title + Art | Full display |
| Artist + Title, no art | Text + fallback art from chain |
| Title only | Title + fallback art |
| No metadata | Station name + "No track info" + station art or placeholder |
No crashes, no error dialogs. Metadata is additive.
Out of Scope (V1)
- AAC/OGG stream formats (MP3 only)
- Stream authentication
- Lyrics lookup
- Scrobbling (Last.fm etc.)
- Similar station recommendations
- Recording / clips (schema supports it, feature deferred)