DOCS/v2/tts-pregenerated-plan.md
Thomas Fransolet a5a8ecdb20 Documentation interne MyInfoMate / Unov
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>
2026-08-11 11:17:01 +02:00

203 lines
10 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.

# 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) |