diff --git a/lib/Services/Glasses/voice_orchestrator.dart b/lib/Services/Glasses/voice_orchestrator.dart index cd9a6c9..a970b34 100644 --- a/lib/Services/Glasses/voice_orchestrator.dart +++ b/lib/Services/Glasses/voice_orchestrator.dart @@ -40,9 +40,17 @@ class VoiceOrchestrator { static const String _wakeSound = 'assets/sounds/wake_detected.mp3'; static const String _thinkingSound = 'assets/sounds/thinking.mp3'; - static const String _doneSound = 'assets/sounds/done.mp3'; final AudioPlayer _soundPlayer = AudioPlayer(); final AudioPlayer _thinkingPlayer = AudioPlayer(); + bool _soundsReady = false; + + /// Le son de réflexion n'apparaît qu'après ce délai : en dessous, le visiteur vient + /// de finir de parler et n'attend pas encore de réponse — le silence s'y lit comme + /// naturel, la nappe comme du bruit. + static const Duration _thinkingDelay = Duration(milliseconds: 700); + static const double _thinkingVolume = 0.45; + Timer? _thinkingTimer; + bool _thinkingWanted = false; final Map _lastQrTime = {}; static const int _qrCooldownMs = 10000; @@ -63,6 +71,7 @@ class VoiceOrchestrator { Future start() async { if (_running) return; _running = true; + await _preloadSounds(); await wakeWordEngine.start( onDetected: _onWakeWord, onDetectedWithCommand: _onWakeWordWithCommand, @@ -74,6 +83,7 @@ class VoiceOrchestrator { await wakeWordEngine.stop(); await sttEngine.cancel(); await ttsEngine.stop(); + await _stopThinkingLoop(); _running = false; debugPrint('[VoiceOrchestrator] Stopped'); } @@ -141,29 +151,52 @@ class VoiceOrchestrator { } } - Future _playSound(String asset) async { + /// Charge les deux sons une fois pour toutes. Sans ça, `setAsset` décode à chaque + /// wake word — sur le chemin le plus sensible à la latence de tout le pipeline. + Future _preloadSounds() async { + if (_soundsReady) return; try { - await _soundPlayer.setAsset(asset); - await _soundPlayer.play(); + await _soundPlayer.setAsset(_wakeSound); + await _thinkingPlayer.setAsset(_thinkingSound); + await _thinkingPlayer.setLoopMode(LoopMode.one); + _soundsReady = true; } catch (_) { - debugPrint('[VoiceOrchestrator] Sound not found: $asset'); + debugPrint('[VoiceOrchestrator] Sons introuvables — pipeline silencieux'); } } - Future _playWakeSound() => _playSound(_wakeSound); - Future _playDoneSound() => _playSound(_doneSound); - - Future _startThinkingLoop() async { + Future _playWakeSound() async { + if (!_soundsReady) return; try { - await _thinkingPlayer.setAsset(_thinkingSound); - await _thinkingPlayer.setLoopMode(LoopMode.one); - _thinkingPlayer.play(); - } catch (_) { - debugPrint('[VoiceOrchestrator] Thinking sound not found'); - } + await _soundPlayer.seek(Duration.zero); + _soundPlayer.play(); + } catch (_) {} + } + + /// Escalade plutôt que nappe permanente : rien pendant [_thinkingDelay], puis + /// fondu d'entrée. Une réponse rapide ne déclenche aucun son. + Future _startThinkingLoop() async { + if (!_soundsReady) return; + _thinkingWanted = true; + _thinkingTimer?.cancel(); + _thinkingTimer = Timer(_thinkingDelay, () async { + if (!_thinkingWanted) return; + try { + await _thinkingPlayer.seek(Duration.zero); + await _thinkingPlayer.setVolume(0); + _thinkingPlayer.play(); + for (var step = 1; step <= 6 && _thinkingWanted; step++) { + await Future.delayed(const Duration(milliseconds: 100)); + await _thinkingPlayer.setVolume(_thinkingVolume * step / 6); + } + } catch (_) {} + }); } Future _stopThinkingLoop() async { + _thinkingWanted = false; + _thinkingTimer?.cancel(); + _thinkingTimer = null; await _thinkingPlayer.stop(); } @@ -209,7 +242,6 @@ class VoiceOrchestrator { await _stopThinkingLoop(); if (result.reply.isNotEmpty) { lastTtsText.value = result.reply; - await _playDoneSound(); await ttsEngine.speak(result.reply, languageCode: langCode); } } catch (e) { @@ -372,30 +404,67 @@ class VoiceOrchestrator { // ── Helpers ──────────────────────────────────────────────────────────────── - bool _isStopCommand(String text) { + // Les quatre listes couvrent les quatre langues de l'assistant vocal (FR/NL/EN/DE). + // Elles étaient en français seul — écrites pour tester, jamais reprises — ce qui rendait + // les trois autres langues muettes sur les commandes. + + static const List _stopPhrases = [ + 'non', 'rien', 'non merci', 'rien merci', 'laisse tomber', 'annule', 'annuler', + 'arrête', 'arrete', 'arrêtez', 'au revoir', 'c\'est bon', 'ok merci', + 'merci c\'est tout', + 'no', 'nothing', 'no thanks', 'never mind', 'nevermind', 'cancel', 'goodbye', + 'that\'s all', 'that\'s it', 'forget it', + 'nee', 'niets', 'nee bedankt', 'laat maar', 'annuleer', 'tot ziens', 'het is goed', + 'nein', 'nichts', 'nein danke', 'lass gut sein', 'abbrechen', 'tschüss', 'das war\'s', + 'stop', 'stopp', + ]; + + static const List _qrScanPhrases = [ + 'scanne', 'scanner', 'scan', 'qr', 'qr code', 'code qr', 'qr-code', 'qrcode', + 'regarde ce code', 'lis le code', + ]; + + static const List _photoPhrases = [ + 'photo', 'une photo', 'prends une photo', 'capture', + 'picture', 'take a photo', 'take a picture', + 'foto', 'neem een foto', 'maak een foto', + 'mach ein foto', 'nimm ein foto', + ]; + + static const List _repeatPhrases = [ + // ⚠️ `encore` nu est volontairement absent : il matchait « raconte encore une + // histoire », qui rejouait la réponse précédente au lieu d'en demander une nouvelle. + 'répète', 'repete', 'répéter', 'répétez', 'encore une fois', 'redis', + 'repeat', 'again', 'say that again', + 'herhaal', 'opnieuw', 'nog eens', + 'wiederhole', 'nochmal', 'noch einmal', + ]; + + /// Match sur mot entier, pas sur sous-chaîne : `contains('prends')` reconnaissait + /// « je ne com**prends** pas » comme une demande de photo. + static bool _matchesAny(String text, List phrases) { final t = text.toLowerCase().trim(); - return t == 'non' || t == 'rien' || t == 'non rien' || t == 'rien merci' || - t == 'non merci' || t == 'laisse tomber' || t == 'annule' || t == 'annuler' || - t.contains('stop') || t.contains('arrête') || t.contains('au revoir') || - t.contains('c\'est bon') || t.contains('ok merci') || t.contains('laisse tomber') || - (t.length < 10 && (t.contains('non') || t.contains('rien'))); + return phrases.any( + (p) => RegExp('(^|\\W)${RegExp.escape(p)}(\$|\\W)').hasMatch(t), + ); } - bool _isQrScanCommand(String text) { - final t = text.toLowerCase(); - return t.contains('scan') || t.contains('qr') || t.contains('code') || t.contains('regarde'); - } + bool _isStopCommand(String text) => _matchesAny(text, _stopPhrases); - bool _isPhotoCommand(String text) { - final t = text.toLowerCase(); - return t.contains('photo') || t.contains('prends') || t.contains('capture'); - } + /// ⚠️ Ne matche plus `code` nu : « what's the code of this painting » déclenchait + /// un scan QR au lieu d'une réponse. + bool _isQrScanCommand(String text) => _matchesAny(text, _qrScanPhrases); - bool _isRepeatCommand(String text) { - final t = text.toLowerCase(); - return t.contains('répète') || t.contains('repete') || t.contains('encore'); - } + bool _isPhotoCommand(String text) => _matchesAny(text, _photoPhrases); + bool _isRepeatCommand(String text) => _matchesAny(text, _repeatPhrases); + + /// ⛔ L'assistant vocal est limité à FR/NL/EN/DE — décidé le 2026-08-13, cf. + /// `DOCS/voice-latency-plan.md` §1.6. Les six autres langues déclarées dans + /// `constants.dart` retombent ici sur `fr-FR` : un visiteur italien se fait répondre + /// en français, sans erreur ni trace. En ajouter une coûte peu — les traductions + /// `voice.*` existent déjà pour les 10 langues — mais il faut aussi compléter les + /// quatre listes de commandes ci-dessous, sinon la langue est à moitié supportée. String _toLangCode(String lang) { switch (lang.toUpperCase()) { case 'FR': return 'fr-FR';