From 2e64740b26d0da8ba1c27beab49df6c6e5389312 Mon Sep 17 00:00:00 2001 From: freetlab Date: Sun, 12 Jul 2026 00:35:28 +0200 Subject: [PATCH] fix(alarm): anchor the fade at alarm time and defer the override to first audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/app.dart | 21 ++---- lib/pantallas/pantalla_alarma_sonando.dart | 70 +++++++++++++++---- .../pantalla_alarma_sonando_test.dart | 48 +++++-------- 3 files changed, 81 insertions(+), 58 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 11edbbd..7d01d9a 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -374,21 +374,12 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { } Future _prearrancarAudioAlarma(AlarmaMusical alarma) async { - // Must run FIRST, before any early return: the override needs to be in - // effect for the whole ring, including fallback-WAV-only alarms that - // never reach the station-playback branch below (Requirement: - // Ring-scoped device-volume override). - // - // The media stream is capped at the alarm's CONFIGURED volume, not the - // 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().android.forzarVolumenMediaParaAlarma( - alarma.volumen.clamp(0.0, 1.0), - ); - if (!mounted) return; + // The ring-scoped stream override is NOT raised here anymore: the ringing + // screen owns it and raises it only when audio is actually about to be + // audible (first `reproduciendo`, or right before the fallback WAV + // 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 + // user's original stream volume that spin-up is inaudible. final emisora = alarma.emisora; if (emisora == null) return; final radio = context.read(); diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart index a492110..a971763 100644 --- a/lib/pantallas/pantalla_alarma_sonando.dart +++ b/lib/pantallas/pantalla_alarma_sonando.dart @@ -41,7 +41,9 @@ class _PantallaAlarmaSonandoState extends State { bool _fallbackActivo = false; bool _radioIntentada = false; bool _audioFlutterConfirmado = false; + bool _volumenMediaForzado = false; bool _volumenMediaRestaurado = false; + bool _fadeInArrancado = false; // Captured while mounted: dispose() also restores the media volume, and by // then the element is defunct, so context.read() would throw there. @@ -70,6 +72,13 @@ class _PantallaAlarmaSonandoState extends State { if (!widget.audioPrearrancado) { 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 — // `reconectando`/`cargando` do NOT count as playing, so the 12-second @@ -79,6 +88,13 @@ class _PantallaAlarmaSonandoState extends State { _estadoSub = radio.estadoStream.listen((estado) { if (estado == EstadoReproduccion.reproduciendo && mounted) { _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(); } if (estado == EstadoReproduccion.error && mounted) { @@ -92,11 +108,12 @@ class _PantallaAlarmaSonandoState extends State { // Pre-started audio can reach `reproduciendo` BEFORE the listener above // subscribes (app.dart starts the station before pushing this screen), // in which case no further state event ever arrives. The handoff - // confirmation and the fade-in must not depend on catching that - // already-missed event, so this branch confirms explicitly too + // confirmation and the stream override must not depend on catching that + // already-missed event, so this branch handles them explicitly too // (idempotent — the listener firing as well is harmless). if (widget.audioPrearrancado && radio.audio.estaSonando) { _fallbackTimer?.cancel(); + await _forzarVolumenMediaUnaVez(); await _confirmarAudioFlutterListo(); } } @@ -105,14 +122,39 @@ class _PantallaAlarmaSonandoState extends State { if (_fallbackActivo) return; _fallbackActivo = true; 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 _confirmarAudioFlutterListo(); 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 _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() { + // 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(); - // 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 // range under that cap. Perceived peak = configured% of the device max, // reached gradually from ~5% of the cap; ramping the player to @@ -153,15 +195,14 @@ class _PantallaAlarmaSonandoState extends State { } /// Confirms the native-to-Flutter audio handoff at most once per screen - /// instance, then starts the single audible Dart fade-in ramp (Slice 3: - /// fade-in dedup at handoff). The native ramp - /// (`PluriWaveAlarmService.startFadeIn`) owns audio until this - /// confirmation lands; starting the Dart ramp any earlier would - /// interleave both ramps and produce an audible jump/reset. If the - /// native confirmation channel call fails (dead or never-there native - /// side), the fade-in still starts in the `finally` block below — Dart - /// is the only audible source either way, so the ring must never stay - /// stuck at [_volumenInicialFadeIn] forever. + /// instance. The Dart ramp is anchored at screen start and does NOT wait + /// for this confirmation (a slow stream would freeze the ring at 5%); the + /// `finally` below only guarantees the ramp exists on exotic paths where + /// `_iniciarAlarma` never reached it — `_iniciarFadeIn` is single-start, + /// so an already-running ramp is never restarted. Both ramps (native on + /// the ALARM stream, Dart under the capped media stream) start at 5% on + /// the same fade duration, so they stay aligned until the native track is + /// stopped here. Future _confirmarAudioFlutterListo() async { if (_audioFlutterConfirmado) return; _audioFlutterConfirmado = true; @@ -275,6 +316,10 @@ class _PantallaAlarmaSonandoState extends State { } catch (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 { // The fade-in mutates the SHARED radio handler gain; exiting mid-ramp // would otherwise leave every later radio play at the partial ramp @@ -284,7 +329,6 @@ class _PantallaAlarmaSonandoState extends State { } catch (e) { debugPrint('[PluriWave][alarmas] restaurar ganancia radio fallo: $e'); } - await _restaurarVolumenMediaUnaVez(); } /// Dismisses the alarm screen safely in both live-app and dead-app states. diff --git a/test/pantallas/pantalla_alarma_sonando_test.dart b/test/pantallas/pantalla_alarma_sonando_test.dart index 12b32f2..5d1f985 100644 --- a/test/pantallas/pantalla_alarma_sonando_test.dart +++ b/test/pantallas/pantalla_alarma_sonando_test.dart @@ -169,47 +169,35 @@ void main() { }, ); - group('fade-in dedup en el handoff (Slice 3)', () { - testWidgets('el fade-in de Dart se retiene hasta que el nativo confirma el ' - 'handoff, y arranca justo despues (camino radio)', (tester) async { + group('rampa anclada y override diferido al primer audio', () { + testWidgets('la rampa arranca al montar sin esperar al stream, y el ' + 'override del volumen del dispositivo espera a "reproduciendo"', + (tester) async { final entorno = await _montarPantalla( tester, audioYaReproduciendo: false, ); - entorno.android.puertaConfirmarAudioFlutter = Completer(); - // Antes de que la radio confirme "reproduciendo", solo debe existir - // el volumen de arranque previo (0.05): el ramp real hacia - // alarma.volumen todavia NO debe haber arrancado. + // La rampa esta anclada al inicio de la alarma: con fade 0 salta ya al + // objetivo (1.0) aunque el stream siga bufferizando. Lo que NO debe + // 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.audio.volumenesAplicados, [0.05]); - // La radio confirma que esta reproduciendo -> dispara - // _confirmarAudioFlutterListo(), que queda bloqueado en la puerta - // (todavia no hay confirmacion nativa real). + // La radio llega a "reproduciendo": recien ahi se sube el stream al + // nivel configurado y se confirma el handoff (stop nativo). entorno.audio.emitirEstado(EstadoReproduccion.reproduciendo); 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')); - // 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 '