feat(eq): add per-device equalizer with 4-level preset resolution

Introduce multi-device EQ support allowing each audio output device
(built-in speaker, wired, USB, individual Bluetooth by MAC) to have
its own equalizer preset, combined with existing per-station presets
for a full station×device matrix.

- Add DispositivoAudio model and ServicioDispositivoAudio interface
- Add Android platform channel (AudioDeviceCallback) for device detection
- Add iOS AudioDevicesPlugin (AVAudioSession route tracking)
- Extend ServicioEcualizador with device and matrix persistence keys
- Implement 4-level resolution: matrix > station > device > global
- Add advanced EQ settings section with feature toggle (off by default)
- Extend export/import to v3 with backward compatibility
- 184 tests passing, zero analyzer issues
This commit is contained in:
2026-06-27 11:33:53 +02:00
parent 8f42e67b48
commit 4632d53eb8
36 changed files with 2850 additions and 64 deletions
+490
View File
@@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_ecualizador.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/dispositivo_audio.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import '../helpers/fakes.dart';
@@ -48,4 +49,493 @@ void main() {
estado.dispose();
});
});
// ---------------------------------------------------------------------------
// Phase 5: 4-level resolution hierarchy
// ---------------------------------------------------------------------------
group('EstadoEcualizador — 4-level resolution (Phase 5)', () {
const btDevice = DispositivoAudio(
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
tipo: TipoDispositivo.bluetoothA2dp,
nombre: 'BT Speaker',
);
const stationUuid = 'station-uuid-123';
EstadoEcualizador buildEq({
FakeServicioEcualizador? servicio,
FakeServicioDispositivoAudio? dispositivoAudio,
bool multiDeviceEnabled = true,
String? emisoraActualUuid,
}) {
return EstadoEcualizador(
audio: FakeServicioAudio(),
servicio:
servicio ??
FakeServicioEcualizador(eqMultiDeviceEnabled: multiDeviceEnabled),
dispositivoAudio: dispositivoAudio ?? FakeServicioDispositivoAudio(),
emisoraActualUuid: () => emisoraActualUuid,
);
}
// 5.1a — matrix entry wins over all others
test(
'5.1a matrix entry (stationUuid:deviceId) wins top priority',
() async {
final matrizPreset = PresetEcualizador.bassBoost;
final stationPreset = PresetEcualizador.rock;
final devicePreset = PresetEcualizador.jazz;
final globalPreset = PresetEcualizador.pop;
final eq = buildEq(
servicio: FakeServicioEcualizador(
principal: globalPreset,
porEmisora: {stationUuid: stationPreset},
eqMultiDeviceEnabled: true,
presetsDispositivo: {btDevice.id: devicePreset},
presetsMatriz: {'$stationUuid:${btDevice.id}': matrizPreset},
),
emisoraActualUuid: stationUuid,
);
await eq.cargarPersistido();
expect(
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
equals(matrizPreset),
);
eq.dispose();
},
);
// 5.1b — falls back to station when no matrix entry
test('5.1b falls back to station preset when no matrix entry', () async {
final stationPreset = PresetEcualizador.rock;
final devicePreset = PresetEcualizador.jazz;
final globalPreset = PresetEcualizador.pop;
final eq = buildEq(
servicio: FakeServicioEcualizador(
principal: globalPreset,
porEmisora: {stationUuid: stationPreset},
eqMultiDeviceEnabled: true,
presetsDispositivo: {btDevice.id: devicePreset},
presetsMatriz: {},
),
emisoraActualUuid: stationUuid,
);
await eq.cargarPersistido();
expect(
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
equals(stationPreset),
);
eq.dispose();
});
// 5.1c — falls back to device when no station preset
test(
'5.1c falls back to device preset when no station or matrix entry',
() async {
final devicePreset = PresetEcualizador.jazz;
final globalPreset = PresetEcualizador.pop;
final eq = buildEq(
servicio: FakeServicioEcualizador(
principal: globalPreset,
porEmisora: {},
eqMultiDeviceEnabled: true,
presetsDispositivo: {btDevice.id: devicePreset},
presetsMatriz: {},
),
emisoraActualUuid: stationUuid,
);
await eq.cargarPersistido();
expect(
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
equals(devicePreset),
);
eq.dispose();
},
);
// 5.1d — falls back to global when nothing else matches
test(
'5.1d falls back to global preset when no matrix, station, or device',
() async {
final globalPreset = PresetEcualizador.pop;
final eq = buildEq(
servicio: FakeServicioEcualizador(
principal: globalPreset,
porEmisora: {},
eqMultiDeviceEnabled: true,
presetsDispositivo: {},
presetsMatriz: {},
),
emisoraActualUuid: stationUuid,
);
await eq.cargarPersistido();
expect(
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
equals(globalPreset),
);
eq.dispose();
},
);
// 5.1e — toggle off collapses to 2-level
test(
'5.1e toggle off: presetEfectivo uses only station → global',
() async {
final stationPreset = PresetEcualizador.rock;
final devicePreset = PresetEcualizador.jazz;
final matrizPreset = PresetEcualizador.bassBoost;
final eq = buildEq(
servicio: FakeServicioEcualizador(
principal: PresetEcualizador.flat,
porEmisora: {stationUuid: stationPreset},
eqMultiDeviceEnabled: false,
presetsDispositivo: {btDevice.id: devicePreset},
presetsMatriz: {'$stationUuid:${btDevice.id}': matrizPreset},
),
multiDeviceEnabled: false,
emisoraActualUuid: stationUuid,
);
await eq.cargarPersistido();
// When toggle is off, device and matrix entries must be ignored
expect(
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
equals(stationPreset),
);
eq.dispose();
},
);
// 5.1f — toggle off with no station falls back to global
test('5.1f toggle off with no station: falls back to global', () async {
final devicePreset = PresetEcualizador.jazz;
final eq = buildEq(
servicio: FakeServicioEcualizador(
principal: PresetEcualizador.flat,
porEmisora: {},
eqMultiDeviceEnabled: false,
presetsDispositivo: {btDevice.id: devicePreset},
presetsMatriz: {},
),
multiDeviceEnabled: false,
emisoraActualUuid: stationUuid,
);
await eq.cargarPersistido();
expect(
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
equals(PresetEcualizador.flat),
);
eq.dispose();
});
});
// ---------------------------------------------------------------------------
// Phase 5.3: Device stream subscription triggers re-resolution
// ---------------------------------------------------------------------------
group('EstadoEcualizador — device stream subscription (Phase 5.35.4)', () {
const stationUuid = 'station-uuid-stream';
const btDevice = DispositivoAudio(
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
tipo: TipoDispositivo.bluetoothA2dp,
nombre: 'BT Speaker',
);
test('5.3a device change emits notifyListeners', () async {
final fakeAudio = FakeServicioAudio();
final fakeDispositivo = FakeServicioDispositivoAudio();
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(
eqMultiDeviceEnabled: true,
presetsDispositivo: {btDevice.id: PresetEcualizador.jazz},
),
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => stationUuid,
);
await eq.cargarPersistido();
var avisos = 0;
eq.addListener(() => avisos++);
fakeDispositivo.emitirDispositivo(btDevice);
// Wait for async processing
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(avisos, greaterThanOrEqualTo(1));
eq.dispose();
});
test(
'5.3b device change re-applies resolved preset to audio engine',
() async {
final fakeAudio = FakeServicioAudio();
final fakeDispositivo = FakeServicioDispositivoAudio();
final btPreset = PresetEcualizador.jazz;
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(
eqMultiDeviceEnabled: true,
presetsDispositivo: {btDevice.id: btPreset},
),
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => stationUuid,
);
await eq.cargarPersistido();
final initialCount = fakeAudio.presetsAplicados.length;
fakeDispositivo.emitirDispositivo(btDevice);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(fakeAudio.presetsAplicados.length, greaterThan(initialCount));
expect(fakeAudio.presetsAplicados.last, equals(btPreset));
eq.dispose();
},
);
test(
'5.3c toggle off: device stream events do NOT trigger any action',
() async {
final fakeAudio = FakeServicioAudio();
final fakeDispositivo = FakeServicioDispositivoAudio();
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: false),
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => stationUuid,
);
await eq.cargarPersistido();
var avisos = 0;
eq.addListener(() => avisos++);
final initialCount = fakeAudio.presetsAplicados.length;
fakeDispositivo.emitirDispositivo(btDevice);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(avisos, equals(0));
expect(fakeAudio.presetsAplicados.length, equals(initialCount));
eq.dispose();
},
);
});
// ---------------------------------------------------------------------------
// Phase 5.55.6: First-seen device copies current preset
// ---------------------------------------------------------------------------
group(
'EstadoEcualizador — first-seen device initialization (Phase 5.55.6)',
() {
const stationUuid = 'station-uuid-first-seen';
const newDevice = DispositivoAudio(
id: 'bt_a2dp:NEW:DE:VI:CE:00:00',
tipo: TipoDispositivo.bluetoothA2dp,
nombre: 'New BT Device',
);
test('5.5a new device gets copy of current resolved preset', () async {
final fakeServicio = FakeServicioEcualizador(
principal: PresetEcualizador.rock,
eqMultiDeviceEnabled: true,
presetsDispositivo: {},
);
final fakeDispositivo = FakeServicioDispositivoAudio();
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: fakeServicio,
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => stationUuid,
);
await eq.cargarPersistido();
fakeDispositivo.emitirDispositivo(newDevice);
await Future<void>.delayed(const Duration(milliseconds: 50));
// The device should now have a preset saved equal to the resolved preset
// at the time of first connection (rock — global fallback)
expect(
eq.presetsDispositivo[newDevice.id],
equals(PresetEcualizador.rock),
);
eq.dispose();
});
test(
'5.5b subsequent reconnection does NOT overwrite user-saved preset',
() async {
final userSavedPreset = PresetEcualizador.jazz;
final fakeServicio = FakeServicioEcualizador(
principal: PresetEcualizador.rock,
eqMultiDeviceEnabled: true,
presetsDispositivo: {newDevice.id: userSavedPreset},
);
final fakeDispositivo = FakeServicioDispositivoAudio();
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: fakeServicio,
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => stationUuid,
);
await eq.cargarPersistido();
fakeDispositivo.emitirDispositivo(newDevice);
await Future<void>.delayed(const Duration(milliseconds: 50));
// Preset must remain the user-saved one, not overwritten with rock
expect(eq.presetsDispositivo[newDevice.id], equals(userSavedPreset));
eq.dispose();
},
);
},
);
// ---------------------------------------------------------------------------
// Phase 5.75.8: Toggle-off path uses only 2-level resolution
// ---------------------------------------------------------------------------
group('EstadoEcualizador — toggle-off path (Phase 5.75.8)', () {
const stationUuid = 'station-toggle-off';
const btDevice = DispositivoAudio(
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
tipo: TipoDispositivo.bluetoothA2dp,
nombre: 'BT Speaker',
);
test(
'5.7a cargarPersistido with toggle off applies 2-level resolution',
() async {
final fakeAudio = FakeServicioAudio();
final stationPreset = PresetEcualizador.rock;
final devicePreset = PresetEcualizador.jazz;
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(
principal: PresetEcualizador.flat,
porEmisora: {stationUuid: stationPreset},
eqMultiDeviceEnabled: false,
presetsDispositivo: {btDevice.id: devicePreset},
),
dispositivoAudio: FakeServicioDispositivoAudio(),
emisoraActualUuid: () => stationUuid,
);
await eq.cargarPersistido();
// With toggle off, no device subscription, direct 2-level on presetEfectivo
expect(
eq.presetEfectivo(stationUuid: stationUuid, deviceId: btDevice.id),
equals(stationPreset),
);
eq.dispose();
},
);
test('5.8a eqMultiDeviceEnabled getter reflects loaded value', () async {
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
dispositivoAudio: FakeServicioDispositivoAudio(),
);
await eq.cargarPersistido();
expect(eq.eqMultiDeviceEnabled, isTrue);
eq.dispose();
});
test('5.8b toggle defaults to false and eq behaves as before', () async {
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: false),
dispositivoAudio: FakeServicioDispositivoAudio(),
);
await eq.cargarPersistido();
expect(eq.eqMultiDeviceEnabled, isFalse);
eq.dispose();
});
test(
'5.8c cambiarMultiDeviceEnabled persists toggle and notifies',
() async {
final fakeServicio = FakeServicioEcualizador(
eqMultiDeviceEnabled: false,
);
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: fakeServicio,
dispositivoAudio: FakeServicioDispositivoAudio(),
);
await eq.cargarPersistido();
var avisos = 0;
eq.addListener(() => avisos++);
await eq.cambiarMultiDeviceEnabled(true);
expect(eq.eqMultiDeviceEnabled, isTrue);
expect(fakeServicio.config.eqMultiDeviceEnabled, isTrue);
expect(avisos, greaterThanOrEqualTo(1));
eq.dispose();
},
);
});
// ---------------------------------------------------------------------------
// Phase 5.9: REFACTOR — aplicarPresetActivo path applies device-resolved preset
// ---------------------------------------------------------------------------
group(
'EstadoEcualizador — aplicarPresetActivo uses device resolution (5.9)',
() {
const stationUuid = 'station-refactor';
const btDevice = DispositivoAudio(
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
tipo: TipoDispositivo.bluetoothA2dp,
nombre: 'BT Speaker',
);
test(
'5.9a aplicarPresetEmisoraActual uses device-resolved preset when toggle on',
() async {
final fakeAudio = FakeServicioAudio();
final fakeDispositivo =
FakeServicioDispositivoAudio()..emitirDispositivo(btDevice);
final matrizPreset = PresetEcualizador.bassBoost;
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(
eqMultiDeviceEnabled: true,
presetsMatriz: {'$stationUuid:${btDevice.id}': matrizPreset},
),
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => stationUuid,
);
await eq.cargarPersistido();
await Future<void>.delayed(const Duration(milliseconds: 50));
// Clear application history from init; emit device to trigger re-apply
fakeAudio.presetsAplicados.clear();
fakeDispositivo.emitirDispositivo(btDevice);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(fakeAudio.presetsAplicados.last, equals(matrizPreset));
eq.dispose();
},
);
},
);
}