Building a LoFi radio | Cieslak.dev
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. For me, it started as something I would put as a background music when studying at college. After graduated, I began also listening it when working. Finally, I realized I was listening to them in another ocasions as well: I started to add it in my personal playlist, then I noticed: well, I might have listened so much to it that I might have a good collection of my favourite ones and therefore I decided to share it in the wild.
I 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 and therefore could have my own playlist to share 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.
lofi radio
How a (lofi) radio could work
Below 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<br>if (skipBlack && lum blackThreshold) continue<br>if (skipWhite && lum > whiteThreshold) continue<br>const rb = Math.floor(r / binSize)<br>const gb = Math.floor(g / binSize)<br>const bb = Math.floor(b / binSize)<br>const key = `${rb},${gb},${bb}`<br>counts.set(key, (counts.get(key) || 0) + 1)<br>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...