Radiophonia 0.8.2 is the release that gives the app a real graphic equaliser — one that actually shapes the audio, on every platform. For a radio app an equaliser sounds like a small feature. It isn’t. Live internet radio streams are a moving target: they change bitrate, start mid-audio-frame, drop and reconnect, and they arrive over a localhost proxy that platform players often treat as an ordinary file. Getting a 10-band curve applied to that — in real time, on every backend, without stalling the stream or tinting a recording — turned out to be the most involved audio work in the project. This release ships it, and ships the batch of stream-stability and purchase fixes that the equaliser work surfaced.
The Equaliser: One Curve, Three Audio Stacks
The Problem
The requirement: a 10-band, ±12 dB graphic equaliser, reachable from Now Playing and Settings, gated behind Premium, with presets (Flat, Bass, Vocal, Treble, Small Speakers, Headphones) leading the UX and per-band sliders as the power-user escape hatch. User-tuned curves saved as Custom, everything persisting across launches via the app’s existing Hive toMap/fromMap convention.
The catch: Radiophonia has no single audio stack. Three platforms, three engines, three different filter languages — and one of them (Apple) had never been able to process live radio streams at all.
The Solution
Each backend got the treatment that fits it:
- Android — ExoPlayer’s band layout is device-dependent. The app reads the actual band centres at runtime and maps each preset onto them by log-frequency interpolation. It never assumes a fixed band count.
- Windows / Linux — media_kit’s minimal libmpv build ships only the single-band FFmpeg
equalizerfilter. There is no multi-band filter to ask for. So the curve is expressed as a comma-separated chain of single-bandequalizerfilters — one per active band — driven through ajust_audio_media_kitfork extension, and applied at player construction. The reason is a hard trap: changing the filter chain mid-stream stalls libmpv on a live source. A curve change rebuilds the player; it never hot-swaps. - iOS / macOS — the headline item, covered below.
Two invariants held across all three:
- The effect re-binds and re-applies persisted gains on every player construction and recreation — including the iOS three-minute background-resume rebuild we shipped in 0.8.1. That lifecycle contract is funnelled through a single player-construction path and covered by unit tests. An equaliser that quietly forgets its curve after a background-resume is worse than none.
- Recordings are never EQ-tinted. The effect lives at the player layer only; the recording tees the clean upstream stream. The equaliser is a listening preference, not a master-bus commit.
Out of scope for this release, deliberately: the libVLC Windows-HLS fallback, web, and audiogram-driven EQ.
The Apple Renderer: Phase B
Apple was the last piece. The equaliser shipped in two phases inside this release: Phase A delivered the Android and Windows/Linux backends, and until Phase B landed the iOS/macOS UI was inert — the app’s existing AVPlayer tap path couldn’t reach live radio streams, so moving the sliders did nothing there. Phase B makes it real on Apple hardware: a new AVAudioEngine streaming pipeline — URLSession → AudioFileStream → AudioConverter → AVAudioPlayerNode → AVAudioUnitEQ — now the sole renderer for live http(s) streams, applying the 10-band curve to the actual decoded audio in real time. Pausing now hard-stops the stream: zero background fetching.
Three stubborn edge cases came with that renderer, and all three were silent:
- Previously-silent stations now play through the EQ. Some live streams begin mid-audio-frame (1.FM Deep House, Radio Caroline) and the system parser could never lock onto them. The renderer now aligns to a valid audio frame sync (MP3 or AAC ADTS) before parsing.
- Channel-hopping no longer goes silent. Rapidly switching stations (‹ ›) now reliably restarts audio: the renderer binds to the current stream’s proxy URL and resynchronises its parser on any decode discontinuity, instead of staying dead until a full stop-then-play.
- The lifecycle contract is load-bearing and tested, per the invariants above.
The Lesson
An equaliser is not a UI feature; it’s a renderer contract. Write the curve once, translate it per backend, and apply it where the player is born — because mid-stream mutation is exactly where audio stacks fall over.
The Stream Proxy Stops Killing Healthy Players
The Problem
Live stations — BBC Radio 6 Music first among them — underran and reconnected in a loop. The diagnosis was uncomfortable: the proxy itself was the killer. A healthy player pauses reading while its buffer refills (ExoPlayer’s window is ~12–20 s); the proxy kept pushing audio — especially HLS’s bursty segment delivery — past the 512 KB socket-queue limit and evicted the socket. The player lost its live connection, re-buffered, reconnected, and the cycle repeated until stop/restart.
Around the same root cause: no timeouts anywhere on the audio path. BBC and Bauer now-playing fetches awaited stalled connections forever — no track title, no album art, not even an error in the log. A stuck HLS playlist request blocked the whole poll forever, and because the poll guards against re-entry, no further polls ever ran: audio delivery permanently stalled.
The Solution
Four changes, all in the proxy:
- Backpressure instead of eviction. The queue-overflow path now stops feeding the affected client and keeps the connection, resuming when the client drains. A legitimately buffered player is never dropped.
- The stale-eviction threshold moved from 15 s to 60 s, so real zombies are still cleaned up while buffered players are left alone.
- Timeouts on every unbounded await. Each BBC/Bauer metadata fetch is bounded by 8 s (plus an 8 s connection timeout) and logs on failure — a dead endpoint now degrades to no metadata, visible error instead of a permanent silent stall. Each HLS playlist fetch, segment request and body read is bounded by 12 s, so a stuck request aborts, logs, and the 5 s poll resumes next tick.
- Dedup anchors to the live edge. The reconnect-dedup search used to return the oldest matching position in the ring buffer — and a 2 KB window recurs in silence, phrases and DC. Switching network providers mid-stream anchored the new stream to already-heard audio: the player replayed a chunk or the ring was mis-trimmed. The match now anchors at the newest position within the ring’s recent tail, and the scan iterates newest→oldest, stopping at the first match — a stream of identical bytes can no longer cause an O(n²) crawl. While in there:
stop()now resets the dedup state (a session that died mid-dedup used to cost the next session 2–10 s of startup), and a malformedIcy-MetaIntheader — previously anint.parsecrash that left the proxy half-bound — isint.tryParse’d with a plain-passthrough fallback.
The Lesson
Eviction is a blunt instrument; backpressure keeps the connection alive. And a timeout is a feature: every unbounded await on the audio path is a stall with no error, and “no error” is the worst kind of bug.
BBC HLS: Teaching the Platform Player to Be Live
The Problem
The proxy exists to give one upstream connection to many concerns — playback, metadata, recording. But routing HLS through it forced ExoPlayer onto ProgressiveMediaSource (just_audio decides from the extension-less localhost URL), which treats reconstructed ADTS audio as a finite file. It can’t sustain live: the recurring buffer-drain → end-of-stream → reconnect underrun on BBC stations was the symptom.
The obvious fix — feed the .m3u8 directly so ExoPlayer picks its live-aware HlsMediaSource — worked, then broke everything BBC: ExoPlayer couldn’t load BBC’s large rewind/DVR-window playlists (thousands of segments, ~192 KB) and reported Source error. Radio 6 and every curated BBC station wouldn’t play at all on Android.
The Solution
The proxy now relays HLS as HLS on Android. It still downloads the upstream segments, but serves a small synthesized live manifest plus per-segment endpoints on localhost — the URL ends in .m3u8, so ExoPlayer uses its native live-aware demuxer against a tiny 6-segment window. Upstream it fetches BBC’s .norewind (small-window) playlists to keep each 5 s poll cheap. Auto-recording is restored through the relay: the recording still tees the extracted AAC. iOS (AVPlayer) and Windows (VLC) keep their direct native-HLS paths, which already worked. The --dart-define=NO_PROXY=true escape hatch remains for diagnostics — it’s also how the native-demuxer fix was bug-proven in the first place: an A/B build with the proxy bypassed for all streams.
The Lesson
The platform player already knows how to be live. The URL decides which demuxer it gets — so the proxy had to speak HLS, not just carry audio.
Premium You Can’t Lose
The Problem
Two ways to pay for Premium and get nothing:
- Signed-out purchase. The server-validation path requires a Firebase ID token. A signed-out buyer completed the store transaction, and the signed-out branch… returned, without granting anything. The user paid and got nothing.
- Product details never loaded. If the launch-time
queryProductDetailscall failed or came back empty (transient connectivity, IAP not yet in “Ready” state), the product stayednulland “Unlock Premium” failed with a misleading Purchase failed.
The Solution
Consistent with the app’s local-first design: the signed-out path (Apple StoreKit and Amazon IAP) now grants Premium locally via a new onLocalGrant callback wired to PremiumService.grantPremium, with entitlement syncing to Firestore later if the user signs in. A purchase is never lost, on any platform. The purchase handler retries the product query once before attempting the purchase, and the error now distinguishes product unavailable from a genuine failure. Alongside, in_app_purchase bumped to ^3.3.0 / in_app_purchase_android 0.5.2 — Google Play Billing Library 8.0.0, meeting Google’s August 2026 minimum-version requirement.
The Lesson
The store is a channel, not the source of truth. Entitlement is local state the app owns, and a payment path must be enumerated for the identity it runs under — signed in and signed out are different code paths, and the second one used to swallow money.
State That Outlives the App
The Problem
The badge system shipped in 0.8.1 kept unlocking state in a device-local Hive box. Fine — until sign-in. Restored stats re-ran the badge checks before the cloud badge state had downloaded, so already-earned achievements re-unlocked with a celebration each. A fresh install was worse: the empty local box meant everything re-unlocked. A one-off alarm had its own version of the same disease: every relaunch re-armed a fresh “today or tomorrow” occurrence, so an alarm that fired while the app was closed re-armed for the next day — effectively recurring, and able to fire even after the UI showed it disabled.
The Solution
- Badge evaluation is suspended for the duration of the sign-in full sync and resumed once the cloud badge state is merged in — restored achievements stay quiet, only genuinely-new ones celebrate.
- Badge state (unlocks + original timestamps + in-progress counters and time-of-day day sets) now syncs to Firestore at
users/{uid}/badges/state; a full sync restores it before the data-source merge. Push is write-only (no read), to keep Firestore costs minimal. - An explicit guard in
_unlock()skips already-unlocked badges, so rapid re-evaluation can’t double-fire. - The Variety Pack badge now counts only the primary tag per station — a single station tagged “rock, pop, alternative” used to unlock it by itself.
- A one-off alarm now persists its single intended occurrence date; relaunch re-arms the same date or disables the alarm once it has passed, and editing the time or repeat pattern resets the target.
- Audio routing learned two distinctions: a device connect (turning on earbuds mid-listen) now re-activates the session and routes output to the new device — previously a no-op — while deliberately never auto-resuming a paused stream; and a pause-type interruption (a phone call) now auto-resumes only if that interruption itself paused a playing stream, so a call landing while already paused leaves playback paused.
The Lesson
State that outlives the process needs a home — in the cloud for achievements, in a persisted timestamp for one-shot events. And interruption handlers must remember who paused playback; “interruption ended” is not the same event as “I paused it”.
Quieter by Default
Defaults tuned to be less intrusive, capability intact:
- Tuning tone at 50% of playback volume (down from 70%), and it now respects the Sound effects toggle — the tuning tone is a sound effect.
- Sound effects off by default — button-press and unlock sounds now opt-in via Settings → Sound effects.
- Continue on Bluetooth disconnect off by default — a device disconnect pauses playback unless you explicitly enable continuation.
Smaller Things
Portable AppImage — the Linux build no longer bundles system libraries (libstdc++, libgcc, …) that caused GLIBC version conflicts. Only app-specific libraries are bundled, so the AppImage runs on virtually any 64-bit Linux distribution (requires only GLIBC 2.2.5).
Downsampled notification artwork — station artwork for the media notification, lock screen and Android Auto MediaSession now decodes at a 256×256 target via audio_service’s artDownscaleWidth/artDownscaleHeight, cutting peak bitmap memory and resolving two of three Google Play “BitmapFactory without downsampling” findings (the third sits inside third-party file_picker and isn’t app-configurable).
Library tab survives relaunch — the app now remembers whether Now Playing was showing, the local playback queue persists device-locally (its own Hive box, deliberately not cloud-synced — another device doesn’t have the same files) with position and shuffle/repeat state, and the last folder/playlist source is remembered with a one-tap Reopen that re-scans the same source. Playback is not auto-started on relaunch.
By the Numbers
- 10 bands, ±12 dB each
- 6 presets, plus Custom
- 3 audio stacks with one curve contract
- 8 s / 12 s timeouts on metadata and HLS paths
- 15 s → 60 s stale-eviction threshold
- 512 KB socket queue — backpressure, not eviction
- 6-segment synthesized BBC live window
- 256×256 notification artwork target
- 0 EQ-tinted recordings — the tee stays clean
- 8.0.0 Google Play Billing Library, per Google’s August 2026 deadline
Lessons
The equaliser work taught us that a feature touching the audio pipeline is a renderer contract, not a widget. The curve gets written once and translated per backend — runtime band layout on Android, a chained single-band filter applied at construction on Windows and Linux (because hot-swapping stalls libmpv), a dedicated AVAudioEngine pipeline on Apple. The load-bearing part isn’t the math, it’s the lifecycle: the effect must re-apply itself on every player construction, or it quietly disappears at exactly the moment a long session resumes.
The stream-proxy work taught us that the proxy’s failure modes were the user’s failure modes. A 15-second eviction threshold looked like hygiene; it was the underrun loop. Missing timeouts weren’t a latent risk; they were the permanent stall. Backpressure, bounded awaits, and a dedup that anchors to the live edge — the boring fixes were the whole release.
The purchase fixes taught us to enumerate the identity paths. A premium flow that only works for signed-in users is a payment bug, not an edge case: the money is real, the entitlement is local state, and the store is just the channel.
Radiophonia 0.8.2 is available from radiophonia.app. Direct-download artifacts are published on the Radiophonia v0.8.2 release page.