# Spec: Android audio settings parity (EQ, normalization, gapless) **Status:** Proposed **Requirements:** UR-031, UR-032, UR-033, UR-027 → DR-034, DR-035, DR-036, DR-030; IR-004 **UX spec:** n/a — no UI change; Settings › Audio already renders these controls **Supersedes / revises:** closes the audio half of the parity gap recorded in [playback-backend-unification.md](playback-backend-unification.md) ## Summary Implement `set_audio_settings` / `audio_settings` on `ExoPlayerBackend` so the equalizer, volume normalization, and gapless playback settings actually take effect on Android. Today the Settings › Audio panel renders these controls on Android and they silently do nothing — `ExoPlayerBackend` is the only backend that does not override the trait's no-op defaults. Crossfade is explicitly **not** included; see Out of scope. ## Motivation `PlayerBackend` declares `set_audio_settings` with a default `Ok(())` body. `MpvBackend`, `NullBackend`, and `WebviewAudioBackend` all override it; `ExoPlayerBackend` does not. The settings are persisted, pushed to the backend on every track load, and displayed in the UI — and then dropped on the floor. This is the single most user-visible platform divergence in the app: a user who sets a "Rock" EQ preset on Android sees the sliders move and hears no change. The backend-unification investigation ruled out fixing this by swapping engines (video cannot be unified; see the sibling spec), so the fix is to implement the trait methods where they are missing. ## Layer assignment | Logic / responsibility | Layer | Why it belongs there | |------------------------|-------|----------------------| | Band count, centre frequencies, gain range, preset→curve map | Rust (existing) | Already domain-owned in `settings.rs` per [audio-equalizer.md](audio-equalizer.md). Android must consume the same `AudioSettings`, not define its own bands. Duplicating the band layout in Kotlin would be a taxonomy leak of exactly the kind `check:boundary` guards against. | | Mapping `AudioSettings` → Android audio-effect parameters | Rust → JNI boundary | Platform playback detail, the direct analogue of `build_af_filter` in `mpv_backend.rs`. Belongs with the other `set_audio_settings` code. | | Attaching/detaching `Equalizer` and `LoudnessEnhancer` to the ExoPlayer audio session | Kotlin (`JellyTauPlayer.kt`) | Android platform API mechanics; needs the live `audioSessionId`, which only the Kotlin layer holds. | | Normalization preset (Loud/Normal/Quiet) → target gain | Rust (existing) | `VolumeLevel` is domain vocabulary; the same preset must mean the same loudness on every platform. | | Rendering sliders / preset chips | Frontend (existing) | Pure presentation; unchanged by this spec. | Borderline row: attaching the effects could arguably be driven entirely from Rust via JNI property calls. It goes to Kotlin because `AudioEffect` construction requires the audio session id and must be re-attached when ExoPlayer rebuilds its audio sink — lifecycle state that lives in `JellyTauPlayer.kt`. Rust still owns *what* the values are; Kotlin owns *when* the effect objects exist. ## Design ### Rust — `ExoPlayerBackend` (`src-tauri/src/player/android/mod.rs`) Override the two defaulted methods, mirroring the shape of the existing `set_audio_track` JNI call: ```rust fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> { let s = settings.clone().with_crossfade_clamped().with_equalizer_normalised(); // Serialize as JSON — the same pattern load() already uses for subtitles, // avoiding a 6-arg JNI signature that has to change every time a field lands. let json = serde_json::to_string(&s).map_err(|e| PlayerError { message: e.to_string() })?; // Kotlin: fun setAudioSettings(json: String) self.call_player_method_string("setAudioSettings", &json)?; self.shared_state.lock_safe().audio_settings = s; Ok(()) } fn audio_settings(&self) -> AudioSettings { self.shared_state.lock_safe().audio_settings.clone() } ``` `ExoPlayerState` gains an `audio_settings: AudioSettings` field. Note `ExoPlayerBackend` currently holds no such state — `position`/`state`/`volume` are all pushed in by JNI callbacks — so this is the first *pull*-side field. That is correct: audio settings are commanded downward, never reported upward. ### Kotlin — `JellyTauPlayer.kt` ```kotlin fun setAudioSettings(json: String) { val s = JSONObject(json) applyEqualizer(s.getBoolean("equalizerEnabled"), s.getJSONArray("equalizerBands")) applyNormalization(s.getBoolean("normalizeVolume"), s.getString("volumeLevel")) exoPlayer.pauseAtEndOfMediaItems = !s.getBoolean("gaplessPlayback") } ``` Three independent mechanisms: - **Gapless** — nearly free. ExoPlayer is gapless by default for compatible formats; honouring the setting means *disabling* it when the user turns it off, via `pauseAtEndOfMediaItems`. Note this only applies within a loaded playlist; our queue loads one item at a time, so verify behaviour before claiming DR-035 on Android (see Testing). - **Equalizer** — `android.media.audiofx.Equalizer` bound to `exoPlayer.audioSessionId`. Android's EQ exposes a device-dependent band count (commonly 5) at fixed centre frequencies, which will **not** match our 10-band ISO layout. Rust owns the canonical 10 bands; Kotlin resamples them onto the device's bands by nearest-centre-frequency interpolation. Gains are in millibels (`setBandLevel` takes mB, we store dB → ×100), clamped to the device's reported `getBandLevelRange()`. - **Normalization** — `android.media.audiofx.LoudnessEnhancer`, also bound to the audio session, `setTargetGain(mB)` derived from `VolumeLevel`. This is a gain booster, not a true EBU R128 normalizer like MPV's `dynaudnorm`; parity is approximate and should be documented as such rather than overclaimed. Lifecycle: build the effects lazily on first use, release them in `release()`, and re-attach on `onAudioSessionIdChanged` — ExoPlayer can rebuild its audio sink (e.g. on a format change), which invalidates effects bound to the old session. ### Make the silent-failure mode impossible The trait's default is the root cause of this whole class of bug: ```rust // backend.rs:85 — reports success while doing nothing fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> { Ok(()) } ``` Android inherits this, so every EQ/normalization change on Android returns `Ok` and silently does nothing — the UI ships and has no effect, with no error anywhere. Once `ExoPlayerBackend` implements the methods, **change the trait default to `Err(PlayerError::not_implemented())`**, matching how `set_audio_track` / `set_subtitle_track` already behave. Any future backend that forgets to implement audio settings then fails loudly instead of lying. Check the call sites before flipping it: `NullBackend` overrides both methods, so the graceful-degradation path is unaffected, but confirm nothing treats a `set_audio_settings` error as fatal to playback. ### Re-application on track load `PlayerController` already re-pushes `AudioSettings` per track on the platforms that implement it; the Android path inherits that for free once the trait methods exist. No controller change. ### 🔴 Threading note `setAudioSettings` is invoked from Rust on whatever thread the command lands on. `AudioEffect` construction must not happen on the ExoPlayer application thread from inside a player callback — that is the re-entrancy hazard CLAUDE.md warns about, and the same shape as the `AutoplayDecision` deadlock. Post the work to the player's handler rather than doing it inline in a listener. ## Out of scope - **Crossfade (UR-031 / DR-034).** Not implemented on *any* platform today, and architecturally blocked on MPV (single-stream audio chain; `acrossfade` needs two inputs). Implementing it on Android alone would invert the parity gap. It needs its own spec and probably two player instances. - True EBU R128 normalization. `LoudnessEnhancer` is a gain stage; matching `dynaudnorm` exactly is out of reach without a custom `AudioProcessor`. - Windows audio settings — see [windows-native-audio-backend.md](windows-native-audio-backend.md). ## Acceptance criteria - [ ] `ExoPlayerBackend` overrides `set_audio_settings` and `audio_settings`. - [ ] EQ preset change on Android audibly changes playback; setting persists across track changes and app restart. - [ ] Normalization toggle audibly changes level; the three presets are ordered Loud > Normal > Quiet. - [ ] Disabling gapless produces a gap between consecutive tracks; enabling it does not. - [ ] Effects are released on `release()` and survive an audio-session rebuild. - [ ] `requirements.md` parity matrix updated: EQ and normalization ✅ Android. - [ ] `bun run check` and `bun run test` pass. - [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes. - [ ] `bun run check:boundary` passes. - [ ] New requirement-implementing code carries `// TRACES:` comments. - [ ] `bindings.ts` regenerated if Rust types changed. ## Testing **Rust** (`cargo test`): `set_audio_settings` stores the sanitized settings and `audio_settings()` returns them — assert clamping/normalisation is applied (crossfade clamped to 12s, band vector normalised to `EQ_BANDS.len()`). The JNI call itself is not unit-testable; extract the JSON serialization into a pure function and test that its shape matches what the Kotlin parser expects. That serialization contract is the part most likely to silently break. **Kotlin**: the band-resampling function (10 canonical bands → N device bands) is pure arithmetic — extract it and unit-test it, including the degenerate cases of a 5-band device and a device reporting 10 bands. **Manual, on device** (these are the ones that actually prove it): 1. Set Bass Boost, play a track, confirm audible change. 2. Toggle normalization mid-track; confirm level change without a playback stall. 3. Queue two gapless-encoded tracks, toggle the setting, confirm the gap appears/disappears. 4. Force a format change (44.1kHz → 48kHz track) and confirm the EQ still applies afterwards — this exercises the session-rebuild re-attach. ## TRACES - `ExoPlayerBackend::set_audio_settings` → `// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036` - Kotlin `setAudioSettings` / `applyEqualizer` / `applyNormalization` → same IDs - Band-resampling helper + its tests → `DR-030 | UT-xxx` ## Notes for the implementer - Read [audio-equalizer.md](audio-equalizer.md) first — it defines the canonical band layout and the preset→curve rule this spec consumes. Do not redefine bands in Kotlin. - Android source edits go in `src-tauri/android/src` (canonical tree), then run `scripts/sync-android-sources.sh`. Never edit the `gen/` tree. - There is a **stale duplicate** `JellyTauPlayer.kt` (285 lines) at `src-tauri/android/app/src/main/java/com/dtourolle/jellytau/player/` alongside the real 1103-line file at `src-tauri/android/src/main/java/...`. Edit the latter. Consider deleting the former as a separate change. - A parallel Claude session may be active — `git diff` before "repairing" unexpected changes.