Skip to main content

Internet Radio Streaming

Radio streaming means decoding an endless HTTP response at exactly real time, on WiFi, forever. The component that makes that boring is stream_client (components/stream_client/src/http_stream.c): one worker task, one ring buffer, and three design decisions this chapter defends.

Shape of the problem

An Icecast/Shoutcast station is just an HTTP response that never ends, carrying an MP3 or AAC bitstream, optionally interleaved with metadata. The consumer (the audio task) drains it at precisely the bitrate. The network, meanwhile, delivers in bursts: seconds of fast data, then silence, then a burst again; servers typically also send a multi-second head start on connect. Something elastic must sit in between.

[station] ──TCP──► stream_worker ──ring buffer (16 KB)──► audio_task ──► codec
(ICY demux) the elastic element (paced by I2S)

The ring buffer

A FreeRTOS byte ring buffer, 16 KB, in internal RAM (kernel objects contain spinlocks; the placement rule from tasks and memory is not optional, and violating it during bring-up produced spectacular lock corruption). 16 KB is one second of 128 kbps audio: enough to absorb WiFi jitter and scheduling noise, small enough to keep the radio "live" and the RAM bill honest. Deeper buffering belongs to TCP, which brings us to the decision that matters.

Backpressure, not drops

What happens when the ring is full because the server bursts faster than real time? The first implementation dropped the excess bytes with a warning log. That is the intuitive choice ("stay live, skip ahead") and it is wrong for compressed audio: dropping arbitrary bytes shears frames, and a sheared MP3 grid cascades into the false-sync death spiral described in the codec chapter. The symptom was playback that degraded within a minute of any burst.

The correct move is to block the producer:

while (active) {
if (xRingbufferSend(ring, data, len, pdMS_TO_TICKS(500)) == pdTRUE) return;
// ring full: retry in slices so a stop request stays responsive
}

When the worker blocks, it stops reading the socket; the kernel's receive window fills; TCP flow control tells the server to slow down; the server buffers or paces. The excess audio waits on the server, in the protocol layer built for exactly this, instead of in device RAM or on the floor. Verified from the outside: during a burst, the sender's queue backs up by hundreds of kilobytes while the device consumes at a measured, exact real time.

The 500 ms slice honors invariant three of the pipeline: stream_client_stop() flips active and the worker notices within half a second even mid-backpressure.

ICY metadata demux

Stations report the current track ("now playing" text) via the ICY protocol: the client sends Icy-MetaData: 1, the server answers with icy-metaint: N, then interleaves the body as N audio bytes, 1 length byte, length x 16 metadata bytes, repeat. The metadata block looks like StreamTitle='...';.

The demux is a small state machine in the worker that must survive two ugly realities:

  • Blocks split across TCP reads: state (bytes-until-metadata, partial block) persists across reads and across reconnects.
  • Arbitrary titles: Arabic titles, quotes, oversized blocks. The parser bounds every copy, deduplicates repeats, and publishes EVENT_STREAM_METADATA, which is how the display and every dashboard show the reciter name seconds after connecting.

The audio bytes, stripped of metadata, are what enter the ring; the codec never sees ICY.

Connection lifecycle

  • TLS via the ESP-IDF certificate bundle, so https:// stations verify against real CAs.
  • The Content-Type header picks the codec (audio/mpeg vs audio/aac) before the first byte is decoded; unknown types fall back to sniffing.
  • Every reconnect publishes EVENT_STREAM_RECONNECTING (the dashboard badge), resets the ICY state, and reuses exponential backoff so a dead station does not hammer anyone.
  • URL validation upfront (scheme allowlist, host present, no userinfo, control characters rejected) because these URLs arrive from the REST API, an authenticated but still external input.

Meanwhile the audio task, finding the ring empty during an outage, feeds silence to the DAC in small writes: the amplifier stays quiet instead of clicking, and position stops advancing because position counts PCM, not time.

Underrun accounting

If the ring stays empty long enough that the DAC misses samples, that is an underrun: counted, published as EVENT_AUDIO_UNDERRUN, surfaced on the dashboard's Now Playing page live. The counter reads zero on a healthy network; it exists because "the audio glitched yesterday" is undebuggable while "underruns incremented at 14:02" is a WiFi survey away from a diagnosis. The bench soak criterion for releases is zero underruns over sustained playback.