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

13 KiB
Raw Blame History

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.