Left of the Dial: Making ldial.org
How I built Ldial, a fast radio streaming app that brings back the magic of music discovery.
I grew up listening to punk and independent music. As a teenager in the mid-to-late 90s my appetite for new music far exceeded my CD budget. I would trade cassette tapes with friends to expand my collection and hear things I wouldn’t have known about, purchased, or might stretch my boundaries a bit (I still get a little nervous thinking about the tape a friend made with Crass’s The Feeding of the 5000 on side A and Stations of the Crass on side B).
Once I learned to drive, I would often tune into the local college stations while I drove around Pittsburgh (notably WRCT from CMU and Pitt’s WPTS). Sometimes the shows were terrible, other times there would be something magical that I’d never heard before and may never hear again. The signal might be flaky and would fade out depending on where in the city or suburbs I might be. An experience Paul Westerberg immortalized in Tim’s “Left of the Dial”:
Passin’ through and it’s late, the station started to fade
Picked another one up in the very next state
Today, with infinite access to streaming music and a fast Internet connection, I’d pretty much lost this practice. Occasionally, I would tune into my local station when driving (the great WPKN) or use a direct stream to listen to a renowned station like KEXP, but I mostly fell prey to the algorithms suggesting and choosing my next listen. It was effective, but kept me in a tight bubble of what I already like. It also made music feel disposable in a way it hadn’t for me when I was younger. I was often listening to something only once then forgetting about it or skipping through tracks just to find the best song to add to a playlist. In short, it felt soulless.
Somewhere I read a quote from a 2019 interview with Julian Casablancas where the reporter asks how he listens to music. His response1 stuck with me:
I listen to the radio and generally stay below 92. That’s the best place to hear music.
That idea stuck with me, not because I wanted to completely give up streaming, but because I loved the idea of re-introducing that personal curation into my listening habits. Particularly when it wasn’t driven by likes, subscriptions, newsletter sign ups, or web analytics.
My desire to listen to radio didn’t really mesh well with the formats for doing so. The popular TuneIn radio would allow me to listen to independent radio, but removing ads required me to pay them, without supporting those stations. I could stream directly from the stations, but that required some planning and jumping through bookmarks when a show wasn’t up my alley. I added a few stations to the Sonos speaker in my office, but I rarely directly open the Sonos app, so it didn’t stick.
In short, the options available didn’t really work for me.
A Couple of False Starts
In 2020 I started to build an early version of Ldial, allowing me to curate and stream the stations I was interested in from one place. It was meant just for me and like often happens with side projects, I got sucked into the minutia and then a global pandemic happened. It sat on the shelf (or rather, my computer’s hard drive).
In November of 2023 I went to Chicago for work. On the last night of my trip, I went out to eat with a close friend. I am usually not a night owl, but the mix of good conversation, the relief of ending a busy week, and reading about how developers were experimenting with LLMs inspired me to pick up my laptop and knock out a vastly simplified version in a couple of hours. The next morning, I texted a launched version to that same friend.
And this version, mostly sat there for the next year and a half. I still didn’t really use it. It felt clunky, the streams were slow, and I didn’t find it fun to select a station, wait a few seconds, and repeat. I rarely used it and chalked it up as another idea that sounded good in theory but wasn’t that great in practice.
A Third Attempt
A few weeks ago, I took a long weekend to visit family. Somewhere on the long road trip home I had the idea to pick Ldial back up, but with two new tweaks:
- I wanted it to be lightweight and fast
- It needed a “random” button to take away the choice paralysis
With that in mind, I woke up early the next morning, before my wife and kids, and had a pretty solid update running by breakfast. Over the next few days, I tweaked the loading performance and layout, but was very happy with it. Enough so that I found myself using it at my desk every day.
Most importantly, the random button feels like magic to me. With a click, I am transported through stations playing every variety of music or discussing issues impacting their local community.
At a time when public media is under attack and search results are often filled with AI slop2, it feels refreshing.
The Technical Bits and Bobs
For anyone who has made it this far and is interested in what made this iteration different, the current version of Ldial is a React application that implements a three-tiered preloading system that “anticipates” user behavior to help make stream loading feel fast.
1. Connection-Level Preconnection
When users hover over a station card, the application immediately establishes preconnect
links to both the streaming endpoint and metadata API endpoints:
export function preconnectForStation(station: {
streamUrl: string;
api?: { url: string };
}): void {
preconnectOrigin(extractOrigin(station.streamUrl));
if (station.api?.url) preconnectOrigin(extractOrigin(station.api.url));
}
This aims to eliminate DNS resolution and TLS handshake latency before audio data is even requested, reducing startup time by hundreds of milliseconds.
2. Audio Stream Prefetching
After a 100ms hover delay, the system creates muted HTML5 audio elements that begin preloading stream data:
const audio = new Audio(getProxiedStreamUrl(station.streamUrl));
audio.preload = "auto";
audio.volume = 0; // Mute the prefetch audio
prefetchAudioRef.current = audio;
setPrefetchedStation(station);
// Auto-cleanup after 60 seconds
prefetchCleanupTimeoutRef.current = setTimeout(() => {
cleanupPrefetch();
}, 60000);
3. Random Station Pre-warming
The application proactively prepares a random station for the shuffle feature, creating a continuously warm audio pipeline:
const prefetchNextRandomStation = useCallback(() => {
const randomStation = getRandomStation();
if (randomStation) {
const audio = new Audio(getProxiedStreamUrl(randomStation.streamUrl));
audio.preload = "auto";
audio.volume = 0;
randomPrefetchAudioRef.current = audio;
setNextRandomStation(randomStation);
}
}, [getRandomStation, prefetchedStation, currentStation]);
Audio Element Adoption Pattern
Rather than creating new audio elements when users select stations, Ldial implements an “adoption” pattern that reuses pre-warmed elements:
let audioToAdopt: HTMLAudioElement | null = null;
if (
prefetchedStation &&
prefetchAudioRef.current &&
prefetchedStation.id === station.id
) {
audioToAdopt = prefetchAudioRef.current;
} else if (
nextRandomStation &&
randomPrefetchAudioRef.current &&
nextRandomStation.id === station.id
) {
audioToAdopt = randomPrefetchAudioRef.current;
}
if (audioToAdopt) {
setAdoptedAudio(audioToAdopt);
}
This pattern allows the Player component to inherit already-buffered audio elements, achieving near-zero startup latency when the preloading system correctly predicts user behavior.
Working with Audio Streams
The way stations implement their streams varies widely. For instance, getting the meta data about track info is surprisingly inconsistent. I’ve implemented it for a few stations, but haven’t figured out a way to do it without a lot of manual wrangling, so songs and artists info are largely left empty.
Also, several stations still rely on http
for serving the stream itself, which causes mixed content errors. Originally, I had set up a simple Node server that would proxy these endpoints on a hobby Railway plan. When Kottke.org and Waxy shared the site, that proxy quickly ran out of memory and my dashboard was anticipating a bill in the range of several hundreds of dollars per month. I quickly took down those streams, but didn’t want to remove them permanently. I replaced the proxy with a simple serverless function, which seems to be quick and is meeting the demand well.
Wrapping Up
To further support the idea of freeform media, Ldial is completely free to use and free of any user tracking, analytics, or cookies. Based on emails and server logs, I suspect a few thousand people are using this site a day, but I actually have no idea! And that feels just as good.