feat: Ajout d'un service de synthèse vocale hybride et intégration de Google Cloud TTS
This commit is contained in:
189
em2rp/lib/services/cloud_text_to_speech_service.dart
Normal file
189
em2rp/lib/services/cloud_text_to_speech_service.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'package:web/web.dart' as web;
|
||||
import 'package:em2rp/config/api_config.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Service de Text-to-Speech utilisant Google Cloud TTS via Cloud Function
|
||||
/// Avec système de cache pour optimiser les performances et réduire les coûts
|
||||
class CloudTextToSpeechService {
|
||||
static final Map<String, String> _audioCache = {};
|
||||
static final Map<String, web.HTMLAudioElement> _audioPlayers = {};
|
||||
|
||||
/// Générer l'audio TTS via Cloud Function
|
||||
/// Retourne l'URL de l'audio (mise en cache automatiquement côté serveur)
|
||||
static Future<String?> generateAudio(String text) async {
|
||||
try {
|
||||
// Vérifier le cache local d'abord
|
||||
if (_audioCache.containsKey(text)) {
|
||||
DebugLog.info('[CloudTTS] ✓ Local cache HIT: "${text.substring(0, 30)}..."');
|
||||
return _audioCache[text];
|
||||
}
|
||||
|
||||
DebugLog.info('[CloudTTS] Generating audio for: "$text"');
|
||||
|
||||
// Récupérer le token d'authentification
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
if (user == null) {
|
||||
DebugLog.error('[CloudTTS] User not authenticated');
|
||||
return null;
|
||||
}
|
||||
|
||||
final token = await user.getIdToken();
|
||||
if (token == null) {
|
||||
DebugLog.error('[CloudTTS] Failed to get auth token');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Préparer la requête
|
||||
final url = '${ApiConfig.baseUrl}/generateTTSV2';
|
||||
final headers = {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
final body = json.encode({
|
||||
'data': {
|
||||
'text': text,
|
||||
'voiceConfig': {
|
||||
'languageCode': 'fr-FR',
|
||||
'name': 'fr-FR-Standard-B', // Voix masculine gratuite
|
||||
'ssmlGender': 'MALE',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
DebugLog.info('[CloudTTS] Calling Cloud Function...');
|
||||
final startTime = DateTime.now();
|
||||
|
||||
// Appeler la Cloud Function
|
||||
final response = await http.post(
|
||||
Uri.parse(url),
|
||||
headers: headers,
|
||||
body: body,
|
||||
);
|
||||
|
||||
final duration = DateTime.now().difference(startTime).inMilliseconds;
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final audioUrl = data['audioUrl'] as String?;
|
||||
final cached = data['cached'] as bool? ?? false;
|
||||
|
||||
if (audioUrl != null) {
|
||||
// Mettre en cache localement
|
||||
_audioCache[text] = audioUrl;
|
||||
|
||||
DebugLog.info('[CloudTTS] ✓ Audio generated - cached: $cached, duration: ${duration}ms');
|
||||
|
||||
return audioUrl;
|
||||
}
|
||||
}
|
||||
|
||||
DebugLog.error('[CloudTTS] Failed to generate audio', {
|
||||
'status': response.statusCode,
|
||||
'body': response.body,
|
||||
});
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
DebugLog.error('[CloudTTS] Exception:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Lire un audio depuis une URL
|
||||
static void playAudio(String audioUrl) {
|
||||
try {
|
||||
DebugLog.info('[CloudTTS] Playing audio...');
|
||||
|
||||
// Créer ou réutiliser un HTMLAudioElement
|
||||
final player = _audioPlayers[audioUrl] ?? web.HTMLAudioElement();
|
||||
if (!_audioPlayers.containsKey(audioUrl)) {
|
||||
player.src = audioUrl;
|
||||
_audioPlayers[audioUrl] = player;
|
||||
}
|
||||
|
||||
// Configurer le volume
|
||||
player.volume = 1.0;
|
||||
|
||||
// Écouter les événements
|
||||
player.onEnded.listen((_) {
|
||||
DebugLog.info('[CloudTTS] ✓ Playback finished');
|
||||
});
|
||||
|
||||
player.onError.listen((event) {
|
||||
DebugLog.error('[CloudTTS] ✗ Playback error:', event);
|
||||
});
|
||||
|
||||
// Lire l'audio (pas de await avec package:web)
|
||||
player.play();
|
||||
|
||||
DebugLog.info('[CloudTTS] ✓ Playback started');
|
||||
} catch (e) {
|
||||
DebugLog.error('[CloudTTS] Error playing audio:', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Générer et lire l'audio en une seule opération
|
||||
static Future<void> speak(String text) async {
|
||||
try {
|
||||
final audioUrl = await generateAudio(text);
|
||||
|
||||
if (audioUrl != null) {
|
||||
playAudio(audioUrl);
|
||||
} else {
|
||||
DebugLog.error('[CloudTTS] Failed to generate audio for speech');
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('[CloudTTS] Error in speak:', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Arrêter tous les audios en cours
|
||||
static void stopAll() {
|
||||
for (final player in _audioPlayers.values) {
|
||||
try {
|
||||
player.pause();
|
||||
player.currentTime = 0;
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de pause
|
||||
}
|
||||
}
|
||||
DebugLog.info('[CloudTTS] All players stopped');
|
||||
}
|
||||
|
||||
/// Nettoyer le cache
|
||||
static void clearCache() {
|
||||
_audioCache.clear();
|
||||
_audioPlayers.clear();
|
||||
DebugLog.info('[CloudTTS] Cache cleared');
|
||||
}
|
||||
|
||||
/// Pré-charger des audios fréquemment utilisés
|
||||
static Future<void> preloadCommonPhrases() async {
|
||||
final phrases = [
|
||||
'Équipement scanné',
|
||||
'Flight case',
|
||||
'Conteneur',
|
||||
'Validé',
|
||||
'Erreur',
|
||||
];
|
||||
|
||||
DebugLog.info('[CloudTTS] Preloading ${phrases.length} common phrases...');
|
||||
|
||||
for (final phrase in phrases) {
|
||||
try {
|
||||
await generateAudio(phrase);
|
||||
} catch (e) {
|
||||
DebugLog.warning('[CloudTTS] Failed to preload: $phrase - $e');
|
||||
}
|
||||
}
|
||||
|
||||
DebugLog.info('[CloudTTS] ✓ Preload complete');
|
||||
}
|
||||
}
|
||||
|
||||
163
em2rp/lib/services/smart_text_to_speech_service.dart
Normal file
163
em2rp/lib/services/smart_text_to_speech_service.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
import 'package:em2rp/services/text_to_speech_service.dart';
|
||||
import 'package:em2rp/services/cloud_text_to_speech_service.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
|
||||
/// Service hybride intelligent pour le Text-to-Speech
|
||||
/// Essaie d'abord Web Speech API (gratuit, rapide), puis fallback vers Cloud TTS
|
||||
class SmartTextToSpeechService {
|
||||
static bool _initialized = false;
|
||||
static bool _webSpeechWorks = true; // Optimiste par défaut
|
||||
static int _webSpeechFailures = 0;
|
||||
static const int _maxFailuresBeforeSwitch = 2;
|
||||
|
||||
/// Initialiser le service
|
||||
static Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
try {
|
||||
DebugLog.info('[SmartTTS] Initializing...');
|
||||
|
||||
// Initialiser Web Speech API
|
||||
await TextToSpeechService.initialize();
|
||||
|
||||
// Pré-charger les phrases courantes pour Cloud TTS en arrière-plan
|
||||
// (ne bloque pas l'initialisation)
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
CloudTextToSpeechService.preloadCommonPhrases();
|
||||
});
|
||||
|
||||
_initialized = true;
|
||||
DebugLog.info('[SmartTTS] ✓ Initialized (Web Speech preferred)');
|
||||
} catch (e) {
|
||||
DebugLog.error('[SmartTTS] Initialization error', e);
|
||||
_initialized = true; // Continuer quand même
|
||||
}
|
||||
}
|
||||
|
||||
/// Lire un texte à haute voix avec stratégie intelligente
|
||||
static Future<void> speak(String text) async {
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
// Si Web Speech a échoué plusieurs fois, utiliser directement Cloud TTS
|
||||
if (!_webSpeechWorks || _webSpeechFailures >= _maxFailuresBeforeSwitch) {
|
||||
return _speakWithCloudTTS(text);
|
||||
}
|
||||
|
||||
// Essayer Web Speech d'abord
|
||||
try {
|
||||
await _speakWithWebSpeech(text);
|
||||
// Si succès, réinitialiser le compteur d'échecs
|
||||
_webSpeechFailures = 0;
|
||||
} catch (e) {
|
||||
DebugLog.warning('[SmartTTS] Web Speech failed ($e), trying Cloud TTS...');
|
||||
_webSpeechFailures++;
|
||||
|
||||
// Si trop d'échecs, basculer vers Cloud TTS par défaut
|
||||
if (_webSpeechFailures >= _maxFailuresBeforeSwitch) {
|
||||
DebugLog.info('[SmartTTS] Switching to Cloud TTS as primary');
|
||||
_webSpeechWorks = false;
|
||||
}
|
||||
|
||||
// Fallback vers Cloud TTS
|
||||
await _speakWithCloudTTS(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Utiliser Web Speech API
|
||||
static Future<void> _speakWithWebSpeech(String text) async {
|
||||
DebugLog.info('[SmartTTS] → Trying Web Speech API');
|
||||
|
||||
// Timeout pour détecter si ça ne marche pas
|
||||
await Future.any([
|
||||
TextToSpeechService.speak(text),
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
throw Exception('Web Speech timeout');
|
||||
}),
|
||||
]);
|
||||
|
||||
DebugLog.info('[SmartTTS] ✓ Web Speech succeeded');
|
||||
}
|
||||
|
||||
/// Utiliser Cloud TTS
|
||||
static Future<void> _speakWithCloudTTS(String text) async {
|
||||
DebugLog.info('[SmartTTS] → Using Cloud TTS');
|
||||
|
||||
try {
|
||||
await CloudTextToSpeechService.speak(text);
|
||||
DebugLog.info('[SmartTTS] ✓ Cloud TTS succeeded');
|
||||
} catch (e) {
|
||||
DebugLog.error('[SmartTTS] ✗ Cloud TTS failed', e);
|
||||
|
||||
// En dernier recours, réessayer Web Speech
|
||||
if (!_webSpeechWorks) {
|
||||
DebugLog.info('[SmartTTS] Last resort: trying Web Speech again');
|
||||
await TextToSpeechService.speak(text);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arrêter toute lecture en cours
|
||||
static Future<void> stop() async {
|
||||
try {
|
||||
await TextToSpeechService.stop();
|
||||
CloudTextToSpeechService.stopAll();
|
||||
} catch (e) {
|
||||
DebugLog.error('[SmartTTS] Error stopping', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifier si une lecture est en cours
|
||||
static Future<bool> isSpeaking() async {
|
||||
try {
|
||||
return await TextToSpeechService.isSpeaking();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Forcer l'utilisation de Cloud TTS (pour tests ou préférence utilisateur)
|
||||
static void forceCloudTTS() {
|
||||
DebugLog.info('[SmartTTS] Forced to use Cloud TTS');
|
||||
_webSpeechWorks = false;
|
||||
_webSpeechFailures = _maxFailuresBeforeSwitch;
|
||||
}
|
||||
|
||||
/// Forcer l'utilisation de Web Speech (pour tests ou préférence utilisateur)
|
||||
static void forceWebSpeech() {
|
||||
DebugLog.info('[SmartTTS] Forced to use Web Speech');
|
||||
_webSpeechWorks = true;
|
||||
_webSpeechFailures = 0;
|
||||
}
|
||||
|
||||
/// Réinitialiser la stratégie (utile pour tests)
|
||||
static void resetStrategy() {
|
||||
DebugLog.info('[SmartTTS] Strategy reset');
|
||||
_webSpeechWorks = true;
|
||||
_webSpeechFailures = 0;
|
||||
}
|
||||
|
||||
/// Obtenir le statut actuel
|
||||
static Map<String, dynamic> getStatus() {
|
||||
return {
|
||||
'initialized': _initialized,
|
||||
'webSpeechWorks': _webSpeechWorks,
|
||||
'failures': _webSpeechFailures,
|
||||
'currentStrategy': _webSpeechWorks ? 'Web Speech (primary)' : 'Cloud TTS (primary)',
|
||||
};
|
||||
}
|
||||
|
||||
/// Nettoyer les ressources
|
||||
static Future<void> dispose() async {
|
||||
try {
|
||||
await TextToSpeechService.dispose();
|
||||
CloudTextToSpeechService.clearCache();
|
||||
} catch (e) {
|
||||
DebugLog.error('[SmartTTS] Error disposing', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user