feat: Ajout d'un service de synthèse vocale hybride et intégration de Google Cloud TTS
This commit is contained in:
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
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
|
## 24/02/2026
|
||||||
Ajout de la gestion des maintenance et synthèse vocale
|
Ajout de la gestion des maintenance et synthèse vocale
|
||||||
|
|
||||||
|
|||||||
146
em2rp/functions/generateTTS.js
Normal file
146
em2rp/functions/generateTTS.js
Normal file
@@ -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 };
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ const { Storage } = require('@google-cloud/storage');
|
|||||||
// Utilitaires
|
// Utilitaires
|
||||||
const auth = require('./utils/auth');
|
const auth = require('./utils/auth');
|
||||||
const helpers = require('./utils/helpers');
|
const helpers = require('./utils/helpers');
|
||||||
|
const { generateTTS } = require('./generateTTS');
|
||||||
|
|
||||||
// Initialisation sécurisée
|
// Initialisation sécurisée
|
||||||
if (!admin.apps.length) {
|
if (!admin.apps.length) {
|
||||||
@@ -4193,3 +4194,72 @@ exports.quickSearch = onRequest(httpOptions, withCors(async (req, res) => {
|
|||||||
res.status(500).json({ error: error.message });
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
42
em2rp/functions/package-lock.json
generated
42
em2rp/functions/package-lock.json
generated
@@ -7,6 +7,7 @@
|
|||||||
"name": "functions",
|
"name": "functions",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google-cloud/storage": "^7.18.0",
|
"@google-cloud/storage": "^7.18.0",
|
||||||
|
"@google-cloud/text-to-speech": "^5.4.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"envdot": "^0.0.3",
|
"envdot": "^0.0.3",
|
||||||
@@ -772,12 +773,23 @@
|
|||||||
"uuid": "dist/bin/uuid"
|
"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": {
|
"node_modules/@grpc/grpc-js": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
|
||||||
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
|
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/proto-loader": "^0.7.13",
|
"@grpc/proto-loader": "^0.7.13",
|
||||||
"@js-sdsl/ordered-map": "^4.4.2"
|
"@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",
|
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
|
||||||
"integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
|
"integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lodash.camelcase": "^4.3.0",
|
"lodash.camelcase": "^4.3.0",
|
||||||
"long": "^5.0.0",
|
"long": "^5.0.0",
|
||||||
@@ -1310,7 +1321,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
|
||||||
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
|
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/js-sdsl"
|
"url": "https://opencollective.com/js-sdsl"
|
||||||
@@ -1631,8 +1641,7 @@
|
|||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
|
||||||
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
|
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/mime": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
@@ -1845,7 +1854,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -1855,7 +1863,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -2379,7 +2386,6 @@
|
|||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"string-width": "^4.2.0",
|
"string-width": "^4.2.0",
|
||||||
@@ -2412,7 +2418,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -2425,7 +2430,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
@@ -2727,7 +2731,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
@@ -2831,7 +2834,6 @@
|
|||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -3576,7 +3578,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -3715,7 +3716,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz",
|
||||||
"integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==",
|
"integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "^1.10.9",
|
"@grpc/grpc-js": "^1.10.9",
|
||||||
"@grpc/proto-loader": "^0.7.13",
|
"@grpc/proto-loader": "^0.7.13",
|
||||||
@@ -3743,7 +3743,6 @@
|
|||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
@@ -4093,7 +4092,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -5110,8 +5108,7 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||||
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.clonedeep": {
|
"node_modules/lodash.clonedeep": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
@@ -5490,7 +5487,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
@@ -5843,7 +5839,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz",
|
||||||
"integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==",
|
"integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"protobufjs": "^7.2.5"
|
"protobufjs": "^7.2.5"
|
||||||
},
|
},
|
||||||
@@ -6035,7 +6030,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6517,7 +6511,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -6532,7 +6525,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -6997,7 +6989,6 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^4.0.0",
|
||||||
@@ -7035,7 +7026,6 @@
|
|||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -7052,7 +7042,6 @@
|
|||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cliui": "^8.0.1",
|
"cliui": "^8.0.1",
|
||||||
@@ -7071,7 +7060,6 @@
|
|||||||
"version": "21.1.1",
|
"version": "21.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google-cloud/storage": "^7.18.0",
|
"@google-cloud/storage": "^7.18.0",
|
||||||
|
"@google-cloud/text-to-speech": "^5.4.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"envdot": "^0.0.3",
|
"envdot": "^0.0.3",
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import 'package:em2rp/services/data_service.dart';
|
|||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'package:em2rp/services/qr_code_processing_service.dart';
|
import 'package:em2rp/services/qr_code_processing_service.dart';
|
||||||
import 'package:em2rp/services/audio_feedback_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/services/equipment_service.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
|
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
|
||||||
import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart';
|
import 'package:em2rp/views/widgets/equipment/container_checklist_item.dart';
|
||||||
@@ -121,8 +121,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialiser le service de synthèse vocale
|
// Initialiser le service de synthèse vocale hybride
|
||||||
TextToSpeechService.initialize();
|
SmartTextToSpeechService.initialize();
|
||||||
|
|
||||||
// Initialiser et débloquer l'audio (pour éviter les problèmes d'autoplay)
|
// Initialiser et débloquer l'audio (pour éviter les problèmes d'autoplay)
|
||||||
AudioFeedbackService.unlockAudio();
|
AudioFeedbackService.unlockAudio();
|
||||||
@@ -164,7 +164,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
_animationController.dispose();
|
_animationController.dispose();
|
||||||
_manualCodeController.dispose();
|
_manualCodeController.dispose();
|
||||||
_manualCodeFocusNode.dispose();
|
_manualCodeFocusNode.dispose();
|
||||||
TextToSpeechService.stop();
|
SmartTextToSpeechService.stop();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1212,9 +1212,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
Future<void> _announceNextItem() async {
|
Future<void> _announceNextItem() async {
|
||||||
final nextItem = _findNextItemToScan();
|
final nextItem = _findNextItemToScan();
|
||||||
if (nextItem != null) {
|
if (nextItem != null) {
|
||||||
await TextToSpeechService.speak('Prochain item: $nextItem');
|
await SmartTextToSpeechService.speak('Prochain item: $nextItem');
|
||||||
} else {
|
} else {
|
||||||
await TextToSpeechService.speak('Tous les items sont validés');
|
await SmartTextToSpeechService.speak('Tous les items sont validés');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:web/web.dart' as web;
|
import 'package:web/web.dart' as web;
|
||||||
import 'package:em2rp/services/audio_feedback_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/utils/debug_log.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
/// Bouton de diagnostic pour tester l'audio et le TTS
|
/// 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] Platform: ${web.window.navigator.platform}');
|
||||||
DebugLog.info('[AudioDiagnostic] Language: ${web.window.navigator.language}');
|
DebugLog.info('[AudioDiagnostic] Language: ${web.window.navigator.language}');
|
||||||
|
|
||||||
await TextToSpeechService.initialize();
|
await SmartTextToSpeechService.initialize();
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
DebugLog.info('[AudioDiagnostic] Speaking test phrase...');
|
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 ==========');
|
DebugLog.info('[AudioDiagnostic] ========== TTS TEST END ==========');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user