feat: Ajout d'un service de synthèse vocale hybride et intégration de Google Cloud TTS

This commit is contained in:
ElPoyo
2026-03-10 15:08:30 +01:00
parent 36b420639d
commit afa2c35c90
9 changed files with 596 additions and 36 deletions

View File

@@ -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

View 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 };

View File

@@ -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 });
}
}
});

View File

@@ -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"

View File

@@ -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",

View 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');
}
}

View 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);
}
}
}

View File

@@ -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');
} }
} }

View File

@@ -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 ==========');