118 lines
3.4 KiB
Dart
118 lines
3.4 KiB
Dart
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<void> 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<void> replay() async {
|
|
if (_lastTempPath == null) return;
|
|
try {
|
|
await _player.seek(Duration.zero);
|
|
await _player.play();
|
|
} catch (e) {
|
|
debugPrint('[GlassesTtsService] replay error: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> stop() async {
|
|
await _player.stop();
|
|
_isSpeaking = false;
|
|
}
|
|
|
|
Future<void> _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<File> _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();
|
|
}
|
|
}
|