diff --git a/em2rp/CHANGELOG.md b/em2rp/CHANGELOG.md index 46572fc..af48a65 100644 --- a/em2rp/CHANGELOG.md +++ b/em2rp/CHANGELOG.md @@ -2,6 +2,9 @@ Toutes les modifications notables de ce projet seront documentées dans ce fichier. +## 10/03/2026 +Ajout d'un service de synthèse vocale hybride et intégration de Google Cloud TTS. Resolution bug d'affichage des evenements pour les membres CREW + ## 24/02/2026 Ajout de la gestion des maintenance et synthèse vocale diff --git a/em2rp/functions/generateTTS.js b/em2rp/functions/generateTTS.js new file mode 100644 index 0000000..c6a6016 --- /dev/null +++ b/em2rp/functions/generateTTS.js @@ -0,0 +1,146 @@ +/** + * Cloud Function: generateTTS + * Génère de l'audio TTS avec Google Cloud Text-to-Speech + * Avec système de cache dans Firebase Storage + */ + +const textToSpeech = require('@google-cloud/text-to-speech'); +const crypto = require('crypto'); +const logger = require('firebase-functions/logger'); + +/** + * Génère un hash MD5 pour le texte (utilisé comme clé de cache) + * @param {string} text - Texte à hasher + * @return {string} Hash MD5 + */ +function generateCacheKey(text, voiceConfig = {}) { + const cacheString = JSON.stringify({ + text, + lang: voiceConfig.languageCode || 'fr-FR', + voice: voiceConfig.name || 'fr-FR-Standard-B', + }); + return crypto.createHash('md5').update(cacheString).digest('hex'); +} + +/** + * Génère l'audio TTS et le stocke dans Firebase Storage + * @param {string} text - Texte à synthétiser + * @param {object} storage - Instance Firebase Storage + * @param {object} bucket - Bucket Firebase Storage + * @param {object} voiceConfig - Configuration de la voix (optionnel) + * @return {Promise<{audioUrl: string, cached: boolean}>} + */ +async function generateTTS(text, storage, bucket, voiceConfig = {}) { + try { + // Validation du texte + if (!text || text.trim().length === 0) { + throw new Error('Text cannot be empty'); + } + + if (text.length > 5000) { + throw new Error('Text too long (max 5000 characters)'); + } + + // Configuration par défaut de la voix + const defaultVoiceConfig = { + languageCode: 'fr-FR', + name: 'fr-FR-Standard-B', // Voix masculine française (Standard = gratuit) + ssmlGender: 'MALE', + }; + + const finalVoiceConfig = { ...defaultVoiceConfig, ...voiceConfig }; + + // Générer la clé de cache + const cacheKey = generateCacheKey(text, finalVoiceConfig); + const fileName = `tts-cache/${cacheKey}.mp3`; + const file = bucket.file(fileName); + + // Vérifier si le fichier existe déjà dans le cache + const [exists] = await file.exists(); + + if (exists) { + logger.info('[generateTTS] ✓ Cache HIT', { cacheKey, text: text.substring(0, 50) }); + + // Générer une URL signée valide 7 jours + const [url] = await file.getSignedUrl({ + action: 'read', + expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours + }); + + return { + audioUrl: url, + cached: true, + cacheKey, + }; + } + + logger.info('[generateTTS] ○ Cache MISS - Generating audio', { + cacheKey, + text: text.substring(0, 50), + voice: finalVoiceConfig.name, + }); + + // Créer le client Text-to-Speech + const client = new textToSpeech.TextToSpeechClient(); + + // Configuration de la requête + const request = { + input: { text: text }, + voice: finalVoiceConfig, + audioConfig: { + audioEncoding: 'MP3', + speakingRate: 0.9, // Légèrement plus lent pour meilleure compréhension + pitch: -2.0, // Voix un peu plus grave + volumeGainDb: 0.0, + }, + }; + + // Appeler l'API Google Cloud TTS + const [response] = await client.synthesizeSpeech(request); + + if (!response.audioContent) { + throw new Error('No audio content returned from TTS API'); + } + + logger.info('[generateTTS] ✓ Audio generated', { + size: response.audioContent.length, + }); + + // Sauvegarder dans Firebase Storage + await file.save(response.audioContent, { + metadata: { + contentType: 'audio/mpeg', + metadata: { + text: text.substring(0, 100), // Premier 100 caractères pour debug + voice: finalVoiceConfig.name, + generatedAt: new Date().toISOString(), + }, + }, + }); + + logger.info('[generateTTS] ✓ Audio cached', { fileName }); + + // Générer une URL signée + const [url] = await file.getSignedUrl({ + action: 'read', + expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours + }); + + return { + audioUrl: url, + cached: false, + cacheKey, + }; + } catch (error) { + logger.error('[generateTTS] ✗ Error', { + error: error.message, + code: error.code, + text: text?.substring(0, 50), + }); + + throw error; + } +} + +module.exports = { generateTTS, generateCacheKey }; + diff --git a/em2rp/functions/index.js b/em2rp/functions/index.js index 4e28f33..2bec9a4 100644 --- a/em2rp/functions/index.js +++ b/em2rp/functions/index.js @@ -16,6 +16,7 @@ const { Storage } = require('@google-cloud/storage'); // Utilitaires const auth = require('./utils/auth'); const helpers = require('./utils/helpers'); +const { generateTTS } = require('./generateTTS'); // Initialisation sécurisée if (!admin.apps.length) { @@ -4193,3 +4194,72 @@ exports.quickSearch = onRequest(httpOptions, withCors(async (req, res) => { res.status(500).json({ error: error.message }); } })); + +// ============================================================================ +// TEXT-TO-SPEECH - Generate TTS Audio +// ============================================================================ +// Options HTTP spécifiques pour TTS avec CORS activé +const ttsHttpOptions = { + cors: true, // Activer CORS automatique + invoker: 'public', + region: 'europe-west9', +}; + +exports.generateTTSV2 = onRequest(ttsHttpOptions, async (req, res) => { + try { + // Authentification utilisateur + const decodedToken = await auth.authenticateUser(req); + + logger.info('[generateTTSV2] Request from user:', { + uid: decodedToken.uid, + email: decodedToken.email, + }); + + // Récupération des paramètres + const { text, voiceConfig } = req.body.data || {}; + + // Validation + if (!text) { + res.status(400).json({ error: 'Text parameter is required' }); + return; + } + + if (text.length > 5000) { + res.status(400).json({ error: 'Text too long (max 5000 characters)' }); + return; + } + + // Génération de l'audio avec cache + const bucketName = admin.storage().bucket().name; + const bucket = storage.bucket(bucketName); + + const result = await generateTTS(text, storage, bucket, voiceConfig); + + logger.info('[generateTTSV2] ✓ Success', { + cached: result.cached, + cacheKey: result.cacheKey, + }); + + res.status(200).json({ + audioUrl: result.audioUrl, + cached: result.cached, + cacheKey: result.cacheKey, + }); + + } catch (error) { + logger.error('[generateTTSV2] ✗ Error:', { + error: error.message, + code: error.code, + }); + + // Gestion des erreurs spécifiques + if (error.code === 'PERMISSION_DENIED') { + res.status(403).json({ error: 'Permission denied. Check Google Cloud TTS API is enabled.' }); + } else if (error.code === 'QUOTA_EXCEEDED') { + res.status(429).json({ error: 'TTS quota exceeded. Try again later.' }); + } else { + res.status(500).json({ error: error.message }); + } + } +}); + diff --git a/em2rp/functions/package-lock.json b/em2rp/functions/package-lock.json index 4ce5bd6..90587fb 100644 --- a/em2rp/functions/package-lock.json +++ b/em2rp/functions/package-lock.json @@ -7,6 +7,7 @@ "name": "functions", "dependencies": { "@google-cloud/storage": "^7.18.0", + "@google-cloud/text-to-speech": "^5.4.0", "axios": "^1.13.2", "dotenv": "^17.2.3", "envdot": "^0.0.3", @@ -772,12 +773,23 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@google-cloud/text-to-speech": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@google-cloud/text-to-speech/-/text-to-speech-5.8.1.tgz", + "integrity": "sha512-HXyZBtfQq+ETSLwWV/k3zFRWSzt+KEfiC5/OqXNNUed+lU/LEyN0CsqqEmkFfkL8BPsVIMAK2xiYCaDsKENukg==", + "license": "Apache-2.0", + "dependencies": { + "google-gax": "^4.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@grpc/proto-loader": "^0.7.13", "@js-sdsl/ordered-map": "^4.4.2" @@ -791,7 +803,6 @@ "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", @@ -1310,7 +1321,6 @@ "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", "license": "MIT", - "optional": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/js-sdsl" @@ -1631,8 +1641,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@types/mime": { "version": "1.3.5", @@ -1845,7 +1854,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -1855,7 +1863,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2379,7 +2386,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "devOptional": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -2412,7 +2418,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2425,7 +2430,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "devOptional": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -2727,7 +2731,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "devOptional": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -2831,7 +2834,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -3576,7 +3578,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "devOptional": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -3715,7 +3716,6 @@ "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@grpc/grpc-js": "^1.10.9", "@grpc/proto-loader": "^0.7.13", @@ -3743,7 +3743,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -4093,7 +4092,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -5110,8 +5108,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/lodash.clonedeep": { "version": "4.5.0", @@ -5490,7 +5487,6 @@ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "license": "MIT", - "optional": true, "engines": { "node": ">= 6" } @@ -5843,7 +5839,6 @@ "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "protobufjs": "^7.2.5" }, @@ -6035,7 +6030,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6517,7 +6511,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6532,7 +6525,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6997,7 +6989,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -7035,7 +7026,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "devOptional": true, "license": "ISC", "engines": { "node": ">=10" @@ -7052,7 +7042,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "devOptional": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -7071,7 +7060,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "devOptional": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/em2rp/functions/package.json b/em2rp/functions/package.json index f82172c..934521e 100644 --- a/em2rp/functions/package.json +++ b/em2rp/functions/package.json @@ -15,6 +15,7 @@ "main": "index.js", "dependencies": { "@google-cloud/storage": "^7.18.0", + "@google-cloud/text-to-speech": "^5.4.0", "axios": "^1.13.2", "dotenv": "^17.2.3", "envdot": "^0.0.3", diff --git a/em2rp/lib/services/cloud_text_to_speech_service.dart b/em2rp/lib/services/cloud_text_to_speech_service.dart new file mode 100644 index 0000000..404dc37 --- /dev/null +++ b/em2rp/lib/services/cloud_text_to_speech_service.dart @@ -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 _audioCache = {}; + static final Map _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 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 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 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'); + } +} + diff --git a/em2rp/lib/services/smart_text_to_speech_service.dart b/em2rp/lib/services/smart_text_to_speech_service.dart new file mode 100644 index 0000000..f0c3e9d --- /dev/null +++ b/em2rp/lib/services/smart_text_to_speech_service.dart @@ -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 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 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 _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 _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 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 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 getStatus() { + return { + 'initialized': _initialized, + 'webSpeechWorks': _webSpeechWorks, + 'failures': _webSpeechFailures, + 'currentStrategy': _webSpeechWorks ? 'Web Speech (primary)' : 'Cloud TTS (primary)', + }; + } + + /// Nettoyer les ressources + static Future dispose() async { + try { + await TextToSpeechService.dispose(); + CloudTextToSpeechService.clearCache(); + } catch (e) { + DebugLog.error('[SmartTTS] Error disposing', e); + } + } +} + diff --git a/em2rp/lib/views/event_preparation_page.dart b/em2rp/lib/views/event_preparation_page.dart index 96e5721..a0276f9 100644 --- a/em2rp/lib/views/event_preparation_page.dart +++ b/em2rp/lib/views/event_preparation_page.dart @@ -11,7 +11,7 @@ import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/services/qr_code_processing_service.dart'; import 'package:em2rp/services/audio_feedback_service.dart'; -import 'package:em2rp/services/text_to_speech_service.dart'; +import 'package:em2rp/services/smart_text_to_speech_service.dart'; import 'package:em2rp/services/equipment_service.dart'; import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep; import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart'; @@ -121,8 +121,8 @@ class _EventPreparationPageState extends State with Single duration: const Duration(milliseconds: 500), ); - // Initialiser le service de synthèse vocale - TextToSpeechService.initialize(); + // Initialiser le service de synthèse vocale hybride + SmartTextToSpeechService.initialize(); // Initialiser et débloquer l'audio (pour éviter les problèmes d'autoplay) AudioFeedbackService.unlockAudio(); @@ -164,7 +164,7 @@ class _EventPreparationPageState extends State with Single _animationController.dispose(); _manualCodeController.dispose(); _manualCodeFocusNode.dispose(); - TextToSpeechService.stop(); + SmartTextToSpeechService.stop(); super.dispose(); } @@ -1212,9 +1212,9 @@ class _EventPreparationPageState extends State with Single Future _announceNextItem() async { final nextItem = _findNextItemToScan(); if (nextItem != null) { - await TextToSpeechService.speak('Prochain item: $nextItem'); + await SmartTextToSpeechService.speak('Prochain item: $nextItem'); } else { - await TextToSpeechService.speak('Tous les items sont validés'); + await SmartTextToSpeechService.speak('Tous les items sont validés'); } } diff --git a/em2rp/lib/views/widgets/common/audio_diagnostic_button.dart b/em2rp/lib/views/widgets/common/audio_diagnostic_button.dart index 504ee0d..6fd3e12 100644 --- a/em2rp/lib/views/widgets/common/audio_diagnostic_button.dart +++ b/em2rp/lib/views/widgets/common/audio_diagnostic_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:web/web.dart' as web; import 'package:em2rp/services/audio_feedback_service.dart'; -import 'package:em2rp/services/text_to_speech_service.dart'; +import 'package:em2rp/services/smart_text_to_speech_service.dart'; import 'package:em2rp/utils/debug_log.dart'; /// Bouton de diagnostic pour tester l'audio et le TTS @@ -59,11 +59,11 @@ class AudioDiagnosticButton extends StatelessWidget { DebugLog.info('[AudioDiagnostic] Platform: ${web.window.navigator.platform}'); DebugLog.info('[AudioDiagnostic] Language: ${web.window.navigator.language}'); - await TextToSpeechService.initialize(); + await SmartTextToSpeechService.initialize(); await Future.delayed(const Duration(milliseconds: 500)); DebugLog.info('[AudioDiagnostic] Speaking test phrase...'); - await TextToSpeechService.speak('Test de synthèse vocale. Un, deux, trois.'); + await SmartTextToSpeechService.speak('Test de synthèse vocale. Un, deux, trois.'); DebugLog.info('[AudioDiagnostic] ========== TTS TEST END ==========');