From e9e68942c95ae5859883624e44f96565fe52c3d0 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Thu, 13 Aug 2026 11:57:44 +0200 Subject: [PATCH] =?UTF-8?q?Proactif=20c=C3=A2bl=C3=A9,=20M3=20ferm=C3=A9,?= =?UTF-8?q?=20voix=20du=20CMS=20honor=C3=A9e,=20et=20trois=20services=20mo?= =?UTF-8?q?rts=20supprim=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Déclenchement proactif (lot F) — quatre branchements : la garde, le cycle de vie accroché à VoiceController, les points GPS peuplés depuis la configuration, et un réglage visiteur. ⚠️ La garde recommandée par le plan aurait coûté de l'argent. _trigger appelle le LLM d'abord et ne parle qu'ensuite via activeVoiceOrchestrator?.ttsEngine, dont le ?. avale le cas « aucun mode vocal actif ». Avec proactiveModeEnabled seul, un visiteur traversant une zone avec l'app en poche et le vocal éteint consommait des jetons Gemini facturés au client pour une phrase que personne n'entend. La garde interroge l'orchestrateur : la vraie condition n'est pas « quel matériel » mais « y a-t-il quelqu'un pour écouter ». Il n'y avait pas de troisième mode à inventer : VoiceMode.voiceOnly existe déjà et construit le même orchestrateur que le mode lunettes. Le proactif est donc un sous-réglage, pas une tuile. glassesEnabled est supprimé, ses trois usages étaient morts — dont une pastille « Lunettes / Déconnecté » jamais affichée. M3 — meterZoneGPS devient le rayon par section, la constante n'étant plus qu'un défaut. Deux pièges de format vérifiés : currentSections porte des maps JSON brutes, pas des DTO, et latitude/longitude y sont des chaînes. Voix du guide — le sélecteur Viva/Marco de manager-app écrivait dans le vide : Instance.GuideVoiceId n'était lu nulle part ici, l'app utilisant une constante de build dont le défaut était Algieba, une voix que personne n'a écoutée. Même forme que W1. Et aucun APK ne parlait avec la voix du produit : GeminiTtsEngine n'est choisi que si GEMINI_API_KEY est injectée, ce qui n'était fait nulle part, donc repli silencieux sur la voix système. Le repli reste le défaut — les tests ne coûtent alors ni jetons ni argent — mais il s'annonce dans les logs. Code mort supprimé : wake_word_service.dart, glasses_qr_scanner_service.dart et glasses_tts_service.dart, les ancêtres de Services/Glasses/. Ils portaient les 4 erreurs kElevenLabs* et l'APK se construisait quand même, personne ne les important. flutter analyze lib rend désormais zéro erreur. ⚠️ android/.../WakeWordService.kt est un homonyme bien vivant, non touché. impl/elevenlabs_tts_engine.dart est conservé comme option. flutter build apk --debug --flavor dev : vert. Co-Authored-By: Claude Opus 5 --- .vscode/launch.json | 17 ++ lib/Components/AssistantChatSheet.dart | 21 ++- lib/Components/VoiceModeSheet.dart | 53 ++++++ lib/Models/visitContext.dart | 21 ++- lib/Services/geo_beacon_trigger_service.dart | 48 +++++- lib/Services/glasses_qr_scanner_service.dart | 118 -------------- lib/Services/glasses_tts_service.dart | 117 ------------- lib/Services/voice_controller.dart | 55 ++++++- lib/Services/wake_word_service.dart | 163 ------------------- lib/constants.dart | 22 ++- lib/main.dart | 15 +- 11 files changed, 222 insertions(+), 428 deletions(-) delete mode 100644 lib/Services/glasses_qr_scanner_service.dart delete mode 100644 lib/Services/glasses_tts_service.dart delete mode 100644 lib/Services/wake_word_service.dart 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'); } }