Files
pluriwave/openspec/changes/multi-device-eq/design.md
T
FreeTLab bccc5c48b8
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
docs(openspec): add SDD artifact trail for recent alarm and EQ changes
Persist the exploration, proposal, spec, design, tasks, and
verify/archive reports produced during the multi-device EQ,
alarm-countdown, and notification-visual-polish SDD cycles.
2026-07-04 12:42:11 +02:00

11 KiB
Raw Blame History

Design: Multi-Device Equalizer

Technical Approach

Add a device dimension to the existing 2-level EQ resolution (station > global) by introducing a platform channel bridge for device detection, a Dart service abstraction, and extending EstadoEcualizador to resolve through a 4-level hierarchy. Follows existing project patterns: ChangeNotifier state, SharedPreferences persistence via ServicioEcualizador, platform channels in MainActivity.kt, and constructor-injected fakes for testing.

Architecture Decisions

ADR-1: Platform Channel vs Package

Option Tradeoff Decision
Custom platform channel pluriwave/audio_devices Native code in Kotlin+Swift; full control over device ID format, BT MAC access Chosen
flutter_audio_output package No native code; unmaintained (2021), no BT MAC, dependency risk Rejected
audio_session events only Zero new code; incomplete -- no BT identity, only becoming-noisy Rejected

Rationale: Project already has 3 platform channels (visualizer, alarm, file_actions). The pattern is established. BT MAC from AudioManager.getDevices() requires no extra permission and gives stable device keys.

ADR-2: Device Service as Abstract Class

Option Tradeoff Decision
Abstract ServicioDispositivoAudio with real + fake impls Testable without platform channels; matches ServicioAudio/ServicioEcualizador pattern Chosen
Concrete class with @visibleForTesting fields Simpler; harder to fake stream behavior in tests Rejected

Rationale: EstadoEcualizador tests must verify device-change reactions. An abstract class with FakeServicioDispositivoAudio in test/helpers/fakes.dart follows the established fake pattern.

ADR-3: Composite Key for Matrix Persistence

Option Tradeoff Decision
"stationUuid:deviceId" string key in flat map Simple; ~80 bytes/entry, predictable SP size Chosen
Nested map {stationUuid: {deviceId: preset}} Type-safe; more complex serialization/deserialization Rejected

Rationale: SharedPreferences stores a single JSON string. A flat map with composite keys is simpler to serialize, query, and migrate. Delimiter : is safe because station UUIDs are RFC 4122 (no colons) and device IDs use : only inside BT MACs which appear after the bt_a2dp: prefix.

ADR-4: Resolution Wiring Point

Option Tradeoff Decision
EstadoEcualizador subscribes to device stream, resolves internally Single owner of resolution logic; matches existing pattern where EstadoEcualizador owns all EQ state Chosen
PluriWaveAudioHandler resolves via callback Keeps resolution near the engine; requires handler to know about stations and persistence Rejected

Rationale: PluriWaveAudioHandler is intentionally thin on state (it stores _presetActual only). The handler calls aplicarPreset() -- it should not know about resolution hierarchy. EstadoEcualizador already owns the station-map resolution.

ADR-5: EQ Re-application After _recrearPlayer()

Option Tradeoff Decision
_activarEcualizador() applies _presetActual (no change to handler) and EstadoEcualizador keeps _presetActual updated on device/station change Handler stays unchanged; state layer ensures _presetActual is always the resolved preset Chosen
Inject resolution callback into handler Handler becomes aware of device dimension; breaks current layering Rejected

Rationale: _activarEcualizador() already calls aplicarPreset(_presetActual). If EstadoEcualizador updates _presetActual via aplicarPresetActivo() whenever device or station changes, the handler needs no modification. The existing aplicarPresetActivo path already flows through ServicioAudio.aplicarPreset() to the handler.

ADR-6: Feature Toggle Scope

Option Tradeoff Decision
SP key eq_multi_device_enabled_v1 read by EstadoEcualizador; when false, skip device subscription and 4-level resolution Zero behavioral change when off; toggle is at the state layer Chosen
Feature flag in UI only (hide settings section) State layer still runs device logic even when "disabled" Rejected

Rationale: Toggle must fully isolate the feature. When off, EstadoEcualizador should behave identically to current code -- no device stream subscription, 2-level resolution only.

Data Flow

                          Platform (Android/iOS)
                                   |
                          AudioDeviceCallback /
                          routeChangeNotification
                                   |
                    +----- EventChannel ------+
                    |  pluriwave/audio_devices |
                    +-------------------------+
                                   |
                    ServicioDispositivoAudio
                      Stream<DispositivoAudio>
                                   |
              +--- EstadoEcualizador (ChangeNotifier) ---+
              |                                           |
              |  resolve: matrix > station > device > global
              |                                           |
              +----→ aplicarPresetActivo(resolved) -------+
                          |                    |
                  ServicioAudio         ServicioEcualizador
                  (apply to engine)     (persist to SP)

Device change flow:

  1. Native callback fires (connect/disconnect)
  2. EventChannel pushes device event to Dart
  3. ServicioDispositivoAudio emits DispositivoAudio on stream
  4. EstadoEcualizador._onDeviceChanged() triggers 4-level resolution
  5. Resolved preset applied via aplicarPresetActivo() (existing path)
  6. If first-seen device: copy current preset as initial device preset

File Changes

File Action Description
lib/modelos/dispositivo_audio.dart Create DispositivoAudio value model + TipoDispositivo enum
lib/servicios/servicio_dispositivo_audio.dart Create Abstract class + platform channel implementation
android/.../MainActivity.kt Modify Add pluriwave/audio_devices EventChannel + MethodChannel
ios/Runner/AudioDevicesPlugin.swift Create AVAudioSession route detection
ios/Runner/AppDelegate.swift Modify Register AudioDevicesPlugin
lib/servicios/servicio_ecualizador.dart Modify New SP keys, device/matrix CRUD, extended ConfiguracionEcualizador
lib/estado/estado_ecualizador.dart Modify Device stream subscription, 4-level resolution, toggle logic
lib/servicios/servicio_export_import.dart Modify v3 schema with presetsPorDispositivo + presetsMatriz fields
lib/pantallas/pantalla_ajustes.dart Modify _SeccionEcualizadorAvanzado widget behind feature toggle
test/helpers/fakes.dart Modify Add FakeServicioDispositivoAudio
test/estado/estado_ecualizador_test.dart Modify Device-dimension test cases

Interfaces / Contracts

DispositivoAudio Model

enum TipoDispositivo {
  altavozInterno,   // "builtin_speaker"
  auricularesCable, // "wired_headset"
  bluetoothA2dp,    // "bt_a2dp:<MAC>"
  usbAudio,         // "usb_headset:<address>"
  desconocido,      // fallback
}

class DispositivoAudio {
  final String id;               // Stable key: "builtin_speaker", "bt_a2dp:AA:BB:CC:DD:EE:FF"
  final TipoDispositivo tipo;
  final String nombre;           // Human-readable: "Galaxy Buds Pro"

  const DispositivoAudio({required this.id, required this.tipo, required this.nombre});
}

ServicioDispositivoAudio Contract

abstract class ServicioDispositivoAudio {
  /// Current active output device (null before first query).
  DispositivoAudio? get dispositivoActual;

  /// Stream of active device changes.
  Stream<DispositivoAudio> get onDispositivoCambiado;

  /// Query current device (pull).
  Future<DispositivoAudio> obtenerDispositivoActual();

  /// Clean up native resources.
  Future<void> dispose();
}

Platform Channel Protocol

Channel: pluriwave/audio_devices

MethodChannel (pull):

  • getActiveDevice -> Map<String, dynamic> ({id, type, name})

EventChannel (push):

  • Stream of Map<String, dynamic> ({id, type, name}) on device change

Type constants (int, matching Android AudioDeviceInfo types):

  • 2 = builtin_speaker, 3 = wired_headset, 8 = bt_a2dp, 14 = usb_headset

Extended ConfiguracionEcualizador

class ConfiguracionEcualizador {
  final PresetEcualizador principal;
  final Map<String, PresetEcualizador> porEmisora;
  final Map<String, PresetEcualizador> porDispositivo;  // NEW
  final Map<String, PresetEcualizador> matriz;           // NEW (key: "uuid:deviceId")
  final bool activo;
  final bool multiDispositivoHabilitado;                 // NEW
}

New SP Keys

eq_multi_device_enabled_v1      -> bool (default false)
eq_preset_por_dispositivo_v1    -> Map<deviceId, PresetEcualizador> (JSON)
eq_presets_matriz_v1            -> Map<"stationUuid:deviceId", PresetEcualizador> (JSON)

Testing Strategy

Layer What to Test Approach
Unit 4-level resolution hierarchy (all combinations) EstadoEcualizador with FakeServicioDispositivoAudio + FakeServicioEcualizador + FakeServicioAudio
Unit Device change triggers preset swap Emit device events on fake stream, assert presetsAplicados
Unit Feature toggle isolation Toggle off: verify no device subscription, 2-level resolution only
Unit First-seen device copies current preset Assert persistence call on unknown device ID
Unit _recrearPlayer() re-applies correct preset Verify _presetActual is device-resolved before _activarEcualizador() runs
Unit ServicioEcualizador CRUD for new SP keys Direct persistence layer tests
Unit Export/import v3 round-trip + v2 backward compat ServicioExportImport with device fields present/absent
Unit DispositivoAudio model equality and serialization Value model tests

FakeServicioDispositivoAudio exposes a StreamController<DispositivoAudio> so tests can push device events synchronously.

Migration / Rollout

  • Feature toggle eq_multi_device_enabled_v1 defaults to false -- zero behavioral change on upgrade.
  • New SP keys are independent of existing keys; no migration needed.
  • Export v3 adds nullable fields; v2 importers ignore unknown keys (existing importar() uses Map<String, dynamic>.from()).
  • Import path handles missing device fields with null-safe defaults.
  • Native channel is additive; no existing channels modified.

Open Questions

  • iOS full implementation or stub? -- Decided: Full detection, no-op EQ application (same as current global EQ on iOS). State is tracked and persisted for UI display.
  • Should station × device matrix entries be cleaned up when a station is removed from favorites? (Low priority; orphaned entries are harmless and tiny.)