Latence vocale : préchargement des sons et nappe de réflexion

⚠️ 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. Le plan
correspondant est DOCS/voice-latency-plan.md.

Les deux sons sont préchargés une fois pour toutes : setAsset décodait à chaque
wake word, sur le chemin le plus sensible à la latence de tout le pipeline. Et
une nappe de réflexion apparaît après 700 ms — en dessous, le visiteur vient de
finir de parler et n'attend pas encore de réponse.
This commit is contained in:
Thomas Fransolet 2026-08-13 16:01:01 +02:00
parent f013d33bba
commit 66301805d8

View File

@ -40,9 +40,17 @@ class VoiceOrchestrator {
static const String _wakeSound = 'assets/sounds/wake_detected.mp3'; static const String _wakeSound = 'assets/sounds/wake_detected.mp3';
static const String _thinkingSound = 'assets/sounds/thinking.mp3'; static const String _thinkingSound = 'assets/sounds/thinking.mp3';
static const String _doneSound = 'assets/sounds/done.mp3';
final AudioPlayer _soundPlayer = AudioPlayer(); final AudioPlayer _soundPlayer = AudioPlayer();
final AudioPlayer _thinkingPlayer = 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<String, int> _lastQrTime = {}; final Map<String, int> _lastQrTime = {};
static const int _qrCooldownMs = 10000; static const int _qrCooldownMs = 10000;
@ -63,6 +71,7 @@ class VoiceOrchestrator {
Future<void> start() async { Future<void> start() async {
if (_running) return; if (_running) return;
_running = true; _running = true;
await _preloadSounds();
await wakeWordEngine.start( await wakeWordEngine.start(
onDetected: _onWakeWord, onDetected: _onWakeWord,
onDetectedWithCommand: _onWakeWordWithCommand, onDetectedWithCommand: _onWakeWordWithCommand,
@ -74,6 +83,7 @@ class VoiceOrchestrator {
await wakeWordEngine.stop(); await wakeWordEngine.stop();
await sttEngine.cancel(); await sttEngine.cancel();
await ttsEngine.stop(); await ttsEngine.stop();
await _stopThinkingLoop();
_running = false; _running = false;
debugPrint('[VoiceOrchestrator] Stopped'); debugPrint('[VoiceOrchestrator] Stopped');
} }
@ -141,29 +151,52 @@ class VoiceOrchestrator {
} }
} }
Future<void> _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<void> _preloadSounds() async {
if (_soundsReady) return;
try { try {
await _soundPlayer.setAsset(asset); await _soundPlayer.setAsset(_wakeSound);
await _soundPlayer.play(); await _thinkingPlayer.setAsset(_thinkingSound);
await _thinkingPlayer.setLoopMode(LoopMode.one);
_soundsReady = true;
} catch (_) { } catch (_) {
debugPrint('[VoiceOrchestrator] Sound not found: $asset'); debugPrint('[VoiceOrchestrator] Sons introuvables — pipeline silencieux');
} }
} }
Future<void> _playWakeSound() => _playSound(_wakeSound); Future<void> _playWakeSound() async {
Future<void> _playDoneSound() => _playSound(_doneSound); if (!_soundsReady) return;
Future<void> _startThinkingLoop() async {
try { try {
await _thinkingPlayer.setAsset(_thinkingSound); await _soundPlayer.seek(Duration.zero);
await _thinkingPlayer.setLoopMode(LoopMode.one); _soundPlayer.play();
_thinkingPlayer.play(); } catch (_) {}
} catch (_) { }
debugPrint('[VoiceOrchestrator] Thinking sound not found');
} /// Escalade plutôt que nappe permanente : rien pendant [_thinkingDelay], puis
/// fondu d'entrée. Une réponse rapide ne déclenche aucun son.
Future<void> _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<void> _stopThinkingLoop() async { Future<void> _stopThinkingLoop() async {
_thinkingWanted = false;
_thinkingTimer?.cancel();
_thinkingTimer = null;
await _thinkingPlayer.stop(); await _thinkingPlayer.stop();
} }
@ -209,7 +242,6 @@ class VoiceOrchestrator {
await _stopThinkingLoop(); await _stopThinkingLoop();
if (result.reply.isNotEmpty) { if (result.reply.isNotEmpty) {
lastTtsText.value = result.reply; lastTtsText.value = result.reply;
await _playDoneSound();
await ttsEngine.speak(result.reply, languageCode: langCode); await ttsEngine.speak(result.reply, languageCode: langCode);
} }
} catch (e) { } catch (e) {
@ -372,30 +404,67 @@ class VoiceOrchestrator {
// Helpers // 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<String> _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<String> _qrScanPhrases = [
'scanne', 'scanner', 'scan', 'qr', 'qr code', 'code qr', 'qr-code', 'qrcode',
'regarde ce code', 'lis le code',
];
static const List<String> _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<String> _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<String> phrases) {
final t = text.toLowerCase().trim(); final t = text.toLowerCase().trim();
return t == 'non' || t == 'rien' || t == 'non rien' || t == 'rien merci' || return phrases.any(
t == 'non merci' || t == 'laisse tomber' || t == 'annule' || t == 'annuler' || (p) => RegExp('(^|\\W)${RegExp.escape(p)}(\$|\\W)').hasMatch(t),
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')));
} }
bool _isQrScanCommand(String text) { bool _isStopCommand(String text) => _matchesAny(text, _stopPhrases);
final t = text.toLowerCase();
return t.contains('scan') || t.contains('qr') || t.contains('code') || t.contains('regarde');
}
bool _isPhotoCommand(String text) { /// Ne matche plus `code` nu : « what's the code of this painting » déclenchait
final t = text.toLowerCase(); /// un scan QR au lieu d'une réponse.
return t.contains('photo') || t.contains('prends') || t.contains('capture'); bool _isQrScanCommand(String text) => _matchesAny(text, _qrScanPhrases);
}
bool _isRepeatCommand(String text) { bool _isPhotoCommand(String text) => _matchesAny(text, _photoPhrases);
final t = text.toLowerCase();
return t.contains('répète') || t.contains('repete') || t.contains('encore');
}
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) { String _toLangCode(String lang) {
switch (lang.toUpperCase()) { switch (lang.toUpperCase()) {
case 'FR': return 'fr-FR'; case 'FR': return 'fr-FR';