_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.
137 lines
6.4 KiB
Markdown
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.
|