import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Services/Glasses/engines/tts_engine.dart'; import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/flutter_tts_engine.dart'; import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/gemini_tts_engine.dart'; import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/myinfomate_llm_client.dart'; import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/native_wake_word_engine.dart'; import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/speech_to_text_stt.dart'; import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/speech_to_text_wake_word.dart'; import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/whisper_stt_engine.dart'; import 'package:mymuseum_visitapp/Services/Glasses/glasses_background_service.dart'; import 'package:mymuseum_visitapp/Services/Glasses/voice_orchestrator.dart'; import 'package:mymuseum_visitapp/Services/geo_beacon_trigger_service.dart'; import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart'; import 'package:mymuseum_visitapp/constants.dart'; enum VoiceMode { none, voiceOnly, glasses } class VoiceController extends ChangeNotifier { VoiceMode _mode = VoiceMode.none; VoiceMode get mode => _mode; bool _isActivating = false; bool get isActivating => _isActivating; VoiceController() { MetaGlassesService.instance.state.addListener(_onGlassesStateChanged); } bool isFeatureAvailable(VisitAppContext ctx) => ctx.applicationInstanceDTO?.isAssistant == true; bool get isGlassesConnected => MetaGlassesService.instance.state.value == GlassesState.connected || MetaGlassesService.instance.state.value == GlassesState.streaming; /// Active le mode demandé. Si un mode est déjà actif, le stoppe d'abord. Future activate(VoiceMode targetMode, VisitAppContext ctx) async { if (_isActivating) return; _isActivating = true; notifyListeners(); try { await _stopOrchestrator(); if (targetMode == VoiceMode.glasses) { await GlassesBackgroundService.initialize(); await GlassesBackgroundService.start(); await MetaGlassesService.instance.connect(); } _buildOrchestrator(ctx); await activeVoiceOrchestrator!.start(); _mode = targetMode; // Le déclencheur géographique n'a de sens que si une sortie audio est vivante : // son cycle de vie est donc celui de l'orchestrateur, pas celui de l'app. Les points // sont relus ici plutôt qu'au démarrage — la configuration a pu changer entre-temps. await GeoBeaconTriggerService.instance.start( visitAppContext: ctx, geoPoints: GeoBeaconTriggerService.buildGeoPoints(ctx.currentSections), ); } catch (e) { debugPrint('[VoiceController] activate error: $e'); await _stopOrchestrator(); _mode = VoiceMode.none; } finally { _isActivating = false; notifyListeners(); } } Future deactivate() async { await _stopOrchestrator(); _mode = VoiceMode.none; notifyListeners(); } /// Relance le wake word après un retour en foreground. void restartWakeWordIfNeeded() { final o = activeVoiceOrchestrator; if (o != null && o.isRunning && !o.isInConversation) { o.restartWakeWord(); } } Future _stopOrchestrator() async { // Avant l'orchestrateur : le déclencheur s'appuie dessus pour parler, le laisser // tourner une fraction de seconde de plus n'apporte rien et peut lancer un appel LLM // dont la réponse n'aurait plus de voix. C'est aussi le chemin du `catch` d'activate, // donc celui d'une activation échouée à mi-course. await GeoBeaconTriggerService.instance.stop(); if (activeVoiceOrchestrator != null) { await activeVoiceOrchestrator!.stop(); activeVoiceOrchestrator = null; } } void _buildOrchestrator(VisitAppContext ctx) { activeVoiceOrchestrator = VoiceOrchestrator( visitAppContext: ctx, wakeWordEngine: Platform.isAndroid ? NativeWakeWordEngine(modelNames: ['hey_marco', 'hey_alba', 'hey_vasco']) : SpeechToTextWakeWordEngine(keyword: 'visite'), sttEngine: kWhisperApiKey.isNotEmpty ? WhisperSttEngine(apiKey: kWhisperApiKey, endpoint: kWhisperEndpoint) : SpeechToTextSttEngine(), ttsEngine: _buildTtsEngine(ctx), llmClient: MyInfoMateLlmClient(visitAppContext: ctx), ); } /// Choix du moteur de synthèse. /// /// **Gemini seulement si `GEMINI_API_KEY` est injectée au build** — sans elle, la voix /// système du téléphone. C'est volontaire et utile : les tests de terrain ne consomment /// alors ni jetons ni argent. Pour entendre la vraie voix : /// `flutter run --dart-define=GEMINI_API_KEY=...` /// /// ⚠️ Le repli était **silencieux** — rien ne distinguait « Gemini indisponible » de /// « Gemini actif », et tous les APK construits jusqu'ici parlaient avec la voix système /// sans que personne le sache. Il s'annonce désormais dans les logs. /// /// La **voix** vient du CMS (`Instance.guideVoiceId`, sélecteur Viva/Marco de manager-app), /// pas du build : `kGeminiTtsVoice` n'est qu'un repli si l'instance n'a rien choisi. TtsEngine _buildTtsEngine(VisitAppContext ctx) { if (kGeminiApiKey.isEmpty) { debugPrint('[VoiceController] GEMINI_API_KEY absente — repli sur la voix système ' '(gratuit, mais ce n\'est pas la voix du produit)'); return FlutterTtsEngine(); } final voice = ctx.guideVoiceId ?? kGeminiTtsVoice; debugPrint('[VoiceController] TTS Gemini, voix « $voice »' '${ctx.guideVoiceId == null ? ' (repli : aucune voix choisie sur l\'instance)' : ''}'); return GeminiTtsEngine( apiKey: kGeminiApiKey, voiceName: voice, voicePrompt: kGeminiTtsPrompt, ); } void _onGlassesStateChanged() { if (_mode == VoiceMode.glasses && MetaGlassesService.instance.state.value == GlassesState.disconnected) { debugPrint('[VoiceController] Glasses disconnected — deactivating'); deactivate(); } } @override void dispose() { MetaGlassesService.instance.state.removeListener(_onGlassesStateChanged); _stopOrchestrator(); super.dispose(); } }