mymuseum-visitapp/lib/Services/wake_word_service.dart

160 lines
5.3 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
// import 'package:porcupine_flutter/porcupine_flutter.dart'; // V2 — lib manquante dans cache pub
import 'package:speech_to_text/speech_to_text.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Services/assistantService.dart';
import 'package:mymuseum_visitapp/Services/glasses_qr_scanner_service.dart';
import 'package:mymuseum_visitapp/Services/glasses_tts_service.dart';
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
import 'package:mymuseum_visitapp/constants.dart';
/// Écoute en permanence le wake word "Hey MyVisit" via Porcupine (on-device).
///
/// Sur détection :
/// 1. Démarre speech_to_text pour transcrire la commande
/// 2. Dispatch :
/// - "scanne ce qr" / "scan" → MetaGlassesService.requestPhotoCapture()
/// - "répète" → GlassesTtsService.replay()
/// - autre → AssistantService.chat() → GlassesTtsService
/// 3. Relance Porcupine
///
/// Prérequis : générer les fichiers .ppn sur https://console.picovoice.ai/
/// et les placer dans assets/wake_words/ (déclarés dans pubspec.yaml).
class WakeWordService {
WakeWordService._();
static final WakeWordService instance = WakeWordService._();
// Chemins des keyword files — générés pour "Hey MyVisit" sur Picovoice Console
static const String _keywordAndroid = 'assets/wake_words/hey_myvisit_android.ppn';
static const String _keywordIOS = 'assets/wake_words/hey_myvisit_ios.ppn';
// PorcupineManager? _porcupineManager; // V2
final SpeechToText _speech = SpeechToText();
AssistantService? _assistantService;
bool _running = false;
bool _inConversation = false;
final ValueNotifier<bool> isListening = ValueNotifier(false);
Future<void> start({required VisitAppContext visitAppContext}) async {
if (_running) return;
if (!Platform.isAndroid && !Platform.isIOS) return;
_assistantService = AssistantService(visitAppContext: visitAppContext);
await _speech.initialize();
// Wake word (Porcupine) désactivé en V1 — lib manquante dans pub cache.
// En attendant, la conversation se déclenche uniquement via le bouton hardware
// des lunettes (MetaGlassesService.requestPhotoCapture) ou depuis l'UI.
_running = true;
debugPrint('[WakeWordService] Started (stub — wake word disabled, button-only mode)');
}
Future<void> stop() async {
await _speech.cancel();
_running = false;
isListening.value = false;
}
// TODO V2 : _startPorcupine() quand porcupine_flutter sera fonctionnel
// TODO V2 : _onWakeWord(int keywordIndex) — brancher Porcupine ici
/// Déclenche une conversation vocale manuellement (ex: depuis un bouton UI ou le bouton hardware lunettes).
Future<void> triggerConversation() async {
if (_inConversation || !_running) return;
_inConversation = true;
isListening.value = true;
final command = await _transcribeCommand();
debugPrint('[WakeWordService] Command: "$command"');
await _dispatch(command);
_inConversation = false;
isListening.value = false;
}
/// Transcrit la commande vocale via speech_to_text.
/// Retourne la transcription ou une chaîne vide en cas d'échec/timeout.
Future<String> _transcribeCommand() async {
final ctx = _assistantService?.visitAppContext;
final locale = ctx?.language?.toLowerCase() ?? 'fr';
final completer = Completer<String>();
String lastResult = '';
// Timeout de 6s pour capturer la commande
Timer? timeout = Timer(const Duration(seconds: 6), () {
if (!completer.isCompleted) completer.complete(lastResult);
});
await _speech.listen(
localeId: locale,
onResult: (result) {
lastResult = result.recognizedWords;
if (result.finalResult) {
timeout?.cancel();
if (!completer.isCompleted) completer.complete(lastResult);
}
},
listenOptions: SpeechListenOptions(partialResults: true),
);
return completer.future;
}
Future<void> _dispatch(String command) async {
final normalized = command.toLowerCase().trim();
if (_isQrScanCommand(normalized)) {
await MetaGlassesService.instance.requestPhotoCapture();
return;
}
if (_isRepeatCommand(normalized)) {
await GlassesTtsService.instance.replay();
return;
}
if (normalized.isEmpty) return;
// Question libre → AssistantService → TTS
try {
final response = await _assistantService!.chat(
message: command,
configurationId: _assistantService!.visitAppContext.configuration?.id,
);
if (response.reply.isNotEmpty) {
await GlassesTtsService.instance.speak(
response.reply,
apiKey: kElevenLabsApiKey,
voiceId: kElevenLabsVoiceId,
);
}
} catch (e) {
debugPrint('[WakeWordService] dispatch error: $e');
}
}
bool _isQrScanCommand(String text) {
return text.contains('scann') ||
text.contains('scan') ||
text.contains('qr') ||
text.contains('code');
}
bool _isRepeatCommand(String text) {
return text.contains('répète') ||
text.contains('repete') ||
text.contains('repeat') ||
text.contains('encore');
}
void dispose() {
stop();
isListening.dispose();
}
}