Part 11 — How iOS Demanded Things Be Done Properly

Or: why the backend suddenly became a proper media server

The iOS version of Needle was so close to being done.

Dangerously close.

The UI looked fantastic.
The library synced beautifully.
Artwork loaded instantly thanks to the cache system.
Offline mode worked.
Heartbeat-based online/offline recovery worked.
The app felt smooth, polished, and oddly… complete.

Then I connected my AirPods Pro, enabled the EQ, fired up my vinyl rip of Dance Fever, and experienced one of those magical moments where all the coding, infrastructure work, and sleep deprivation suddenly become worth it.

It sounded glorious.

Warm.
Atmospheric.
Those tiny vinyl pops and crackles still intact.
My own rip.
My own backend.
My own app.
My own infrastructure.

For approximately seven minutes, I believed I had achieved enlightenment.

Then the audio stream exploded.

Not literally, thankfully.

But playback started:

  • cutting out

  • restarting

  • behaving unpredictably

  • and generally acting like it had consumed dangerous quantities of caffeine

Which naturally sent me into a debugging spiral.

At first, the culprit seemed obvious:
the backend stream responses were missing proper HTTP range support.

iOS’s AVPlayer, unlike Android which happily accepts “close enough” solutions from time to time, apparently prefers standards and emotional suffering.

The backend responses looked like this:

Accept-Ranges: none

Which, in hindsight, was not exactly ideal for a media streaming platform.

So I fixed the headers.
Implemented proper streaming behaviour.
Added the necessary flags.

And for a brief, beautiful moment, everything worked.

Then it broke again.

This is usually the point where software development transitions into psychological warfare.

The issue turned out to be much deeper.

The real problem wasn’t streaming itself.
It was the interaction between:

  • live transcoding

  • AVPlayer

  • and the EQ pipeline using MTAudioProcessingTap

Or in simpler terms:

iOS did not appreciate me piping freshly transcoded audio directly into advanced realtime audio processing.

Honestly?
Fair enough.

At this point I had two options:

Option 1

Continue fighting increasingly bizarre realtime transcoding edge cases forever.

Option 2

Rethink the architecture entirely.

Thankfully, exhaustion occasionally improves decision making.

So instead of:

“transcode everything live during playback”

Needle now does this:

Original lossless file
↓
Generate AAC streaming-quality version once
↓
Store it in cache
↓
Serve it like a normal static media file

Suddenly:

  • streaming became stable

  • AVPlayer stopped complaining

  • seeking worked properly

  • EQ behaved

  • buffering became predictable

  • CPU load dropped

  • offline downloads became smaller

  • and the backend architecture became dramatically cleaner

The entire system improved because iOS effectively forced me to stop treating the backend like:

“an API that somehow also plays music”

…and start treating it like:

“an actual media platform.”

Which, annoyingly enough, was the correct direction all along.

The best part?

The entire AAC cache for my 2.6k-track lossless library only consumed around 16GB of extra storage.

That’s it.

Sixteen gigabytes in exchange for:

  • stable playback

  • smoother mobile experience

  • faster streaming

  • better offline support

  • and dramatically simpler infrastructure

A ridiculously good trade.

Naturally, while all this was happening, I also briefly thought:

“hmm… visualisers on an Apple TV app would look really cool…”

But we are absolutely NOT discussing that right now.

The important thing is this:

The iOS app works beautifully now.

And more importantly:
the backend grew up in the process.

Which may actually be the bigger milestone here.

In the end, iOS didn’t break Needle.

It simply demanded things be done properly.