274 lines
9.8 KiB
Dart
274 lines
9.8 KiB
Dart
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<web.SpeechSynthesisVoice> _cachedVoices = [];
|
|
static bool _isChromium = false;
|
|
|
|
/// Initialiser le service TTS
|
|
static Future<void> initialize() async {
|
|
if (_isInitialized) return;
|
|
|
|
try {
|
|
_isInitialized = true;
|
|
|
|
// Détecter si on est sur Chromium
|
|
final userAgent = web.window.navigator.userAgent.toLowerCase();
|
|
_isChromium = userAgent.contains('chrome') && !userAgent.contains('edg');
|
|
|
|
if (_isChromium) {
|
|
DebugLog.info('[TextToSpeechService] Chromium detected - applying workarounds');
|
|
}
|
|
|
|
final synthesis = web.window.speechSynthesis;
|
|
|
|
// WORKAROUND CHROMIUM: Forcer le chargement des voix avec un speak/cancel
|
|
if (_isChromium) {
|
|
try {
|
|
final dummy = web.SpeechSynthesisUtterance('');
|
|
synthesis.speak(dummy);
|
|
synthesis.cancel();
|
|
DebugLog.info('[TextToSpeechService] Chromium voice loading triggered');
|
|
} catch (e) {
|
|
DebugLog.warning('[TextToSpeechService] Chromium workaround failed: $e');
|
|
}
|
|
}
|
|
|
|
// 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<bool> _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<void> 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 = <web.SpeechSynthesisVoice>[];
|
|
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');
|
|
|
|
// WORKAROUND CHROMIUM: Appeler resume() immédiatement après speak()
|
|
// Ceci est nécessaire sur Chromium/Linux pour que le TTS démarre réellement
|
|
if (_isChromium) {
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
synthesis.resume();
|
|
DebugLog.info('[TextToSpeechService] Chromium resume() called');
|
|
}
|
|
} catch (e) {
|
|
DebugLog.error('[TextToSpeechService] Erreur lors de la lecture', e);
|
|
}
|
|
}
|
|
|
|
/// Arrêter la lecture en cours
|
|
static Future<void> 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<bool> isSpeaking() async {
|
|
try {
|
|
return web.window.speechSynthesis.speaking;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Nettoyer les ressources
|
|
static Future<void> dispose() async {
|
|
try {
|
|
web.window.speechSynthesis.cancel();
|
|
} catch (e) {
|
|
DebugLog.error('[TextToSpeechService] Erreur lors du nettoyage', e);
|
|
}
|
|
}
|
|
}
|