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

13 KiB
Raw Permalink Blame History

⚠️ 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, 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) :

// Sur l'entité Venue / Instance
public List<PersonaConfig> Personas { get; set; }  // max 2 en plan de base
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 :

// 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.