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 _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';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user