244 lines
13 KiB
Markdown
244 lines
13 KiB
Markdown
> # ⚠️ 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<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) :
|
||
|
||
```csharp
|
||
// Sur l'entité Venue / Instance
|
||
public List<PersonaConfig> 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<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 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<IContentPipelineService>(
|
||
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<TranslationDTO>` 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.
|