245 lines
13 KiB
Markdown
245 lines
13 KiB
Markdown
# 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.
|