DOCS/v2/tts-pregenerated-plan.md
2026-09-03 14:00:51 +02:00

244 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

> # ⚠️ 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.