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>
203 lines
10 KiB
Markdown
203 lines
10 KiB
Markdown
# 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) |
|