diff --git a/.vscode/launch.json b/.vscode/launch.json index e249065..11a182b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -17,6 +17,23 @@ "--dart-define=API_KEY=ak_WJDTunmXQSaRmZSulSdYNP64wnPsFMIIS9X5nAfSBfE" ] }, + { + "name": "Dev + voix Gemini (coûte des jetons)", + "request": "launch", + "type": "dart", + "flutterMode": "debug", + "args": [ + "--flavor", + "dev", + "-t", + "lib/main.dart", + "--dart-define=FLAVOR=dev", + "--dart-define=INSTANCE_ID=63514fd67ed8c735aaa4b8f2", + "--dart-define=API_BASE_URL=http://192.168.31.228:5000", + "--dart-define=API_KEY=ak_WJDTunmXQSaRmZSulSdYNP64wnPsFMIIS9X5nAfSBfE", + "--dart-define=GEMINI_API_KEY=REPLACE_WITH_GEMINI_KEY" + ] + }, { "name": "Debug MDLF", "request": "launch", diff --git a/lib/Components/AssistantChatSheet.dart b/lib/Components/AssistantChatSheet.dart index cbabb9a..7bcc2c1 100644 --- a/lib/Components/AssistantChatSheet.dart +++ b/lib/Components/AssistantChatSheet.dart @@ -126,9 +126,7 @@ class _AssistantChatSheetState extends State { }); // Pipe TTS vers les lunettes si connectées - if (widget.visitAppContext.glassesEnabled && - MetaGlassesService.instance.isConnected && - response.reply.isNotEmpty) { + if (MetaGlassesService.instance.isConnected && response.reply.isNotEmpty) { final lang = widget.visitAppContext.language ?? 'FR'; activeVoiceOrchestrator?.ttsEngine.speak( response.reply, @@ -236,12 +234,17 @@ class _AssistantChatSheetState extends State { ), ), const SizedBox(width: 8), - if (widget.visitAppContext.glassesEnabled) - ValueListenableBuilder( - valueListenable: MetaGlassesService.instance.state, - builder: (_, glassesState, __) { + ValueListenableBuilder( + valueListenable: MetaGlassesService.instance.state, + builder: (_, glassesState, __) { final connected = glassesState == GlassesState.connected || glassesState == GlassesState.streaming; + // Rien à montrer tant qu'aucune paire n'est entrée en scène : + // l'ancienne garde `glassesEnabled` n'était jamais vraie, donc + // cette pastille n'a jamais été affichée à personne. + if (glassesState == GlassesState.disconnected) { + return const SizedBox.shrink(); + } return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -260,8 +263,8 @@ class _AssistantChatSheetState extends State { ), ], ); - }, - ), + }, + ), const Spacer(), IconButton( icon: const Icon(Icons.close), diff --git a/lib/Components/VoiceModeSheet.dart b/lib/Components/VoiceModeSheet.dart index 615960c..463ffaf 100644 --- a/lib/Components/VoiceModeSheet.dart +++ b/lib/Components/VoiceModeSheet.dart @@ -102,6 +102,12 @@ class VoiceModeSheet extends StatelessWidget { ), const SizedBox(height: 16), + _ProactiveTile( + visitAppContext: visitAppContext, + enabled: controller.mode != VoiceMode.none, + ), + const SizedBox(height: 16), + // Bouton désactiver if (controller.mode != VoiceMode.none) SizedBox( @@ -190,6 +196,53 @@ class _GlassesConnectionTile extends StatelessWidget { } } +/// Le mode proactif est un **sous-réglage**, pas une troisième tuile : il vaut aussi bien +/// aux lunettes qu'aux écouteurs, et l'opposer aux deux autres forcerait un choix entre +/// « je pose des questions » et « on me parle », alors que les deux vont ensemble. +/// +/// Désactivé tant qu'aucun mode vocal n'est actif — sans sortie audio, l'activer ne +/// produirait que des appels au guide dont personne n'entendrait la réponse. +class _ProactiveTile extends StatefulWidget { + final VisitAppContext visitAppContext; + final bool enabled; + + const _ProactiveTile({required this.visitAppContext, required this.enabled}); + + @override + State<_ProactiveTile> createState() => _ProactiveTileState(); +} + +class _ProactiveTileState extends State<_ProactiveTile> { + @override + Widget build(BuildContext context) { + final on = widget.visitAppContext.proactiveModeEnabled; + + return Opacity( + opacity: widget.enabled ? 1 : 0.4, + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + value: on, + onChanged: widget.enabled + ? (value) => setState(() => widget.visitAppContext.proactiveModeEnabled = value) + : null, + title: const Text( + 'Me parler automatiquement', + style: TextStyle(color: Colors.white, fontSize: 15), + ), + subtitle: Text( + widget.enabled + // On informe, on ne détecte pas : le visiteur a choisi son mode, c'est à lui + // de savoir s'il est dans une salle où le haut-parleur dérangerait. + ? 'Le guide se déclenche à l\'approche d\'un point d\'intérêt. Écouteurs recommandés.' + : 'Activez d\'abord un mode vocal ci-dessus.', + style: const TextStyle(color: Colors.white54, fontSize: 12), + ), + activeThumbColor: Colors.lightBlueAccent, + ), + ); + } +} + class _ModeTile extends StatelessWidget { final IconData icon; final String label; diff --git a/lib/Models/visitContext.dart b/lib/Models/visitContext.dart index 17029b6..3a8a745 100644 --- a/lib/Models/visitContext.dart +++ b/lib/Models/visitContext.dart @@ -46,11 +46,24 @@ class VisitAppContext with ChangeNotifier { bool? isAllLanguages = false; bool notificationsEnabled = true; - /// Active le mode lunettes Ray-Ban Meta (SDK DAT + TTS Bluetooth). - bool glassesEnabled = false; + /// Voix du guide choisie par le client dans manager-app (`Sulafat` = Viva, féminine ; + /// `Umbriel` = Marco, masculine). Null tant que l'instance n'a pas été lue. + /// + /// ⚠️ C'est **le CMS qui décide**, pas le build : `kGeminiTtsVoice` n'est plus qu'un + /// repli. Le sélecteur existait dans manager-app et la colonne côté serveur depuis le + /// 07/08, mais rien ne les lisait ici — le choix du client était enregistré et ignoré. + String? guideVoiceId; - /// Mode proactif lunettes : l'assistant parle automatiquement à l'approche - /// d'un beacon ou d'une zone géographique, sans que le visiteur pose de question. + /// Mode proactif : l'assistant parle automatiquement à l'approche d'un beacon + /// ou d'une zone géographique, sans que le visiteur pose de question. + /// + /// Indépendant du support : vaut pour les lunettes comme pour des écouteurs. + /// C'est un **sous-réglage** du mode vocal, pas un mode à part — il n'a d'effet + /// que lorsqu'un `VoiceMode` est actif, seul cas où il existe une sortie audio. + /// + /// ⚠️ Volontairement **non persisté** (absent de `toMap`), comme `VoiceController.mode` : + /// un mode qui parle tout seul et se réactiverait en silence au lancement suivant + /// est une mauvaise surprise. Opt-in par session. bool proactiveModeEnabled = false; String? localPath; diff --git a/lib/Services/geo_beacon_trigger_service.dart b/lib/Services/geo_beacon_trigger_service.dart index 1cc9dea..f89a69b 100644 --- a/lib/Services/geo_beacon_trigger_service.dart +++ b/lib/Services/geo_beacon_trigger_service.dart @@ -20,7 +20,7 @@ class GeoTriggerPoint { required this.id, required this.latitude, required this.longitude, - this.radiusMeters = 20.0, + this.radiusMeters = GeoBeaconTriggerService.defaultRadiusMeters, }); } @@ -35,6 +35,10 @@ class GeoBeaconTriggerService { static final GeoBeaconTriggerService instance = GeoBeaconTriggerService._(); static const double beaconProximityMeters = 3.0; + + /// Rayon d'une zone GPS qui n'a pas de `meterZoneGPS` configuré. + static const double defaultRadiusMeters = 20.0; + static const int cooldownMillis = 30000; // 30s entre deux triggers du même point VisitAppContext? _visitAppContext; @@ -187,9 +191,49 @@ class GeoBeaconTriggerService { return (now - last) >= cooldownMillis; } + /// ⚠️ **`isRunning` n'est pas une précaution décorative.** `_trigger` appelle le LLM + /// d'abord et ne parle qu'ensuite, via `activeVoiceOrchestrator?.ttsEngine` — le `?.` + /// avale silencieusement le cas « aucun mode vocal actif ». Sans cette condition, un + /// visiteur qui traverse une zone avec l'app en poche et le vocal éteint **consomme des + /// jetons Gemini facturés au quota du client pour une phrase que personne n'entend**. + /// + /// L'ancienne garde exigeait `glassesEnabled`, un drapeau jamais assigné : le service + /// était donc inerte. Le remplacer par `proactiveModeEnabled` seul aurait ouvert la + /// dépense ci-dessus ; le remplacer par l'orchestrateur dit la vraie condition — il y + /// a quelqu'un pour écouter, lunettes ou écouteurs, peu importe. bool _isProactiveModeActive() { return _visitAppContext?.proactiveModeEnabled == true && - _visitAppContext?.glassesEnabled == true; + activeVoiceOrchestrator?.isRunning == true; + } + + /// Construit les points de déclenchement GPS depuis les sections de la configuration. + /// + /// ⚠️ Deux pièges du format, vérifiés plutôt que supposés : `currentSections` porte des + /// **maps JSON brutes**, pas des DTO (cf. `assistantSuggestions` et `ScannerDialog`), et + /// `latitude`/`longitude` y sont des **chaînes**, pas des nombres — c'est le type du + /// `SectionDTO` généré. D'où le `double.tryParse` plutôt qu'un cast. + /// + /// `meterZoneGPS` est le rayon réglé par section dans manager-app. C'est ici que le bug + /// M3 se ferme : le rayon n'est plus une constante mais la valeur configurée, et + /// [defaultRadiusMeters] ne sert que pour une section qui n'en porte pas. + static List buildGeoPoints(List? sections) { + return (sections ?? []) + .whereType>() + .where((s) => s['isActive'] != false) + .map((s) { + final lat = double.tryParse('${s['latitude']}'); + final lng = double.tryParse('${s['longitude']}'); + final id = s['id'] as String?; + if (lat == null || lng == null || id == null) return null; + return GeoTriggerPoint( + id: id, + latitude: lat, + longitude: lng, + radiusMeters: (s['meterZoneGPS'] as int?)?.toDouble() ?? defaultRadiusMeters, + ); + }) + .whereType() + .toList(); } Future _trigger({ diff --git a/lib/Services/glasses_qr_scanner_service.dart b/lib/Services/glasses_qr_scanner_service.dart deleted file mode 100644 index 5a45501..0000000 --- a/lib/Services/glasses_qr_scanner_service.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; -import 'package:mymuseum_visitapp/Models/visitContext.dart'; -import 'package:mymuseum_visitapp/Services/assistantService.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'; - -/// Décode les QR codes depuis les photos capturées par les lunettes. -/// -/// Déclencheur : bouton hardware des lunettes → MetaGlassesService.requestPhotoCapture() -/// → capturePhoto() retourne un chemin fichier → analyzeImage() → stream barcodes -/// -/// Sur QR valide (format web.mymuseum.be ou web.myinfomate.be) : -/// - Appelle AssistantService avec un prompt de résumé de la section -/// - Joue la réponse via GlassesTtsService -/// -/// Anti-spam : 10s de cooldown entre deux traitements du même QR. -class GlassesQrScannerService { - GlassesQrScannerService._(); - static final GlassesQrScannerService instance = GlassesQrScannerService._(); - - static const int _cooldownMs = 10000; - - static final RegExp _urlPattern1 = - RegExp(r'https://web\.mymuseum\.be/([^/]+)/([^/]+)/([^/\s]+)'); - static final RegExp _urlPattern2 = - RegExp(r'https://web\.myinfomate\.be/([^/]+)/([^/]+)/([^/\s]+)'); - - VisitAppContext? _visitAppContext; - AssistantService? _assistantService; - MobileScannerController? _scannerController; - - final Map _lastScanTime = {}; - - void start({required VisitAppContext visitAppContext}) { - _visitAppContext = visitAppContext; - _assistantService = AssistantService(visitAppContext: visitAppContext); - - _scannerController = MobileScannerController(); - _scannerController!.barcodes.listen((capture) { - for (final barcode in capture.barcodes) { - final raw = barcode.rawValue; - if (raw != null) _processRawValue(raw); - } - }); - - // meta_wearables_dat : capturePhoto() retourne un CapturedPhoto avec .path - MetaGlassesService.instance.onPhotoCaptured = _onPhotoCaptured; - debugPrint('[GlassesQrScannerService] Started'); - } - - void stop() { - MetaGlassesService.instance.onPhotoCaptured = null; - _scannerController?.dispose(); - _scannerController = null; - debugPrint('[GlassesQrScannerService] Stopped'); - } - - Future _onPhotoCaptured(String photoPath) async { - if (_visitAppContext == null || _scannerController == null) return; - try { - // analyzeImage déclenche le stream .barcodes si un QR est trouvé - await _scannerController!.analyzeImage(photoPath); - } catch (e) { - debugPrint('[GlassesQrScannerService] analyzeImage error: $e'); - } - } - - void _processRawValue(String raw) { - final sectionId = _extractSectionId(raw); - if (sectionId == null) { - debugPrint('[GlassesQrScannerService] QR not recognized: $raw'); - return; - } - - final ctx = _visitAppContext!; - if (ctx.sectionIds != null && !ctx.sectionIds!.contains(sectionId)) { - debugPrint('[GlassesQrScannerService] Section $sectionId not in current config'); - return; - } - - final now = DateTime.now().millisecondsSinceEpoch; - if ((now - (_lastScanTime[sectionId] ?? 0)) < _cooldownMs) return; - _lastScanTime[sectionId] = now; - - _explainSection(sectionId); - } - - String? _extractSectionId(String raw) { - final m1 = _urlPattern1.firstMatch(raw); - if (m1 != null) return m1.group(3); - final m2 = _urlPattern2.firstMatch(raw); - if (m2 != null) return m2.group(3); - if (_visitAppContext?.sectionIds?.contains(raw) == true) return raw; - return null; - } - - Future _explainSection(String sectionId) async { - debugPrint('[GlassesQrScannerService] Explaining section: $sectionId'); - try { - final response = await _assistantService!.chat( - message: 'Le visiteur vient de scanner le QR code de la section "$sectionId". ' - 'Présente-lui ce contenu de façon engageante en 3 phrases maximum.', - configurationId: _visitAppContext?.configuration?.id, - ); - if (response.reply.isNotEmpty) { - await GlassesTtsService.instance.speak( - response.reply, - apiKey: kElevenLabsApiKey, - voiceId: kElevenLabsVoiceId, - ); - } - } catch (e) { - debugPrint('[GlassesQrScannerService] explainSection error: $e'); - } - } -} diff --git a/lib/Services/glasses_tts_service.dart b/lib/Services/glasses_tts_service.dart deleted file mode 100644 index 298b529..0000000 --- a/lib/Services/glasses_tts_service.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import 'package:just_audio/just_audio.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:mymuseum_visitapp/PlatformChannels/audio_routing_channel.dart'; -import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart'; - -/// Service TTS qui synthétise du texte via ElevenLabs -/// et joue l'audio directement dans les lunettes via Bluetooth A2DP. -/// -/// Si les lunettes ne sont pas connectées, le son joue sur le haut-parleur. -/// Si la clé ElevenLabs n'est pas configurée, le TTS est silencieux. -class GlassesTtsService { - GlassesTtsService._(); - static final GlassesTtsService instance = GlassesTtsService._(); - - static const String _baseUrl = 'https://api.elevenlabs.io/v1'; - - final AudioPlayer _player = AudioPlayer(); - String? _lastSpokenText; - String? _lastTempPath; - bool _isSpeaking = false; - - bool get isSpeaking => _isSpeaking; - - /// Synthétise [text] et joue l'audio sur les lunettes (ou haut-parleur en fallback). - /// - /// [apiKey] : clé ElevenLabs (typiquement kElevenLabsApiKey de constants.dart) - /// [voiceId] : ID de voix ElevenLabs (kElevenLabsVoiceId) - Future speak( - String text, { - required String apiKey, - required String voiceId, - }) async { - if (text.isEmpty) return; - _lastSpokenText = text; - - try { - _isSpeaking = true; - - if (MetaGlassesService.instance.isConnected && !Platform.isWindows) { - await AudioRoutingChannel.enableBluetoothOutput(); - } - - if (apiKey.isNotEmpty) { - await _speakElevenLabs(text, apiKey, voiceId); - } else { - debugPrint('[GlassesTtsService] No ElevenLabs API key — TTS skipped'); - } - } catch (e) { - debugPrint('[GlassesTtsService] speak error: $e'); - } finally { - _isSpeaking = false; - } - } - - /// Rejoue la dernière synthèse (commande vocale "répète"). - Future replay() async { - if (_lastTempPath == null) return; - try { - await _player.seek(Duration.zero); - await _player.play(); - } catch (e) { - debugPrint('[GlassesTtsService] replay error: $e'); - } - } - - Future stop() async { - await _player.stop(); - _isSpeaking = false; - } - - Future _speakElevenLabs( - String text, String apiKey, String voiceId) async { - final body = jsonEncode({ - 'text': text, - 'model_id': 'eleven_multilingual_v2', - 'voice_settings': {'stability': 0.5, 'similarity_boost': 0.75}, - }); - - final response = await http.post( - Uri.parse('$_baseUrl/text-to-speech/$voiceId'), - headers: { - 'xi-api-key': apiKey, - 'Content-Type': 'application/json', - 'Accept': 'audio/mpeg', - }, - body: body, - ); - - if (response.statusCode != 200) { - throw Exception( - 'ElevenLabs error ${response.statusCode}: ${response.body}'); - } - - final file = await _writeTempMp3(response.bodyBytes); - _lastTempPath = file.path; - - await _player.setFilePath(file.path); - await _player.play(); - } - - Future _writeTempMp3(Uint8List bytes) async { - final dir = await getTemporaryDirectory(); - final file = File('${dir.path}/glasses_tts.mp3'); - await file.writeAsBytes(bytes, flush: true); - return file; - } - - void dispose() { - _player.dispose(); - AudioRoutingChannel.restoreDefaultOutput(); - } -} diff --git a/lib/Services/voice_controller.dart b/lib/Services/voice_controller.dart index d341f75..0ff2f99 100644 --- a/lib/Services/voice_controller.dart +++ b/lib/Services/voice_controller.dart @@ -1,6 +1,7 @@ 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'; @@ -10,6 +11,7 @@ import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/speech_to_text_w 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'; @@ -51,6 +53,14 @@ class VoiceController extends ChangeNotifier { _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(); @@ -76,6 +86,12 @@ class VoiceController extends ChangeNotifier { } 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; @@ -91,17 +107,42 @@ class VoiceController extends ChangeNotifier { sttEngine: kWhisperApiKey.isNotEmpty ? WhisperSttEngine(apiKey: kWhisperApiKey, endpoint: kWhisperEndpoint) : SpeechToTextSttEngine(), - ttsEngine: kGeminiApiKey.isNotEmpty - ? GeminiTtsEngine( - apiKey: kGeminiApiKey, - voiceName: kGeminiTtsVoice, - voicePrompt: kGeminiTtsPrompt, - ) - : FlutterTtsEngine(), + 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) { diff --git a/lib/Services/wake_word_service.dart b/lib/Services/wake_word_service.dart deleted file mode 100644 index 89030b8..0000000 --- a/lib/Services/wake_word_service.dart +++ /dev/null @@ -1,163 +0,0 @@ -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, - isVoice: true, - ); - if (response.reply.isNotEmpty) { - await GlassesTtsService.instance.speak( - response.reply, - apiKey: kElevenLabsApiKey, - voiceId: kElevenLabsVoiceId, - ); - } - if (!response.expectsReply) { - debugPrint('[WakeWordService] No follow-up expected, conversation ends here.'); - } - } 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(); - } -} diff --git a/lib/constants.dart b/lib/constants.dart index ff1422f..e4c9b34 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,15 +1,29 @@ import 'package:flutter/material.dart'; -// Third-party integrations — injectées via --dart-define ou à définir ici pour le dev -// Ex: flutter run --dart-define=ELEVENLABS_API_KEY=xxxx --dart-define=PICOVOICE_ACCESS_KEY=yyyy -// Whisper STT (OpenAI ou serveur auto-hébergé compatible) +// Third-party integrations — injectées via --dart-define, jamais committées. +// +// ⚠️ GEMINI_API_KEY absente = repli sur la voix système du téléphone, et c'est +// **le comportement par défaut de tous les builds**. Utile pour tester sans dépenser de +// jetons, trompeur si on l'ignore : jusqu'au 2026-08-13 le repli était silencieux et +// personne ne savait que les APK ne parlaient pas avec la voix du produit. Il se signale +// désormais dans les logs (`[VoiceController] GEMINI_API_KEY absente…`). +// +// Pour entendre la vraie voix : +// flutter run --flavor dev --dart-define=GEMINI_API_KEY=xxxx +// flutter build apk --flavor mdlf --dart-define=GEMINI_API_KEY=xxxx --dart-define=INSTANCE_ID=... --dart-define=API_BASE_URL=... --dart-define=API_KEY=... +// +// Whisper STT (OpenAI ou serveur auto-hébergé compatible) — clé vide = STT du device. const kWhisperApiKey = String.fromEnvironment('WHISPER_API_KEY', defaultValue: ''); const kWhisperEndpoint = String.fromEnvironment('WHISPER_ENDPOINT', defaultValue: 'https://api.openai.com/v1/audio/transcriptions'); // Gemini TTS (2.5 Flash) const kGeminiApiKey = String.fromEnvironment('GEMINI_API_KEY', defaultValue: ''); -const kGeminiTtsVoice = String.fromEnvironment('GEMINI_TTS_VOICE', defaultValue: 'Algieba'); +// ⚠️ Repli seulement. La voix réelle vient du CMS — `Instance.guideVoiceId`, réglée par le +// client dans manager-app (sélecteur Viva/Marco). Le défaut valait `Algieba`, une voix que +// personne n'a validée : il date d'avant le sélecteur, et rien ne lisait le choix du client. +// Sulafat = Viva (féminine), écoutée et validée le 2026-08-07 ; Umbriel = Marco (masculine). +const kGeminiTtsVoice = String.fromEnvironment('GEMINI_TTS_VOICE', defaultValue: 'Sulafat'); const kGeminiTtsPrompt = String.fromEnvironment('GEMINI_TTS_PROMPT', defaultValue: 'Voix de guide de musée, ton chaleureux et bienveillant, rythme posé et clair.'); diff --git a/lib/main.dart b/lib/main.dart index fb68342..dd406d7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -78,19 +78,26 @@ class _AppBootstrapState extends State { localContext.clientAPI = Client(kApiBaseUrl, apiKey: localContext.apiKey ?? (kApiKey.isNotEmpty ? kApiKey : null)); - // Récupère publicApiKey depuis le backend si pas encore en mémoire - if (localContext.apiKey == null && localContext.instanceId != null) { + // Récupère publicApiKey (si pas encore en mémoire) et la voix du guide choisie par + // le client. La condition portait sur `apiKey == null` : une fois la clé en base, la + // réponse n'était plus jamais lue, et `guideVoiceId` avec elle. Elle porte désormais + // sur l'instance — un appel au démarrage, déjà non bloquant en cas d'échec réseau. + if (localContext.instanceId != null) { try { final instanceDto = await localContext.clientAPI.instanceApi! .instanceGetDetail(localContext.instanceId!); - if (instanceDto?.publicApiKey != null) { + + if (localContext.apiKey == null && instanceDto?.publicApiKey != null) { localContext.apiKey = instanceDto!.publicApiKey; localContext.clientAPI = Client(kApiBaseUrl, apiKey: localContext.apiKey); DatabaseHelper.instance.updateTableMain(DatabaseTableType.main, localContext); print('publicApiKey loaded and saved'); } + + localContext.guideVoiceId = instanceDto?.guideVoiceId; } catch (e) { - print('Could not load publicApiKey: $e'); + // Hors ligne : la voix retombe sur le repli du build, le reste de l'app tourne. + print('Could not load instance detail: $e'); } }