Files
pluriwave/openspec/changes/archive/2026-07-10-eq-device-disconnect-revert/design.md
T
FreeTLab 8f7ca8059b
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
fix(eq): resolve base-speaker preset live instead of pinning a stale copy
_onDispositivoCambiado() bootstrapped a device-level preset entry for
every never-seen device id, including the built-in speaker. That
persistent level-3 entry masked later global-preset edits (level 3
beats level 4 on every resolution), so disconnecting a BT device or
cold-starting without one could leave the EQ stuck on an outdated
copy instead of the current global preset.

The base speaker is now excluded from the first-seen bootstrap:
disconnect and cold start always resolve through the live hierarchy.
BT/wired/USB devices keep their bootstrap behavior unchanged.
2026-07-10 23:54:01 +02:00

6.7 KiB

Design: EQ Device Disconnect / Cold-Start Revert

Technical Approach

Keep the single handler _onDispositivoCambiado(DispositivoAudio) — the platform stream has no add/remove discriminator; it always emits the now-active device (disconnect emits the fallback, e.g. builtin_speaker). The handler already sets _dispositivoActualId, re-resolves via _resolverPresetActivo(), and applies via audio.aplicarPreset(). The change is NOT new logic — it is removing the first-seen bootstrap collision so revert-to-base resolves live through the 4-level hierarchy instead of being pinned by a forced device-level copy, then locking the corrected behavior with disconnect / cold-start / cycle tests. No parallel resolution path is added; _resolverPresetActivo() stays the sole resolver (spec REQ-2, REQ-4).

Architecture Decisions

# Decision Options Choice + Rationale
1 Handler shape (a) keep single handler, unconditional resolve+apply · (b) split connect/disconnect dispatch · (c) add Kotlin type field (a). Stream carries only the active device; disconnect == "resolve for whatever is now active". Splitting needs a native payload change (out of scope) and buys nothing — resolution is already device-uniform.
2 Bootstrap collision (base device) (a) exclude base type from bootstrap · (b) bootstrap only BT/USB · (c) keep, tolerate redundant entry (a) gate on dispositivo.tipo != TipoDispositivo.altavozInterno. builtin_speaker must always fall through to global (L4) so a later global-preset edit is not masked by a stale L3 entry. (b) is (a) plus wired-headset ambiguity; wired is legitimately per-device, so only the base speaker is excluded. (c) leaves the persisted mask bug.
3 Revert semantics (a) deterministic re-resolution · (b) snapshot/restore prior preset (a) — locked by exploration. Hierarchy is a pure fn of (station, device); it self-heals and is the ONLY mechanism that also covers cold start (no snapshot exists at boot).
4 Apply guarantee reuse aplicarPreset() choke-point vs state-only mutation Reuse. _presetActual = resuelto; await audio.aplicarPreset(resuelto) already present — verified reaching native EQ (REQ-3). Keep idempotent.
5 Cold start trust seed path vs add parallel apply Trust + test. cargarPersistido_sembrarDispositivoActualobtenerDispositivoActual (base fallback) → _onDispositivoCambiado already applies base resolution. No new code; add proof tests.
6 Toggle-off invariance Early return at L211 (if (!_eqMultiDeviceEnabled) return;) is untouched. Subscription is never created when off (_configurarSuscripcionDispositivo). Byte-for-byte 2-level preserved.

Data Flow

Disconnect (BT removed)                 Cold start (no device)
  platform emits builtin_speaker          cargarPersistido()
        │                                   ├─ _resolverPresetActivo() [deviceId null → global]
        ▼                                   ├─ aplicarPreset(base)
  _onDispositivoCambiado(builtin_speaker)   └─ _sembrarDispositivoActual()
   ├─ guard _eqMultiDeviceEnabled                 └─ obtenerDispositivoActual() → builtin_speaker
   ├─ _dispositivoActualId = builtin_speaker           └─ _onDispositivoCambiado(builtin_speaker)
   ├─ BOOTSTRAP: SKIP (tipo == altavozInterno)   ← the fix
   ├─ resuelto = _resolverPresetActivo()  [matrix→station→device→GLOBAL]
   ├─ _presetActual = resuelto
   └─ audio.aplicarPreset(resuelto) ──────────────► native EQ

File Changes

File Action Description
lib/estado/estado_ecualizador.dart Modify _onDispositivoCambiado: gate the first-seen bootstrap (L216-220) on dispositivo.tipo != TipoDispositivo.altavozInterno. Resolve+apply stay unconditional and unchanged.
test/estado/estado_ecualizador_test.dart Modify Add disconnect-revert group (see Testing Strategy). Extend existing fakes only via helpers already present.

Interfaces / Contracts

No new public API, no persistence field, no migration. Bootstrap guard uses the existing DispositivoAudio.tipo discriminator:

// First-seen device bootstrap: skip base speaker so it always resolves
// live through the hierarchy (never pin a stale device-level entry).
final esBase = dispositivo.tipo == TipoDispositivo.altavozInterno;
if (!esBase && !_presetsDispositivo.containsKey(dispositivo.id)) {
  final presetBase = _resolverPresetActivo();
  _presetsDispositivo[dispositivo.id] = presetBase;
  await servicio.guardarPresetDispositivo(dispositivo.id, presetBase);
}

Testing Strategy

Existing fakes suffice — no new fake types. FakeServicioDispositivoAudio.emitirDispositivo() drives connect/disconnect; obtenerDispositivoActual() returns builtin_speaker when nothing emitted (cold-start path). Reuse the base-speaker DispositivoAudio(id:'builtin_speaker', tipo: altavozInterno).

ID Layer What to test
D.1 Unit Disconnect: BT active → emit builtin_speakerpresetActual == resolved base (global when no station/matrix); presetsAplicados.last == base.
D.2 Unit Disconnect does NOT create presetsDispositivo['builtin_speaker'] (bootstrap skip proven — guards the L3 mask bug).
D.3 Unit Cold start, nothing emitted: cargarPersistido applies base (global), dispositivoActualId == 'builtin_speaker', and no stale device preset applied.
D.4 Unit Cycle: connect BT (device preset) → disconnect (base) → reconnect BT lands on the same device preset each step; 5.5b invariant holds (reconnect never overwrites user preset).
D.5 Unit Station-override interaction: station has own preset + BT connected → disconnect → resolution still honors the station preset for builtin_speaker (L2 beats L4), proving revert is deterministic, not flatten-to-global.

Consistency: cases 5.1-5.8, 2.1-2.4 stay green unchanged. 5.5a (BT first-seen bootstrap) and 2.2 (BT startup bootstrap) still pass because both use bluetoothA2dp, which is NOT excluded by the guard. Full flutter test green before/after (Strict TDD, RED→GREEN per new case).

Migration / Rollout

No migration. Any presetsDispositivo['builtin_speaker'] entries already persisted by the old bootstrap remain harmless (L3 still resolves) and are not written going forward; optional one-line cleanup deferred — out of scope.

Open Questions

  • None blocking. Wired-headset (auricularesCable) is intentionally treated as per-device (bootstrap applies) — confirm in tasks if product wants it excluded too; default keeps current per-device behavior.