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.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user