13 KiB
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_audio0.10.6 ships no parametric EQ. Its only effect classes areAndroidEqualizer(a graphic, gain-only, Android-only system EQ) andAndroidLoudnessEnhancer;DarwinAudioEffectis an empty marker mixin (just_audio.dart:4392) with no iOS implementation. CustomAudioEffectsubclasses aren't possible from outside the package (the wiring is package-private).flutter_solouddoes 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-writtenaudio_servicebridge. Rejected: we must keep background controls.- Therefore: keep
just_audio+just_audio_backgrounduntouched at the app level, and inject a custom biquad DSP intojust_audio's native audio pipeline on each platform. This preserves the entire existing engine (queue, 100-track windowing, streaming, error recovery inlib/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 customRenderersFactorylambda at lines 779–786, wrappingnew DefaultRenderersFactory(context).- Patch: replace that with a
DefaultRenderersFactorysubclass overridingbuildAudioSink(...)to returnnew DefaultAudioSink.Builder(context).setAudioProcessors(new AudioProcessor[]{ biquadProcessor }).build(). biquadProcessorimplementsandroidx.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 inMainMethodCallHandler.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 byIndexedPlayerItem.m. - Patch: attach an
AVMutableAudioMixcarrying anMTAudioProcessingTapto eachIndexedPlayerItem's audio track. The tap'sprocesscallback 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):
MTAudioProcessingTapdoes 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 toAVAudioEngine+AVAudioUnitEQ(which has native.parametricbands) — 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:
- iOS tap spike: vendor
just_audio, add a trivial pass-through (or fixed −6 dB gain)MTAudioProcessingTapto 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. - Android sink spike: vendor
just_audio, add a pass-throughAudioProcessorvia thebuildAudioSinkoverride, confirm audio plays and the processor receives buffers. Confirmjust_audio_backgroundstill shows notification controls. - 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 futurejust_audioupgrades 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 AutoEqParametricEQ.txt:Preamp: -6.0 dBandFilter 1: ON PK Fc 105 Hz Gain -2.0 dB Q 0.70lines (PK→peaking, LSC→lowShelf, HSC→highShelf). IgnoreOFFfilters.
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(defaultfalse)double eqPreampDb(default0)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.listenonsettingsProvider.select((s) => (s.eqEnabled, s.eqBands, s.eqPreampDb))and push updated coefficients througheq_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 withTimbreColors.accent/border— visually a sibling ofblock_progress_bar.dart. - Per-band controls: a new custom control (there is no Material
Sliderin the app). Cheapest on-brand option = reuse_Steppersemantics 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 inlib/screens/settings_screen.dart(place after "Downloads", ~line 95) whose tap pushesEqualizerScreen— 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), newBiquadAudioProcessor.java,MainMethodCallHandler.java(channel). - iOS/macOS:
AudioPlayer.m/IndexedPlayerItem.m(audio mix + tap), newBiquadTap.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):
- Play a Subsonic stream; toggle EQ on/off — audible change, no dropouts.
- Set a strong low-shelf boost / narrow peaking cut and confirm by ear +, if possible, a spectrum-analyzer app on a sine sweep.
- Import a real AutoEq
ParametricEQ.txtand confirm the curve + preamp apply. - 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)
- iOS
MTAudioProcessingTapon remote streams — validated in Phase 0; AVAudioEngine fallback if it fails. - Vendoring
just_audio— future upstream upgrades require re-applying the patch; pin the base version and keep the diff minimal/documented. - Sample-rate handling for hi-res (up to 192 kHz) — decide coefficient recompute vs. fixed-rate approximation and document it.
- CPU cost of many biquads per channel on low-end devices — keep default band count modest (≤10) and profile.