diff --git a/STATUS.md b/STATUS.md index b9a8130..623bcf5 100644 --- a/STATUS.md +++ b/STATUS.md @@ -797,6 +797,17 @@ Pas « le SDK est en preview » — la liste est plus concrète : - ⛔ **Correction du 2026-08-13 : « l'APK se construit sans le POC dedans » est faux, et la conclusion qu'on en tirait aussi.** Les 4 erreurs ne sont pas dans le POC vivant, elles sont dans ses **ancêtres** : `wake_word_service.dart` n'est importé par personne, et `glasses_qr_scanner_service.dart` seulement par lui — un îlot de deux fichiers hors du graphe de `main.dart`, la génération d'avant l'orchestrateur. Le POC **actuel**, lui, est bien dans l'APK : `Services/Glasses/` est importé par `VoiceController`, et `GlassesStatusWidget` est monté sur l'accueil.
⚠️ **Conséquence pratique, inverse de celle qui était écrite ici** : « démontrable » **ne suppose pas** de remettre les deux constantes ElevenLabs. Le chemin vivant choisit `GeminiTtsEngine` dès que `kGeminiApiKey` est renseignée (`voice_controller.dart:94-100`) et ne touche jamais `kElevenLabs*`. La bascule TTS → Gemini réclamée ci-dessus **est déjà faite dans le code** ; ce qui reste est de supprimer les deux ancêtres morts. - ✅ ~~**Branche jamais mergée / à trancher avant K6**~~ — **tranché le 2026-08-13 par le propriétaire du projet : `Meta-Rayban-Test` est la branche de travail à jour, pas un POC de côté.** Le nom est trompeur, il date de la première expérimentation ; tout le travail V1 de `mymuseum-visitapp` y vit et les lunettes n'en sont qu'une partie — invisibles, d'ailleurs, si l'instance n'a pas l'assistant. On développe et on publie depuis elle. Idem `tablet-app` sur `AI-Assistant-test`. **Rien à clarifier avant K6** ; c'est ce paragraphe qui affirmait le contraire et fabriquait l'alerte. +### Latence, langues et accusés de réception — relevé le 2026-08-13 + +> Plan complet : **[voice-latency-plan.md](voice-latency-plan.md)**, découpé en « maintenant / après les tests / V2 ». Ce qui suit n'en garde que les constats vérifiés dans le code. + +- ⛔ **L'assistant vocal ne parle réellement que FR/NL/EN/DE, et personne ne le disait.** `_toLangCode` (`voice_orchestrator.dart:399-407`) ne mappe que ces quatre langues et **renvoie `fr-FR` par défaut** ; `constants.dart:59-70` en déclare 10. Un visiteur en italien se fait répondre en français, **sans erreur ni trace**. Décidé le 2026-08-13 : la limite est assumée et documentée, le reste de l'app garde ses 10 langues. Le coût d'en rajouter une 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 et est multilingue, le wake word est phonétique. +- ⛔ **Et les quatre langues annoncées ne le sont pas non plus.** `_isStopCommand`, `_isRepeatCommand`, `_isQrScanCommand`, `_isPhotoCommand` (`:375-397`) cherchent des mots **français en dur** — « répète », « arrête », « prends », « regarde ». Elles avaient été écrites en français pour tester et n'ont jamais été reprises. Un néerlandophone qui dit « herhaal » n'est pas compris, et `_isQrScanCommand` matche sur `code` : « what's the **code** of this painting » déclenche un scan QR. **À corriger avant le lot H** — sinon les tests multilingues valident autre chose que ce qu'on croit. +- ⚠️ **`done.mp3` retarde la réponse de toute sa durée.** `:212` fait `await _playDoneSound()` juste avant `ttsEngine.speak()`, et `_playSound` attend `play()`, dont le future ne se résout qu'à **la fin de la lecture**. En prime c'est un doublon : la parole *est* le signal de fin. +- ⚠️ **Le time-to-first-audio est la somme de tout.** `LlmClient.chat()` retourne un future de réponse complète (pas de flux) et `GeminiTtsEngine._synthesize()` fait un `generateContent` unaire qui attend **tout** le PCM avant d'écrire le WAV. D'où le son de réflexion en boucle, qui bouche ce trou. Le remède qui ne dépend d'aucune API nouvelle : **découper la réponse en phrases** et synthétiser la première pendant que les suivantes se préparent — contenu dans `GeminiTtsEngine`, sans toucher au backend. +- 💡 **Idée retenue mais repoussée : remplacer le bip du wake word par une phrase parlée** (« Oui, je vous écoute ») dans la langue et la voix du visiteur, 2-3 variantes. Repoussée **après les tests** parce qu'elle demande de générer et valider à l'oreille ~40 fichiers (2 voix × 4 langues × 5 phrases), et qu'une partie du besoin qu'elle compense disparaît si la latence baisse. ⚠️ Règle de cohérence si on la fait : les acks ne s'activent que si le moteur runtime est **Gemini** et que la voix correspond à `guideVoiceId` — des acks en Sulafat suivis d'une réponse en voix système Android seraient pires que le bip. +- 🔭 **Piste V2 : le Live API de Gemini** (WebSocket, audio natif bidirectionnel) supprimerait les trois maillons Whisper → LLM → TTS et débloquerait le barge-in et le VAD serveur. Faisable, mais le tool calling devrait passer par un proxy WebSocket dans `manager-service` (option retenue sur le papier), le modèle de coût passe à la **session ouverte** avec des jetons audio bien plus chers, et les modèles sont **en preview**. **À ouvrir par un spike chiffré d'une journée, pas par une décision.** + ### Ce que ça change côté commercial Le POC est **démontrable**. Face à un concurrent mono-usage type Musa Guide, une démo qui tourne pèse plus qu'une ligne « bientôt » sur la landing. À condition d'assumer le kit Android prêté, et de ne pas vendre l'add-on avant la bascule TTS. diff --git a/voice-latency-plan.md b/voice-latency-plan.md new file mode 100644 index 0000000..a9031f1 --- /dev/null +++ b/voice-latency-plan.md @@ -0,0 +1,270 @@ +# 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`