mymuseum-visitapp/lib/Services/voice_controller.dart
Thomas Fransolet 2fd2132b31 Charger le modèle de réveil hey_viva
Le manager propose deux voix, Viva et Marco, et annonce au client que ses
visiteurs diront « Hey Viva » ou « Hey Marco ». Or l'orchestrateur ne
chargeait que hey_marco, hey_alba et hey_vasco : le mot de réveil promis
pour la voix Viva n'était jamais écouté.

Le modèle existe déjà dans assets/files/ et le dossier est embarqué en bloc
par pubspec.yaml — seul son nom manquait dans la liste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:10:18 +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_viva', '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();
}
}