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.

A photo from the first stream

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:

radio.liq
# Live input from DJs via BUTT
live = input.harbor(
"/live",
port=8800,
password="hackme"
)
# Fallback to Soma FM when no DJ is live
soma = mksafe(
input.http("https://somafm.com/groovesalad256.pls")
)
# Use live if available, otherwise fallback
radio = fallback(
track_sensitive=false,
[live, soma, blank()]
)
# Output to Icecast at 320kbps
output.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.xml
<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:

Player.tsx
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:

Player.tsx
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:

~/.buttrc
[radio]
address = radio.company.com
port = 8800
password = hackme
type = 1
tls = 0
mount = live
usr = source

They 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:

  1. Liquidsoap (StatefulSet): Receives DJ input, handles fallback logic
  2. Icecast (StatefulSet): Primary streaming server
  3. Icecast Relay (Deployment): CDN-like layer to reduce load on primary
  4. 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:

nginx.conf
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:

  1. Reduced Icecast burst buffer from 64KB to 16KB for ~0.4s startup
  2. Disabled Nginx proxy buffering to avoid adding latency
  3. Seek to live edge in the player to stay synchronized
  4. 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.