Files
Android-247-Radio/docs/plans/2026-03-09-android-247-radio-design.md
cottongin c98f0f32fc Add project idea and design document
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
2026-03-10 00:18:05 -04:00

12 KiB
Raw Permalink Blame History

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

  1. User taps a station in the UI.
  2. ViewModel tells RadioPlaybackService to play a URL.
  3. Service starts foreground, acquires wifi + wake locks, launches the audio engine.
  4. Audio engine opens HTTP connection with Icy-MetaData: 1 header.
  5. Engine reads raw bytes, splits stream data from ICY metadata blocks.
  6. MP3 frame synchronizer finds frame boundaries, feeds complete frames to MediaCodec.
  7. MediaCodec decodes MP3 → PCM, output goes directly to AudioTrack.
  8. ICY metadata (track title) is emitted as events back up to the service → UI.
  9. 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 error
  • StreamEnded — server closed the connection
  • DecoderError(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:

  1. UI sends "play station X" command.
  2. Service starts foreground with notification showing station name.
  3. Acquires PARTIAL_WAKE_LOCK and WifiLock.
  4. Creates and starts the audio engine on its dedicated thread.
  5. Listens for engine events (metadata, errors, disconnects).
  6. 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:

  1. Engine emits Disconnected or ConnectionFailed.
  2. Service checks Stay Connected flag. If off, stop. If on, continue.
  3. Show "Reconnecting..." in the notification.
  4. Retry immediately (first attempt).
  5. If that fails, backoff: 1s, 2s, 4s, 8s... capped at 30s.
  6. Monitor ConnectivityManager — when network returns, retry immediately (skip backoff timer).
  7. 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: String
  • url: String (stream URL)
  • playlistId: Long? (nullable FK → Playlist)
  • sortOrder: Int
  • starred: Boolean (default false)
  • defaultArtworkUrl: String? (from #EXTIMG or manually set)

Playlist

  • id: Long (auto-generated PK)
  • name: String
  • sortOrder: Int
  • starred: Boolean (default false)

MetadataSnapshot (append-only, logs every track change)

  • id: Long
  • stationId: 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: Long
  • stationId: Long (FK → Station)
  • startedAt: Long (epoch millis)
  • endedAt: Long? (null while active)

ConnectionSpan (one per TCP connection within a session)

  • id: Long
  • sessionId: 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. MetadataSnapshot timestamps enable splitting by track.
  • Clips: Reference MetadataSnapshot timestamp ranges within a recording.
  • Analytics: ListeningSession + ConnectionSpan enable 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

  1. IcyParser extracts StreamTitle from the raw metadata string.
  2. Attempt split on - into artist + title. If no separator, treat whole string as title.
  3. Emit MetadataUpdate(artist, title, raw) event.
  4. Service persists as MetadataSnapshot, updates MediaSession, UI observes via StateFlow.

Album Art Priority

  1. MusicBrainz / Cover Art Archive — free, no API key. Query by artist + title. Skip if metadata lacks - separator (likely spoken word).
  2. ICY StreamUrl — use if the station provides a valid image URL in the metadata.
  3. Station defaultArtworkUrl — from #EXTIMG in M3U or manually set.
  4. Station favicon/logo — scraped from station website.
  5. 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)