This commit is contained in:
Forrest 2026-08-06 21:32:28 -04:00
parent b6639fb1c9
commit 0a7af1d813
10 changed files with 743 additions and 35 deletions

245
eq-implementation-plan.md Normal file
View file

@ -0,0 +1,245 @@
# 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.