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>
161 lines
6.3 KiB
Dart
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();
|
|
}
|
|
}
|