
You have probably have seen or listened to the classic “LoFi Girl” radio. It is still very much a big hit: if you open that link right now, you should see at least 10k viewers in that stream, with people constantly interacting in the chat.
The stream exists almost interruptly since 2018. The live is still very popular, with around ~20k viewers every single day and some interacting in the chat.
I like LoFi music. Started listening to the genre more or less when the graduating from college which helped me focus and study. Then, I became to also listen to it when working. After some years, I noticed I was also listening to it in another ocasions too, adding it to playlists here and there and then I realized that I might have some good collection of tracks that could be shared in a playlist to the world.
At that point creating the playlist would be enough but as an engineer that sometimes overengineer things, I decided to build a whole radio station from scratch. This is a blog post that goes over the whole process in depth. All the code you see here is open-source so you can create your own version if you’d like.
Before continuing reading, what if we listen to the music together? Just press the play below.
How a (lofi) radio could work

Above we have the diagram of how it is working right now. All the code used here is open source on this repositories:
- player: The client side consuming the audio stream and interacting with the server via HTTP requests and SSE.
- lofi-radio: Server aplication using Bun. Store the files, streams the audio and holds the Admin UI.
- shelf: Playlist management and replication in music apps like Spotify and YouTube Music.
We will deep dive in each of these parts in the following sections.
Client side
The client side consumes 2 main APIs:
- Audio stream via HTTP request that return a stream of bytes.
- Metadata information about the track via Server Sent Events (SSE). It pings the client with the information about the current track being played.
If you played the radio in the callout above, you are already seeing the metadata in the player either at the right side of the screen or at the bottom if you are on mobile.
The audio tag itself is straightforward from this point, but we also have custom logic built on top of it to handle server reconnections.
Finally, we also have custom logic to prevent the screen to lock for mobile browsers and push metadata using MediaSession API to devices, so the information is visible in radio cars or lock screen in mobiles for example.
Album cover and blur gradient
Look and observe the album cover while listening to the music is a crucial part of the experience. The art of the cover is not born out of nowhere and is created in the same way as the music is.
Thinking about it, the most important element on the radio page should be the album cover. It is the biggest element there and around it there is a custom blur gradient effect from the colors that are extracted from the image.

The effect lives in src/lib/ambient-glow/index.ts. At a high level it samples the cover image on a small grid, picks one dominant color per cell, and paints a stack of soft radial gradients on layers sitting behind the image.
The inspiration is the “Ambilight” effect in Philips TVs where they put a strip of LED lights behind the TV that changes according to the colors appearing in the screen.

Colors
First of all, like the TV, we need to extract which colors we will show in the gradient.
We divide the image in a grid and for each cell in the sampling grid the code calls dominantColor(), which is just a histogram with three escape hatches. It walks the pixel buffer four bytes at a time, drops anything that’s too transparent, too dark, or too bright via a simple luminance check, and then bins what’s left into coarse RGB buckets:
const lum = 0.299 * r + 0.587 * g + 0.114 * b
if (skipBlack && lum < blackThreshold) continue
if (skipWhite && lum > whiteThreshold) continue
const rb = Math.floor(r / binSize)
const gb = Math.floor(g / binSize)
const bb = Math.floor(b / binSize)
const key = `${rb},${gb},${bb}`
counts.set(key, (counts.get(key) || 0) + 1)
The bucket with the highest count wins, and its center is reconstructed back into an { r, g, b } triplet. binSize controls how forgiving the buckets are: large bins merge close shades into one dominant color, small bins preserve fine variation at the cost of fragmenting the vote. The black/white skips exist because letterboxing and bright backgrounds otherwise dominate the count and drain the gradient of the colors that actually matter.
Here’s the same algorithm running on a 4×4 grid so you can see the palette it picks:

What is crazy for me is that the exact same algorithm can be used to pixelize an image if you go long enough increasing the grid size.
Nowadays with Ultra HD 8k screens it is easy to forget the image we see in front of us in a computer is a grid of pixels, but we are actually seeing billions of them at every single frame.
When we have the colors set, we can then mount our glow effect. It is highly customizable and can create a wide variety of effects.
Have a play with the controls below — every slider maps directly to a field of GlowOptions that the radio page passes into mount().
Server side
Now into the server side of things: A Bun application that runs the radio engine streaming out the audio frames to all listeners.
Since we want to mimick the radio behavior, all listeners need to receive the same audio frames so at the same time. If a listener joins “late”, it needs to pick up the song where it is currently playing, without further delay.
broadcast
listener 1
listener 2
listener 3
joined mid-song
listener 4
joined mid-song
something needs to go every 26ms
The number comes from how we parse the MP3 frames.
The MP3 format itself is widely used to store and transport audio files. It is very efficient to compress an audio file size while maintaing the quality.
When it landed was a success as people could listen to music without needing to carry anything but a small device like iPod or this stick below.

Inside the MP3 spec, there are several ways of compressing data. The most used of them, MPEG-1 Layer 3, is a very efficient lossy compression, meaning it can store the audio in a good quality while keeping the size file short.
This compression format states each frame of an audio file stores exactly 1152 samples. Now, there is another metric in digital audio called sampling rate, which basically tell us how many times per second the analog audio signal is converted to 1’s and 0’s.
In most audios, we have a 44.1kHz sampling rate, meaning it samples the audio 44100 per second. Finally, if the compression spec has 1152 samples per frame and we usually sample 44100 per second, the frame duration is 1152 / 44100 = 0.026 seconds, or 26 ms.
1,152 samples ÷ 44,100 Hz × 1000 = 26.1 ms per frame
32 kHz 44.1 kHz 48 kHz
≈ 38.3 frames per second · ~6,891 frames in a 3-minute track
Therefore, in order to the listener hear the song without interruptions, we need to send audio frame every 26ms and hold that cadence to avoid jittery.
This 26ms cadence can vary if the sampling rate is different or the song is compressed in a different format. We need to respect that number otherwise the song will be all choppy.
Why setTimeout() can’t be used
A naive implementation of “send an audio frame every 26ms” would be to use setTimeout to send the frame and then wait for the next 26ms for doing it again
setTimeout(() => {
sendAudioFrame()
}, 26) // works? not quite.
setTimeout works in a best-effort way when it comes to precise milliseconds. It can’t guarantee something will wait to be executed in exact 26ms. It can be 26, 24, 32ms, depending how soon the CPU can handle that process. We gotta remind ourselves that server aplications are CPU-bound and therefore share the Operational System resources like any other process running alongside it.
So if that doesn’t work, what does? Well, something called PreciseTimer needs to be implement, which can respond in sub-millisecond range.
Its calculated how many time it takes to broadcast the audio frame and wait the difference so it adds up to the 26ms we need to follow:
26 ms grid
perfect clock
setTimeout
best-effort
PreciseTimer
clock-corrected
Slowed down and the wobble exaggerated so you can see it. A few milliseconds per frame sound harmless — but a 3-minute track is roughly 7,000 frames, so the slip compounds into seconds of desync. That's why the stream re-checks the clock on every frame instead of trusting setTimeout.
Real world needs a buffer
We could keep sending audio frames every 26ms only and that would work in theory. But the real world is not that simple: Poor network connections, latency and slow CPUs can affect the hearing experience and you would ended up with a choppy audio.
To avoid this problem, the server keeps a small ring buffer of the most recently broadcast frames and replays it the instant a client connects, so the decoder starts with a few seconds of runway instead of nothing.
no buffer
live edge
burst-on-connect
~4 s buffered
A live stream sends exactly one frame per frame-duration, it never sends faster. So a fresh listener's buffer starts at zero and stays razor-thin. One late packet and it underruns. Replaying the last few seconds of frames the instant they connect gives the decoder something to coast on. The cost: that listener is now a few seconds behind the live edge — making "perfect sync" a few seconds behind within an acceptable window.
Reading files are a expensive operation
I/O operations are generally expensive.In order to play the next song, the next file read is triggered a few seconds before the current track ends so it has enough time for the mp3Parser function to run. To also migigate any latency, we read the file in async mode.
Tracks
In this current version of the radio, it only supports MP3 files. In order to support more extensions, the same algorithm to determine the frame duration would need to be replicated to them so the frames constant cadence is maintainted in the stream level.
The addition of files is done using the Admin APIs that can manage the playlist by adding, editing metadata or removing songs in the playlist.
Shelf for Playlist management
The playlist you can hear here is also available in Spotify and YouTube Music.
To avoid too much manual work, I built a tool called Shelf that can sync the playlist state between both platforms. I originally built the playlist on Spotify and then easily replicate it on YouTube Music:
For local sync, we are also able to export the playlist as a JSON file that comes in order, which is the one we use at the lofi-radio server:
The cool thing about Shelf is that, after we sync the playlists between all platforms, we can use the tool to add new songs which will be propagated to all:
Next steps
- First DMCA Takedown Notice?
- Normalize the MP3 tracks in the server so they are all in 44.1kHz sampling rate. Done: upload endpoint re-encodes the files. Commit here.
- Normalize tracks volume Done: upload endpoint also apply a volume re-encoding if necessary. Stream feels way nicer. Commit here.
- Strengthen the Admin UI in the server to be more than just a simple secret
- Open up a YouTube live video streaming the radio, perhaps even with a comissioned (make by a real human) art.