160 lines
5.3 KiB
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();
|
|
}
|
|
}
|