Building an Internal Radio Station
Working at a music company means working with people who have strong opinions about what to listen to. We’re fully remote, so we built an internal radio station where anyone can broadcast live DJ sets to the team. Every first Friday of the month, we run an all-day stream with 6-8 DJs taking turns. Here’s how we built it.
Update (2026): We’ve been doing this every month since 2018, and it’s become one of our most important team ceremonies.

The Stack
The streaming infrastructure runs on three components:
- Icecast: The streaming server that distributes audio to listeners
- Liquidsoap: A programmable audio routing language that handles stream mixing and fallbacks
- BUTT (Broadcast Using This Tool): Desktop app for DJs to broadcast live
The frontend is a React app that connects to Icecast’s status API and plays the stream.
How It Works
The flow looks like this:
flowchart LR
DJ[DJ via BUTT] --> LS[Liquidsoap]
FB[Fallback Stream] --> LS
LS --> IC[Icecast]
IC --> L[Listeners]
When a DJ broadcasts, Liquidsoap receives their audio via the Shoutcast protocol and routes it to Icecast. Icecast handles the actual distribution to listeners. When no one is live, Liquidsoap automatically falls back to a relay stream.
Liquidsoap Configuration
Liquidsoap uses its own scripting language. Here’s the core of our setup:
# Live input from DJs via BUTTlive = input.harbor( "/live", port=8800, password="hackme")
# Fallback to Soma FM when no DJ is livesoma = mksafe( input.http("https://somafm.com/groovesalad256.pls"))
# Use live if available, otherwise fallbackradio = fallback( track_sensitive=false, [live, soma, blank()])
# Output to Icecast at 320kbpsoutput.icecast( %mp3(bitrate=320), host="icecast", port=8000, password="hackme", mount="/stream.mp3", radio)The fallback function is the key. It takes a list of sources and uses the first one that’s available. When a DJ connects, live becomes available and takes over. When they disconnect, it falls back to Soma FM. The blank() at the end ensures silence instead of errors if everything fails.
Icecast Configuration
Icecast handles client connections and stream distribution. The important bits:
<icecast> <limits> <clients>100</clients> <sources>2</sources> </limits>
<listen-socket> <port>8000</port> </listen-socket>
<!-- Lower burst size for faster stream startup --> <burst-size>16384</burst-size>
<mount type="normal"> <mount-name>/stream.mp3</mount-name> <public>0</public> </mount>
<authentication> <source-password>hackme</source-password> <admin-user>admin</admin-user> <admin-password>hackme</admin-password> </authentication></icecast>The burst-size setting controls how much audio Icecast buffers before sending to new listeners. Lower values mean faster startup but more sensitivity to network hiccups. At 16KB with a 320kbps stream, listeners get audio in about 0.4 seconds.
The Web Player
The frontend is a React app that does three things: plays the stream, shows what’s currently playing, and displays the listener count.
Icecast exposes a JSON status endpoint at /status-json.xsl. We poll it every few seconds:
const [status, setStatus] = useState<IcecastStatus | null>(null);
useEffect(() => { const fetchStatus = async () => { const res = await fetch('/status-json.xsl'); const data = await res.json(); setStatus(data.icestats); };
fetchStatus(); const interval = setInterval(fetchStatus, 3000); return () => clearInterval(interval);}, []);The status includes the current track title (if the DJ is sending metadata), listener count, and stream details.
For playback, we use an HTML5 audio element with some tricks for low latency:
const audioRef = useRef<HTMLAudioElement>(null);
const play = () => { const audio = audioRef.current; if (!audio) return;
// Append timestamp to bust cache audio.src = `/stream.mp3?t=${Date.now()}`; audio.load();
// Seek close to live edge once buffered audio.addEventListener('canplay', () => { if (audio.seekable.length > 0) { audio.currentTime = audio.seekable.end(0) - 2; } audio.play(); }, { once: true });};The timestamp query parameter prevents browser caching of the stream URL. Seeking to 2 seconds before the live edge keeps playback synchronized without risking buffer underruns.
DJ Setup
DJs use BUTT to broadcast. The configuration is straightforward:
[radio]address = radio.company.comport = 8800password = hackmetype = 1tls = 0mount = liveusr = sourceThey just open BUTT, hit broadcast, and their audio goes live. No scheduling system, no reservations. If someone else is already live, Liquidsoap just rejects the connection.
Infrastructure
Everything runs on Kubernetes. The architecture has four components:
- Liquidsoap (StatefulSet): Receives DJ input, handles fallback logic
- Icecast (StatefulSet): Primary streaming server
- Icecast Relay (Deployment): CDN-like layer to reduce load on primary
- Radio (Deployment): React frontend served by Nginx
flowchart TB
subgraph External
DJ[DJ via BUTT]
Listeners
end
subgraph Kubernetes
LS[Liquidsoap]
IC[Icecast Primary]
IR[Icecast Relay]
FE[React Frontend]
end
DJ -->|broadcast| LS
LS -->|stream| IC
IC -->|single connection| IR
IR -->|distribute| Listeners
Listeners -->|UI| FE
The relay is important for scale. Icecast can handle maybe 100 concurrent listeners before struggling. The relay connects to the primary Icecast as a single listener, then redistributes to everyone else. We put Nginx in front of it to handle the HTTP layer:
location /stream.mp3 { proxy_pass http://localhost:8000/stream.mp3; proxy_buffering off; proxy_read_timeout 86400s; proxy_set_header Host $host;}The proxy_buffering off is critical for streaming. Without it, Nginx buffers the response and adds latency.
Latency Optimization
For a radio station, latency matters less than for live video. But we still optimized for it because nobody wants to hear the beat drop 10 seconds after everyone else.
The key changes:
- Reduced Icecast burst buffer from 64KB to 16KB for ~0.4s startup
- Disabled Nginx proxy buffering to avoid adding latency
- Seek to live edge in the player to stay synchronized
- 320kbps bitrate fills buffers faster than lower bitrates
End-to-end latency is about 5-10 seconds from DJ to listener. Most of that is encoding latency in BUTT and Liquidsoap. Good enough for a company radio station.
Final Thoughts
Building a radio station turned out to be simpler than expected. Icecast and Liquidsoap are battle-tested tools that handle the hard parts. The custom work was mostly glue code and UI.
The best part is hearing what people play. Turns out the team has surprisingly good taste.