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 isListening = ValueNotifier(false); Future 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 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 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 _transcribeCommand() async { final ctx = _assistantService?.visitAppContext; final locale = ctx?.language?.toLowerCase() ?? 'fr'; final completer = Completer(); 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 _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(); } }