import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_tts/flutter_tts.dart'; /// Lightweight wrapper around FlutterTts to centralize configuration class TextToSpeechService { final FlutterTts _tts = FlutterTts(); bool _initialized = false; bool _available = false; bool _voiceConfigured = false; VoidCallback? _onStart; VoidCallback? _onComplete; VoidCallback? _onCancel; VoidCallback? _onPause; VoidCallback? _onContinue; void Function(String message)? _onError; bool get isInitialized => _initialized; bool get isAvailable => _available; /// Register callbacks for TTS lifecycle events void bindHandlers({ VoidCallback? onStart, VoidCallback? onComplete, VoidCallback? onCancel, VoidCallback? onPause, VoidCallback? onContinue, void Function(String message)? onError, }) { _onStart = onStart; _onComplete = onComplete; _onCancel = onCancel; _onPause = onPause; _onContinue = onContinue; _onError = onError; _tts.setStartHandler(_handleStart); _tts.setCompletionHandler(_handleComplete); _tts.setCancelHandler(_handleCancel); _tts.setPauseHandler(_handlePause); _tts.setContinueHandler(_handleContinue); _tts.setErrorHandler(_handleError); } /// Initialize the native TTS engine lazily Future initialize() async { if (_initialized) { return _available; } try { await _tts.awaitSpeakCompletion(false); if (!kIsWeb && Platform.isIOS) { await _tts.setSharedInstance(true); await _tts.setIosAudioCategory(IosTextToSpeechAudioCategory.playback, [ IosTextToSpeechAudioCategoryOptions.mixWithOthers, IosTextToSpeechAudioCategoryOptions.defaultToSpeaker, IosTextToSpeechAudioCategoryOptions.allowBluetooth, IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP, ]); } await _configurePreferredVoice(); _available = true; } catch (e) { _available = false; _onError?.call(e.toString()); } _initialized = true; return _available; } Future speak(String text) async { if (text.trim().isEmpty) { throw ArgumentError('Cannot speak empty text'); } if (!_initialized) { await initialize(); } if (!_available) { throw StateError('Text-to-speech is unavailable on this device'); } await _tts.stop(); if (!_voiceConfigured) { await _configurePreferredVoice(); } final result = await _tts.speak(text); if (result == null) { return; } if (result is int && result != 1) { _onError?.call('Text-to-speech engine returned code $result'); } } Future pause() async { if (!_initialized || !_available) { return; } try { await _tts.pause(); } catch (e) { _onError?.call(e.toString()); } } Future stop() async { if (!_initialized) { return; } try { await _tts.stop(); } catch (e) { _onError?.call(e.toString()); } } Future dispose() async { await stop(); } Future _configurePreferredVoice() async { if (_voiceConfigured) { return; } if (kIsWeb || (!Platform.isIOS && !Platform.isAndroid)) { _voiceConfigured = true; return; } var configured = false; try { Map? defaultVoice; bool voiceSet = false; if (Platform.isIOS) { try { final rawDefault = await _tts.getDefaultVoice; if (rawDefault is Map) { defaultVoice = _normalizeVoiceEntry(rawDefault); await _tts.setVoice(_voiceCommandFrom(defaultVoice)); configured = true; voiceSet = true; } } catch (_) { defaultVoice = null; } } if (voiceSet) { return; } final voicesRaw = await _tts.getVoices; if (voicesRaw is! List) { return; } final parsedVoices = >[]; for (final entry in voicesRaw) { if (entry is Map) { final normalized = _normalizeVoiceEntry(entry); if (normalized.isNotEmpty) { parsedVoices.add(normalized); } } } if (parsedVoices.isEmpty) { return; } final localeTag = WidgetsBinding.instance.platformDispatcher.locale .toLanguageTag() .toLowerCase(); final preferred = _selectPreferredVoice( parsedVoices, localeTag, defaultVoice: defaultVoice, ); if (preferred == null) { if (Platform.isIOS) { configured = true; // Allow system default voice to be used } return; } await _tts.setVoice(_voiceCommandFrom(preferred)); configured = true; } catch (e) { _onError?.call(e.toString()); } finally { _voiceConfigured = configured || _voiceConfigured; } } Map _normalizeVoiceEntry(Map entry) { final normalized = {}; entry.forEach((key, value) { if (key != null) { normalized[key.toString()] = value; } }); return normalized; } Map _voiceCommandFrom(Map voice) { final command = {}; for (final key in [ 'name', 'locale', 'identifier', 'id', 'voiceIdentifier', 'engine', ]) { final value = voice[key]; if (value != null) { command[key] = value.toString(); } } if (!command.containsKey('name') && voice['name'] != null) { command['name'] = voice['name'].toString(); } if (!command.containsKey('locale') && voice['locale'] != null) { command['locale'] = voice['locale'].toString(); } return command; } int _iosVoiceScore(Map voice) { final identifier = voice['identifier']?.toString().toLowerCase() ?? voice['id']?.toString().toLowerCase() ?? ''; final name = voice['name']?.toString().toLowerCase() ?? ''; int score = 0; if (identifier.contains('premium')) { score += 400; } else if (identifier.contains('enhanced')) { score += 250; } else if (identifier.contains('compact')) { score += 50; } if (identifier.contains('siri') || name.contains('siri')) { score += 150; } if (identifier.contains('female') || name.contains('female')) { score += 15; } if (identifier.contains('male') || name.contains('male')) { score += 10; } // Prefer non-compact by default when no other hints are present if (!identifier.contains('compact')) { score += 25; } return score; } Map? _selectPreferredVoice( List> voices, String localeTag, { Map? defaultVoice, }) { Map? matchesLocale(Iterable> input) { for (final voice in input) { final locale = voice['locale']?.toString().toLowerCase(); if (locale == null) continue; if (locale == localeTag) { return voice; } final localePrimary = locale.split(RegExp('[-_]')).first; final tagPrimary = localeTag.split(RegExp('[-_]')).first; if (localePrimary == tagPrimary) { return voice; } } return null; } Map? matchDefaultVoice() { final dv = defaultVoice; if (dv == null) { return null; } final identifiers = {}; for (final key in ['identifier', 'id', 'voiceIdentifier', 'voice']) { final value = dv[key]?.toString(); if (value != null && value.isNotEmpty) { identifiers.add(value.toLowerCase()); } } if (identifiers.isNotEmpty) { for (final voice in voices) { for (final key in ['identifier', 'id', 'voiceIdentifier', 'voice']) { final value = voice[key]?.toString(); if (value != null && identifiers.contains(value.toLowerCase())) { return voice; } } } } final defaultName = dv['name']?.toString(); final defaultLocale = dv['locale']?.toString(); if (defaultName != null && defaultLocale != null) { final lowerName = defaultName.toLowerCase(); final lowerLocale = defaultLocale.toLowerCase(); for (final voice in voices) { final name = voice['name']?.toString(); final locale = voice['locale']?.toString(); if (name != null && locale != null && name.toLowerCase() == lowerName && locale.toLowerCase() == lowerLocale) { return voice; } } } return null; } Map? pickIosVoice() { final userDefault = matchDefaultVoice(); if (userDefault != null) { return userDefault; } final siriCandidates = voices.where((voice) { final name = voice['name']?.toString().toLowerCase() ?? ''; final identifier = voice['identifier']?.toString().toLowerCase() ?? ''; final voiceId = voice['id']?.toString().toLowerCase() ?? ''; return name.contains('siri') || identifier.contains('siri') || voiceId.contains('siri'); }).toList(); if (siriCandidates.isNotEmpty) { siriCandidates.sort((a, b) => _iosVoiceScore(b) - _iosVoiceScore(a)); final localeMatch = matchesLocale(siriCandidates); if (localeMatch != null) { return localeMatch; } return siriCandidates.first; } final ranked = [...voices]; ranked.sort((a, b) => _iosVoiceScore(b) - _iosVoiceScore(a)); final localeMatch = matchesLocale(ranked); if (localeMatch != null) { return localeMatch; } return ranked.isNotEmpty ? ranked.first : null; } Map? pickAndroidVoice() { int qualityScore(String? quality) { switch ((quality ?? '').toLowerCase()) { case 'very_high': case 'very-high': return 3; case 'high': return 2; case 'normal': return 1; default: return 0; } } final preferredEngineVoices = voices .where( (voice) => (voice['engine']?.toString() ?? '').toLowerCase().contains( 'google', ) || voice['engine'] is! String, ) .toList(); preferredEngineVoices.sort((a, b) { final qualityDiff = qualityScore(b['quality']?.toString()) - qualityScore(a['quality']?.toString()); if (qualityDiff != 0) { return qualityDiff; } final latencyA = a['latency']?.toString() ?? ''; final latencyB = b['latency']?.toString() ?? ''; return latencyA.compareTo(latencyB); }); final ordered = preferredEngineVoices.isEmpty ? voices : preferredEngineVoices; return matchesLocale(ordered) ?? matchesLocale(voices); } Map? selected; if (Platform.isIOS) { selected = pickIosVoice(); } else if (Platform.isAndroid) { selected = pickAndroidVoice(); } if (selected == null) { return null; } final name = selected['name']?.toString(); final locale = selected['locale']?.toString(); if (name == null || locale == null) { return null; } return selected; } void _handleStart() { _onStart?.call(); } void _handleComplete() { _onComplete?.call(); } void _handleCancel() { _onCancel?.call(); } void _handlePause() { _onPause?.call(); } void _handleContinue() { _onContinue?.call(); } void _handleError(dynamic message) { final safeMessage = message == null ? 'Unknown TTS error' : message.toString(); _onError?.call(safeMessage); } }