import 'dart:js_interop'; import 'package:web/web.dart' as web; import 'package:em2rp/utils/debug_log.dart'; /// Service de synthèse vocale pour lire des textes à haute voix (Web) class TextToSpeechService { static bool _isInitialized = false; static bool _voicesLoaded = false; static List _cachedVoices = []; /// Initialiser le service TTS static Future initialize() async { if (_isInitialized) return; try { _isInitialized = true; final synthesis = web.window.speechSynthesis; // Essayer de charger les voix immédiatement _cachedVoices = synthesis.getVoices().toDart; if (_cachedVoices.isNotEmpty) { _voicesLoaded = true; DebugLog.info('[TextToSpeechService] Service initialized with ${_cachedVoices.length} voices'); return; } // Sur certains navigateurs (Firefox notamment), les voix se chargent de manière asynchrone DebugLog.info('[TextToSpeechService] Waiting for voices to load asynchronously...'); // Attendre l'événement voiceschanged (si supporté) final voicesLoaded = await _waitForVoices(synthesis); if (voicesLoaded) { _cachedVoices = synthesis.getVoices().toDart; _voicesLoaded = true; DebugLog.info('[TextToSpeechService] ✓ Voices loaded asynchronously: ${_cachedVoices.length}'); } else { DebugLog.warning('[TextToSpeechService] ⚠ No voices found after initialization'); } } catch (e) { DebugLog.error('[TextToSpeechService] Erreur lors de l\'initialisation', e); } } /// Attendre le chargement des voix (avec timeout) static Future _waitForVoices(web.SpeechSynthesis synthesis) async { // Essayer plusieurs fois avec des délais croissants for (int attempt = 0; attempt < 5; attempt++) { await Future.delayed(Duration(milliseconds: 100 * (attempt + 1))); final voices = synthesis.getVoices().toDart; if (voices.isNotEmpty) { return true; } DebugLog.info('[TextToSpeechService] Attempt ${attempt + 1}/5: No voices yet'); } return false; } /// Lire un texte à haute voix static Future speak(String text) async { if (!_isInitialized) { await initialize(); } try { final synthesis = web.window.speechSynthesis; DebugLog.info('[TextToSpeechService] Speaking requested: "$text"'); // Arrêter toute lecture en cours synthesis.cancel(); // Attendre un peu pour que le cancel soit effectif await Future.delayed(const Duration(milliseconds: 50)); // Créer une nouvelle utterance final utterance = web.SpeechSynthesisUtterance(text); utterance.lang = 'fr-FR'; utterance.rate = 0.7; utterance.pitch = 0.7; utterance.volume = 1.0; // Récupérer les voix (depuis le cache ou re-charger) var voices = _cachedVoices; // Si le cache est vide, essayer de recharger if (voices.isEmpty) { DebugLog.info('[TextToSpeechService] Cache empty, reloading voices...'); voices = synthesis.getVoices().toDart; // Sur Firefox/Linux, les voix peuvent ne pas être disponibles immédiatement if (voices.isEmpty && !_voicesLoaded) { DebugLog.info('[TextToSpeechService] Waiting for voices with multiple attempts...'); // Essayer plusieurs fois avec des délais for (int i = 0; i < 3; i++) { await Future.delayed(Duration(milliseconds: 100 * (i + 1))); voices = synthesis.getVoices().toDart; if (voices.isNotEmpty) { DebugLog.info('[TextToSpeechService] ✓ Voices loaded on attempt ${i + 1}'); break; } } } // Mettre à jour le cache if (voices.isNotEmpty) { _cachedVoices = voices; _voicesLoaded = true; } } DebugLog.info('[TextToSpeechService] Available voices: ${voices.length}'); if (voices.isNotEmpty) { web.SpeechSynthesisVoice? selectedVoice; // Lister TOUTES les voix françaises pour debug final frenchVoices = []; for (final voice in voices) { final lang = voice.lang.toLowerCase(); if (lang.startsWith('fr')) { frenchVoices.add(voice); DebugLog.info('[TextToSpeechService] French: ${voice.name} (${voice.lang}) ${voice.localService ? 'LOCAL' : 'REMOTE'}'); } } if (frenchVoices.isEmpty) { DebugLog.warning('[TextToSpeechService] ⚠ NO French voices found!'); DebugLog.info('[TextToSpeechService] Available languages:'); for (final voice in voices.take(5)) { DebugLog.info('[TextToSpeechService] - ${voice.name} (${voice.lang})'); } } // Stratégie de sélection: préférer les voix LOCALES (plus fiables sur Linux) for (final voice in frenchVoices) { if (voice.localService) { selectedVoice = voice; DebugLog.info('[TextToSpeechService] ✓ Selected LOCAL French voice: ${voice.name}'); break; } } // Si pas de voix locale, chercher une voix masculine if (selectedVoice == null) { for (final voice in frenchVoices) { final name = voice.name.toLowerCase(); if (name.contains('male') || name.contains('homme') || name.contains('thomas') || name.contains('paul') || name.contains('bernard')) { selectedVoice = voice; DebugLog.info('[TextToSpeechService] Selected male voice: ${voice.name}'); break; } } } // Fallback: première voix française selectedVoice ??= frenchVoices.isNotEmpty ? frenchVoices.first : null; if (selectedVoice != null) { utterance.voice = selectedVoice; utterance.lang = selectedVoice.lang; // Utiliser la langue de la voix DebugLog.info('[TextToSpeechService] Final voice: ${selectedVoice.name} (${selectedVoice.lang})'); } else { DebugLog.warning('[TextToSpeechService] No French voice, using default with lang=fr-FR'); } } else { DebugLog.warning('[TextToSpeechService] ⚠ NO voices available at all!'); DebugLog.warning('[TextToSpeechService] On Linux: install speech-dispatcher and espeak-ng'); } // Ajouter des événements pour le debug utterance.onstart = (web.SpeechSynthesisEvent event) { DebugLog.info('[TextToSpeechService] ✓ Speech started'); }.toJS; utterance.onend = (web.SpeechSynthesisEvent event) { DebugLog.info('[TextToSpeechService] ✓ Speech ended'); }.toJS; utterance.onerror = (web.SpeechSynthesisErrorEvent event) { DebugLog.error('[TextToSpeechService] ✗ Speech error: ${event.error}'); // Messages spécifiques pour aider au diagnostic if (event.error == 'synthesis-failed') { DebugLog.error('[TextToSpeechService] ⚠ SYNTHESIS FAILED - Common on Linux'); DebugLog.error('[TextToSpeechService] Possible causes:'); DebugLog.error('[TextToSpeechService] 1. speech-dispatcher not installed/running'); DebugLog.error('[TextToSpeechService] 2. espeak or espeak-ng not installed'); DebugLog.error('[TextToSpeechService] 3. No TTS engine configured'); DebugLog.error('[TextToSpeechService] Fix: sudo apt-get install speech-dispatcher espeak-ng'); DebugLog.error('[TextToSpeechService] Then restart browser'); } else if (event.error == 'network') { DebugLog.error('[TextToSpeechService] Network error - online voice unavailable'); } else if (event.error == 'audio-busy') { DebugLog.error('[TextToSpeechService] Audio system is busy'); } }.toJS; // Lire le texte synthesis.speak(utterance); DebugLog.info('[TextToSpeechService] Speech command sent'); } catch (e) { DebugLog.error('[TextToSpeechService] Erreur lors de la lecture', e); } } /// Arrêter la lecture en cours static Future stop() async { try { web.window.speechSynthesis.cancel(); } catch (e) { DebugLog.error('[TextToSpeechService] Erreur lors de l\'arrêt', e); } } /// Vérifier si le service est en train de lire static Future isSpeaking() async { try { return web.window.speechSynthesis.speaking; } catch (e) { return false; } } /// Nettoyer les ressources static Future dispose() async { try { web.window.speechSynthesis.cancel(); } catch (e) { DebugLog.error('[TextToSpeechService] Erreur lors du nettoyage', e); } } }