mobile-music/eq-implementation-plan.md
2026-08-06 21:32:28 -04:00

245 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<EqBand> 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<EqBand> 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<void> setEnabled(bool)`, `Future<void> setCoeffs(List<BiquadCoeffs>, 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<bool>` 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.