fix(alarm): anchor the fade at alarm time and defer the override to first audio
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m41s

On-device logcat from the latest test showed two defects the previous
design created. The fade-in was gated on the station reaching
`reproduciendo`, and the stream took 18.7 seconds to buffer: the ring
sat frozen at 5% the whole time and the configured fade seconds only
started counting afterwards. And the stream override was raised during
pre-start, so the ExoPlayer AudioTrack spin-up — which runs at gain 1.0
for an instant before the player gain lands — blasted at the configured
ring level, heard as "starts directly at the alarm volume".

The ramp is now anchored at alarm time: it starts when the screen
starts, buffering just joins it at the elapsed level, and the fade
duration means seconds-from-alarm. _iniciarFadeIn is single-start so
the handoff confirmation and fallback paths can no longer restart an
in-progress ramp from 5%. The stream override moved from the app-side
pre-start into the screen and is raised only when audio is actually
about to flow (first `reproduciendo`, the already-playing branch, or
right before the fallback WAV plays), so track spin-up happens under
the user's original low volume and the blast is physically impossible.
Exit teardown restores the device stream before resetting the player
gain, removing the brief exit blip seen in the capture.
This commit is contained in:
2026-07-12 00:35:29 +02:00
parent f73a12ad48
commit 2e64740b26
3 changed files with 81 additions and 58 deletions
+6 -15
View File
@@ -374,21 +374,12 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
} }
Future<void> _prearrancarAudioAlarma(AlarmaMusical alarma) async { Future<void> _prearrancarAudioAlarma(AlarmaMusical alarma) async {
// Must run FIRST, before any early return: the override needs to be in // The ring-scoped stream override is NOT raised here anymore: the ringing
// effect for the whole ring, including fallback-WAV-only alarms that // screen owns it and raises it only when audio is actually about to be
// never reach the station-playback branch below (Requirement: // audible (first `reproduciendo`, or right before the fallback WAV
// Ring-scoped device-volume override). // plays). Raising it during pre-start let the ExoPlayer track spin-up —
// // which briefly runs at gain 1.0 — blast at the ring level; under the
// The media stream is capped at the alarm's CONFIGURED volume, not the // user's original stream volume that spin-up is inaudible.
// device max: this makes the ring independent of the device's own volume
// (audible even at 0) while keeping "50%" meaning 50% of the phone's
// maximum. The player then ramps from ~5% up to full under this cap, so
// the perceived peak is exactly the configured fraction of max, reached
// gradually — not the device-relative level, and not a full-blast max.
await context.read<EstadoAlarmas>().android.forzarVolumenMediaParaAlarma(
alarma.volumen.clamp(0.0, 1.0),
);
if (!mounted) return;
final emisora = alarma.emisora; final emisora = alarma.emisora;
if (emisora == null) return; if (emisora == null) return;
final radio = context.read<EstadoRadio>(); final radio = context.read<EstadoRadio>();
+57 -13
View File
@@ -41,7 +41,9 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
bool _fallbackActivo = false; bool _fallbackActivo = false;
bool _radioIntentada = false; bool _radioIntentada = false;
bool _audioFlutterConfirmado = false; bool _audioFlutterConfirmado = false;
bool _volumenMediaForzado = false;
bool _volumenMediaRestaurado = false; bool _volumenMediaRestaurado = false;
bool _fadeInArrancado = false;
// Captured while mounted: dispose() also restores the media volume, and by // Captured while mounted: dispose() also restores the media volume, and by
// then the element is defunct, so context.read() would throw there. // then the element is defunct, so context.read() would throw there.
@@ -70,6 +72,13 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
if (!widget.audioPrearrancado) { if (!widget.audioPrearrancado) {
unawaited(radio.reproducir(emisora)); unawaited(radio.reproducir(emisora));
} }
// The ramp is anchored at ALARM time, not at stream-connect time: the
// configured fade seconds count from the moment the alarm starts, so a
// slow station buffering for many seconds cannot freeze the ring at 5%
// (observed on-device: 18.7s stuck waiting for `reproduciendo`). While
// the stream is still connecting nothing is audible anyway; when audio
// starts it simply joins the ramp at the elapsed level.
_iniciarFadeIn();
// S7-R4 boundary: only `reproduciendo` cancels the fallback timer — // S7-R4 boundary: only `reproduciendo` cancels the fallback timer —
// `reconectando`/`cargando` do NOT count as playing, so the 12-second // `reconectando`/`cargando` do NOT count as playing, so the 12-second
@@ -79,6 +88,13 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
_estadoSub = radio.estadoStream.listen((estado) { _estadoSub = radio.estadoStream.listen((estado) {
if (estado == EstadoReproduccion.reproduciendo && mounted) { if (estado == EstadoReproduccion.reproduciendo && mounted) {
_fallbackTimer?.cancel(); _fallbackTimer?.cancel();
// Raise the ring-scoped stream override only now that audio is
// actually flowing: the ExoPlayer AudioTrack spins up at gain 1.0
// for an instant before the player gain lands, and doing that under
// the user's ORIGINAL (low) stream volume makes the spin-up blast
// physically impossible. Order matters: override first, then the
// native handoff stop.
unawaited(_forzarVolumenMediaUnaVez());
_confirmarAudioFlutterListo(); _confirmarAudioFlutterListo();
} }
if (estado == EstadoReproduccion.error && mounted) { if (estado == EstadoReproduccion.error && mounted) {
@@ -92,11 +108,12 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
// Pre-started audio can reach `reproduciendo` BEFORE the listener above // Pre-started audio can reach `reproduciendo` BEFORE the listener above
// subscribes (app.dart starts the station before pushing this screen), // subscribes (app.dart starts the station before pushing this screen),
// in which case no further state event ever arrives. The handoff // in which case no further state event ever arrives. The handoff
// confirmation and the fade-in must not depend on catching that // confirmation and the stream override must not depend on catching that
// already-missed event, so this branch confirms explicitly too // already-missed event, so this branch handles them explicitly too
// (idempotent — the listener firing as well is harmless). // (idempotent — the listener firing as well is harmless).
if (widget.audioPrearrancado && radio.audio.estaSonando) { if (widget.audioPrearrancado && radio.audio.estaSonando) {
_fallbackTimer?.cancel(); _fallbackTimer?.cancel();
await _forzarVolumenMediaUnaVez();
await _confirmarAudioFlutterListo(); await _confirmarAudioFlutterListo();
} }
} }
@@ -105,14 +122,39 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
if (_fallbackActivo) return; if (_fallbackActivo) return;
_fallbackActivo = true; _fallbackActivo = true;
await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno)); await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno));
// The local WAV is about to be audible: raise the stream override before
// play so the fallback honors the configured ring level too.
await _forzarVolumenMediaUnaVez();
await _fallbackPlayer.play(); await _fallbackPlayer.play();
await _confirmarAudioFlutterListo(); await _confirmarAudioFlutterListo();
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
/// Raises the ring-scoped `STREAM_MUSIC` override (device-volume
/// independence: the ring is audible even with the device at 0, capped at
/// the alarm's configured fraction of the device maximum) at most once per
/// screen instance, and only when audio is about to be audible — never
/// during player spin-up, so track creation can't blast at full gain.
Future<void> _forzarVolumenMediaUnaVez() async {
if (_volumenMediaForzado) return;
_volumenMediaForzado = true;
try {
await _estadoAlarmas.android.forzarVolumenMediaParaAlarma(
widget.alarma.volumen.clamp(0.0, 1.0),
);
} catch (e) {
debugPrint('[PluriWave][alarmas] forzar volumen media fallo: $e');
}
}
void _iniciarFadeIn() { void _iniciarFadeIn() {
// Anchored, single-start ramp: the first caller (screen start) wins and
// later idempotent calls (handoff confirm, fallback) must NOT restart it
// from 5% — that would audibly drop an already-progressed ring.
if (_fadeInArrancado) return;
_fadeInArrancado = true;
_fadeInTimer?.cancel(); _fadeInTimer?.cancel();
// The media stream is already capped at the alarm's configured volume // The media stream is capped at the alarm's configured volume
// (forzarVolumenMediaParaAlarma), so the player ramps up to its OWN full // (forzarVolumenMediaParaAlarma), so the player ramps up to its OWN full
// range under that cap. Perceived peak = configured% of the device max, // range under that cap. Perceived peak = configured% of the device max,
// reached gradually from ~5% of the cap; ramping the player to // reached gradually from ~5% of the cap; ramping the player to
@@ -153,15 +195,14 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
} }
/// Confirms the native-to-Flutter audio handoff at most once per screen /// Confirms the native-to-Flutter audio handoff at most once per screen
/// instance, then starts the single audible Dart fade-in ramp (Slice 3: /// instance. The Dart ramp is anchored at screen start and does NOT wait
/// fade-in dedup at handoff). The native ramp /// for this confirmation (a slow stream would freeze the ring at 5%); the
/// (`PluriWaveAlarmService.startFadeIn`) owns audio until this /// `finally` below only guarantees the ramp exists on exotic paths where
/// confirmation lands; starting the Dart ramp any earlier would /// `_iniciarAlarma` never reached it — `_iniciarFadeIn` is single-start,
/// interleave both ramps and produce an audible jump/reset. If the /// so an already-running ramp is never restarted. Both ramps (native on
/// native confirmation channel call fails (dead or never-there native /// the ALARM stream, Dart under the capped media stream) start at 5% on
/// side), the fade-in still starts in the `finally` block below — Dart /// the same fade duration, so they stay aligned until the native track is
/// is the only audible source either way, so the ring must never stay /// stopped here.
/// stuck at [_volumenInicialFadeIn] forever.
Future<void> _confirmarAudioFlutterListo() async { Future<void> _confirmarAudioFlutterListo() async {
if (_audioFlutterConfirmado) return; if (_audioFlutterConfirmado) return;
_audioFlutterConfirmado = true; _audioFlutterConfirmado = true;
@@ -275,6 +316,10 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
} catch (e) { } catch (e) {
debugPrint('[PluriWave][alarmas] pausar radio fallo: $e'); debugPrint('[PluriWave][alarmas] pausar radio fallo: $e');
} }
// Restore the DEVICE stream first, then the player gain: raising the
// gain to 1.0 while the stream is still at the ring level would be
// audible for an instant if the pause hasn't fully landed.
await _restaurarVolumenMediaUnaVez();
try { try {
// The fade-in mutates the SHARED radio handler gain; exiting mid-ramp // The fade-in mutates the SHARED radio handler gain; exiting mid-ramp
// would otherwise leave every later radio play at the partial ramp // would otherwise leave every later radio play at the partial ramp
@@ -284,7 +329,6 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
} catch (e) { } catch (e) {
debugPrint('[PluriWave][alarmas] restaurar ganancia radio fallo: $e'); debugPrint('[PluriWave][alarmas] restaurar ganancia radio fallo: $e');
} }
await _restaurarVolumenMediaUnaVez();
} }
/// Dismisses the alarm screen safely in both live-app and dead-app states. /// Dismisses the alarm screen safely in both live-app and dead-app states.
@@ -169,47 +169,35 @@ void main() {
}, },
); );
group('fade-in dedup en el handoff (Slice 3)', () { group('rampa anclada y override diferido al primer audio', () {
testWidgets('el fade-in de Dart se retiene hasta que el nativo confirma el ' testWidgets('la rampa arranca al montar sin esperar al stream, y el '
'handoff, y arranca justo despues (camino radio)', (tester) async { 'override del volumen del dispositivo espera a "reproduciendo"',
(tester) async {
final entorno = await _montarPantalla( final entorno = await _montarPantalla(
tester, tester,
audioYaReproduciendo: false, audioYaReproduciendo: false,
); );
entorno.android.puertaConfirmarAudioFlutter = Completer<void>();
// Antes de que la radio confirme "reproduciendo", solo debe existir // La rampa esta anclada al inicio de la alarma: con fade 0 salta ya al
// el volumen de arranque previo (0.05): el ramp real hacia // objetivo (1.0) aunque el stream siga bufferizando. Lo que NO debe
// alarma.volumen todavia NO debe haber arrancado. // haber ocurrido todavia es el override del stream del dispositivo
// (el spin-up del reproductor debe pasar bajo el volumen original) ni
// el stop nativo del handoff.
expect(entorno.audio.volumenesAplicados, [0.05, 1.0]);
expect(
entorno.android.volumenForzado,
isEmpty,
reason: 'el override debe esperar a que el audio realmente fluya',
);
expect(entorno.android.detenidas, isEmpty); expect(entorno.android.detenidas, isEmpty);
expect(entorno.audio.volumenesAplicados, [0.05]);
// La radio confirma que esta reproduciendo -> dispara // La radio llega a "reproduciendo": recien ahi se sube el stream al
// _confirmarAudioFlutterListo(), que queda bloqueado en la puerta // nivel configurado y se confirma el handoff (stop nativo).
// (todavia no hay confirmacion nativa real).
entorno.audio.emitirEstado(EstadoReproduccion.reproduciendo); entorno.audio.emitirEstado(EstadoReproduccion.reproduciendo);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(
entorno.android.detenidas,
isEmpty,
reason: 'confirmarAudioFlutter sigue bloqueado en la puerta de prueba',
);
expect(
entorno.audio.volumenesAplicados,
[0.05],
reason: 'el fade-in de Dart no debe arrancar antes del handoff',
);
// Se libera la puerta: recien ahi "confirma" el nativo, y solo
// entonces debe arrancar el fade-in de Dart (una unica rampa
// audible a la vez).
entorno.android.puertaConfirmarAudioFlutter!.complete();
await tester.pumpAndSettle();
expect(entorno.android.volumenForzado, [0.85]);
expect(entorno.android.detenidas, contains('ring1')); expect(entorno.android.detenidas, contains('ring1'));
// Player ramps to its OWN full range (1.0); the configured level is
// enforced by the media-stream cap, not by the player target.
expect(entorno.audio.volumenesAplicados, [0.05, 1.0]);
}); });
testWidgets('si confirmar el audio con el nativo falla, el fade-in de Dart ' testWidgets('si confirmar el audio con el nativo falla, el fade-in de Dart '