DOCS/voice-latency-plan.md
Thomas Fransolet 016e880a39 Latence vocale, langues et accusés de réception — relevé du 13/08
⚠️ Travail d'une autre session, committé tel quel pour ne pas le laisser en
working tree. Il n'est pas de cette session-ci et n'a pas été relu ici.

Deux constats qui touchent le travail livré aujourd'hui : l'assistant vocal ne
parle réellement que FR/NL/EN/DE et retombe silencieusement sur le français, et
les commandes vocales cherchent des mots français en dur dans les quatre langues.
2026-08-13 16:01:43 +02:00

15 KiB
Raw Permalink Blame History

Assistant vocal — latence, accusés de réception et langues

Créé le 2026-08-13. Point d'entrée unique pour le finetuning de l'expérience vocale (mymuseum-visitapp, mode lunettes et mode téléphone). Découpé en trois horizons : ce qui se fait maintenant, ce qui se décide après les tests de terrain, et ce qui est V2.

⚠️ Ce document ne remet pas en cause la priorité du plan V1. Tout ce qui est ici est du finetuning : le flux vocal complet doit d'abord être testé et validé de bout en bout. Le bloc « maintenant » est inclus parce qu'il rend les tests eux-mêmes plus honnêtes — pas parce qu'il est urgent.


0. L'idée de départ, et pourquoi elle est repoussée

Remplacer le bip de détection du wake word (assets/sounds/wake_detected.mp3) par une phrase parlée dans la langue et la voix du visiteur — « Oui, je vous écoute » — avec 2 ou 3 variantes pour éviter la répétition mécanique sur une visite entière.

L'idée est bonne et elle est retenue — mais pas maintenant, et la raison est chiffrable : elle demande de générer, écouter et valider à la main de l'ordre de 40 fichiers audio (2 voix × 4 langues × 5 phrases). Produire ce lot avant d'avoir vécu le flux réel avec Viva et Marco, c'est décider à l'aveugle. Et surtout : une partie du problème que l'ack parlé compense — l'attente — se règle en amont, par la latence. Si le §1 fait son travail, l'ack parlé redevient un raffinement plutôt qu'un pansement.


1. Maintenant — avant les tests, sans produire un seul fichier audio

Six chantiers, tous contenus dans mymuseum-visitapp, aucun nouvel asset.

Livré le 2026-08-13 : 1.1, 1.2, 1.4, 1.5, 1.6. flutter analyze lib/Services/Glasses/0 erreur (5 issues restantes, toutes préexistantes et hors de ces changements). Le matching des commandes est couvert par 16 cas de test joués en vrai (dart run), 16/16. ⏸️ 1.3 (découpage du TTS par phrase) volontairement non fait — c'est le seul chantier structurant du lot, il mérite un feu vert séparé.

1.1 Le son « done » bloque la réponse

voice_orchestrator.dart:212 fait await _playDoneSound() juste avant ttsEngine.speak(). Or _playSound attend _soundPlayer.play(), dont le future ne se résout qu'à la fin de la lecture. Ce son retarde donc la réponse de toute sa durée, et fait doublon avec la voix qui démarre immédiatement après : la parole est le signal de fin.

Fait. L'appel est retiré. _playDoneSound, la constante _doneSound et l'asset ne sont plus référencés nulle part : le helper et la constante sont supprimés plutôt que conservés « au cas où », il n'existe aujourd'hui aucun chemin de réponse sans TTS. assets/sounds/done.mp3 reste sur disque, à supprimer si rien ne le reprend.

1.2 Les assets se décodent à chaud

_playSound appelle setAsset à chaque déclenchement — décodage à chaque wake word, sur le chemin le plus sensible à la latence qui soit.

Fait. _preloadSounds() appelé une fois depuis start(), les deux players restent chargés et _playWakeSound se contente d'un seek(0) + play(). Un drapeau _soundsReady rend le pipeline silencieux — et non plantant — si les assets manquent.

⚠️ play() n'est jamais awaité sur ces sons, et c'est délibéré : dans just_audio, le future de play() ne se résout qu'à la fin de la lecture. C'est exactement le piège du §1.1.

1.3 Découpage du TTS par phrase — le vrai gain

C'est le chantier structurant, et le seul qui change l'ordre de grandeur.

Aujourd'hui la chaîne est bloquante de bout en bout :

  • LlmClient.chat() retourne un Future<({String reply, bool expectsReply})> — réponse complète, pas de flux. Avec les tool calls (GetSectionDetail, RAG), c'est plusieurs secondes.
  • GeminiTtsEngine._synthesize() fait un generateContent unaire, récupère tout le PCM base64, écrit un WAV sur disque, puis lit.

Donc time-to-first-audio = LLM complet + TTS complet. C'est ce trou que le son de réflexion bouche aujourd'hui.

À faire : découper result.reply en phrases, synthétiser la première, la jouer, et synthétiser les suivantes en tâche de fond pendant la lecture. Le time-to-first-audio tombe à LLM + TTS(1 phrase). Contenu dans GeminiTtsEngine, ne touche ni l'orchestrateur ni le backend.

⚠️ Non vérifié : :streamGenerateContent émet-il des chunks audio progressifs sur gemini-2.5-flash-preview-tts ? Si oui, ce chantier devient encore meilleur. C'est un test curl de 20 minutes — à faire, pas à supposer.

1.4 Les commandes ne sont reconnues qu'en français

_isStopCommand, _isRepeatCommand, _isQrScanCommand, _isPhotoCommand (voice_orchestrator.dart:375-397) cherchent des mots français en dur : « répète », « arrête », « prends », « regarde ».

Ce n'est pas un manque de langues, c'est un manque dans les langues déjà annoncées. Un visiteur néerlandophone qui dit « herhaal » n'est pas compris. Tester un flux multilingue avec ces listes, c'est tester autre chose que ce qu'on croit.

Et un faux positif franc : _isQrScanCommand matche sur code. Un anglophone qui demande « what's the code of this painting » déclenche un scan QR au lieu d'une réponse.

Fait. Quatre listes static const couvrant FR/NL/EN/DE, et surtout un changement de méthode de matching : _matchesAny compare sur mot entier ((^|\W)phrase($|\W)) au lieu de contains.

Ce changement de méthode a révélé deux faux positifs qui vivaient déjà là :

  • contains('prends') reconnaissait « je ne comprends pas » comme une demande de photo ;
  • contains('encore') reconnaissait « raconte encore une histoire » comme « répète », ce qui rejouait la réponse précédente au lieu d'en produire une nouvelle. encore nu est retiré au profit d'encore une fois. Arbitrage assumé : « encore ? » tout court ne déclenche plus de replay et part au LLM, qui s'en sort. À rouvrir si les tests montrent que le mot seul est fréquent.

Et le match nu sur code est supprimé — « what's the code of this painting » ne déclenche plus de scan QR.

Vérifié, pas supposé : 16 cas joués via dart run (11 doivent matcher dans les 4 langues, 5 ne doivent pas). 16/16.

1.5 Escalade du son de réflexion

Le drone continu (thinking.mp3 en LoopMode.one) joue même quand la réponse arrive vite — il devient alors du bruit pur.

À faire, sans nouvel asset, uniquement du timing sur le fichier existant :

Délai Comportement
0 → 700 ms silence — le visiteur vient de parler, il n'attend pas encore
700 ms → 3,5 s nappe en fade-in, plus discrète qu'aujourd'hui
> 3,5 s (après les tests) filler parlé, cf. §2

Fait pour les deux premiers paliers. _thinkingDelay = 700ms, fondu d'entrée en 6 pas de 100 ms jusqu'à _thinkingVolume = 0.45 (soit ~7 dB sous le niveau précédent). Un drapeau _thinkingWanted interrompt le fondu si la réponse arrive pendant la montée.

⚠️ Corrigé au passage : stop() n'appelait pas _stopThinkingLoop(). Le timer de 700 ms pouvait donc se déclencher après l'arrêt de l'orchestrateur et jouer une nappe alors que plus rien n'était en cours.

1.6 Acter la limite à 4 langues

_toLangCode (voice_orchestrator.dart:399-407) ne mappe que FR/NL/EN/DE et renvoie fr-FR par défaut, alors que constants.dart:59-70 déclare 10 langues. Un visiteur en IT ou ES se fait déjà répondre en français aujourd'hui, silencieusement.

Décidé le 2026-08-13 : l'assistant vocal est officiellement à 4 langues — FR, NL, EN, DE. À refléter dans le CMS et la doc commerciale. Le reste de l'app garde ses 10 langues, seul le canal vocal est restreint.

Le coût d'en ajouter une plus tard est faible, et c'est vérifié :

  • les traductions voice.* existent déjà pour les 10 langues dans translations.dart ;
  • Whisper prend le code générique (fr-FRfr) et est multilingue ;
  • Gemini TTS couvre largement IT/ES/PL ;
  • le wake word est phonétique, indépendant de la langue parlée ensuite.

Reste donc, par langue ajoutée : une entrée dans _toLangCode, les listes de mots-clés du §1.4, et une vérification du prompt côté backend. Pas de chantier caché.

Fait côté code : _toLangCode porte désormais le commentaire qui dit la limite et ce qu'ajouter une langue implique — les deux endroits, pas seulement le mapping. La décision est répercutée dans v1-plan.md §5 et STATUS.md §5bis. Reste à faire hors code : la refléter dans le CMS et la doc commerciale.


2. Après les tests — décisions qui demandent d'avoir entendu le flux

Rien ici ne se tranche sur intuition. Toutes ces décisions supposent d'avoir vécu une visite complète avec Viva et avec Marco, sur lunettes et sur téléphone.

a. Ack parlé ou earcon ? Et si parlé, combien de variantes. Le point de comparaison n'existe qu'après avoir mesuré la latence post-§1.3.

b. Génération du lot audio — uniquement si (a) est positif. Arborescence : assets/sounds/ack/{viva|marco}/{fr,nl,en,de}/ack_{1..3}.mp3, plus filler_{1,2}. ~40 fichiers, ~1,5 Mo.

Deux pièges à ne pas redécouvrir :

  • ⚠️ pubspec.yaml:133 déclare assets/sounds/les déclarations de dossier ne sont pas récursives en Flutter. Chaque sous-dossier devra être listé.
  • ⚠️ Générer avec exactement le même voicePrompt que le runtime (kGeminiTtsPrompt), sinon le timbre et la prosodie décrochent entre l'ack et la réponse. Gemini TTS n'est pas déterministe : prévoir plusieurs prises et un choix à l'oreille. Via un script tool/generate_voice_assets.dart, joué une fois, résultat commité.

c. Ordonnancement ack → écoute. Aujourd'hui : bip non awaité + Future.delayed(200ms). Avec un ack parlé d'environ 800 ms, ouvrir le micro pendant la lecture ferait transcrire la voix de l'assistant par Whisper — sur Ray-Ban, micro et haut-parleur partagent la monture, le couplage est fort. Plutôt que de faire de l'AEC : démarrer le STT à player.duration - 150ms, calibré à l'oreille sur le terrain. Budget avant écoute : ~400 ms → ~700 ms, acceptable parce que pendant ces 700 ms le visiteur sait qu'il a été entendu.

d. Filler parlé au-delà de 3,5 s. Probablement inutile si §1.3 fait son travail — à décider sur mesure.

e. Règle de cohérence, non négociable. Si GEMINI_API_KEY est absente, le moteur retombe sur FlutterTtsEngine (voix système). Des acks pré-générés en Sulafat suivis d'une réponse en voix Android seraient pires que le bip. Les acks vocaux ne s'activent que si le moteur runtime est Gemini et que la voix de l'asset correspond à guideVoiceId. Sinon, earcon.


3. V2

3.1 Streaming LLM

C'est là qu'est le gros de la latence restante, une fois §1.3 livré. Implique du SSE côté manager-service et un changement de signature de LlmClient. Combiné au découpage par phrase, l'assistant parle dès la première phrase générée.

3.2 Live API Gemini — audio natif bidirectionnel

Ce que c'est. Aujourd'hui : trois maillons distincts (Whisper → LLM → Gemini TTS), trois allers-retours, et deux conversions qui perdent l'intonation et l'hésitation du visiteur. Le Live API les supprime : une connexion WebSocket permanente où l'on pousse le flux micro brut (PCM 16 kHz) et reçoit du flux audio de réponse (PCM 24 kHz), sans jamais passer par du texte.

Trois choses que l'architecture actuelle ne peut structurellement pas faire :

  • le barge-in — le visiteur coupe la parole à l'assistant, qui s'arrête net ;
  • la détection de fin de phrase côté serveur (VAD) — fin du timeout: 5 secondes en dur de _listenForFollowUp ;
  • une latence conversationnelle de l'ordre de la seconde.

Les voix prébuilt sont de la même famille que celles utilisées aujourd'hui : Viva/Marco pourraient survivre à la bascule.

Comment on l'intégrerait. Le point dur n'est pas l'audio, c'est le tool calling. MyInfoMateLlmClient parle à manager-service, qui détient les outils (GetSectionDetail, RAG, contexte de visite). Le Live API supporte le function calling, mais il faut décider qui exécute :

  • Option A — l'app se connecte directement à Gemini. Latence minimale, mais l'app porterait les définitions d'outils et rappellerait manager-service pour chacun. Et on ne met pas une clé Gemini dans un APK distribué à des visiteurs : il faut des jetons éphémères émis par le backend. Faisable, mais ça déplace de la logique métier dans le client — l'inverse de l'architecture actuelle.
  • Option B — manager-service proxifie le WebSocket. L'app parle au backend, le backend parle à Gemini et exécute les outils là où ils vivent déjà. La clé reste au chaud, on garde la main sur le prompt et les stats. Mais c'est du relais audio bidirectionnel temps réel en C#, avec sessions, reconnexions et backpressure.

C'est l'option B qui a du sens ici, et c'est elle qui coûte cher.

Faisable ? Techniquement oui, sans obstacle bloquant. Trois réserves sérieuses :

  1. ⚠️ Le modèle de coût change de nature. On ne paie plus à la requête mais à la session ouverte, et les jetons audio coûtent nettement plus cher que le texte. Un musée avec 80 visiteurs simultanés = 80 sessions live. À chiffrer avant toute décision technique — les tarifs ne sont pas connus ici, à vérifier et non à supposer.
  2. ⚠️ Sessions à durée limitée, avec reprise à gérer. Sur un réseau mobile de visiteur dans un bâtiment en pierre, c'est du vrai travail de robustesse.
  3. Les modèles Live sont en preview. Construire une fonction vendue à des clients sur une API preview, après l'historique de bascule ElevenLabs → Gemini, serait imprudent.

Verdict : piste V2 réelle, à ouvrir par un spike chiffré d'une journée — connecter, mesurer la latence réelle, mesurer le coût d'une session de 5 minutes — avant tout engagement.

3.3 Le reste

  • Barge-in / AEC sur l'architecture actuelle, si le Live API n'est pas retenu.
  • Extension au-delà de 4 langues (cf. §1.6 pour le coût réel).

Références

  • mymuseum-visitapp/lib/Services/Glasses/voice_orchestrator.dart
  • mymuseum-visitapp/lib/Services/Glasses/engines/impl/gemini_tts_engine.dart
  • mymuseum-visitapp/lib/Services/voice_controller.dart_buildTtsEngine
  • mymuseum-visitapp/lib/constants.dartkGeminiTtsVoice, kGeminiTtsPrompt
  • v1-plan.md §5 — les lunettes Ray-Ban sont en V1 depuis le 2026-08-12
  • DOCS/rayban-meta-integration.md