⚠️ 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.
271 lines
15 KiB
Markdown
271 lines
15 KiB
Markdown
# 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 com**prends** 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-FR` → `fr`) 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.dart` — `kGeminiTtsVoice`, `kGeminiTtsPrompt`
|
||
- `v1-plan.md` §5 — les lunettes Ray-Ban sont en V1 depuis le 2026-08-12
|
||
- `DOCS/rayban-meta-integration.md`
|