mymuseum-visitapp/lib/Services/voice_controller.dart
Thomas Fransolet e9e68942c9 Proactif câblé, M3 fermé, voix du CMS honorée, et trois services morts supprimés
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 <noreply@anthropic.com>
2026-08-13 11:57:44 +02:00

161 lines
6.3 KiB
Dart

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<void> 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<void> 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<void> _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();
}
}