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:
parent
f013d33bba
commit
66301805d8
@ -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<String, int> _lastQrTime = {};
|
||||
static const int _qrCooldownMs = 10000;
|
||||
@ -63,6 +71,7 @@ class VoiceOrchestrator {
|
||||
Future<void> 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<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 {
|
||||
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<void> _playWakeSound() => _playSound(_wakeSound);
|
||||
Future<void> _playDoneSound() => _playSound(_doneSound);
|
||||
|
||||
Future<void> _startThinkingLoop() async {
|
||||
Future<void> _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<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 {
|
||||
_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<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();
|
||||
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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user