Files
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

137 lines
6.4 KiB
Markdown

# Delta for multi-device-eq
No prior `openspec/specs/multi-device-eq/spec.md` exists. This delta adds
requirements as new (ADDED), scoped to disconnect/cold-start revert only.
Connect-path and 4-level-hierarchy requirements are out of scope (already
covered by the passing test suite).
## ADDED Requirements
### Requirement: Device-Event Resolve-and-Apply
The system MUST treat every device-change event — connect or disconnect —
identically: set the now-active device as current, re-resolve via the
existing 4-level hierarchy (matrix -> station -> device -> global), and
APPLY via `ServicioAudio.aplicarPreset()`. The system MUST NOT branch on
event type; no add/remove discriminator exists on the event, and none is
required.
#### Scenario: Connect applies the device's own preset
- GIVEN `_eqMultiDeviceEnabled` is true and a station is active
- WHEN a device-change event arrives for a device with a resolvable preset
- THEN the resolved preset becomes `presetActual`
- AND `audio.aplicarPreset()` is called with it (asserted via
`FakeServicioAudio.presetsAplicados`)
#### Scenario: Disconnect resolves and applies the base preset
- GIVEN `_eqMultiDeviceEnabled` is true, a BT device is active with its own
resolved preset, and `builtin_speaker` resolves to a distinct preset
- WHEN a device-change event arrives carrying `builtin_speaker` (simulating
disconnect-to-base via `FakeServicioDispositivoAudio.emitirDispositivo`,
which carries no add/remove flag)
- THEN `_dispositivoActualId` becomes `'builtin_speaker'`
- AND the hierarchy resolution for `builtin_speaker` is applied
- AND `audio.aplicarPreset()` records it as the last entry in
`presetsAplicados` (proves push, not state-only mutation)
### Requirement: First-Seen Bootstrap Must Not Hijack Base-Device Resolution
The system MUST NOT force-copy a stale resolved preset into
`presetsDispositivo['builtin_speaker']` on disconnect when it has no
existing entry. The base speaker is excluded from first-seen bootstrap
entirely: no `presetsDispositivo['builtin_speaker']` entry is ever
auto-created, so the base device always resolves live through the
hierarchy (a persistent device-level entry would mask later global-preset
edits, since level 3 beats level 4 on every subsequent resolution).
#### Scenario: Disconnect to a never-seen builtin_speaker resolves from the hierarchy
- GIVEN `_eqMultiDeviceEnabled` is true, `presetsDispositivo` has no entry
for `builtin_speaker`, a BT device is active resolved to `jazz`, and
global `_presetPrincipal` is `rock`
- WHEN the device-change event carries `builtin_speaker`
- THEN no `presetsDispositivo['builtin_speaker']` entry is created, and
resolution falls through to the global preset `rock`, NOT `jazz`
- AND `audio.aplicarPreset()` is called with `rock`
- AND a later change of the global preset while on `builtin_speaker`
re-resolves to the new global value (no stale level-3 entry masks it)
### Requirement: Cold Start Applies Base Resolution, Never a Stale Persisted Preset
The system MUST guarantee that cold start with no external device connected
applies the phone/base resolution for `builtin_speaker` through
`audio.aplicarPreset()`, never a stale persisted preset for a
previously-connected, now-inactive device.
#### Scenario: Cold start with no device connected applies the base preset
- GIVEN `_eqMultiDeviceEnabled` is true, `presetsDispositivo` has a stale
entry for a BT device id that is NOT currently connected, and
`obtenerDispositivoActual()` resolves to `builtin_speaker` (its default
when no device was ever emitted)
- WHEN `cargarPersistido()` runs (`_sembrarDispositivoActual()` ->
`_onDispositivoCambiado()`)
- THEN `_dispositivoActualId` becomes `'builtin_speaker'`
- AND `presetActual` is the hierarchy resolution for `builtin_speaker`
- AND `audio.aplicarPreset()` records that resolution, NOT the stale BT
device's preset
### Requirement: Connect-Disconnect-Reconnect Cycle Lands on the Correct Preset at Each Step
The system MUST resolve and apply the correct preset at every step of a
connect -> disconnect -> reconnect cycle, honoring station and matrix
overrides, and MUST NOT let disconnect corrupt state such that reconnect
resolves incorrectly.
#### Scenario: Full cycle honors station/matrix overrides at each step
- GIVEN a matrix override for `(stationUuid, btDevice.id)` = `bassBoost`,
and `builtin_speaker` resolves to global `flat`
- WHEN the device stream emits: BT device (connect) -> `builtin_speaker`
(disconnect) -> BT device (reconnect)
- THEN after connect, `presetActual` is `bassBoost`; after disconnect,
`flat`; after reconnect, `bassBoost` again — each recorded as the last
entry in `presetsAplicados`
- AND the BT device's `presetsDispositivo` entry is UNCHANGED by the
disconnect step (consistent with existing test 5.5b: reconnect must not
overwrite a user-saved device preset)
### Requirement: Toggle-Off Behavior Is Byte-for-Byte Unchanged
When `_eqMultiDeviceEnabled` is false, device-change events MUST NOT alter
resolution: no device subscription is active, no first-seen bootstrap runs,
and resolution stays 2-level (station -> global) only.
#### Scenario: Toggle off ignores device events entirely
- GIVEN `_eqMultiDeviceEnabled` is false
- WHEN a device-change event is emitted (any device, including
`builtin_speaker`)
- THEN `_onDispositivoCambiado` returns immediately with no state change
- AND `presetEfectivo(stationUuid, deviceId)` still resolves via the
2-level path exactly as before this change
- AND `presetsAplicados` gains no new entry from the event
## Testability
All scenarios are Dart-testable via existing fakes in
`test/helpers/fakes.dart` — no new fake infrastructure required:
- `FakeServicioDispositivoAudio.emitirDispositivo(dispositivo)` — pushes any
`DispositivoAudio` through `onDispositivoCambiado`; used identically for
simulated connect and disconnect (no discriminator on the real stream).
- `FakeServicioDispositivoAudio.obtenerDispositivoActual()` — defaults to
`builtin_speaker`; used for the cold-start scenario.
- `FakeServicioAudio.presetsAplicados` — proves gains were PUSHED via
`aplicarPreset()`, not just mutated in state. Every scenario asserts this
list, not only `presetActual`.
- `FakeServicioEcualizador` constructor params (`principal`, `porEmisora`,
`presetsDispositivo`, `presetsMatriz`) — seed the hierarchy as the
existing Phase 5 suite already does.
## Localization
No new l10n strings are introduced by this change.