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
+10
View File
@@ -1006,6 +1006,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler
await _player.setVolume(_volumenEfectivo);
}
/// Fix "EQ Re-Apply After Audio-Focus Interruption": thin delegate to the
/// existing [_activarEcualizador] (already does the correct idempotent
/// `setEnabled` + re-push-gains work, already re-asserts the CURRENT
/// [_ecualizadorActivo] rather than forcing it on). Called by
/// [ServicioAudioSession] on resume-from-pause and on un-duck — see that
/// interface member's doc for why the existing session-id-change trigger
/// misses this case.
@override
Future<void> reaplicarEcualizador() => _activarEcualizador();
@override
Future<void> play() {
_intencionReproducir = true;
+21
View File
@@ -20,6 +20,20 @@ abstract class ObjetivoAudioInterrumpible {
/// Temporarily lowers ("ducks") the output volume without pausing.
Future<void> setAtenuado(bool atenuado);
/// Re-attaches the equalizer effect and re-pushes the current preset's
/// gains (fix "EQ Re-Apply After Audio-Focus Interruption"). Called after
/// resuming from a transient interruption pause and after un-ducking,
/// because Android's AudioEffect framework can let a higher-priority
/// client silently disable this app's effect instance while the
/// underlying player session id never changes — the existing session-id
/// rotation trigger (`ServicioAudio.debeReaplicarEcualizador`) therefore
/// never fires for a SHORT interruption (e.g. a nav-app voice prompt).
/// Idempotent and cheap (a `setEnabled` plus band `setGain` calls); takes
/// no argument by design — it re-asserts whatever enabled/disabled state
/// the handler ALREADY holds, so a caller here can never force the
/// equalizer on. Never restarts or repositions playback.
Future<void> reaplicarEcualizador();
}
/// Wrapper around `package:audio_session` (S3-R1): configures the session
@@ -84,11 +98,18 @@ class ServicioAudioSession {
switch (evento.type) {
case AudioInterruptionType.duck:
await _objetivo.setAtenuado(false);
// Un-ducking never rotates the native player session id, so the
// session-id-change trigger never fires for this case — re-assert
// here too (belt-and-braces, additive to that trigger).
await _objetivo.reaplicarEcualizador();
case AudioInterruptionType.pause:
// Transient loss ended and the OS says we may resume.
if (_pausadoPorInterrupcion) {
_pausadoPorInterrupcion = false;
await _objetivo.reanudar();
// Same rationale as the duck branch above: a short transient
// interruption keeps the SAME player session id.
await _objetivo.reaplicarEcualizador();
}
case AudioInterruptionType.unknown:
// Permanent focus loss: never auto-resume.
@@ -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