Import initial de la documentation : statut, roadmap, plans V1/V2, specs verticales (creche, sport), audits securite, plan de test, analyse concurrentielle et maquettes de design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 KiB
TTS Pré-généré — Plan d'intégration backend .NET
Contexte : Delta technique #4 issu de
myinfomate-ai-persona-analysis.md. Stack retenue : Google Cloud TTS + Firebase Storage (upload serveur via Firebase Admin SDK).
Décisions d'architecture
| Décision | Choix | Raison |
|---|---|---|
| TTS engine | Google Cloud TTS | Même compte GCP que Gemini, large choix de voix Neural2/Studio/Chirp, pricing à la seconde |
| Stockage MP3 | Firebase Storage (upload serveur) | Convention existante du projet ; backend a déjà les credentials Firebase (FCM) — ajouter le SDK Storage |
| Déclenchement | Hangfire background job | Même trigger que l'embedding RAG au save d'un contenu |
| Pattern audio | List<TranslationDTO> d'IDs Resource |
Pattern identique à ArticleAudioIds déjà en place sur SectionArticle |
Périmètre V1 — Sections concernées
| Section | Champ audio existant | Action |
|---|---|---|
SectionArticle |
ArticleAudioIds: List<TranslationDTO> |
Déjà en place — brancher le pipeline TTS |
GeoPoint (POIs de SectionMap) |
Aucun | Ajouter AudioIds: List<TranslationDTO> |
GuidedStep (étapes de GuidedPath/Parcours) |
À confirmer | Ajouter AudioIds: List<TranslationDTO> si absent |
Hors périmètre V1 : SectionEvent (contenu trop éphémère, re-génération trop fréquente).
Structure Firebase Storage
# Samples de voix (générés une fois, globaux, multilingues)
tts-samples/{voiceId}/{languageCode}.mp3
# Contenu TTS par venue
{instanceId}/tts/article/{sectionId}/{languageCode}.mp3
{instanceId}/tts/poi/{geoPointId}/{languageCode}.mp3
{instanceId}/tts/step/{guidedStepId}/{languageCode}.mp3
Chaque MP3 généré → crée une entité Resource (type Audio) → son ID stocké dans le List<TranslationDTO> de la section concernée, par code langue et par persona.
Le stockage est multiplié par le nombre de personas actifs (max ×2 en plan de base, borné et prévisible).
Modèle dual-persona
Deux personas fixes inclus dans tous les plans, liés à des wakewords OpenWakeWord pré-entraînés une fois pour tous les clients :
| Persona | Wakeword | Voix retenue (2026-08-07) | Timbre |
|---|---|---|---|
| Viva | "Viva" | Sulafat |
Warm, middle pitch |
| Marco | "Marco" | Umbriel |
Easy-going, lower middle pitch |
Critères retenus : du long format (2-3 min d'audio par POI, donc pas de voix « Bright / Upbeat / Higher pitch » qui fatiguent), de mauvaises conditions d'écoute (haut-parleur de téléphone, casemate réverbérante — le medium-grave passe, les aigus se délitent), et des voix par défaut pour tous les clients, donc neutres : la personnalité vient du prompt, pas du timbre.
Écartées pour cette raison :
Zephyr(Bright, higher pitch — c'était le défaut en place, correct pour un chatbot en une phrase, discutable pour un audioguide),Leda,Laomedeia,Achernar, et tout descripteur trop marqué (Gravelly, Excitable, Breathy).
Le client configure pour chaque persona :
- Le choix entre Viva et Marco (la voix, pas la personnalité)
- Le prompt de personnalité (ex: "Tu parles en belge familier, tu es spécialisé sur le Moyen-Âge")
Persona custom (nom différent) = add-on facturable : collecte samples audio, entraînement OpenWakeWord, intégration dans le build de l'app.
Configuration voix par venue
À ajouter sur la table venue (même migration que le delta #2) :
// Sur l'entité Venue / Instance
public List<PersonaConfig> Personas { get; set; } // max 2 en plan de base
public class PersonaConfig
{
public string WakewordId { get; set; } // "viva" | "marco" — doit matcher le modèle OpenWakeWord
public string GuideName { get; set; } // nom libre affiché au visiteur : "Léon"
public string PersonaPrompt { get; set; } // description de personnalité, libre
public string VoiceName { get; set; } // "Sulafat" | "Umbriel"
}
⚠️ Simplification par rapport à la version précédente du plan (2026-08-07). Le modèle prévoyait une
List<VoiceConfig>avec une voix par langue, hérité du nommage Cloud TTS (fr-FR-Studio-C). Les voix Gemini TTS retenues sont multilingues : la même voix parle les 10 langues supportées. Donc un seulVoiceNamepar persona, et le guide garde une identité vocale constante d'une langue à l'autre — ce qui est précisément ce qu'on veut. La liste par langue et le champLanguageCodedisparaissent.
Gating — plan Premium
La vue "Configuration du guide IA" n'est accessible que si Instance.IsAssistant == true (flag déjà en place sur le backend, déjà utilisé comme gate dans AiController). Côté manager-app, conditionner l'affichage du menu sur ce flag — même pattern que l'existant.
UX de sélection des voix dans le CMS
Flow dans manager-app (page "Configuration du guide IA") :
- Pour chaque langue supportée par le venue : afficher la liste des voix Google disponibles
- Bouton play → sample MP3 pré-généré (
tts-samples/{voiceId}/{lang}.mp3) - Client sélectionne une voix par langue → sauvegardé dans
TtsVoices - Ensuite : champ texte
PersonaPrompt(ex: "Tu t'appelles Léon, tu parles en belge familier...") - Warning explicite à la sauvegarde : "Modifier la voix ou le persona va re-générer tous les fichiers audio du venue. Cette opération peut prendre plusieurs minutes."
Génération des samples de voix
- Phrase fixe par langue définie en dur dans le backend (ex: "Bonjour, je suis votre guide pour cette visite. Je suis ravi de vous accompagner.")
- Générés une seule fois au démarrage ou à la demande via un endpoint admin
- Stockés dans Firebase à
tts-samples/{voiceId}/{languageCode}.mp3 - Liste des voix disponibles : endpoint
GET /tts/voicesretourne les voix Google par langue, avec l'URL du sample
Pipeline Hangfire — Save d'un contenu
Au save d'un POI, article ou étape de parcours, un seul job Hangfire orchestre embedding + TTS :
// Dans SectionController, GeoPointController, GuidedStepController — après SaveChanges()
_backgroundJobClient.Enqueue<IContentPipelineService>(
s => s.OnContentSavedAsync(venueId, contentId, contentType));
OnContentSavedAsync :
- Enqueue job embedding (delta #1)
- Pour chaque langue active du venue : enqueue job TTS si
TtsVoicesconfiguré
Job TTS individuel :
- Récupère le texte source (titre + description)
- Appelle Google Cloud TTS avec la
VoiceNameconfigurée pour cette langue - Upload le MP3 dans Firebase Storage
- Crée ou met à jour la
Resource(type Audio) correspondante - Met à jour le
List<TranslationDTO>de la section
Re-génération suite à changement de voix/persona
Quand VoiceName ou PersonaPrompt change sur un venue :
- Avertissement dans le CMS (voir UX ci-dessus)
- Job Hangfire massif : itère sur tous les articles/POIs/steps du venue, enqueue un job TTS par item par langue
- Les anciens fichiers Firebase sont remplacés (même chemin → écrasement)
Les Resource entities existantes conservent leurs IDs — seul le fichier Firebase est remplacé. Pas de suppression/recréation de Resource nécessaire.
Clé de cache / invalidation
Chaque MP3 est identifié par son chemin Firebase qui encode (instanceId, contentType, contentId, languageCode). Le VoiceName n'est pas dans le chemin — c'est le même fichier écrasé lors d'un changement de voix.
Alternative à considérer : inclure un hash court de VoiceName dans le chemin pour garder l'ancien fichier pendant la re-génération (transition sans coupure). À évaluer selon la criticité.
Migration : uploads Firebase côté backend (refactor transverse)
⚠️ Ce refactor a été partiellement devancé (2026-08-07).
media-storage-plan.mdacte la colonneStoragePath+IStorageServicependant la migration Postgres, parce que les URL absolues Firebase sont dispersées jusque dans le JSONB des sections et coûteraient deux migrations si on attendait. Lire ce doc avant d'attaquer cette section — ce qui reste ici, c'est la centralisation de l'upload lui-même.
Aujourd'hui les uploads Firebase se font côté Flutter (manager-app) — le backend ne stocke que l'URL résultante dans Resource.Url. Le TTS nécessite des uploads serveur de toute façon, ce qui est l'occasion de centraliser tous les uploads Firebase dans le backend.
Avantages
- Un seul endroit pour gérer les credentials Firebase Storage
- Quota de stockage (
Instance.StorageQuotaBytes) enforceable côté serveur pour tous les uploads, pas seulement ceux du TTS - Pas de credentials Firebase exposés dans l'app Flutter
- Simplification du code Flutter (plus d'upload direct, juste un
POSTmultipart au backend)
Impact
ResourceController.Upload: remplacer le stockage Base64 actuel par un vrai upload Firebase Storage, retourner l'URL- Flutter manager-app : remplacer les uploads Firebase directs par des appels à l'endpoint existant
- Firebase Admin SDK (
FirebaseAdminNuGet) : déjà nécessaire pour le TTS — couvre aussi les uploads de ressources
Chemin Firebase pour les ressources existantes
Confirmé le 2026-08-07 depuis resources_screen.dart — la convention réelle est :
pictures/{instanceId}/{resourceId}
Sans extension, et quel que soit le type de ressource (le préfixe pictures/ est historique). Le chemin est donc déterministe : il se reconstruit depuis la ligne Resource, sans parser l'URL à jeton. C'est ce qui rend le backfill de StoragePath trivial (voir media-storage-plan.md).
Ce refactor est indépendant du TTS mais logiquement groupé avec lui puisque le SDK Firebase Admin est de toute façon ajouté. À traiter en même temps ou juste avant.
Dépendances avec les autres deltas
| Delta | Lien |
|---|---|
| #1 — pgvector | Même trigger Hangfire au save — orchestrer dans IContentPipelineService |
| #2 — Persona config | Même migration DB ; PersonaPrompt utilisé dans le prompt Gemini pour les questions libres |
#3 — Endpoint /ask |
Indépendant du TTS pré-généré (questions libres = TTS dynamique) |
| #5 — Canal Flutter | Consomme les URLs Firebase MP3 directement (stream ou download préalable) |