> # ⚠️ CORRECTIONS — 2026-09-02 > > Ce plan reste la référence du pipeline TTS, mais **trois points sont périmés**. Arbitrés dans > [studio-plan.md §3.8](studio-plan.md), qui est désormais la source de vérité sur les personnages. > > **1. Le moteur est Gemini TTS, pas Google Cloud TTS.** L'en-tête et l'étape 2 du job disent > « Google Cloud TTS », mais la section dual-persona dit « les voix **Gemini TTS** retenues sont > multilingues » — deux moteurs dans le même document. **Le code tranche** : `GeminiTtsEngine`, > `gemini-2.5-flash-preview-tts`. Conséquence : pas de `enable_time_pointing`, donc pas de word > timestamps (c'est ce qui a fait abandonner `talking-head-plan.md`). > > **2. « Le visiteur choisit entre Viva et Marco » est remplacé par « un guide + N narrateurs ».** > C'était un choix de *voix* déguisé en choix de guide. Le besoin réel est celui du **Bastogne War > Museum** : trois ou quatre personnages qui narrent chacun *leurs* stations. Le conservateur > assigne (`NarratorPersonaId` sur `GuidedStep` / `GeoPoint` / `SectionArticle`), le visiteur ne > choisit rien. > > **3. Le stockage est donc divisé par deux, pas multiplié.** La note « le stockage est multiplié > par le nombre de personas actifs (max ×2) » n'était vraie que parce que le visiteur choisissait : > chaque contenu devait exister dans les deux voix. Avec un narrateur **assigné par contenu**, > chaque contenu n'existe qu'une fois — **quatre narrateurs coûtent moins que deux personas au choix.** > > **Deux champs à ajouter** : `Persona.VoicePrompt` (l'intonation, aujourd'hui la constante de build > `kGeminiTtsPrompt` dans `visitapp constants.dart:27` — c'est elle qui fait que le gouverneur ne > sonne pas comme la sentinelle) et une table `TtsVoice` à la place des deux constantes. > > **Le wakeword** n'est plus une propriété du persona mais une **exigence du canal mains-libres** > (lunettes, casque VR). Mobile / web / kiosk : push-to-talk, aucun wakeword, nom du guide libre. --- # 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` 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` | Déjà en place — brancher le pipeline TTS | | `GeoPoint` (POIs de SectionMap) | Aucun | Ajouter `AudioIds: List` | | `GuidedStep` (étapes de GuidedPath/Parcours) | À confirmer | Ajouter `AudioIds: List` 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` 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) : ```csharp // Sur l'entité Venue / Instance public List Personas { get; set; } // max 2 en plan de base ``` ```csharp 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` 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 seul `VoiceName` par 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 champ `LanguageCode` disparaissent. --- ## 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") : 1. Pour chaque langue supportée par le venue : afficher la liste des voix Google disponibles 2. Bouton play → sample MP3 pré-généré (`tts-samples/{voiceId}/{lang}.mp3`) 3. Client sélectionne une voix par langue → sauvegardé dans `TtsVoices` 4. Ensuite : champ texte `PersonaPrompt` (ex: "Tu t'appelles Léon, tu parles en belge familier...") 5. **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/voices` retourne 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 : ```csharp // Dans SectionController, GeoPointController, GuidedStepController — après SaveChanges() _backgroundJobClient.Enqueue( s => s.OnContentSavedAsync(venueId, contentId, contentType)); ``` `OnContentSavedAsync` : 1. Enqueue job embedding (delta #1) 2. Pour chaque langue active du venue : enqueue job TTS si `TtsVoices` configuré Job TTS individuel : 1. Récupère le texte source (titre + description) 2. Appelle Google Cloud TTS avec la `VoiceName` configurée pour cette langue 3. Upload le MP3 dans Firebase Storage 4. Crée ou met à jour la `Resource` (type Audio) correspondante 5. Met à jour le `List` de la section --- ## Re-génération suite à changement de voix/persona Quand `VoiceName` ou `PersonaPrompt` change sur un venue : 1. Avertissement dans le CMS (voir UX ci-dessus) 2. Job Hangfire massif : itère sur tous les articles/POIs/steps du venue, enqueue un job TTS par item par langue 3. 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.md` acte la colonne `StoragePath` + `IStorageService` **pendant 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 `POST` multipart 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 (`FirebaseAdmin` NuGet) : 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) | --- ## Volet commercial (ajouté le 2026-08-28) Ce plan est purement technique ; les décisions de vente sont dans `todo-features.md` — section « TTS pré-généré — volet commercial ». En résumé, et parce que deux d'entre elles ont un impact sur ce plan : - **Facturer la génération, pas le stockage.** Le pipeline ci-dessus écrase le fichier au même chemin Firebase à chaque re-génération : le stockage ne bouge pas, le coût TTS oui. Le compteur à exposer est donc le nombre de secondes/caractères générés, pas `StorageQuotaBytes`. - **Prérequis d'annonce : le déclenchement doit être self-service.** Le pipeline part aujourd'hui d'un `Enqueue` au save ; la re-génération massive (changement de voix/persona) suppose un geste depuis le back-office, pas une intervention interne. Sans cet écran, la fonctionnalité reste « Bientôt » sur le site. - Le reste (positionnement audioguide face aux bornes physiques, argument accessibilité, upsell Fourneau Saint-Michel) n'a pas d'incidence sur l'architecture.