fix(eq): re-apply the equalizer after an audio-focus interruption

The equalizer stopped applying after another app interrupted audio (e.g. a
navigation app's voice prompt): play a station with EQ working, let the
prompt speak, resume -- the audio sounds flat until the station is
re-tapped.

debeReaplicarEcualizador only re-attaches the equalizer when the native
player session id actually changes. A short transient interruption keeps
the SAME session (no id rotation), so that trigger never fires, while
Android's AudioEffect framework can let a higher-priority client silently
disable this app's effect instance in the meantime.

Add reaplicarEcualizador() to ObjetivoAudioInterrumpible, implemented as a
thin delegate to the existing _activarEcualizador() (already the correct
idempotent setEnabled + re-push-gains path). ServicioAudioSession calls it
on resume-from-pause (after reanudar()) and on un-duck (after
setAtenuado(false)) -- additive to the existing session-id trigger, not a
replacement. The method takes no argument, so it can only re-assert
whatever enabled/disabled state the handler already holds -- an
interruption cycle with the equalizer OFF stays OFF.
This commit is contained in:
2026-07-31 00:56:37 +02:00
parent 9eff760462
commit 491585ad12
4 changed files with 228 additions and 0 deletions
@@ -1,5 +1,7 @@
import 'package:audio_session/audio_session.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/servicios/servicio_audio_session.dart';
/// EQ audio-focus re-apply — pure decision predicate truth table.
///
@@ -96,4 +98,192 @@ void main() {
);
});
});
// ── EQ re-apply after a SHORT audio-focus interruption ──────────────────
// debeReaplicarEcualizador only fires on a session-id CHANGE. A short
// transient interruption (a nav-app voice prompt) keeps the SAME player
// session id, so that trigger never fires and the equalizer stays
// silently disabled after Android lets another app's AudioEffect steal
// control. Fix: re-assert the equalizer on resume-from-pause and on
// un-duck too, via a new no-arg ObjetivoAudioInterrumpible.reaplicarEcualizador()
// that the handler implements as a thin delegate to the existing
// _activarEcualizador() (setEnabled + band gains, already correct).
//
// ServicioAudioSession is the orchestration layer under test here (the
// same layer servicio_audio_session_test.dart already covers) -- it is
// fully unit-testable, unlike PluriWaveAudioHandler itself.
group(
'ServicioAudioSession re-applies the equalizer on interruption resume '
'(no session-id change involved)',
() {
test(
'a pause-interruption cycle (begin -> end/resume) calls '
'reaplicarEcualizador exactly once, AFTER reanudar()',
() async {
final objetivo = _ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.pause),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(objetivo.reaplicaciones, 1);
expect(
objetivo.eventos,
['pausar', 'reanudar', 'reaplicar'],
reason:
'the re-apply must happen on RESUME, after reanudar() -- '
'never before, never on the begin/pause side',
);
},
);
test(
'a duck cycle (begin -> end/un-duck) calls reaplicarEcualizador '
'exactly once, AFTER setAtenuado(false)',
() async {
final objetivo = _ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.duck),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.duck),
);
expect(objetivo.reaplicaciones, 1);
expect(
objetivo.eventos,
['atenuado:true', 'atenuado:false', 'reaplicar'],
reason:
'the re-apply must happen on UN-DUCK, after '
'setAtenuado(false)',
);
expect(objetivo.pausas, 0, reason: 'a duck never pauses');
},
);
test(
'with the equalizer switched OFF by the user, an interruption '
'cycle still only calls the SAME parameterless reassert -- '
'ServicioAudioSession has no way to force it on',
() async {
final objetivo = _ObjetivoFake()
..reproduciendo = true
..intencion = true
..eqActivo = false;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.pause),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(objetivo.reaplicaciones, 1);
expect(
objetivo.estadosReaplicados,
[false],
reason:
'reaplicarEcualizador takes no boolean argument -- it can '
'only ask the handler to reassert whatever state it '
'ALREADY holds, never flip it on',
);
},
);
test(
'end without a prior begin/pause never calls reaplicarEcualizador '
'(mirrors "end sin pausa previa" -- no resume happened)',
() async {
final objetivo = _ObjetivoFake();
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(objetivo.reaplicaciones, 0);
},
);
test(
'a permanent (unknown-type) focus loss never calls '
'reaplicarEcualizador -- there is no resume to re-assert after',
() async {
final objetivo = _ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.unknown),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.unknown),
);
expect(objetivo.reaplicaciones, 0);
},
);
},
);
}
class _ObjetivoFake implements ObjetivoAudioInterrumpible {
bool intencion = false;
bool reproduciendo = false;
bool eqActivo = true;
int pausas = 0;
int reaplicaciones = 0;
final List<bool> atenuaciones = [];
final List<bool> estadosReaplicados = [];
/// Ordering log shared across every method — proves reaplicarEcualizador
/// fires at the EXACT point in the sequence the fix requires (after
/// reanudar()/setAtenuado(false)), not merely "at some point".
final List<String> eventos = [];
@override
bool get intencionReproducir => intencion;
@override
bool get estaReproduciendo => reproduciendo;
@override
Future<void> pausar() async {
pausas++;
reproduciendo = false;
intencion = false;
eventos.add('pausar');
}
@override
Future<void> reanudar() async {
reproduciendo = true;
intencion = true;
eventos.add('reanudar');
}
@override
Future<void> setAtenuado(bool atenuado) async {
atenuaciones.add(atenuado);
eventos.add('atenuado:$atenuado');
}
@override
Future<void> reaplicarEcualizador() async {
reaplicaciones++;
estadosReaplicados.add(eqActivo);
eventos.add('reaplicar');
}
}
@@ -33,6 +33,13 @@ class _ObjetivoFake implements ObjetivoAudioInterrumpible {
Future<void> setAtenuado(bool atenuado) async {
atenuaciones.add(atenuado);
}
int reaplicaciones = 0;
@override
Future<void> reaplicarEcualizador() async {
reaplicaciones++;
}
}
/// S3-R1: audio-session interruptions (phone call, transient loss, duck) and