A radio station in 90 megabytes
clock_window = 12
Twelve minutes. That is how long after the hour Radio Mutation may still say what time it is. At 02:11 the DJ can announce two in the morning. At 02:13 the same sentence would be a lie, so that file is not played.
The station plays garage punk, surf and psych around the clock. Its path to a listener is short: a folder of MP3s, Liquidsoap, Icecast, Traefik. There is no database. Most of what makes it sound like radio is a handful of constants in one Liquidsoap script.
The server is the cheap part
Both containers together take 90 MB of RAM on an idle stream: Liquidsoap 87, Icecast 4. Encoding the MP3 costs about 5% of one core. Each listener adds a buffer of a few dozen kilobytes, not megabytes.
At 128 kbps, one listener who never switches off moves about 41 GB a month. On this host outbound traffic is unmetered under a fair-use policy, so that figure is arithmetic, not a bill. The port is 200 Mbit/s to 1 Gbit/s, depending on the plan. At 200 Mbit/s it carries some 1 500 simultaneous listeners before it becomes the limit. At 1 Gbit/s it is about 7 800.
The format is constant-bitrate MP3, not Opus. It has to open in Safari and in a 2011-era desktop player. Icecast never faces the internet. Traefik terminates TLS and forwards exactly three paths: /stream, /stream.mp3 and /status-json.xsl. Icecast's own admin pages stay unrouted.
A break is chosen five seconds before it airs
Every word the DJ says was recorded in advance. What makes it sound live is the choosing, and the choosing has to happen against the clock on the wall.
An earlier version used Liquidsoap's rotate. It asks each source whether it is ready, and a dynamic request gets ready by resolving a file. So the next break was picked the moment the previous one ended, 7 to 26 minutes before it went to air. Over 34 hours of the station, 0 of 18 clock breaks aired inside their twelve-minute window. They landed 26 to 48 minutes past the hour. The station announced two in the morning at ten to three.
No setting fixes that. The script now holds breaks in an empty request.queue, which is never ready, so fallback plays music. A callback fires when the current record has five seconds left. It reads the hour, picks a file and pushes it. The two-second crossfade asks for the next source and finds the break already waiting.
The hour, but not every time
The pick is a chain, from the most particular line to the least. A comment on the record that just ended, then on its band, then the hour, the date, a story, the part of the day, and finally anything. Most links have their own odds, and each one falls through when its folder is empty.
Inside clock_window, the hour outranks everything except the record and the band. Outside it, part_chance sets how often the station talks about the time of day. It is set to 0.4, not higher: a DJ who mentions the time at every break sounds scripted. Night is 00–07, morning 08–11, afternoon 12–16, evening 17–23.
Spacing works the same way. After each break the script draws a new gap with random.int(min=2, max=6). The upper bound is exclusive, so the gap is two to five records. A break at exactly every third track is a metronome. Once the ear finds the beat, every break after it is expected.
The audio carries the rest. Every break passes a high-pass at 110 Hz, a low-pass at 7.2 kHz, a gentle 1.6:1 compressor and a floor of brown noise. Its silences shift by up to 0.18 seconds either way, so no two pauses match.
The clock itself is an editorial setting, STATION_TZ. A container with no timezone believes it is in UTC. In testing, one called half past eight in the evening the middle of the night. It said so on air.
Listeners are a hash, or nothing
audience.mjs reads the public status document every 30 seconds. That is 2 880 rows a day, about 60 KB a month. Thirty seconds was chosen because it is the shortest interval that stays this cheap, and because someone who arrives and leaves inside half a minute did not listen.
Counting is easy. Telling a returning listener from a new one usually means storing addresses. Instead, each finished session is keyed by a SHA-256 of the address plus a salt. The key is cut to 16 characters. The salt never rotates, so the same person is the same key tomorrow. The file is still not a list of addresses. The one thing lost is geography: a country cannot be resolved from a hash.
Behind Traefik, Icecast sees the proxy's address for every listener. A naive log would record one person who tuned in at launch and never left. The live station has the trusted-proxy setting in place, so real addresses reach Icecast and sessions are recorded. Without it, the code drops connections from the machine's own network and writes nothing for them. The half-minute count keeps going regardless, because it needs nobody's address at all.