Files
pluriwave/openspec/changes/archive/2026-07-10-eq-audiofocus-reapply/design.md
T
FreeTLab 0ab63731d0
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m33s
fix(eq): re-apply equalizer when the native audio session rotates
ExoPlayer assigns a new audio session id after transient audio-focus
interruptions (navigation prompts, radar warnings), leaving the
AndroidEqualizer attached to the dead session so playback resumed
without equalization until the next station switch. The session-id
listener now detects genuine rotations through a dedicated guard and
re-activates the equalizer with the current preset, gated on EQ
availability to stay clear of player teardown/rebuild.
2026-07-10 18:51:45 +02:00

172 lines
9.0 KiB
Markdown

# Design: EQ Re-Apply After Audio-Focus Ducking
## Technical Approach
Extend the existing `_androidAudioSessionIdSub` listener in `PluriWaveAudioHandler`
(`lib/servicios/servicio_audio.dart:258-265`) so that, after it broadcasts a new
native session id, it re-attaches the equalizer and re-pushes the CURRENT gains
when the id genuinely rotated mid-playback. The fix is a single conditional call
to the already-idempotent `_activarEcualizador()` choke-point, guarded by a
dedicated change-guard field and the `_eqDisponible` lifecycle flag. Mirrors the
proven in-repo pattern `VisualizadorAudio._onSessionId`
(`lib/widgets/visualizador_audio.dart:82-88`). Entirely service-layer; no
`ServicioAudioSession`, `EstadoEcualizador`, or Kotlin changes. Realizes spec
`eq-audiofocus-reapply`.
## Architecture Decisions
### Decision: Where the re-apply logic lives
| Option | Tradeoff | Decision |
|--------|----------|----------|
| New listener on `androidAudioSessionIdStream` | Duplicate subscription lifecycle, extra teardown in `_recrearPlayer`/`onTaskRemoved` | Rejected |
| Hook into `ServicioAudioSession.interruptionEventStream` | Couples EQ to app-focus events, not the actual native session signal; misses non-focus rotations | Rejected |
| Add conditional inside the EXISTING `_androidAudioSessionIdSub` listener | Zero new subscriptions; reuses teardown already in place; precise native signal | **Chosen** |
**Rationale**: The listener already fires on every native session id and already
owns its cancel path in `_recrearPlayer()` (L492) and `onTaskRemoved()` (L671).
Adding one branch there is the smallest, lowest-risk surface and uses the most
precise trigger (the session id itself, per the just_audio internals traced in
exploration).
### Decision: Change-guard field separate from the broadcast field
| Option | Tradeoff | Decision |
|--------|----------|----------|
| Reuse `_androidAudioSessionId` as the guard | Guard would need to read the field AFTER it is overwritten for broadcast; entangles EQ semantics with external-consumer broadcast state | Rejected |
| New private `int? _ultimaSessionIdEq` dedicated to the EQ guard | One extra field; keeps broadcast semantics untouched for `ServicioAudio`/`VisualizadorAudio` consumers | **Chosen** |
**Rationale**: Per proposal note, `_androidAudioSessionId` must keep pure
broadcast semantics so external consumers are unaffected. A dedicated
`_ultimaSessionIdEq` lets the EQ compare-and-swap independently. It is reset to
`null` inside `_recrearPlayer()` alongside `_androidAudioSessionId` so a rebuilt
player's first id is treated as fresh, not a rotation.
### Decision: Re-apply call target and current-gains source
| Option | Tradeoff | Decision |
|--------|----------|----------|
| `aplicarPreset(_presetActual)` only | Skips `_eq.parameters` re-probe / `setEnabled`; assumes params already valid on the new session | Rejected as sole call |
| `_activarEcualizador()` | Re-probes `_eq.parameters`, re-sets enabled state, then calls `aplicarPreset(_presetActual)` | **Chosen** |
**Rationale**: `_activarEcualizador()` (L526) is the exact activate/attach
choke-point used by the working station-switch path; on a rotated session the
native effect must be re-probed and re-enabled before gains land, which
`aplicarPreset` alone does not do. Both paths read gains from the single source
of truth `_presetActual` (field L194), which is also mutated by `aplicarPreset`
(L541), `setBanda` (L568), and `setEcualizadorActivo` (L600) — so re-apply always
uses LIVE state, never a stale snapshot. No gain value is captured or copied at
subscribe time.
## Data Flow
Another app ducks/interrupts
ExoPlayer rotates native session id (issue #5302)
_player.androidAudioSessionIdStream emits new id
_androidAudioSessionIdSub listener:
1. _androidAudioSessionId = id (broadcast state, unchanged)
2. controller.add(id) (external consumers: unchanged)
3. re-apply gate ↓
id == null ? ──yes──► return (no-op)
│ no
id == _ultimaSessionIdEq ? ──yes──► return (redundant, guard)
│ no
_eqDisponible == false ? ──yes──► return (mid-teardown / not attached)
│ no
_ultimaSessionIdEq = id
unawaited(_activarEcualizador()) ──► re-probe + setEnabled + aplicarPreset(_presetActual)
Native EQ re-attached to new session → music stays equalized
## Race Analysis (`_recrearPlayer`, L488-508)
Teardown order makes the guard safe. `_recrearPlayer()`:
1. `await _androidAudioSessionIdSub?.cancel()` (L492) — old listener silenced.
2. `_eq = AndroidEqualizer()` (L502) — fresh effect, empty params.
3. `_eqDisponible = false` (L503) — **gate closes BEFORE any new id can arrive**.
4. `_androidAudioSessionId = null` (L504) + reset `_ultimaSessionIdEq = null`.
5. `_player = _crearPlayer()` (L505) then `_conectarStreamsPlayer()` (L507)
re-subscribes.
Because `_eqDisponible` is set `false` (step 3) before the new subscription
exists (step 5), any id the rebuilt player emits is dropped by the gate until the
station-switch path's own `_activarEcualizador()` (L449) flips `_eqDisponible`
back to `true`. Once true, that same call sets `_ultimaSessionIdEq`, so the very
first post-rebuild rotation does not double-fire.
**Rapid consecutive rotations**: the compare-and-swap on `_ultimaSessionIdEq`
collapses duplicate emissions of the same id. Distinct ids each trigger one
`_activarEcualizador()`; the call is `unawaited` and internally guarded/idempotent
(`aplicarPreset` returns early if `!_eqDisponible`), so overlapping in-flight
re-applies converge on `_presetActual` without corruption. No lock needed.
## File Changes
| File | Action | Description |
|------|--------|-------------|
| `lib/servicios/servicio_audio.dart` | Modify | Add `int? _ultimaSessionIdEq`; extend `_androidAudioSessionIdSub` listener (after L263) with the guarded re-apply; reset guard in `_recrearPlayer` (near L504); extract the pure decision predicate for tests |
| `test/servicios/servicio_audio_eq_reapply_test.dart` | Create | Unit-test the extracted predicate: rotation, first-activation no-op, null id, teardown gate, rapid duplicates |
Explicitly NOT touched: `servicio_audio_session.dart`, `estado_ecualizador.dart`,
Kotlin. Fix is orthogonal to `_eqMultiDeviceEnabled` (an `EstadoEcualizador`
concern absent from this file) — it operates on the single live `_presetActual`,
so it applies regardless of that toggle.
## Interfaces / Contracts
Testable seam (minimal refactor — the handler cannot be constructed in unit
tests because its constructor builds a real `AudioPlayer` needing MethodChannels,
confirmed by `servicio_audio_source_switch_test.dart:13-18`). Extract the pure
decision so tests exercise it directly, mirroring how that test isolates
`ControladorReconexion`:
```dart
/// Pure re-apply decision for a native session-id emission. No side effects.
/// Returns true only when the EQ must re-attach + re-push current gains.
@visibleForTesting
static bool debeReaplicarEcualizador({
required int? sessionId,
required int? ultimaSessionIdEq,
required bool eqDisponible,
}) =>
sessionId != null && sessionId != ultimaSessionIdEq && eqDisponible;
```
The listener calls this predicate; on `true` it sets
`_ultimaSessionIdEq = sessionId` and `unawaited(_activarEcualizador())`. Keeping
the mutation + async call in the listener (not the predicate) preserves the "no
side effects in the tested function" boundary and needs no fake player.
## Testing Strategy
| Layer | What to Test | Approach |
|-------|-------------|----------|
| Unit | `debeReaplicarEcualizador` truth table | Direct static calls; no handler instance |
| Unit | Rotation → true (new non-null id, eq available) | `debeReaplicarEcualizador(sessionId:2, ultimaSessionIdEq:1, eqDisponible:true) == true` |
| Unit | First activation no-op (same id re-emitted) | `sessionId:1, ultimaSessionIdEq:1 → false` |
| Unit | Null id → false | `sessionId:null → false` |
| Unit | Teardown gate (`_eqDisponible==false`) → false | `eqDisponible:false, sessionId:9 → false` |
| Unit | Rapid duplicate emissions | Two calls same id: second sees updated guard → false |
| Regression | Station-switch + manual slider paths intact | Full `flutter test` (Strict TDD) — call sites unchanged |
Strict TDD: write each predicate case RED first, then implement the predicate and
wire the listener GREEN. Integration-level "gains actually re-pushed to native"
is not unit-testable without platform channels; it is covered by the predicate
contract plus the unchanged, already-tested `_activarEcualizador`/`aplicarPreset`
idempotency.
## Migration / Rollout
No migration required. No persistence, schema, or public API change. Revert the
single commit to restore prior behavior.
## Open Questions
- [ ] None blocking. Predicate seam chosen because full-handler construction is
not unit-testable in this repo; if a future integration harness fakes the
player, an end-to-end re-apply assertion could be added then.