Files
pluriwave/openspec/specs/multi-device-eq/spec.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.4 KiB

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.