From 8955e5ebfc93cac024b1174d7253f52e3b9885c8 Mon Sep 17 00:00:00 2001 From: Ray <38275852+RaycarlLei@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:23:05 -0400 Subject: [PATCH] Bound native audio calls and preserve playback ownership --- CHANGELOG.md | 13 + README.md | 1 + README.zh-CN.md | 2 + docs/audio-lifecycle.md | 80 +++ docs/speech-gateway.md | 5 +- lib/pages/flash_cards/flash_cards_widget.dart | 26 + lib/services/review_pronunciation.dart | 5 + lib/services/tts_service.dart | 527 +++++++++++++----- pubspec.lock | 2 +- pubspec.yaml | 3 +- test/flash_cards_widget_test.dart | 19 +- test/support/controlled_audio_platform.dart | 138 +++++ test/tts_service_lifecycle_test.dart | 481 ++++++++++++++++ 13 files changed, 1145 insertions(+), 157 deletions(-) create mode 100644 docs/audio-lifecycle.md create mode 100644 test/support/controlled_audio_platform.dart create mode 100644 test/tts_service_lifecycle_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e82dd71..0df61f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.1.2 + +- Give each media playback its own player and keep native ownership separate + from UI completion. Old events and page exits cannot cancel a newer request. +- Bound each native call to eight seconds. An ambiguous timeout disables audio + for the application session, immediately attempts to stop the captured source, + and observes late results for cleanup. Learning and answer submission remain + available with a text status explaining that audio is unavailable. +- Disable unused native position polling and test the actual service against + controlled player and system-speech platform calls, including delayed failures. +- Document system-speech callback limitations and best-effort native cleanup. + These tests do not establish physical-device silence or a total exit deadline. + ## 0.1.1 - Keep imported content and learning progress when the home screen refreshes. diff --git a/README.md b/README.md index 335da63..0ccbc29 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ not a signed store release. Platform support beyond those checks should be verif | Bound a slow speech download | [community_gateway.dart](lib/services/community_gateway.dart) | [gateway lifecycle](test/community_gateway_test.dart) | | Prepare a usable review from available content | [review_preparation.dart](lib/services/review_preparation.dart) | [review_preparation_test.dart](test/review_preparation_test.dart) | | Coordinate pronunciation during review | [review_pronunciation.dart](lib/services/review_pronunciation.dart) | [review_pronunciation_test.dart](test/review_pronunciation_test.dart), [playback arbitration](test/tts_playback_arbiter_test.dart) | +| Isolate old audio events and bound native waits | [audio ownership contract](docs/audio-lifecycle.md), [tts_service.dart](lib/services/tts_service.dart) | [native lifecycle](test/tts_service_lifecycle_test.dart) | | Keep review states explicit | [review availability tests](test/review_availability_widget_test.dart) | [loading-state tests](test/review_loading_widget_test.dart) | ```sh diff --git a/README.zh-CN.md b/README.zh-CN.md index e55f4a8..17b3046 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -47,6 +47,8 @@ flutter run --dart-define=WORD_AI_API_BASE_URL=https://your-gateway.example 该地址会编译进客户端,只能放非敏感的服务地址。供应商密钥只能保存在服务端。使用自建网关时,当前需要发音的文字会发送给该网关;失败不会阻止本地学习。 +发音协调器区分界面状态与原生播放归属,隔离旧播放器事件,并为每次原生调用设置期限。超时后会尝试停止音频,页面仍可继续答题;本次应用会话不再启动新的音频。具体保证、插件限制和设备验证范围见 [音频生命周期说明](docs/audio-lifecycle.md),回归入口为 [实际服务测试](test/tts_service_lifecycle_test.dart)。 + ## 开源与贡献 源代码采用 Apache-2.0,可修改、自托管、再分发及商用,遵守许可证和第三方许可即可。WordAI 名称与图标不构成商标授权。依赖包继续适用各自许可证。 diff --git a/docs/audio-lifecycle.md b/docs/audio-lifecycle.md new file mode 100644 index 0000000..8f5ddc2 --- /dev/null +++ b/docs/audio-lifecycle.md @@ -0,0 +1,80 @@ +# Audio ownership and native failure boundaries + +WordAI has one audio coordinator. A review owns its pending request and native +playback by source ID and request generation. Finishing the on-screen animation +does not release native ownership: leaving the review still requests a stop. +Review answers and saved progress do not depend on audio being available. + +## What the Dart service enforces + +- Every media utterance gets a fresh `AudioPlayer`. The old player must + acknowledge stop and finish disposal before another backend may speak. + Media events belong to that captured player and request; a retired player's + completion or error cannot finish a new player or trigger system speech. +- Fresh media players disable the unused position updater before configuration. + Speech has no timeline UI; native position polling would otherwise introduce + plugin Futures outside the coordinator's error handling and disposal bounds. +- Device speech is created independently of media playback. Using local system + speech does not allocate or wait for an unused media player. +- Each native operation has an eight-second Dart deadline. This covers player + creation (awaited by its first configuration call), configuration, preparing + a source, resume/speak, rate changes, stop and disposal. A sequence of successful + calls can take more than eight seconds; this is a per-call bound. Downloads + have the separate [gateway deadline](speech-gateway.md). +- Timeout does not cancel a native call. A timed-out call, rejected stop or + failed disposal makes the coordinator permanently unavailable for that app + session. Queued and future playback requests finish with an error; neither + another media player nor system speech is started as a workaround. +- Late results and errors remain observed. A late result can request another + bounded stop of its captured native owner. It cannot restore availability, + update the current indicator, resume playback or create another backend. +- Entering the unavailable state immediately attempts a separate bounded stop, + even if the original call never returns. That attempt is cleanup only; a + later source/resume result still gets another stop attempt. Cleanup cannot + restore availability and a stuck cleanup stop does not retry recursively. +- Disposal rejects new work immediately and performs bounded cleanup. A media + handle with an outstanding native call is retained rather than treating + `AudioPlayer.dispose()` as a way to kill that call. Logical disposal may finish + while native cleanup remains unconfirmed. No successful return from Dart + disposal promises acoustic silence. + +An ordinary media error can fall back to device speech only after the old +player's stop and disposal have both succeeded. An uncertain stop cannot fall +back. The review shows an inline unavailable notice and remains usable; restart +the app before trying audio again. Playback's boolean result means the current +request was accepted, not that a speaker produced sound or the utterance finished. + +## Limits of the locked plugins + +The lockfile currently resolves `audioplayers` 6.8.1 and `flutter_tts` 4.2.5. +This implementation depends on their public behavior: + +| Plugin boundary | Consequence | +| --- | --- | +| AudioPlayer routes native events by `playerId`, but not by source within one player. | Separate players provide an event boundary between media utterances. | +| AudioPlayer disposal calls stop/release before native disposal. | A hung stop can also hang disposal; neither method is a forced native termination API. | +| FlutterTts instances share the `flutter_tts` channel and native engine. Constructing one replaces the Dart handler. | Creating another FlutterTts is not independent engine isolation or timeout recovery. | +| FlutterTts forwards system completion/cancel/error events without an utterance ID. | A delayed event for system utterance A can still change the indicator for system utterance B. Backend checks prevent it affecting media; retained ownership ensures B can still be stopped. | +| Android/iOS system stop returns a plugin acknowledgement without propagating the native stop result. | Acknowledgement is the strongest available handoff signal, not proof of silence. | + +Strict correlation between successive system utterances needs a native bridge +that returns an utterance ID and engine generation with each event. Switching +to `awaitSpeakCompletion` does not supply this: the locked plugin uses shared +completion state. WordAI does not claim this stronger guarantee. + +## Verification and remaining device checks + +[`tts_service_lifecycle_test.dart`](../test/tts_service_lifecycle_test.dart) +drives the actual TTSService, AudioPlayer wrapper, per-player platform event +streams and FlutterTts method channel. Gates control pending and late native +calls. It verifies cross-backend events, retired media instances, cancellation, +failed recovery, blocked source/resume/stop/dispose, queued callers and late +cleanup. These are host tests; they do not play audio. +The fixture returns unmodified AudioPlayers and rejects native position queries, +so it also verifies that production code disables the unused updater. + +On Android and iOS devices, still verify audible stop/handoff, interruptions, +background/resume, rapid A-to-B system speech, missing voices and speaker/route +changes. In particular, measure whether native audio continues after a stop +acknowledgement or plugin failure. Host tests cannot establish acoustic silence +or the OS's resource-release behavior. No device result is claimed here. diff --git a/docs/speech-gateway.md b/docs/speech-gateway.md index 1c7159e..1692a71 100644 --- a/docs/speech-gateway.md +++ b/docs/speech-gateway.md @@ -7,7 +7,10 @@ No server is bundled or configured. An operator may implement `POST /tts` relati - One ten-second deadline covers connection, response headers and the complete body. Receiving more chunks does not extend it. Timeout aborts the request, cancels body reading and closes the client dedicated to that request. -- Failed/invalid responses fall back to system speech where available. Successful audio is cached locally. +- Failed/invalid responses can fall back to system speech where available. + An unconfirmed native stop disables further audio until the app restarts; + see [audio ownership and failure boundaries](audio-lifecycle.md). Successful + audio is cached locally. - The client sends no provider credentials. Keep provider keys on the server, outside source control. - Operators must add input limits, rate limits, abuse prevention and appropriate access control; this minimal client does not implement user authentication. Do not expose an unrestricted paid provider proxy. - Configure only infrastructure you control. Use of the gateway sends the requested speech text to that operator. diff --git a/lib/pages/flash_cards/flash_cards_widget.dart b/lib/pages/flash_cards/flash_cards_widget.dart index 6ae3886..fc2c307 100644 --- a/lib/pages/flash_cards/flash_cards_widget.dart +++ b/lib/pages/flash_cards/flash_cards_widget.dart @@ -10,6 +10,7 @@ import '/flutter_flow/flutter_flow_util.dart'; import '/services/learning_repository.dart'; import '/services/review_preparation.dart'; import '/services/review_pronunciation.dart'; +import '/services/tts_service.dart'; import '/widgets/review_loading_view.dart'; class FlashCardsWidget extends StatefulWidget { @@ -764,6 +765,31 @@ class _FlashCardsWidgetState extends State : '${math.min(session.currentIndex + 1, session.targetIds.length)} / ${session.targetIds.length}', onClose: _requestExit, ), + if (_pronunciation.playbackState case final playbackState?) + ValueListenableBuilder( + valueListenable: playbackState, + builder: (context, state, child) => + state.phase == TtsPlaybackPhase.unavailable + ? Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + child: Semantics( + liveRegion: true, + child: Text( + _t( + 'Pronunciation is unavailable. You can keep reviewing. If sound continues, close the app. Reopen it to try audio again.', + '发音暂不可用,可继续复习。如仍有声音,请关闭应用;重新打开后可再试。', + '發音暫不可用,可繼續複習。如仍有聲音,請關閉應用程式;重新開啟後可再試。', + ), + key: const ValueKey('audio-unavailable'), + style: theme.bodySmall.copyWith( + color: theme.secondaryText, + height: 1.4, + ), + ), + ), + ) + : const SizedBox.shrink(), + ), Expanded( child: AnimatedSwitcher( duration: diff --git a/lib/services/review_pronunciation.dart b/lib/services/review_pronunciation.dart index 4c29844..9e32a39 100644 --- a/lib/services/review_pronunciation.dart +++ b/lib/services/review_pronunciation.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; + import 'tts_service.dart'; /// Owns only this review's pronunciation; downloads may finish caching after @@ -8,6 +10,7 @@ class ReviewPronunciation { ReviewPronunciation({ required Future Function(String word) play, required Future Function() stop, + this.playbackState, }) : _play = play, _stop = stop; @@ -18,12 +21,14 @@ class ReviewPronunciation { await TTSService.instance.generateAndPlay(text: word, sourceId: source); }, stop: () => TTSService.instance.stopSource(source), + playbackState: TTSService.instance.playbackState, ); } static int _sequence = 0; final Future Function(String) _play; final Future Function() _stop; + final ValueListenable? playbackState; String? _lastQuestion; bool _started = false; bool _disposed = false; diff --git a/lib/services/tts_service.dart b/lib/services/tts_service.dart index 56bc76d..02e9bef 100644 --- a/lib/services/tts_service.dart +++ b/lib/services/tts_service.dart @@ -8,7 +8,7 @@ import 'community_gateway.dart'; import 'speech_audio.dart'; import 'speech_error_reporter.dart'; -enum TtsPlaybackPhase { idle, loading, playing } +enum TtsPlaybackPhase { idle, loading, playing, unavailable } @immutable class TtsPlaybackSnapshot { @@ -19,6 +19,9 @@ class TtsPlaybackSnapshot { const TtsPlaybackSnapshot.idle() : this._(phase: TtsPlaybackPhase.idle); + const TtsPlaybackSnapshot.unavailable() + : this._(phase: TtsPlaybackPhase.unavailable); + const TtsPlaybackSnapshot.loading(String sourceId) : this._(phase: TtsPlaybackPhase.loading, sourceId: sourceId); @@ -28,8 +31,7 @@ class TtsPlaybackSnapshot { final TtsPlaybackPhase phase; final String? sourceId; - bool isActiveFor(String id) => - sourceId == id && phase != TtsPlaybackPhase.idle; + bool isActiveFor(String id) => isLoadingFor(id) || isPlayingFor(id); bool isLoadingFor(String id) => sourceId == id && phase == TtsPlaybackPhase.loading; bool isPlayingFor(String id) => @@ -79,15 +81,38 @@ class TtsPlaybackArbiter { state.value = const TtsPlaybackSnapshot.idle(); } + void makeUnavailable() { + ++_generation; + state.value = const TtsPlaybackSnapshot.unavailable(); + } + void dispose() => state.dispose(); } -/// Text-to-Speech Service. -/// Live speech is generated only through authenticated WordAI Cloud. +/// Native ownership survives a UI completion notification. In particular, +/// flutter_tts does not identify the utterance in completion/cancel callbacks. +class _PlaybackOwner { + _PlaybackOwner(this.token, this.sourceId, + {this.player, this.path, this.text}); + + final int token; + final String sourceId; + final AudioPlayer? player; + final String? path; + final String? text; + final List> subscriptions = []; + bool acceptsEvents = true; + bool started = false; + bool nativeError = false; + bool recovering = false; + int pendingCalls = 0; +} + +/// Coordinates cached/gateway audio and local device speech fallback. class TTSService { static final TTSService instance = TTSService._internal(); - TTSService._internal() : this._(AudioPlayer(), FlutterTts(), null); + TTSService._internal() : this._(AudioPlayer.new, FlutterTts(), null); @visibleForTesting TTSService.forTesting({ @@ -95,79 +120,70 @@ class TTSService { TTSCacheManager? cacheManager, Future> Function(String)? cloudSpeechLoader, Future?> Function(String)? offlineSpeechLoader, - AudioPlayer? audioPlayer, + AudioPlayer Function()? audioPlayerFactory, FlutterTts? systemTts, - }) : this._(audioPlayer ?? AudioPlayer(), systemTts ?? FlutterTts(), + Duration nativeCallTimeout = const Duration(seconds: 8), + }) : this._(audioPlayerFactory ?? AudioPlayer.new, systemTts ?? FlutterTts(), audioLoader, cacheManager: cacheManager, cloudSpeechLoader: cloudSpeechLoader, - offlineSpeechLoader: offlineSpeechLoader); + offlineSpeechLoader: offlineSpeechLoader, + nativeCallTimeout: nativeCallTimeout); TTSService._( - this._audioPlayer, + this._audioPlayerFactory, this._systemTts, this._audioLoader, { TTSCacheManager? cacheManager, Future> Function(String)? cloudSpeechLoader, Future?> Function(String)? offlineSpeechLoader, + Duration nativeCallTimeout = const Duration(seconds: 8), }) : _cacheManager = cacheManager, _cloudSpeechLoader = cloudSpeechLoader, - _offlineSpeechLoader = offlineSpeechLoader { + _offlineSpeechLoader = offlineSpeechLoader, + _nativeCallTimeout = nativeCallTimeout { + if (nativeCallTimeout <= Duration.zero) { + throw ArgumentError.value(nativeCallTimeout, 'nativeCallTimeout'); + } // The plugin's default logger includes source URLs/data URIs in errors. // Our listeners below report only sanitized stage/code metadata instead. AudioLogger.logLevel = AudioLogLevel.none; - _playerCompleteSubscription = _audioPlayer.onPlayerComplete.listen((_) { - if (_playbackArbiter.state.value.phase == TtsPlaybackPhase.playing) { - _currentPlayingUrl = null; - _playbackArbiter.complete(); - } - }, onError: (Object error, StackTrace stack) { - reportSpeechFailure('playback', 'speech-native-player-error'); - if (_playbackArbiter.state.value.phase == TtsPlaybackPhase.playing) { - unawaited(_recoverNativePlayback()); - } - }); - _systemTts.setCompletionHandler(() { - if (_currentPlayingUrl == _systemSpeechMarker && - _playbackArbiter.state.value.phase == TtsPlaybackPhase.playing) { - _currentPlayingUrl = null; - _playbackArbiter.complete(); - } - }); - _systemTts.setCancelHandler(() { - if (_currentPlayingUrl == _systemSpeechMarker) { - _currentPlayingUrl = null; - _playbackArbiter.complete(); - } - }); + _systemTts.setCompletionHandler(_systemFinished); + _systemTts.setCancelHandler(_systemFinished); _systemTts.setErrorHandler((_) { - if (_currentPlayingUrl == _systemSpeechMarker) { + final owner = _owner; + if (owner != null && owner.player == null && _acceptsEvents(owner)) { reportSpeechFailure('system', 'speech-system-player-error'); - _currentPlayingUrl = null; - _playbackArbiter.stop(); + _systemFinished(); } }); } - final AudioPlayer _audioPlayer; + final AudioPlayer Function() _audioPlayerFactory; final FlutterTts _systemTts; final Future Function(String)? _audioLoader; final TTSCacheManager? _cacheManager; final Future> Function(String)? _cloudSpeechLoader; final Future?> Function(String)? _offlineSpeechLoader; - String? _activeSpeechText; - int? _activeToken; - bool _recoveringNative = false; - late final StreamSubscription _playerCompleteSubscription; + final Duration _nativeCallTimeout; final TtsPlaybackArbiter _playbackArbiter = TtsPlaybackArbiter(); - String? _currentPlayingUrl; + final StreamController _playerStates = + StreamController.broadcast(); + _PlaybackOwner? _owner; + bool _unavailable = false; + bool _disposed = false; + bool _notifierDisposed = false; + Future? _disposeOperation; int _sourceSequence = 0; Future _audioOperation = Future.value(); - static const String _systemSpeechMarker = 'wordai-system-speech'; ValueNotifier get playbackState => _playbackArbiter.state; + /// False after ambiguous native failure or disposal. Recreating FlutterTts + /// cannot recover this safely: that plugin shares one native engine. + bool get isAudioAvailable => !_unavailable && !_disposed; + /// Normalize text for consistent hashing and API results String _normalizeText(String text) { return text @@ -292,25 +308,23 @@ class TTSService { /// previous playback. The returned token prevents stale network requests /// from starting after a newer button has been tapped. Future beginPlayback(String sourceId) async { + _requireAvailable(); final token = _playbackArbiter.claim(sourceId); - _activeSpeechText = null; - _activeToken = null; await _serializeAudioOperation(() async { - await _stopPlayers(); - _currentPlayingUrl = null; + _requireAvailable(); + await _retireCurrentOwner(); }); return token; } bool isPlaybackRequestCurrent(int token, String sourceId) => - _playbackArbiter.isCurrent(token, sourceId); + isAudioAvailable && _playbackArbiter.isCurrent(token, sourceId); bool isPlaybackGenerationCurrent(int token) => - _playbackArbiter.isGenerationCurrent(token); + isAudioAvailable && _playbackArbiter.isGenerationCurrent(token); void abandonPlaybackRequest(int token, String sourceId) { if (!isPlaybackRequestCurrent(token, sourceId)) return; - _currentPlayingUrl = null; _playbackArbiter.abandon(token, sourceId); } @@ -330,7 +344,8 @@ class TTSService { fallbackText: text, ); } catch (error) { - if (!_playbackArbiter.isGenerationCurrent(token)) return false; + if (!isAudioAvailable) _requireAvailable(); + if (!isPlaybackGenerationCurrent(token)) return false; _reportGenerationFailure(error); if (shouldUseSystemSpeech(error)) { return playSystemSpeech( @@ -347,7 +362,9 @@ class TTSService { bool shouldUseSystemSpeech(Object error) { if (kIsWeb) return false; if (error is SpeechAudioException && - error.code.startsWith('speech-system-')) { + (error.code.startsWith('speech-system-') || + error.code == 'speech-audio-unavailable' || + error.code == 'speech-audio-disposed')) { return false; } // System speech is local and free. Input mistakes/cancellation still stop. @@ -376,30 +393,68 @@ class TTSService { } } - Future _recoverNativePlayback() async { - if (_recoveringNative || _currentPlayingUrl == _systemSpeechMarker) return; - final text = _activeSpeechText; - final token = _activeToken; - final source = _playbackArbiter.state.value.sourceId; - final path = _currentPlayingUrl; - if (text == null || token == null || source == null) { - _playbackArbiter.stop(); - return; - } - _recoveringNative = true; + bool _acceptsEvents(_PlaybackOwner owner) => + isAudioAvailable && + identical(_owner, owner) && + owner.acceptsEvents && + _playbackArbiter.isGenerationCurrent(owner.token); + + void _systemFinished() { + final owner = _owner; + if (owner == null || owner.player != null || !_acceptsEvents(owner)) return; + // flutter_tts events have no utterance ID. They can update the indicator, + // but cannot release ownership or prove that this utterance has stopped. + _playbackArbiter.complete(); + } + + void _listenToPlayer(_PlaybackOwner owner) { + final player = owner.player!; + owner.subscriptions.add(player.onPlayerComplete.listen((_) { + if (_acceptsEvents(owner)) _playbackArbiter.complete(); + }, onError: (Object _, StackTrace __) { + if (!_acceptsEvents(owner) || owner.recovering) return; + owner.nativeError = true; + // Preparation/resume failures are handled by their awaited request. + // Only an already accepted playback needs asynchronous recovery. + if (!owner.started) return; + owner.recovering = true; + owner.acceptsEvents = false; + reportSpeechFailure('playback', 'speech-native-player-error'); + unawaited(_recoverNativePlayback(owner)); + })); + owner.subscriptions.add(player.onPlayerStateChanged.listen((state) { + if (_acceptsEvents(owner)) _playerStates.add(state); + })); + } + + Future _recoverNativePlayback(_PlaybackOwner owner) async { try { - if (path != null) await _evictFailedAudio(path); - await playSystemSpeech(text, sourceId: source, requestToken: token); + final retired = await _serializeAudioOperation(() async { + if (!identical(_owner, owner)) return false; + _requireAvailable(); + await _retireCurrentOwner(); + return true; + }); + if (!retired) return; + if (owner.path != null) unawaited(_evictFailedAudio(owner.path!)); + if (!isPlaybackRequestCurrent(owner.token, owner.sourceId)) return; + if (owner.text != null) { + await playSystemSpeech(owner.text!, + sourceId: owner.sourceId, requestToken: owner.token); + } else { + abandonPlaybackRequest(owner.token, owner.sourceId); + } } catch (_) { - reportSpeechFailure('system', 'speech-system-player-error'); - } finally { - _recoveringNative = false; + // A failed/timed-out stop leaves audio unavailable. Never fall back + // while the previous source could still be audible. + reportSpeechFailure('playback', 'speech-native-recovery-failed'); } } Future _evictFailedAudio(String path) async { try { - await TTSCacheManager.instance.evictPlaybackSource(path); + await (_cacheManager ?? TTSCacheManager.instance) + .evictPlaybackSource(path); } catch (_) { reportSpeechFailure('cache', 'speech-cache-remove-failed'); } @@ -410,38 +465,54 @@ class TTSService { required String sourceId, required int requestToken, }) async { + _requireAvailable(); if (!isPlaybackRequestCurrent(requestToken, sourceId)) return false; final normalized = _normalizeText(text); if (normalized.isEmpty) return false; try { - await _serializeAudioOperation(() async { - await _stopPlayers(); - if (!isPlaybackRequestCurrent(requestToken, sourceId)) return; + return await _serializeAudioOperation(() async { + _requireAvailable(); + if (!isPlaybackRequestCurrent(requestToken, sourceId)) return false; + await _retireCurrentOwner(); + if (!isPlaybackRequestCurrent(requestToken, sourceId)) return false; + final owner = _PlaybackOwner(requestToken, sourceId); + _owner = owner; final hanCount = RegExp(r'[\u3400-\u9fff]').allMatches(normalized).length; - await _systemTts.setLanguage( - hanCount * 2 >= normalized.runes.length ? 'zh-CN' : 'en-US', - ); - await _systemTts.setSpeechRate(0.48); - await _systemTts.setPitch(1.0); - await _systemTts.setVolume(1.0); - if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) { - await _systemTts.setIosAudioCategory( - IosTextToSpeechAudioCategory.playback, - [], - IosTextToSpeechAudioMode.defaultMode, - ); - await _systemTts.setSharedInstance(true); + final configuration = Function()>[ + () => _systemTts.setLanguage( + hanCount * 2 >= normalized.runes.length ? 'zh-CN' : 'en-US'), + () => _systemTts.setSpeechRate(0.48), + () => _systemTts.setPitch(1.0), + () => _systemTts.setVolume(1.0), + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) ...[ + () => _systemTts.setIosAudioCategory( + IosTextToSpeechAudioCategory.playback, + [], + IosTextToSpeechAudioMode.defaultMode, + ), + () => _systemTts.setSharedInstance(true), + ], + ]; + for (final configure in configuration) { + await _nativeCall(owner, configure); + if (!isPlaybackRequestCurrent(requestToken, sourceId)) return false; } - _currentPlayingUrl = _systemSpeechMarker; - if (!_playbackArbiter.markPlaying(requestToken, sourceId)) return; - final started = await _systemTts.speak(normalized); + owner.started = true; + _playbackArbiter.markPlaying(requestToken, sourceId); + final started = + await _nativeCall(owner, () => _systemTts.speak(normalized)); if (started != 1) { throw const SpeechAudioException('speech-system-unavailable'); } + return isPlaybackGenerationCurrent(requestToken); }); - return isPlaybackRequestCurrent(requestToken, sourceId); } catch (error) { + if (!isAudioAvailable) _requireAvailable(); + // A rejected call is not evidence that nothing started. Confirm stop + // before freeing this owner, including failed system speak requests. + await _stopFailedRequest(requestToken); + if (!isPlaybackGenerationCurrent(requestToken)) return false; abandonPlaybackRequest(requestToken, sourceId); reportSpeechFailure('system', 'speech-system-player-error'); throw const SpeechAudioException('speech-system-player-error'); @@ -456,6 +527,7 @@ class TTSService { int? requestToken, String? fallbackText, }) async { + _requireAvailable(); final resolvedSourceId = sourceId ?? createPlaybackSourceId('direct-playback'); final resolvedToken = requestToken ?? await beginPlayback(resolvedSourceId); @@ -466,46 +538,61 @@ class TTSService { try { return await _serializeAudioOperation(() async { + _requireAvailable(); if (!isPlaybackRequestCurrent(resolvedToken, resolvedSourceId)) { return false; } - await _stopPlayers(); + await _retireCurrentOwner(); if (!isPlaybackRequestCurrent(resolvedToken, resolvedSourceId)) { return false; } - _currentPlayingUrl = audioPath; - _activeSpeechText = fallbackText; - _activeToken = resolvedToken; - await _audioPlayer.setAudioContext(AudioContext( - iOS: AudioContextIOS(category: AVAudioSessionCategory.playback), - )); - await _audioPlayer.setVolume(1.0); - - await _audioPlayer - .setSource( - kIsWeb ? UrlSource(audioPath) : DeviceFileSource(audioPath)) - .timeout(const Duration(seconds: 8)); - if (!isPlaybackRequestCurrent(resolvedToken, resolvedSourceId)) { - return false; + // A fresh player gives native events an instance boundary. Reusing + // one player cannot distinguish late completion of its old source. + final player = _audioPlayerFactory(); + // Speech has no position/progress UI. The plugin's default updater + // starts unobserved native position Futures on resume/completion; + // disable it before this fresh player can issue any playback call. + player.positionUpdater = null; + final owner = _PlaybackOwner(resolvedToken, resolvedSourceId, + player: player, path: audioPath, text: fallbackText); + _owner = owner; + _listenToPlayer(owner); + final preparation = Function()>[ + () => player.setAudioContext(AudioContext( + iOS: AudioContextIOS(category: AVAudioSessionCategory.playback), + )), + () => player.setVolume(1.0), + () => player.setSource( + kIsWeb ? UrlSource(audioPath) : DeviceFileSource(audioPath)), + if (_currentPlaybackRate != 1.0) + () => player.setPlaybackRate(_currentPlaybackRate), + ]; + for (final prepare in preparation) { + await _nativeCall(owner, prepare); + if (owner.nativeError) { + throw const SpeechAudioException('speech-native-player-error'); + } + if (!isPlaybackRequestCurrent(resolvedToken, resolvedSourceId)) { + return false; + } } - await _audioPlayer.resume(); - if (!_playbackArbiter.markPlaying(resolvedToken, resolvedSourceId)) { - await _audioPlayer.stop(); - return false; - } - - if (_currentPlaybackRate != 1.0 && - isPlaybackRequestCurrent(resolvedToken, resolvedSourceId)) { - await _audioPlayer.setPlaybackRate(_currentPlaybackRate); + _playbackArbiter.markPlaying(resolvedToken, resolvedSourceId); + await _nativeCall(owner, player.resume); + if (owner.nativeError) { + throw const SpeechAudioException('speech-native-player-error'); } - return isPlaybackRequestCurrent(resolvedToken, resolvedSourceId); + owner.started = true; + return isPlaybackGenerationCurrent(resolvedToken); }); } catch (e) { - if (!_playbackArbiter.isGenerationCurrent(resolvedToken)) return false; + if (!isAudioAvailable) _requireAvailable(); + await _stopFailedRequest(resolvedToken); + if (!isPlaybackGenerationCurrent(resolvedToken)) return false; reportSpeechFailure('playback', 'speech-native-player-error'); - await _evictFailedAudio(audioPath); + // Cache IO is not part of the native operation queue. + unawaited(_evictFailedAudio(audioPath)); if (fallbackText != null && shouldUseSystemSpeech(e) && isPlaybackRequestCurrent(resolvedToken, resolvedSourceId)) { @@ -519,62 +606,176 @@ class TTSService { /// Cancel only this owner's playback, including a pending download. Future stopSource(String sourceId) async { - if (_playbackArbiter.state.value.sourceId != sourceId) return; - await stop(); + if (_disposed) return; + _requireAvailable(); + final owner = _owner; + final ownsNative = owner != null && owner.sourceId == sourceId; + final ownsRequest = _playbackArbiter.state.value.sourceId == sourceId; + if (!ownsNative && !ownsRequest) return; + if (ownsRequest || + (ownsNative && _playbackArbiter.isGenerationCurrent(owner.token))) { + _playbackArbiter.stop(); + } + if (ownsNative) owner.acceptsEvents = false; + await _serializeAudioOperation(() async { + if (_unavailable) _requireAvailable(); + if (ownsNative && identical(_owner, owner)) await _retireCurrentOwner(); + }); } /// Stop current playback Future stop() async { + if (_disposed) return; + _requireAvailable(); _playbackArbiter.stop(); - _currentPlayingUrl = null; + _owner?.acceptsEvents = false; _currentPlaybackRate = 1.0; + await _serializeAudioOperation(() async { + if (_unavailable) _requireAvailable(); + await _retireCurrentOwner(); + }); + } + + Future _serializeAudioOperation(Future Function() operation) { + final result = _audioOperation.then((_) => operation()); + _audioOperation = + result.then((_) {}, onError: (Object _, StackTrace __) {}); + return result; + } + + void _requireAvailable() { + if (_disposed) throw const SpeechAudioException('speech-audio-disposed'); + if (_unavailable) { + throw const SpeechAudioException('speech-audio-unavailable'); + } + } + + void _quarantine() { + if (_unavailable) return; + _unavailable = true; + _owner?.acceptsEvents = false; + if (!_notifierDisposed) _playbackArbiter.makeUnavailable(); + reportSpeechFailure('playback', 'speech-audio-unavailable'); + final owner = _owner; + if (owner != null) unawaited(_attemptQuarantinedStop(owner)); + } + + /// Bounds the Dart wait, not the native operation. Late completion/error is + /// observed and can only request another stop of this captured owner. + Future _nativeCall( + _PlaybackOwner owner, + Future Function() operation, { + bool stopping = false, + }) async { + var expired = false; + owner.pendingCalls++; + final pending = Future.sync(operation); + void settled() { + owner.pendingCalls--; + if (expired) unawaited(_attemptQuarantinedStop(owner)); + } + + unawaited(pending.then((_) => settled(), + onError: (Object _, StackTrace __) { + settled(); + })); try { - await _serializeAudioOperation(_stopPlayers); - } catch (e) { - // Ignore errors when stopping + return await pending.timeout(_nativeCallTimeout, onTimeout: () { + expired = true; + _quarantine(); + throw const SpeechAudioException('speech-audio-unavailable'); + }); + } catch (_) { + if (stopping) _quarantine(); + rethrow; } } - Future _serializeAudioOperation(Future Function() operation) { - final completer = Completer(); - _audioOperation = _audioOperation.catchError((_) {}).then((_) async { - try { - completer.complete(await operation()); - } catch (error, stackTrace) { - completer.completeError(error, stackTrace); + Future _stopNative(_PlaybackOwner owner) async { + if (owner.player case final player?) { + await player.stop(); + } else { + final result = await _systemTts.stop(); + if (result != 1) { + throw const SpeechAudioException('speech-system-stop-failed'); } - }); - return completer.future; + } } - Future _stopPlayers() async { - // A broken cloud-audio player must not prevent local system recovery. + Future _attemptQuarantinedStop(_PlaybackOwner owner) async { + if (owner.player?.state == PlayerState.disposed) return; + // This is best-effort cleanup only. Even a late stop acknowledgement does + // not re-enable audio after an ambiguous native operation. Each late + // operation gets its own attempt: an earlier late stop may settle before + // a later resume that was already in flight when disposal began. + owner.pendingCalls++; + final pending = Future.sync(() => _stopNative(owner)); + // Observe this cleanup call too, but do not recursively retry a late + // cleanup stop. Only the original operation may have started new sound. + unawaited(pending.then((_) { + owner.pendingCalls--; + }, onError: (Object _, StackTrace __) { + owner.pendingCalls--; + })); try { - await _audioPlayer.stop(); + await pending.timeout(_nativeCallTimeout); } catch (_) { reportSpeechFailure('playback', 'speech-native-stop-failed'); } + } + + Future _cancelSubscriptions(_PlaybackOwner owner) async { + final subscriptions = List.of(owner.subscriptions); + owner.subscriptions.clear(); + await Future.wait( + subscriptions.map((subscription) => subscription.cancel())) + .timeout(_nativeCallTimeout); + } + + Future _retireCurrentOwner() async { + final owner = _owner; + if (owner == null) return; + owner.acceptsEvents = false; + await _nativeCall(owner, () => _stopNative(owner), stopping: true); + // dispose() itself calls stop/release in audioplayers. It is not a kill + // switch for an earlier source/resume Future that is still outstanding. + if (owner.pendingCalls != 0) return; try { - await _systemTts.stop(); + await _cancelSubscriptions(owner); } catch (_) { - reportSpeechFailure('system', 'speech-system-stop-failed'); + _quarantine(); + rethrow; + } + if (owner.player case final player?) { + await _nativeCall(owner, player.dispose, stopping: true); } + if (identical(_owner, owner)) _owner = null; } + Future _stopFailedRequest(int token) => + _serializeAudioOperation(() async { + if (_owner?.token == token) await _retireCurrentOwner(); + }); + double _currentPlaybackRate = 1.0; /// Set playback rate (speed) /// Can be called during playback to adjust speed in real-time /// Rate: 0.25 to 4.0 (1.0 = normal speed) Future setPlaybackRate(double rate) async { - try { - if (_currentPlayingUrl != null) { - await _audioPlayer.setPlaybackRate(rate); - _currentPlaybackRate = rate; - } - } catch (e) { - debugPrint('Error setting playback rate: $e'); + _requireAvailable(); + if (!rate.isFinite || rate < 0.25 || rate > 4.0) { + throw ArgumentError.value(rate, 'rate', 'Must be between 0.25 and 4.0'); } + final owner = _owner; + await _serializeAudioOperation(() async { + _requireAvailable(); + if (owner == null || owner.player == null || !_acceptsEvents(owner)) { + return; + } + await _nativeCall(owner, () => owner.player!.setPlaybackRate(rate)); + if (_acceptsEvents(owner)) _currentPlaybackRate = rate; + }); } /// Get current playback rate @@ -587,14 +788,38 @@ class TTSService { _playbackArbiter.state.value.phase == TtsPlaybackPhase.playing; /// Get audio player state stream - Stream get playerStateStream => _audioPlayer.onPlayerStateChanged; + Stream get playerStateStream => _playerStates.stream; /// Dispose resources - Future dispose() async { - await stop(); - await _playerCompleteSubscription.cancel(); - await _audioPlayer.dispose(); - _playbackArbiter.dispose(); + Future dispose() { + if (_disposeOperation != null) return _disposeOperation!; + _disposed = true; + if (!_unavailable) _playbackArbiter.stop(); + _owner?.acceptsEvents = false; + _systemTts.setCompletionHandler(() {}); + _systemTts.setCancelHandler(() {}); + _systemTts.setErrorHandler((_) {}); + return _disposeOperation = _serializeAudioOperation(() async { + try { + await _retireCurrentOwner(); + } catch (_) { + _quarantine(); + } finally { + final owner = _owner; + if (owner != null) { + try { + await _cancelSubscriptions(owner); + } catch (_) { + _quarantine(); + } + } + // Closing a broadcast stream can wait for a paused consumer. It must + // not hold native cleanup or logical service disposal hostage. + unawaited(_playerStates.close()); + _notifierDisposed = true; + _playbackArbiter.dispose(); + } + }); } /// Clear all cached audio diff --git a/pubspec.lock b/pubspec.lock index b1ab20e..141232c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -50,7 +50,7 @@ packages: source: hosted version: "4.3.0" audioplayers_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: audioplayers_platform_interface sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" diff --git a/pubspec.yaml b/pubspec.yaml index 3460e3c..c46aab9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: word_a_i description: Free and open-source offline vocabulary learning with optional self-hosted speech. publish_to: none -version: 0.1.1+2 +version: 0.1.2+3 environment: sdk: ">=3.6.0 <4.0.0" flutter: ">=3.44.0" @@ -23,6 +23,7 @@ dependencies: http: ^1.6.0 file_selector: ^1.0.3 dev_dependencies: + audioplayers_platform_interface: ^7.2.0 flutter_test: sdk: flutter flutter_lints: ^6.0.0 diff --git a/test/flash_cards_widget_test.dart b/test/flash_cards_widget_test.dart index 7634e1e..60d68ed 100644 --- a/test/flash_cards_widget_test.dart +++ b/test/flash_cards_widget_test.dart @@ -1,4 +1,5 @@ import 'package:word_a_i/services/review_pronunciation.dart'; +import 'package:word_a_i/services/tts_service.dart'; import 'dart:async'; import 'package:flutter/material.dart'; @@ -13,7 +14,8 @@ import 'package:word_a_i/services/wordai_dossier.dart'; void main() { sqfliteFfiInit(); - testWidgets('correct answer stays minimal, then advances in 1s', + testWidgets( + 'audio unavailable is visible without blocking answer and advance', (tester) async { late Database db; late LearningRepository repository; @@ -29,6 +31,9 @@ void main() { } }); addTearDown(db.close); + final audioState = + ValueNotifier(const TtsPlaybackSnapshot.idle()); + addTearDown(audioState.dispose); await tester.pumpWidget( MaterialApp( @@ -41,8 +46,11 @@ void main() { ], supportedLocales: const [Locale('en')], home: FlashCardsWidget( - pronunciation: - ReviewPronunciation(play: (_) async {}, stop: () async {}), + pronunciation: ReviewPronunciation( + play: (_) async {}, + stop: () async {}, + playbackState: audioState, + ), repository: repository, testUid: 'widget-user', initialWords: const ['word0', 'word1', 'word2', 'word3'], @@ -60,6 +68,11 @@ void main() { expect(find.text('Read it in context'), findsOneWidget); expect(find.text('Not sure'), findsOneWidget); + expect(find.byKey(const ValueKey('audio-unavailable')), findsNothing); + audioState.value = const TtsPlaybackSnapshot.unavailable(); + await tester.pump(); + expect(find.byKey(const ValueKey('audio-unavailable')), findsOneWidget); + expect(find.textContaining('You can keep reviewing.'), findsOneWidget); final session = (await tester.runAsync( () => repository.resumeActiveSession('widget-user'), ))!; diff --git a/test/support/controlled_audio_platform.dart b/test/support/controlled_audio_platform.dart new file mode 100644 index 0000000..6ee1849 --- /dev/null +++ b/test/support/controlled_audio_platform.dart @@ -0,0 +1,138 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; + +/// Exercises the real AudioPlayer wrapper without loading a native engine. +/// Gates are consumed by one method call; events are routed by native player ID. +class ControlledAudioPlatform extends AudioplayersPlatformInterface { + final calls = []; + final _streams = >{}; + final _gates = >{}; + final _outstanding = >[]; + + Completer hold(String method, {String id = '*'}) { + final gate = Completer(); + _gates['$id:$method'] = gate; + _outstanding.add(gate); + return gate; + } + + Future _call(String id, String method) async { + calls.add('$id:$method'); + final gate = _gates.remove('$id:$method') ?? _gates.remove('*:$method'); + if (gate != null) await gate.future; + } + + void complete(String id) => _streams[id]!.add( + const AudioEvent(eventType: AudioEventType.complete), + ); + + void fail(String id) => _streams[id]!.addError(StateError('decoder failed')); + + void releaseGates() { + for (final gate in _outstanding) { + if (!gate.isCompleted) gate.complete(); + } + _gates.clear(); + } + + Future close() async { + for (final stream in _streams.values) { + await stream.close(); + } + } + + @override + Future create(String playerId) async { + _streams[playerId] = StreamController.broadcast(); + await _call(playerId, 'create'); + } + + @override + Stream getEventStream(String playerId) => + _streams[playerId]!.stream; + + @override + Future dispose(String playerId) => _call(playerId, 'dispose'); + // Keep the fake native channel alive until fixture cleanup, so a test can + // deliver an event already queued by a retired native instance. + + @override + Future stop(String playerId) => _call(playerId, 'stop'); + @override + Future resume(String playerId) => _call(playerId, 'resume'); + @override + Future release(String playerId) => _call(playerId, 'release'); + @override + Future pause(String playerId) => _call(playerId, 'pause'); + @override + Future seek(String playerId, Duration position) => + _call(playerId, 'seek'); + @override + Future setBalance(String playerId, double balance) => + _call(playerId, 'setBalance'); + @override + Future setVolume(String playerId, double volume) => + _call(playerId, 'setVolume'); + @override + Future setReleaseMode(String playerId, ReleaseMode releaseMode) => + _call(playerId, 'setReleaseMode'); + @override + Future setPlaybackRate(String playerId, double playbackRate) => + _call(playerId, 'setPlaybackRate'); + @override + Future setAudioContext(String playerId, AudioContext audioContext) => + _call(playerId, 'setAudioContext'); + @override + Future setPlayerMode(String playerId, PlayerMode playerMode) => + _call(playerId, 'setPlayerMode'); + + @override + Future setSourceUrl(String playerId, String url, + {bool? isLocal, String? mimeType}) async { + try { + await _call(playerId, 'setSourceUrl'); + _streams[playerId]!.add( + const AudioEvent(eventType: AudioEventType.prepared, isPrepared: true), + ); + } catch (error, stack) { + _streams[playerId]!.addError(error, stack); + rethrow; + } + } + + @override + Future setSourceBytes(String playerId, Uint8List bytes, + {String? mimeType}) => + throw UnimplementedError('Tests use DeviceFileSource'); + @override + Future getDuration(String playerId) async => 1000; + @override + Future getCurrentPosition(String playerId) async { + await _call(playerId, 'getCurrentPosition'); + throw StateError('Speech playback must not poll native position'); + } + + @override + Future emitError(String playerId, String code, String message) async => + fail(playerId); + @override + Future emitLog(String playerId, String message) async {} +} + +class ControlledGlobalAudioPlatform + extends GlobalAudioplayersPlatformInterface { + final events = StreamController.broadcast(); + + @override + Future init() async {} + @override + Future setGlobalAudioContext(AudioContext ctx) async {} + @override + Stream getGlobalEventStream() => events.stream; + @override + Future emitGlobalError(String code, String message) async {} + @override + Future emitGlobalLog(String message) async {} +} diff --git a/test/tts_service_lifecycle_test.dart b/test/tts_service_lifecycle_test.dart new file mode 100644 index 0000000..f160bf6 --- /dev/null +++ b/test/tts_service_lifecycle_test.dart @@ -0,0 +1,481 @@ +import 'dart:async'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_tts/flutter_tts.dart'; +import 'package:word_a_i/services/speech_audio.dart'; +import 'package:word_a_i/services/tts_service.dart'; + +import 'support/controlled_audio_platform.dart'; + +const _deadline = Duration(seconds: 1); +final _unavailable = throwsA(isA() + .having((error) => error.code, 'code', 'speech-audio-unavailable')); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final global = ControlledGlobalAudioPlatform(); + GlobalAudioplayersPlatformInterface.instance = global; + tearDownAll(() => global.events.close()); + + _lifecycleTest( + 'device speech does not allocate or stop an unused media backend', + (h) async { + expect(await h.speak('system'), isTrue); + expect(h.players, isEmpty); + expect(h.systemCalls.where((method) => method == 'stop'), isEmpty); + await h.service.stopSource('system'); + expect(h.systemCalls.last, 'stop'); + }); + + _lifecycleTest('retired media events cannot finish or recover system speech', + (h) async { + await h.media('media'); + await h.speak('system'); + h.platform.complete('p1'); + h.platform.fail('p1'); + await _flush(); + expect(h.service.playbackState.value.isPlayingFor('system'), isTrue); + expect(h.systemCalls.where((method) => method == 'speak'), hasLength(1)); + await h.service.stopSource('system'); + expect(h.systemCalls.last, 'stop'); + }); + + _lifecycleTest('system events cannot complete or stop a media owner', + (h) async { + await h.speak('system'); + await h.media('media'); + await h.systemEvent('speak.onComplete'); + await h.systemEvent('speak.onCancel'); + await h.systemEvent('speak.onError'); + expect(h.service.playbackState.value.isPlayingFor('media'), isTrue); + expect(h.platform.calls.where((call) => call == 'p1:stop'), isEmpty); + }); + + _lifecycleTest('a system callback cannot erase stopSource ownership', + (h) async { + await h.speak('first'); + await h.speak('second'); + // The locked flutter_tts protocol cannot distinguish a delayed callback + // for "first" from completion of "second". Its UI indicator is advisory. + await h.systemEvent('speak.onComplete'); + expect(h.service.playbackState.value.phase, TtsPlaybackPhase.idle); + final stops = h.systemCalls.where((method) => method == 'stop').length; + await h.service.stopSource('second'); + expect(h.systemCalls.where((method) => method == 'stop'), + hasLength(stops + 1)); + }); + + _lifecycleTest( + 'each media utterance retires its player before a new one starts', + (h) async { + await h.media('first'); + await h.media('second'); + h.platform.complete('p1'); + h.platform.fail('p1'); + await _flush(); + expect(h.service.playbackState.value.isPlayingFor('second'), isTrue); + expect(h.platform.calls.indexOf('p1:dispose'), + lessThan(h.platform.calls.indexOf('p2:resume'))); + h.platform.complete('p2'); + await _flush(); + expect(h.service.playbackState.value.phase, TtsPlaybackPhase.idle); + await h.service.stopSource('second'); + expect(h.platform.calls, contains('p2:stop')); + }); + + _lifecycleTest('a delayed download cannot play after its source is stopped', + (h) async { + final download = Completer(); + h.audioLoader = (_) => download.future; + final pending = + h.service.generateAndPlay(text: 'word', sourceId: 'download'); + await _flush(); + await h.service.stopSource('download'); + await h.speak('new'); + download.complete('/fake/word.mp3'); + expect(await pending, isFalse); + expect(h.players, isEmpty); + expect(h.service.playbackState.value.isPlayingFor('new'), isTrue); + }); + + _lifecycleTest( + 'stopSource interrupts preparation without resuming late audio', + (h) async { + final source = h.platform.hold('setSourceUrl'); + final pending = h.media('old'); + await _flush(); + expect(h.platform.calls, contains('p1:setSourceUrl')); + final stopped = h.service.stopSource('old'); + source.complete(); + expect(await pending, isFalse); + await stopped; + expect(h.platform.calls, isNot(contains('p1:resume'))); + expect(await h.media('new'), isTrue); + }); + + _lifecycleTest('stopping an old native owner does not cancel a newer claim', + (h) async { + await h.media('old'); + final held = h.platform.hold('stop', id: 'p1'); + final newClaim = h.service.beginPlayback('new'); + await _flush(); + final oldStop = h.service.stopSource('old'); + held.complete(); + final token = await newClaim; + await oldStop; + expect(h.service.isPlaybackRequestCurrent(token, 'new'), isTrue); + expect( + await h.service + .playAudio('/fake/new.mp3', sourceId: 'new', requestToken: token), + isTrue); + }); + + _lifecycleTest( + 'hung media stop bounds queued callers and forbids both backends', + (h) async { + await h.media('old'); + final held = h.platform.hold('stop', id: 'p1'); + final first = expectLater(h.service.beginPlayback('next'), _unavailable); + final queued = expectLater(h.service.beginPlayback('queued'), _unavailable); + await _flush(); + await _flush(_deadline); + await Future.wait([first, queued]); + expect(h.service.playbackState.value.phase, TtsPlaybackPhase.unavailable); + await expectLater(h.media('again'), _unavailable); + await expectLater(h.speak('again'), _unavailable); + held.complete(); + await _flush(); + expect(h.players, hasLength(1)); + expect(h.systemCalls, isEmpty); + expect(h.service.isAudioAvailable, isFalse); + }); + + _lifecycleTest( + 'hung system stop observes a late rejection without restarting', + (h) async { + await h.speak('system'); + final held = h.holdSystem('stop'); + final stop = expectLater(h.service.stopSource('system'), _unavailable); + await _flush(); + await _flush(_deadline); + await stop; + held.completeError(StateError('late native error')); + await _flush(); + await expectLater(h.media('media'), _unavailable); + expect(h.players, isEmpty); + expect(h.systemCalls.where((method) => method == 'speak'), hasLength(1)); + }); + + for (final method in [ + 'create', + 'setAudioContext', + 'setSourceUrl', + 'resume' + ]) { + _lifecycleTest( + 'hung media $method is bounded; its late result cannot advance playback', + (h) async { + final held = h.platform.hold(method); + final result = expectLater(h.media('held'), _unavailable); + await _flush(); + expect(h.platform.calls, contains('p1:$method')); + await _flush(_deadline); + await result; + final audibleCalls = + h.platform.calls.where((call) => call.endsWith(':resume')).length; + held.complete(); + await _flush(); + expect(h.platform.calls.where((call) => call.endsWith(':resume')), + hasLength(audibleCalls)); + expect(h.platform.calls, contains('p1:stop')); + await expectLater(h.media('new'), _unavailable); + expect(h.service.playbackState.value.phase, TtsPlaybackPhase.unavailable); + expect(h.players, hasLength(1)); + }); + } + + for (final method in ['setLanguage', 'speak']) { + _lifecycleTest('hung system $method prevents later settings and utterances', + (h) async { + final held = h.holdSystem(method); + final result = expectLater(h.speak('held'), _unavailable); + await _flush(); + await _flush(_deadline); + await result; + final callsAtTimeout = List.of(h.systemCalls); + held.complete(1); + await _flush(); + expect(h.systemCalls.take(callsAtTimeout.length), callsAtTimeout); + expect(h.systemCalls.skip(callsAtTimeout.length), everyElement('stop')); + await expectLater(h.speak('new'), _unavailable); + expect(h.service.isAudioAvailable, isFalse); + }); + } + + _lifecycleTest( + 'a source error arriving after timeout is observed and cannot fall back', + (h) async { + final held = h.platform.hold('setSourceUrl'); + final result = expectLater( + h.service.playAudio('/fake/broken.mp3', + sourceId: 'broken', fallbackText: 'word'), + _unavailable); + await _flush(); + await _flush(_deadline); + await result; + held.completeError(StateError('late source error')); + await _flush(); + expect(h.platform.calls, isNot(contains('p1:resume'))); + expect(h.systemCalls, isEmpty); + expect(h.service.isAudioAvailable, isFalse); + }); + + _lifecycleTest('a rejected source is retired before system fallback', + (h) async { + final source = h.platform.hold('setSourceUrl'); + final playback = h.service + .playAudio('/fake/broken.mp3', sourceId: 'word', fallbackText: 'word'); + await _flush(); + source.completeError(StateError('decoder rejected source')); + expect(await playback, isTrue); + expect(h.platform.calls, contains('p1:dispose')); + expect(h.platform.calls, isNot(contains('p1:resume'))); + expect(h.systemCalls.where((method) => method == 'speak'), hasLength(1)); + }); + + _lifecycleTest('a negative system stop response is not a safe handoff', + (h) async { + await h.speak('old'); + final stop = h.holdSystem('stop'); + final replacement = + expectLater(h.media('new'), throwsA(isA())); + await _flush(); + stop.complete(0); + await replacement; + expect(h.service.playbackState.value.phase, TtsPlaybackPhase.unavailable); + expect(h.players, isEmpty); + await expectLater(h.speak('retry'), _unavailable); + }); + + _lifecycleTest( + 'a hung rate change attempts stop before the held call settles', + (h) async { + await h.media('word'); + final rate = h.platform.hold('setPlaybackRate'); + final change = expectLater(h.service.setPlaybackRate(0.8), _unavailable); + await _flush(); + await _flush(_deadline); + await change; + expect(rate.isCompleted, isFalse); + expect(h.platform.calls.where((call) => call == 'p1:stop'), hasLength(1)); + await expectLater(h.service.stopSource('word'), _unavailable); + rate.complete(); + await _flush(); + expect(h.platform.calls.where((call) => call == 'p1:stop'), hasLength(2)); + expect(h.service.isAudioAvailable, isFalse); + expect(h.players, hasLength(1)); + expect(h.systemCalls, isEmpty); + }); + + _lifecycleTest( + 'runtime media failure falls back only after confirmed retirement', + (h) async { + await h.service + .playAudio('/fake/broken.mp3', sourceId: 'word', fallbackText: 'word'); + final stop = h.platform.hold('stop', id: 'p1'); + h.platform.fail('p1'); + await _flush(); + expect(h.systemCalls, isEmpty); + stop.complete(); + await _flush(); + expect(h.platform.calls, contains('p1:dispose')); + expect(h.systemCalls.where((method) => method == 'speak'), hasLength(1)); + expect(h.service.playbackState.value.isPlayingFor('word'), isTrue); + }); + + _lifecycleTest('failed media recovery stop cannot start system fallback', + (h) async { + await h.service + .playAudio('/fake/broken.mp3', sourceId: 'word', fallbackText: 'word'); + final stop = h.platform.hold('stop', id: 'p1'); + h.platform.fail('p1'); + await _flush(); + stop.completeError(StateError('native stop failed')); + await _flush(); + expect(h.service.playbackState.value.phase, TtsPlaybackPhase.unavailable); + expect(h.systemCalls, isEmpty); + }); + + _lifecycleTest( + 'dispose during hung resume is bounded and ignores later events', + (h) async { + final resume = h.platform.hold('resume'); + final nativeStop = h.platform.hold('stop'); + final playback = expectLater( + h.media('word'), + throwsA(isA() + .having((error) => error.code, 'code', 'speech-audio-disposed'))); + await _flush(); + final disposal = h.service.dispose(); + expect(identical(h.service.dispose(), disposal), isTrue); + await _flush(_deadline); + await playback; + await _flush(_deadline); + await disposal; + expect(h.platform.calls, isNot(contains('p1:dispose')), + reason: 'Do not use dispose as a kill switch for pending resume'); + // A late stop may settle before an already dispatched resume. Cleanup + // must stop again when that resume eventually returns. + nativeStop.complete(); + await _flush(); + final previousStops = + h.platform.calls.where((call) => call == 'p1:stop').length; + resume.complete(); + h.platform.complete('p1'); + h.platform.fail('p1'); + await h.systemEvent('speak.onComplete'); + await _flush(); + expect(h.platform.calls.where((call) => call == 'p1:stop'), + hasLength(previousStops + 1)); + expect(h.platform.calls.where((call) => call == 'p1:resume'), hasLength(1)); + await expectLater( + h.media('after disposal'), throwsA(isA())); + }); + + _lifecycleTest( + 'native disposal has a deadline and never re-enables the service', + (h) async { + await h.media('word'); + final held = h.platform.hold('dispose'); + final disposed = h.service.dispose(); + await _flush(); + await _flush(_deadline); + await disposed; + held.complete(); + await _flush(); + expect(h.service.isAudioAvailable, isFalse); + expect(h.players, hasLength(1)); + }); + + _lifecycleTest('a paused state subscriber cannot block logical disposal', + (h) async { + final subscription = h.service.playerStateStream.listen((_) {})..pause(); + await h.media('word'); + await h.service.dispose(); + await subscription.cancel(); + expect(h.service.isAudioAvailable, isFalse); + }); + + _lifecycleTest('completion, replacement and disposal never poll position', + (h) async { + // The factory returns unmodified AudioPlayers. The fake platform rejects + // position queries, including updater calls made by the plugin itself. + expect(await h.media('first'), isTrue); + h.platform.complete('p1'); + await _flush(); + expect(await h.media('second'), isTrue); + await h.service.stopSource('second'); + await h.service.dispose(); + expect( + h.platform.calls, + isNot(contains(predicate( + (call) => call.endsWith(':getCurrentPosition'))))); + expect(h.platform.calls, + containsAll(['p1:resume', 'p1:dispose', 'p2:resume', 'p2:dispose'])); + }); +} + +void _lifecycleTest( + String description, Future Function(_Harness harness) body) { + test(description, () async { + final harness = _Harness(); + try { + await body(harness); + } finally { + harness.releaseGates(); + await _flush(); + await harness.close(); + } + }); +} + +// These tests exercise plugin Futures/streams, not widgets. Method gates fix +// event order; the one-second injected deadline leaves ample scheduling room. +Future _flush([Duration duration = Duration.zero]) => + Future.delayed(duration); + +class _Harness { + _Harness() { + AudioplayersPlatformInterface.instance = platform; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_systemChannel, (call) async { + systemCalls.add(call.method); + final gate = _systemGates.remove(call.method); + return gate == null ? 1 : await gate.future; + }); + tts = FlutterTts(); + service = TTSService.forTesting( + audioPlayerFactory: () { + final player = AudioPlayer(playerId: 'p${players.length + 1}'); + players.add(player); + return player; + }, + systemTts: tts, + nativeCallTimeout: _deadline, + audioLoader: (text) => + audioLoader?.call(text) ?? Future.value('/fake/word.mp3'), + ); + } + + static const _systemChannel = MethodChannel('flutter_tts'); + final platform = ControlledAudioPlatform(); + final players = []; + final systemCalls = []; + final _systemGates = >{}; + final _outstanding = >[]; + late final FlutterTts tts; + late final TTSService service; + Future Function(String)? audioLoader; + + Completer holdSystem(String method) { + final gate = Completer(); + _systemGates[method] = gate; + _outstanding.add(gate); + return gate; + } + + Future systemEvent(String method) => tts.platformCallHandler( + MethodCall(method, method == 'speak.onError' ? 'native error' : true)); + + Future media(String source) => + service.playAudio('/fake/$source.mp3', sourceId: source); + + Future speak(String source) async { + final token = await service.beginPlayback(source); + return service.playSystemSpeech('word', + sourceId: source, requestToken: token); + } + + void releaseGates() { + platform.releaseGates(); + for (final gate in _outstanding) { + if (!gate.isCompleted) gate.complete(1); + } + _systemGates.clear(); + } + + Future close() async { + await service.dispose(); + // Quarantine deliberately retains a native handle when an earlier call + // might still act. Test teardown settles every gate before releasing it. + for (final player in players) { + if (player.state != PlayerState.disposed) await player.dispose(); + } + await platform.close(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_systemChannel, null); + } +}