DEV Community

Christiaan Maks
Christiaan Maks

Posted on

Recognizing every song played on internet radio

Internet radio stations need play logs. If you report to SoundExchange or a European collecting society, you need to know exactly which recordings aired and when, down to the ISRC. And if you just run a station, you want a now-playing feed that doesn't lie to you.

I run SonoVault, a music metadata API, and last year we added live stream monitoring: you give us an Icecast or Shoutcast URL, we tell you what's playing. This post is about everything that turned out to be harder than expected.

The metadata is right there. It's also wrong.

Icecast streams carry ICY metadata: a StreamTitle field injected into the byte stream every few kilobytes. In theory that's your now-playing feed for free.

In practice:

  • Stations tag ads and jingles as if they were songs.
  • Automation software pushes "Artist - Title" in whatever format the intern configured in 2014. Sometimes it's "Title - Artist". Sometimes it's the station slogan on repeat.
  • Some feeds just freeze. The stream plays on, the metadata shows the same track for six hours.

The one that really got us: a major station where the ICY metadata was consistently 10 minutes ahead of the audio. Their CDN buffers deep, the metadata injection happens at the source. So the "now playing" tag told us about a song that listeners would hear 10 minutes later. If you trust the tags and timestamp them on arrival, your entire play log is shifted by 10 minutes and every royalty line is wrong.

Conclusion after a few weeks: ICY metadata is a hint, not a source of truth. If you want a log you can put your name under, you have to listen to the audio.

Fingerprinting a stream that never ends

Audio fingerprinting against a known catalog is a solved problem for files. For live streams it gets messier, because you don't get a clean file. You get an endless byte stream that reconnects, drops, changes bitrate, and occasionally decays into noise without telling you.

Our pipeline looks roughly like this: ffmpeg decodes the stream to PCM, we cut it into overlapping chunks, fingerprint each chunk, and match against a reference index built from our catalog. A single chunk match means little. Radio edits, DJ transitions and voiceovers produce garbage matches all the time. A track only counts as playing when consecutive chunks agree on it. That one rule kills most false positives.

Two things surprised me about the matching itself.

First, genre changes everything. Electronic music on a current-hits station is the easy case: a small hot set of tracks covers most of the airtime, so you can keep the popular fingerprints in memory and match fast. Classical radio is the opposite: long works, dozens of recordings of the same piece, quiet passages that fingerprint poorly. The precision settings that work for one format actively hurt on the other, so this ended up as a per-stream configuration instead of a global one.

Second, your reference audio matters as much as your algorithm. Fingerprints made from 30-second preview clips match studio versions fine, but radio plays are sped up, pitched, and cut differently. We got a real recall jump from using fingerprint methods that tolerate time-warping, and another one from fingerprinting actual broadcast recordings instead of only previews.

The stream will gaslight you

The nastiest bug of the whole project: recognition on one station died, and stayed dead, while the stream sounded completely fine in a browser.

The cause took a while to find. The station's CDN was intermittently returning 404s for audio segments. Our long-running decoder survived the errors but silently lost sync with the HE-AAC stream, and from that point on it produced PCM that looked like audio, decoded without errors, and matched nothing. A freshly started decoder on the same URL was fine. So the source was pristine, our process was broken, and no error was raised anywhere.

The fix is humbling: watch your own recognition rate per stream, and when a stream that normally matches 15 tracks an hour matches zero for long enough, kill the decoder and start a new one. We tried being smarter. Restarting works.

Same lesson in smaller doses everywhere:

  • Set read timeouts on the stream connection. Some servers keep the socket open and send nothing forever.
  • Back off and retry when a stream goes down, but keep trying for a long time. Stations go offline for hours and come back.
  • Do the heavy matching off the main thread. We had alert storms that looked like network problems and were actually the Node event loop starving while a matcher chewed on a chunk.

What comes out the other end

Per stream we end up with a timestamped log of recognized plays, with adjacent detections of the same track collapsed into one airing. Because the recognition runs against a full metadata catalog, every play carries its ISRC, label, album and release date, which is exactly the set of fields royalty reports ask for and playout software doesn't export.

Registering a stream is one call:

curl -X POST https://api.sonovault.now/v1/streams \
  -H "x-api-key: $KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/stream", "name": "My station"}'
Enter fullscreen mode Exit fullscreen mode

Then you either poll the history endpoint, subscribe to webhooks, or open an SSE feed and watch plays arrive live.

This all runs behind sonovault.now/streams. The metadata API next to it has a free tier if you just want ISRC lookups or cross-platform track IDs without the stream part. Docs are at sonovault.now/docs.

If you're building anything in this space, or you run a station and your play logs are lying to you, I'm happy to compare notes in the comments.

Top comments (0)