# Parametric EQ — Implementation Plan (native DSP + vendored just_audio) ## Context Timbre has no built-in EQ; users listening on headphones without a hardware EQ box have no way to shape tone or apply AutoEq headphone-correction profiles. We want a **true parametric EQ** (per-band adjustable frequency / Q / gain, i.e. the AutoEq `PK Fc/Gain/Q` model) that works on **both iOS and Android**, while **keeping the existing lock-screen / notification / Control Center transport** that `just_audio_background` provides today. ### Why this architecture - `just_audio` 0.10.6 ships **no parametric EQ**. Its only effect classes are `AndroidEqualizer` (a graphic, gain-only, Android-only system EQ) and `AndroidLoudnessEnhancer`; `DarwinAudioEffect` is an empty marker mixin (`just_audio.dart:4392`) with no iOS implementation. Custom `AudioEffect` subclasses aren't possible from outside the package (the wiring is package-private). - `flutter_soloud` *does* offer a cross-platform parametric EQ, but it is a bare audio engine with **no media-session integration** — adopting it means rewriting the playback engine **and** rebuilding lock-screen/notification controls via a hand-written `audio_service` bridge. Rejected: we must keep background controls. - Therefore: keep `just_audio` + `just_audio_background` untouched at the app level, and inject a custom biquad DSP into `just_audio`'s native audio pipeline on each platform. This preserves the entire existing engine (queue, 100-track windowing, streaming, error recovery in `lib/playback/playback_engine.dart`). ### Core design decision Compute **RBJ-cookbook biquad coefficients once in Dart** and push them to a "dumb" native biquad-cascade processor on each platform. This keeps all filter math in one place (Dart, unit-testable) and makes the native code identical in concept across platforms — it just runs `y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2` per section, per channel. The EQ lives at the **audio-sink / output stage**, so it is independent of source windowing and survives track/window swaps automatically. `just_audio` exposes **no hook** to inject a processor, so we must **vendor `just_audio` 0.10.6 as a path (or git-fork) dependency** and patch its native source. The patch is small and localized on each platform (see below). --- ## Confirmed native injection points **Android** (`android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java`): - `ensurePlayerInitialized()` builds ExoPlayer via a custom `RenderersFactory` lambda at **lines 779–786**, wrapping `new DefaultRenderersFactory(context)`. - Patch: replace that with a `DefaultRenderersFactory` subclass overriding `buildAudioSink(...)` to return `new DefaultAudioSink.Builder(context).setAudioProcessors(new AudioProcessor[]{ biquadProcessor }).build()`. - `biquadProcessor` implements `androidx.media3.common.audio.AudioProcessor`, running the cascaded biquads on the PCM buffer. Coefficients + enabled flag are set on it live via a new method-channel handler in `MainMethodCallHandler.java` / `JustAudioPlugin.java`. **iOS / macOS** (`darwin/just_audio/Sources/just_audio/`): - Playback is AVPlayer-based; player items are created/inserted in `AudioPlayer.m` (~lines 513–570) and modeled by `IndexedPlayerItem.m`. - Patch: attach an `AVMutableAudioMix` carrying an `MTAudioProcessingTap` to each `IndexedPlayerItem`'s audio track. The tap's `process` callback runs the same biquad cascade on the PCM. Coefficients pushed via the plugin's method channel and read by the tap (double-buffered / lock-free swap). - **KNOWN RISK — must de-risk first (Phase 0):** `MTAudioProcessingTap` does **not** fire for HLS remote streams. Subsonic uses progressive HTTP (not HLS), so the tap *should* fire, but this is the single biggest unknown. Fallback if it doesn't: rewrite the darwin backend to `AVAudioEngine` + `AVAudioUnitEQ` (which has native `.parametric` bands) — substantially larger, so we validate before committing. **Unsupported targets:** gate exactly like the engine's existing `_audioSupported` (`playback_engine.dart:190-191`, Android/iOS/macOS). On Linux/tests the EQ is a no-op; the UI still renders and persists settings. --- ## Phase 0 — De-risk (do this before anything else) Small throwaway spikes against the vendored fork: 1. **iOS tap spike:** vendor `just_audio`, add a trivial pass-through (or fixed −6 dB gain) `MTAudioProcessingTap` to player items, play a real Subsonic **stream** URL (not a local file), and confirm the callback fires and audio is audibly altered. Test both original and transcoded (mp3/opus) streams. This validates or kills the primary architecture. 2. **Android sink spike:** vendor `just_audio`, add a pass-through `AudioProcessor` via the `buildAudioSink` override, confirm audio plays and the processor receives buffers. Confirm `just_audio_background` still shows notification controls. 3. **Fork maintainability:** confirm the vendored package builds cleanly for both platforms in CI (`codemagic.yaml`) and document the pin (exact 0.10.6 base + our patch) so future `just_audio` upgrades are a deliberate re-patch. Exit criteria: EQ demonstrably alters audio on a real device on both platforms while lock-screen controls still work. If the iOS tap fails on streams, revisit the AVAudioEngine fallback (or reconsider `flutter_soloud`) before proceeding. --- ## Phase 1 — DSP core in Dart (pure, unit-tested) New file `lib/eq/biquad.dart`: - `EqBandType { peaking, lowShelf, highShelf }`. - `EqBand { EqBandType type; double freqHz; double q; double gainDb; }` (immutable). - `BiquadCoeffs { double b0,b1,b2,a1,a2; }` (a0-normalized). - `BiquadCoeffs coeffsFor(EqBand band, int sampleRate)` — RBJ Audio-EQ-Cookbook formulas for peaking/low-shelf/high-shelf. - Master **preamp** (dB → linear gain) applied as a final scalar. New file `lib/eq/autoeq.dart`: - `List parseAutoEqProfile(String text)` — parses AutoEq `ParametricEQ.txt`: `Preamp: -6.0 dB` and `Filter 1: ON PK Fc 105 Hz Gain -2.0 dB Q 0.70` lines (PK→peaking, LSC→lowShelf, HSC→highShelf). Ignore `OFF` filters. Unit tests in `test/eq_biquad_test.dart`, `test/eq_autoeq_test.dart` (mirrors the existing `test/` style): verify coefficients against known reference values (e.g. a 0 dB peaking filter → identity `b0=1,b1=0,b2=0,a1=0,a2=0` after normalization; a known peaking case against hand-computed values) and AutoEq parsing of a real profile. --- ## Phase 2 — Persistence (extend the existing settings store) Extend `AppSettings` in `lib/settings/settings_store.dart` following its exact conventions (immutable + `copyWith` with the `_unset` sentinel + `toJson`/`fromJson`, enums by `.name`): - `bool eqEnabled` (default `false`) - `double eqPreampDb` (default `0`) - `List eqBands` (default a sensible starter set, e.g. 5–10 peaking bands at ISO centers 31/62/125/250/500/1k/2k/4k/8k/16k with 0 dB gain) - JSON: bands serialize as a list of `{type, freqHz, q, gainDb}` maps. Add `SettingsController` setters mirroring the existing ones (each does `state = state.copyWith(...); _persist();`): `setEqEnabled`, `setEqPreampDb`, `setEqBand(int index, EqBand)`, `setEqBands`, `resetEq`. Static helpers for default band lists + labels follow the file's `bitrateChoices`/`bitrateLabel` convention. --- ## Phase 3 — Dart↔native bridge + engine wiring New file `lib/eq/eq_bridge.dart`: - A thin `MethodChannel('com.laforrestchurch.timbre/eq')` wrapper: `Future setEnabled(bool)`, `Future setCoeffs(List, double preampLinear)`. - Recompute coefficients whenever bands/preamp change and push them; the native side swaps them atomically. - Platform-gated: no-op where `!_audioSupported`. Wire into playback in `lib/state/providers.dart` (mirrors how `streamMaxBitRate` is read and how concurrency changes are reacted to at lines 480–483): - In `playbackProvider`, after constructing the controller, `ref.listen` on `settingsProvider.select((s) => (s.eqEnabled, s.eqBands, s.eqPreampDb))` and push updated coefficients through `eq_bridge`. - Sample rate: the biquad cache assumes a nominal rate (44.1/48 kHz); the native processor recomputes/accepts coefficients per its actual output rate — pass the rate up from native on format change, or compute for the common rate and accept the minor center-frequency drift on hi-res (document the choice). - Note the **remote-playback scope**: EQ applies on the device actually outputting audio (the local engine), consistent with `activePlaybackProvider` / `playbackCommandsProvider`. No change needed for the remote path. Native method-channel handlers added to the vendored plugin (Android `MainMethodCallHandler.java`; iOS `JustAudioPlugin.m`), each forwarding enabled/coeffs to the biquad processor/tap. --- ## Phase 4 — UI (matches the app's design language) New file `lib/screens/equalizer_screen.dart`, pushed as a full sub-screen via `Navigator.of(context).push(MaterialPageRoute(...))` (same as `SettingsScreen` from `lib/shell/app_shell.dart:287`): - Reuse `HairlinePanel` (`lib/widgets/hairline_panel.dart`), `TimbreColors`, `TimbreSpacing`, JetBrains Mono. - An **enable toggle** modeled as the existing `_ChoiceChips` On/Off pattern (`settings_screen.dart:141`). - A **live EQ curve** painted with a `CustomPainter` (sum of band magnitude responses in dB across log-frequency), styled with `TimbreColors.accent`/`border` — visually a sibling of `block_progress_bar.dart`. - **Per-band controls**: a new custom control (there is no Material `Slider` in the app). Cheapest on-brand option = reuse `_Stepper` semantics for discrete dB/freq/Q steps; nicer option = a custom vertical gain slider built from the token primitives (`InkWell`+`Container`, `minTouchTarget`). Start with steppers, upgrade later if desired. - **Master preamp** stepper. - **Import AutoEq profile** affordance (paste text / file pick) → `parseAutoEqProfile` → `setEqBands` + `setEqPreampDb`. **Reset** button → `resetEq`. Entry points: - **Settings**: a new `HairlinePanel(title: 'Equalizer')` row in `lib/screens/settings_screen.dart` (place after "Downloads", ~line 95) whose tap pushes `EqualizerScreen` — model the tappable row on the existing "Add server" `InkWell` (`settings_screen.dart:225`). - **Now Playing**: an EQ button in the bottom-bar row (`now_playing_screen.dart` ~159–174), styled exactly like `_RemoteButton` (`now_playing_screen.dart:583`), opening the same screen. --- ## Files to create / modify **Create:** `lib/eq/biquad.dart`, `lib/eq/autoeq.dart`, `lib/eq/eq_bridge.dart`, `lib/screens/equalizer_screen.dart`, `test/eq_biquad_test.dart`, `test/eq_autoeq_test.dart`. **Modify (app):** `lib/settings/settings_store.dart` (EQ fields + setters), `lib/state/providers.dart` (listen + push coeffs), `lib/screens/settings_screen.dart` (entry row), `lib/screens/now_playing_screen.dart` (EQ button), `pubspec.yaml` (point `just_audio` at the vendored fork). **Modify (vendored `just_audio` fork):** - Android: `AudioPlayer.java` (~779–786, sink override), new `BiquadAudioProcessor.java`, `MainMethodCallHandler.java` (channel). - iOS/macOS: `AudioPlayer.m` / `IndexedPlayerItem.m` (audio mix + tap), new `BiquadTap.m/.h`, `JustAudioPlugin.m` (channel). **Build config:** Android minSdk stays at current (biquad AudioProcessor needs no new API); iOS deployment target 15.0 is fine (`MTAudioProcessingTap` since iOS 6). `codemagic.yaml` must build the vendored fork; pin/document the fork revision. --- ## Verification - **Unit:** `flutter test` — coefficient math (identity + known cases) and AutoEq parsing. - **Manual (both platforms, real device):** 1. Play a Subsonic **stream**; toggle EQ on/off — audible change, no dropouts. 2. Set a strong low-shelf boost / narrow peaking cut and confirm by ear +, if possible, a spectrum-analyzer app on a sine sweep. 3. Import a real AutoEq `ParametricEQ.txt` and confirm the curve + preamp apply. 4. Confirm lock-screen / notification / Control Center controls still work with EQ active, and that EQ persists across app restart and survives track changes and 100-track window slides. - **Regression:** existing `test/` suite still green; playback error-recovery and gapless behavior unchanged. ## Biggest risks (watch these) 1. **iOS `MTAudioProcessingTap` on remote streams** — validated in Phase 0; AVAudioEngine fallback if it fails. 2. **Vendoring `just_audio`** — future upstream upgrades require re-applying the patch; pin the base version and keep the diff minimal/documented. 3. **Sample-rate handling** for hi-res (up to 192 kHz) — decide coefficient recompute vs. fixed-rate approximation and document it. 4. **CPU cost** of many biquads per channel on low-end devices — keep default band count modest (≤10) and profile.