26 changed files with 3314 additions and 1952 deletions
@ -1,161 +0,0 @@ |
|||
import 'package:get/get.dart'; |
|||
import 'package:logger/logger.dart'; |
|||
import '../data/services/azure_tts_service.dart'; |
|||
|
|||
class AzureTtsTestController extends GetxController { |
|||
final AzureTtsService _ttsService = Get.find<AzureTtsService>(); |
|||
final Logger _logger = Logger(); |
|||
|
|||
// 状态变量 |
|||
final RxBool isInitialized = false.obs; |
|||
final RxBool isProcessing = false.obs; |
|||
final RxString statusMessage = ''.obs; |
|||
final RxString lastFilePath = ''.obs; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
|
|||
// 监听TTS状态 |
|||
_ttsService.ttsStatus.listen((status) { |
|||
final statusStr = status['status'] as String?; |
|||
|
|||
switch (statusStr) { |
|||
case 'started': |
|||
isProcessing.value = true; |
|||
statusMessage.value = '正在合成语音...'; |
|||
break; |
|||
case 'completed': |
|||
isProcessing.value = false; |
|||
statusMessage.value = '语音合成完成'; |
|||
|
|||
if (status.containsKey('filePath')) { |
|||
lastFilePath.value = status['filePath'] as String; |
|||
} |
|||
break; |
|||
case 'error': |
|||
isProcessing.value = false; |
|||
statusMessage.value = '语音合成失败: ${status['message'] ?? '未知错误'}'; |
|||
break; |
|||
default: |
|||
statusMessage.value = '未知状态: $statusStr'; |
|||
break; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
/// 初始化Azure TTS服务 |
|||
Future<void> initialize({ |
|||
required String subscriptionKey, |
|||
required String region, |
|||
}) async { |
|||
try { |
|||
isProcessing.value = true; |
|||
statusMessage.value = '正在初始化Azure TTS...'; |
|||
|
|||
await _ttsService.initialize( |
|||
subscriptionKey: subscriptionKey, |
|||
region: region, |
|||
); |
|||
|
|||
isInitialized.value = _ttsService.isInitialized; |
|||
|
|||
if (isInitialized.value) { |
|||
statusMessage.value = 'Azure TTS初始化成功'; |
|||
} else { |
|||
statusMessage.value = 'Azure TTS初始化失败'; |
|||
} |
|||
} catch (e) { |
|||
isInitialized.value = false; |
|||
statusMessage.value = '初始化异常: $e'; |
|||
_logger.e('初始化异常: $e'); |
|||
} finally { |
|||
isProcessing.value = false; |
|||
} |
|||
} |
|||
|
|||
/// 设置语音 |
|||
Future<void> setVoice(String voiceName) async { |
|||
if (!isInitialized.value) { |
|||
statusMessage.value = 'Azure TTS未初始化,请先初始化'; |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
isProcessing.value = true; |
|||
statusMessage.value = '正在设置语音...'; |
|||
|
|||
final success = await _ttsService.setVoice(voiceName); |
|||
|
|||
if (success) { |
|||
statusMessage.value = '语音设置成功: $voiceName'; |
|||
} else { |
|||
statusMessage.value = '语音设置失败'; |
|||
} |
|||
} catch (e) { |
|||
statusMessage.value = '设置语音异常: $e'; |
|||
_logger.e('设置语音异常: $e'); |
|||
} finally { |
|||
isProcessing.value = false; |
|||
} |
|||
} |
|||
|
|||
/// 合成语音到扬声器 |
|||
Future<void> speakText(String text) async { |
|||
if (!isInitialized.value) { |
|||
statusMessage.value = 'Azure TTS未初始化,请先初始化'; |
|||
return; |
|||
} |
|||
|
|||
if (text.isEmpty) { |
|||
statusMessage.value = '文本不能为空'; |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
isProcessing.value = true; |
|||
statusMessage.value = '正在合成语音...'; |
|||
|
|||
final success = await _ttsService.synthesizeToSpeaker(text); |
|||
|
|||
if (!success) { |
|||
statusMessage.value = '语音合成失败'; |
|||
} |
|||
} catch (e) { |
|||
statusMessage.value = '语音合成异常: $e'; |
|||
_logger.e('语音合成异常: $e'); |
|||
isProcessing.value = false; |
|||
} |
|||
} |
|||
|
|||
/// 合成语音到文件 |
|||
Future<void> synthesizeToFile(String text, {String? fileName}) async { |
|||
if (!isInitialized.value) { |
|||
statusMessage.value = 'Azure TTS未初始化,请先初始化'; |
|||
return; |
|||
} |
|||
|
|||
if (text.isEmpty) { |
|||
statusMessage.value = '文本不能为空'; |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
isProcessing.value = true; |
|||
statusMessage.value = '正在合成语音到文件...'; |
|||
|
|||
final filePath = await _ttsService.synthesizeToFile(text, fileName: fileName); |
|||
|
|||
if (filePath != null) { |
|||
lastFilePath.value = filePath; |
|||
} else { |
|||
statusMessage.value = '语音合成到文件失败'; |
|||
isProcessing.value = false; |
|||
} |
|||
} catch (e) { |
|||
statusMessage.value = '语音合成到文件异常: $e'; |
|||
_logger.e('语音合成到文件异常: $e'); |
|||
isProcessing.value = false; |
|||
} |
|||
} |
|||
} |
|||
@ -1,144 +1,27 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../../core/controllers/permission_controller.dart'; |
|||
import '../../data/services/audio_service.dart'; |
|||
import '../../data/services/notification_service.dart'; |
|||
import '../../data/services/volcano_ai_service.dart'; |
|||
import '../../data/services/volcano_tts_api_service.dart'; |
|||
import '../../data/services/volcano_translation_service.dart'; |
|||
import '../../data/services/azure_asr_service.dart'; |
|||
import '../../data/services/azure_tts_service.dart'; |
|||
|
|||
import '../../data/services/background_agent_service.dart'; |
|||
import '../../data/services/chat_history_service.dart'; |
|||
import '../../data/services/flutter_tts_service.dart'; |
|||
import '../../data/services/volcano_asr_api_service.dart'; |
|||
// import '../../data/services/audio_processing_service.dart'; |
|||
import '../../core/utils/logger.dart'; |
|||
import '../../core/utils/app_lifecycle_manager.dart'; |
|||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|||
import '../../data/services/volcano_translation_service.dart'; |
|||
|
|||
/// 初始绑定,用于管理全局依赖 |
|||
class InitialBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
// 立即初始化关键服务 |
|||
_initializeCriticalServices(); |
|||
|
|||
// 延时加载其他服务 |
|||
_registerLazyServices(); |
|||
} |
|||
|
|||
/// 初始化关键服务(立即加载) |
|||
void _initializeCriticalServices() async { |
|||
// 应用生命周期管理器是最基础的服务,需要最先初始化 |
|||
Get.put(AppLifecycleManager(), permanent: true); |
|||
|
|||
// 通知服务和权限控制器是关键服务,需要立即初始化 |
|||
final notificationService = Get.put(NotificationService(), permanent: true); |
|||
final permissionController = Get.put(PermissionController(), permanent: true); |
|||
|
|||
// 音频服务也是基础服务,需要立即初始化 |
|||
final audioService = Get.put(AudioServiceManager(), permanent: true); |
|||
// Azure 语音识别服务 |
|||
Get.lazyPut<AzureAsrService>(() => AzureAsrService(), fenix: true); |
|||
|
|||
// 异步初始化通知和音频服务 |
|||
_initializeNotificationAndAudioServices(notificationService, audioService); |
|||
} |
|||
|
|||
/// 注册延时加载的服务 |
|||
void _registerLazyServices() { |
|||
// 使用lazyPut注册服务,只有在首次访问时才会被实例化 |
|||
|
|||
// AI相关服务 |
|||
Get.lazyPut<VolcanoAIService>(() => VolcanoAIService(), fenix: true); |
|||
Get.lazyPut<VolcanoTtsApiService>(() { |
|||
final service = VolcanoTtsApiService(); |
|||
return service; |
|||
}, fenix: true); |
|||
|
|||
// Azure TTS 服务 |
|||
// Azure TTS 服务 (原生实现) |
|||
Get.lazyPut<AzureTtsService>(() => AzureTtsService(), fenix: true); |
|||
|
|||
// 语音识别服务 |
|||
// Get.lazyPut<AzureAsrService>(() { |
|||
// final service = AzureAsrService(); |
|||
// // 在首次访问时初始化 |
|||
// _initializeAzureService(service); |
|||
// return service; |
|||
// }, fenix: true); |
|||
|
|||
// 翻译服务 |
|||
Get.lazyPut<VolcanoTranslationService>(() { |
|||
final service = VolcanoTranslationService(); |
|||
// 在首次访问时初始化 |
|||
_initializeVolcanoTranslationService(service); |
|||
return service; |
|||
}, fenix: true); |
|||
|
|||
// TTS服务 |
|||
// Get.lazyPut<FlutterTtsService>(() => FlutterTtsService(), fenix: true); |
|||
|
|||
// 音频处理服务 |
|||
// Get.lazyPut<AudioProcessingService>(() => AudioProcessingService(), fenix: true); |
|||
|
|||
// 火山ASR API服务 |
|||
Get.lazyPut<VolcanoAsrApiService>(() => VolcanoAsrApiService(), fenix: true); |
|||
|
|||
// 聊天历史服务 |
|||
// 历史记录服务 |
|||
Get.lazyPut<ChatHistoryService>(() => ChatHistoryService(), fenix: true); |
|||
|
|||
// 后台代理服务 (依赖于聊天历史服务) |
|||
Get.lazyPut<BackgroundAgentService>(() => BackgroundAgentService(), fenix: true); |
|||
} |
|||
|
|||
// 初始化通知和音频服务 |
|||
void _initializeNotificationAndAudioServices(NotificationService notificationService, AudioServiceManager audioService) async { |
|||
try { |
|||
// 首先初始化通知服务 |
|||
await notificationService.init().catchError((error) { |
|||
Logger.error('通知服务初始化失败', error); |
|||
return null; |
|||
}); |
|||
// 火山翻译服务 |
|||
Get.lazyPut<VolcanoTranslationService>(() => VolcanoTranslationService(), fenix: true); |
|||
|
|||
// 等待一段时间,确保通知权限请求完成 |
|||
// await Future.delayed(Duration(seconds: 1)); |
|||
|
|||
// 然后初始化音频服务 |
|||
await audioService.init().then((_) { |
|||
Logger.info('AudioServiceManager 初始化完成,可以接收蓝牙耳机按键事件'); |
|||
}); |
|||
} catch (e) { |
|||
Logger.error('服务初始化过程中发生错误', e); |
|||
} |
|||
} |
|||
|
|||
// // 初始化 Azure 语音识别服务 |
|||
// void _initializeAzureService(AzureAsrService azureAsrService) async { |
|||
// try { |
|||
// // 直接调用初始化方法,服务内部会读取环境变量配置 |
|||
// final success = await azureAsrService.initialize(); |
|||
|
|||
// if (success) { |
|||
// Logger.info('Azure 语音识别服务初始化成功'); |
|||
// } else { |
|||
// Logger.error('Azure 语音识别服务初始化失败'); |
|||
// } |
|||
// } catch (e) { |
|||
// Logger.error('初始化 Azure 语音识别服务失败', e); |
|||
// } |
|||
// } |
|||
|
|||
// 初始化火山翻译服务 |
|||
void _initializeVolcanoTranslationService(VolcanoTranslationService translationService) async { |
|||
try { |
|||
// 直接调用初始化方法,服务内部会读取环境变量配置 |
|||
final success = await translationService.initialize(); |
|||
|
|||
if (success) { |
|||
Logger.info('火山翻译服务初始化成功'); |
|||
} else { |
|||
Logger.error('火山翻译服务初始化失败'); |
|||
} |
|||
} catch (e) { |
|||
Logger.error('初始化火山翻译服务失败', e); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,793 @@ |
|||
import 'dart:async'; |
|||
import 'dart:typed_data'; |
|||
import 'dart:io'; |
|||
import 'dart:math' as math; |
|||
import 'package:get/get.dart'; |
|||
import 'package:just_audio/just_audio.dart'; |
|||
import 'package:audio_session/audio_session.dart'; |
|||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|||
import 'package:flutter_azure_tts/flutter_azure_tts.dart'; |
|||
import '../../core/utils/logger.dart'; |
|||
|
|||
/// Azure语音服务异常 |
|||
class AzureTtsException implements Exception { |
|||
final String message; |
|||
AzureTtsException(this.message); |
|||
|
|||
@override |
|||
String toString() => message; |
|||
} |
|||
|
|||
/// 自定义音频源,用于从字节数组读取音频数据 |
|||
class BytesAudioSource extends StreamAudioSource { |
|||
final Uint8List _bytes; |
|||
static const int _bufferSize = 4096; // 4KB buffer size for better Android compatibility |
|||
|
|||
BytesAudioSource(this._bytes); |
|||
|
|||
@override |
|||
Future<StreamAudioResponse> request([int? start, int? end]) async { |
|||
start = start ?? 0; |
|||
end = end ?? _bytes.length; |
|||
|
|||
try { |
|||
final subData = _bytes.sublist(start, end); |
|||
|
|||
// 使用固定大小的块进行流式传输 |
|||
final chunks = <List<int>>[]; |
|||
var offset = 0; |
|||
while (offset < subData.length) { |
|||
final chunkSize = math.min(_bufferSize, subData.length - offset); |
|||
chunks.add(subData.sublist(offset, offset + chunkSize)); |
|||
offset += chunkSize; |
|||
} |
|||
|
|||
return StreamAudioResponse( |
|||
sourceLength: _bytes.length, |
|||
contentLength: subData.length, |
|||
offset: start, |
|||
stream: Stream.fromIterable(chunks), |
|||
contentType: 'audio/mpeg', |
|||
); |
|||
} catch (e) { |
|||
Logger.error('BytesAudioSource请求错误: $e'); |
|||
rethrow; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<int> get length => Future.value(_bytes.length); |
|||
} |
|||
|
|||
/// Azure语音合成服务 |
|||
class AzureFlutterTtsService extends GetxService { |
|||
// 配置参数 |
|||
final String _subscriptionKey; |
|||
final String _region; |
|||
final String _voiceLocale; |
|||
|
|||
// 语音配置 |
|||
Voice? _selectedVoice; |
|||
final String _audioFormat = AudioOutputFormat.audio16khz32kBitrateMonoMp3; |
|||
|
|||
// 音频播放器 |
|||
late AudioPlayer _audioPlayer; |
|||
final ConcatenatingAudioSource _playlist = ConcatenatingAudioSource(children: []); |
|||
final isEnabled = true.obs; // 默认启用 |
|||
final isPlaying = false.obs; |
|||
StreamSubscription? _playerStateSubscription; |
|||
bool _playlistInitialized = false; |
|||
|
|||
bool _isDisposed = false; |
|||
|
|||
// 句子管理 |
|||
String _pendingText = ''; |
|||
final List<String> _sentenceQueue = []; |
|||
static final _sentenceBreaks = RegExp(r'[。!?.!?,,、]'); // 添加更多标点符号 |
|||
bool _isFetching = false; |
|||
static const int _minSegmentLength = 10; // 最小分段长度 |
|||
static const int _optimalSegmentLength = 30; // 最佳分段长度 |
|||
|
|||
// 音频数据控制器 - 用于事件通知 |
|||
final _eventController = StreamController<Map<String, dynamic>>.broadcast(); |
|||
Stream<Map<String, dynamic>> get eventStream => _eventController.stream; |
|||
|
|||
AzureFlutterTtsService() : |
|||
_subscriptionKey = dotenv.env['AZURE_TTS_SUBSCRIPTION_KEY'] ?? '', |
|||
_region = dotenv.env['AZURE_TTS_SERVICE_REGION'] ?? '', |
|||
_voiceLocale = dotenv.env['AZURE_TTS_DEFAULT_VOICE']?.split('-').take(2).join('-') ?? 'zh-CN' { |
|||
if (_subscriptionKey.isEmpty || _region.isEmpty) { |
|||
throw Exception('Azure语音配置信息不完整,请检查环境变量'); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<void> onInit() async { |
|||
super.onInit(); |
|||
try { |
|||
// 初始化Azure TTS |
|||
await _initAzureTts(); |
|||
|
|||
// 初始化音频播放器 |
|||
await _initAudioPlayer(); |
|||
|
|||
// 配置音频设备 |
|||
await _configureAudioDevice(); |
|||
} catch (e, stackTrace) { |
|||
Logger.error('初始化Azure TTS服务失败: $e'); |
|||
Logger.error('Stack trace: $stackTrace'); |
|||
} |
|||
} |
|||
|
|||
Future<void> _initAzureTts() async { |
|||
try { |
|||
// 初始化Azure TTS服务 |
|||
FlutterAzureTts.init( |
|||
subscriptionKey: _subscriptionKey, |
|||
region: _region, |
|||
withLogs: false, // 生产环境禁用日志 |
|||
); |
|||
|
|||
// 获取可用的语音列表 |
|||
// final voicesResponse = await FlutterAzureTts.getAvailableVoices(); |
|||
|
|||
// if (voicesResponse.voices.isEmpty) { |
|||
// throw AzureTtsException('未找到可用的Azure语音'); |
|||
// } |
|||
|
|||
// // 选择指定区域的语音 |
|||
// _selectedVoice = voicesResponse.voices |
|||
// .where((voice) => voice.locale.startsWith(_voiceLocale)) |
|||
// .toList(growable: false) |
|||
// .firstOrNull; |
|||
|
|||
// // 如果没有找到指定区域的语音,使用第一个可用的语音 |
|||
// _selectedVoice ??= voicesResponse.voices.first; |
|||
|
|||
Logger.info('已选择Azure语音: $_voiceLocale'); |
|||
} catch (e) { |
|||
Logger.error('初始化Azure TTS失败: $e'); |
|||
rethrow; |
|||
} |
|||
} |
|||
|
|||
Future<void> _initAudioPlayer() async { |
|||
if (_isDisposed) return; |
|||
|
|||
_audioPlayer = AudioPlayer(); |
|||
|
|||
// 配置Android特定的播放参数 |
|||
if (Platform.isAndroid) { |
|||
await _audioPlayer.setAndroidAudioAttributes(const AndroidAudioAttributes( |
|||
contentType: AndroidAudioContentType.speech, |
|||
usage: AndroidAudioUsage.media, |
|||
flags: AndroidAudioFlags.audibilityEnforced, |
|||
)); |
|||
} |
|||
|
|||
// 基本播放器配置 |
|||
await _audioPlayer.setVolume(1.0); |
|||
await _audioPlayer.setLoopMode(LoopMode.off); |
|||
await _audioPlayer.setSpeed(1.0); |
|||
|
|||
// 设置音频缓冲配置,启用预加载以减少延迟 |
|||
await _audioPlayer.setAudioSource( |
|||
_playlist, |
|||
initialPosition: Duration.zero, |
|||
preload: true, |
|||
); |
|||
|
|||
_playlistInitialized = true; |
|||
|
|||
_setupEventListeners(); |
|||
} |
|||
|
|||
Future<bool> _configureAudioDevice() async { |
|||
try { |
|||
// 使用AudioSession配置音频设备 |
|||
final session = await AudioSession.instance; |
|||
|
|||
if (Platform.isAndroid) { |
|||
// Android平台 - 明确指定只使用默认设备 |
|||
await session.configure(AudioSessionConfiguration( |
|||
avAudioSessionCategory: AVAudioSessionCategory.playback, |
|||
androidAudioAttributes: const AndroidAudioAttributes( |
|||
contentType: AndroidAudioContentType.speech, |
|||
usage: AndroidAudioUsage.media, |
|||
// 移除audibilityEnforced标志,它可能导致多路输出 |
|||
flags: AndroidAudioFlags.none, |
|||
), |
|||
androidAudioFocusGainType: AndroidAudioFocusGainType.gain, |
|||
androidWillPauseWhenDucked: true, |
|||
)); |
|||
|
|||
// 确保只使用默认音频路由 |
|||
if (Platform.isAndroid) { |
|||
final audioManager = AndroidAudioManager(); |
|||
await audioManager.setSpeakerphoneOn(false); |
|||
} |
|||
|
|||
} else if (Platform.isIOS) { |
|||
// iOS平台 - 明确指定只使用默认设备 |
|||
await session.configure(AudioSessionConfiguration( |
|||
avAudioSessionCategory: AVAudioSessionCategory.soloAmbient, // 使用soloAmbient而不是playback |
|||
avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.none, // 不使用任何额外选项 |
|||
)); |
|||
|
|||
// 确保使用默认路由 |
|||
if (Platform.isIOS) { |
|||
final avAudioSession = AVAudioSession(); |
|||
await avAudioSession.overrideOutputAudioPort(AVAudioSessionPortOverride.none); |
|||
} |
|||
} |
|||
|
|||
// 激活音频会话 |
|||
await session.setActive(true); |
|||
|
|||
return true; |
|||
} catch (e) { |
|||
Logger.error('配置音频设备失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
Future<void> speak(String text) async { |
|||
if (!isEnabled.value || text.trim().isEmpty) return; |
|||
|
|||
try { |
|||
_pendingText += text; |
|||
_extractFullSentences(); |
|||
await _startPreloadIfNeeded(); |
|||
} catch (e, stack) { |
|||
Logger.error('语音合成失败: $e'); |
|||
await _reinitializePlayer(); |
|||
} |
|||
} |
|||
|
|||
void _extractFullSentences() { |
|||
if (_pendingText.isEmpty) return; |
|||
|
|||
// 查找所有句子分隔符 |
|||
final matches = _sentenceBreaks.allMatches(_pendingText).toList(); |
|||
|
|||
// 如果没有找到句子分隔符 |
|||
if (matches.isEmpty) { |
|||
// 如果累积的文本长度超过最佳长度,才进行处理 |
|||
if (_pendingText.length > _optimalSegmentLength) { |
|||
// 将整个文本添加到队列 |
|||
_sentenceQueue.add(_pendingText); |
|||
_pendingText = ''; |
|||
} |
|||
// 否则继续累积文本,等待更多输入 |
|||
return; |
|||
} |
|||
|
|||
// 找到了句子分隔符,处理完整的句子 |
|||
int lastPos = 0; |
|||
|
|||
for (final match in matches) { |
|||
final end = match.end; |
|||
// 提取完整的句子(包含分隔符) |
|||
final sentence = _pendingText.substring(lastPos, end).trim(); |
|||
|
|||
if (sentence.isNotEmpty) { |
|||
// 将完整的句子添加到队列 |
|||
_sentenceQueue.add(sentence); |
|||
} |
|||
|
|||
lastPos = end; |
|||
} |
|||
|
|||
// 处理剩余的不完整文本(不包含分隔符的部分) |
|||
if (lastPos < _pendingText.length) { |
|||
final remaining = _pendingText.substring(lastPos).trim(); |
|||
|
|||
// 如果剩余文本长度超过最佳长度,也将其添加到队列 |
|||
if (remaining.length > _optimalSegmentLength) { |
|||
_sentenceQueue.add(remaining); |
|||
_pendingText = ''; |
|||
} else { |
|||
// 否则保留在_pendingText中,等待更多输入 |
|||
_pendingText = remaining; |
|||
} |
|||
} else { |
|||
// 所有文本都已处理,清空_pendingText |
|||
_pendingText = ''; |
|||
} |
|||
} |
|||
|
|||
// 按长度分割文本的方法(保留以便其他地方调用) |
|||
void _splitByLength(String text) { |
|||
if (text.isEmpty) return; |
|||
|
|||
// 如果文本长度不超过最佳长度,直接添加整个文本 |
|||
if (text.length <= _optimalSegmentLength) { |
|||
_sentenceQueue.add(text); |
|||
return; |
|||
} |
|||
|
|||
int start = 0; |
|||
while (start < text.length) { |
|||
int end = math.min(start + _optimalSegmentLength, text.length); |
|||
|
|||
// 如果剩余文本长度小于最小字符数,直接取到末尾 |
|||
if (text.length - start <= _minSegmentLength) { |
|||
end = text.length; |
|||
} |
|||
// 否则尝试在标点或空格处分割 |
|||
else if (end < text.length) { |
|||
final spacePos = text.lastIndexOf(' ', end); |
|||
final breakPos = _sentenceBreaks.firstMatch( |
|||
text.substring(start, end) |
|||
)?.end; |
|||
|
|||
if (breakPos != null && breakPos > start) { |
|||
end = start + breakPos; |
|||
} else if (spacePos > start) { |
|||
end = spacePos; |
|||
} |
|||
} |
|||
|
|||
final segment = text.substring(start, end).trim(); |
|||
if (segment.isNotEmpty) { |
|||
_sentenceQueue.add(segment); |
|||
} |
|||
start = end; |
|||
} |
|||
} |
|||
|
|||
Future<void> _startPreloadIfNeeded() async { |
|||
if (_isFetching || _sentenceQueue.isEmpty) return; |
|||
_isFetching = true; |
|||
await _fetchNextSegment(); |
|||
} |
|||
|
|||
Future<void> _fetchNextSegment() async { |
|||
if (_sentenceQueue.isEmpty) { |
|||
_isFetching = false; |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
// 预加载多个片段以实现流畅播放 |
|||
final segments = <String>[]; |
|||
while (_sentenceQueue.isNotEmpty && segments.length < 2) { // 减少预加载数量以降低内存压力 |
|||
segments.add(_sentenceQueue.removeAt(0)); |
|||
} |
|||
|
|||
for (final segment in segments) { |
|||
final audioData = await _synthesize(segment); |
|||
|
|||
if (audioData != null) { |
|||
try { |
|||
// 添加到播放列表 |
|||
await _addToPlaylist(audioData); |
|||
} catch (e) { |
|||
Logger.error('添加音频源失败: $e'); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 如果队列还有内容,继续获取下一批 |
|||
if (_sentenceQueue.isNotEmpty) { |
|||
await Future.delayed(const Duration(milliseconds: 100)); // 添加延迟以避免过快请求 |
|||
await _fetchNextSegment(); |
|||
} else { |
|||
_isFetching = false; |
|||
// 检查是否还有未处理的文本 |
|||
_extractFullSentences(); |
|||
if (_sentenceQueue.isNotEmpty) { |
|||
await _startPreloadIfNeeded(); |
|||
} |
|||
} |
|||
} catch (e, stackTrace) { |
|||
Logger.error('获取音频失败: $e'); |
|||
Logger.error('Stack trace: $stackTrace'); |
|||
_isFetching = false; |
|||
} |
|||
} |
|||
|
|||
Future<Uint8List?> _synthesize(String text) async { |
|||
try { |
|||
if (_selectedVoice == null) { |
|||
// 如果未选择语音,使用默认语音 |
|||
Logger.info('未选择语音,使用默认语音'); |
|||
await setVoice('zh-CN-XiaoxiaoNeural'); // 使用默认语音 |
|||
|
|||
// 如果设置后仍然为null,则无法继续 |
|||
if (_selectedVoice == null) { |
|||
throw AzureTtsException('无法设置默认语音'); |
|||
} |
|||
} |
|||
|
|||
// 创建TTS参数 |
|||
final params = TtsParams( |
|||
voice: _selectedVoice!, |
|||
audioFormat: _audioFormat, |
|||
text: text, |
|||
rate: 1.0, // 语速,可以根据需要调整 |
|||
); |
|||
|
|||
// 请求语音合成 |
|||
final ttsResponse = await FlutterAzureTts.getTts(params); |
|||
|
|||
// 获取音频数据 |
|||
final audioBytes = ttsResponse.audio.buffer.asUint8List(); |
|||
return audioBytes; |
|||
} catch (e) { |
|||
Logger.error('Azure语音合成请求失败: $e'); |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
/// 将音频数据添加到播放列表 |
|||
Future<void> _addToPlaylist(Uint8List audioData) async { |
|||
try { |
|||
if (audioData.isEmpty) return; |
|||
|
|||
// 确保播放列表已初始化 |
|||
if (!_playlistInitialized) { |
|||
await _initAudioPlayer(); |
|||
} |
|||
|
|||
// 创建音频源 |
|||
final audioSource = BytesAudioSource(audioData); |
|||
|
|||
// 添加到播放列表 |
|||
await _playlist.add(audioSource); |
|||
|
|||
// 如果当前没有播放,开始播放 |
|||
if (!isPlaying.value) { |
|||
await _startPlayback(); |
|||
} else if (_audioPlayer.processingState == ProcessingState.completed) { |
|||
// 如果播放已完成但状态还没更新,手动前进到下一个 |
|||
await _advanceToNextItem(); |
|||
} |
|||
} catch (e) { |
|||
Logger.error('添加音频到播放列表失败: $e'); |
|||
// 通知错误 |
|||
_notifyEvent('playbackError', '添加音频到播放列表失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 开始播放收集到的音频数据 |
|||
Future<void> _startPlayback() async { |
|||
try { |
|||
if (!_playlistInitialized || _playlist.length == 0) { |
|||
return; |
|||
} |
|||
|
|||
// 检查当前播放状态 |
|||
final processingState = _audioPlayer.processingState; |
|||
|
|||
// 如果已经播放完成,需要重新设置位置到第一个未播放的项目 |
|||
if (processingState == ProcessingState.completed) { |
|||
// 找到第一个未播放的项目 |
|||
int nextIndex = 0; |
|||
if (_audioPlayer.currentIndex != null) { |
|||
nextIndex = math.min(_audioPlayer.currentIndex! + 1, _playlist.length - 1); |
|||
} |
|||
|
|||
// 跳转到下一个项目 |
|||
await _audioPlayer.seek(Duration.zero, index: nextIndex); |
|||
} |
|||
|
|||
// 开始播放 |
|||
await _audioPlayer.play(); |
|||
|
|||
// 手动更新播放状态,避免等待事件流更新可能导致的延迟 |
|||
if (_playlist.length > 0 && !isPlaying.value) { |
|||
isPlaying.value = true; |
|||
} |
|||
} catch (e) { |
|||
Logger.error('开始播放失败: $e'); |
|||
// 通知错误 |
|||
_notifyEvent('playbackError', '开始播放失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 前进到播放列表中的下一个项目 |
|||
Future<void> _advanceToNextItem() async { |
|||
try { |
|||
if (_playlist.length == 0) { |
|||
// 如果播放列表为空,确保 isPlaying 为 false |
|||
if (isPlaying.value) { |
|||
isPlaying.value = false; |
|||
} |
|||
return; |
|||
} |
|||
|
|||
// 获取当前索引和序列状态 |
|||
final currentIndex = _audioPlayer.currentIndex; |
|||
final sequenceState = _audioPlayer.sequenceState; |
|||
|
|||
if (sequenceState == null) { |
|||
// 如果没有序列状态,直接开始播放 |
|||
await _audioPlayer.seek(Duration.zero, index: 0); |
|||
await _audioPlayer.play(); |
|||
|
|||
// 更新播放状态 |
|||
if (!isPlaying.value) { |
|||
isPlaying.value = true; |
|||
} |
|||
return; |
|||
} |
|||
|
|||
// 如果当前索引无效,从头开始播放 |
|||
if (currentIndex == null || currentIndex < 0) { |
|||
await _audioPlayer.seek(Duration.zero, index: 0); |
|||
await _audioPlayer.play(); |
|||
|
|||
// 更新播放状态 |
|||
if (!isPlaying.value) { |
|||
isPlaying.value = true; |
|||
} |
|||
return; |
|||
} |
|||
|
|||
// 计算下一个索引 |
|||
final nextIndex = currentIndex + 1; |
|||
|
|||
// 检查是否还有下一个项目 |
|||
if (nextIndex < sequenceState.sequence.length) { |
|||
// 跳转到下一个项目并开始播放 |
|||
await _audioPlayer.seek(Duration.zero, index: nextIndex); |
|||
await _audioPlayer.play(); |
|||
|
|||
// 更新播放状态 |
|||
if (!isPlaying.value) { |
|||
isPlaying.value = true; |
|||
} |
|||
} else { |
|||
// 已经是最后一个项目 |
|||
// 确保最后一个项目播放完成 |
|||
if (_audioPlayer.position < _audioPlayer.duration!) { |
|||
await _audioPlayer.seek(_audioPlayer.duration!); |
|||
} |
|||
|
|||
// 如果已经是最后一个项目且已播放完成,更新播放状态为 false |
|||
if (isPlaying.value) { |
|||
isPlaying.value = false; |
|||
} |
|||
} |
|||
} catch (e) { |
|||
Logger.error('前进到下一个音频项目失败: $e'); |
|||
} |
|||
} |
|||
|
|||
Future<void> stop() async { |
|||
try { |
|||
// 立即更新播放状态,避免等待事件流更新可能导致的延迟 |
|||
if (isPlaying.value) { |
|||
isPlaying.value = false; |
|||
} |
|||
|
|||
await _audioPlayer.stop(); |
|||
await _playlist.clear(); |
|||
_pendingText = ''; |
|||
_sentenceQueue.clear(); |
|||
_isFetching = false; |
|||
|
|||
// 确保清空所有待处理的音频数据 |
|||
try { |
|||
// 重置播放器状态 |
|||
await _audioPlayer.pause(); |
|||
await _audioPlayer.seek(Duration.zero); |
|||
} catch (e) { |
|||
Logger.error('清空音频缓存时出错: $e'); |
|||
} |
|||
} catch (e) { |
|||
Logger.error('停止播放失败: $e'); |
|||
} |
|||
} |
|||
|
|||
// 添加一个方法来检查音频播放器是否真正在播放 |
|||
bool isActuallyPlaying() { |
|||
try { |
|||
// 检查播放器状态 |
|||
final isPlayerPlaying = _audioPlayer.playing; |
|||
|
|||
// 检查播放列表是否为空 |
|||
final hasAudioSource = _playlist.length > 0; |
|||
|
|||
// 只有当播放器正在播放且播放列表不为空时,才认为真正在播放 |
|||
final actuallyPlaying = isPlayerPlaying && hasAudioSource; |
|||
|
|||
// 如果状态不一致,记录日志 |
|||
if (isPlaying.value != actuallyPlaying) { |
|||
Logger.info('音频播放状态不一致: isPlaying.value=${isPlaying.value}, 实际播放状态=$actuallyPlaying (播放器状态=$isPlayerPlaying, 播放列表长度=${_playlist.length})'); |
|||
} |
|||
|
|||
return actuallyPlaying; |
|||
} catch (e) { |
|||
Logger.error('检查实际播放状态失败: $e'); |
|||
// 如果检查失败,返回当前的 isPlaying 值 |
|||
return isPlaying.value; |
|||
} |
|||
} |
|||
|
|||
Future<void> _reinitializePlayer() async { |
|||
try { |
|||
await stop(); |
|||
await _audioPlayer.dispose(); |
|||
|
|||
// 重新初始化播放器 |
|||
_audioPlayer = AudioPlayer(); |
|||
await _audioPlayer.setVolume(1.0); |
|||
await _audioPlayer.setLoopMode(LoopMode.off); |
|||
|
|||
// 重新配置Android特定的播放参数 |
|||
if (Platform.isAndroid) { |
|||
await _audioPlayer.setAndroidAudioAttributes(const AndroidAudioAttributes( |
|||
contentType: AndroidAudioContentType.speech, |
|||
usage: AndroidAudioUsage.media, |
|||
flags: AndroidAudioFlags.audibilityEnforced, |
|||
)); |
|||
} |
|||
|
|||
await _audioPlayer.setAudioSource( |
|||
_playlist, |
|||
initialPosition: Duration.zero, |
|||
preload: true, |
|||
); |
|||
|
|||
_playlistInitialized = true; |
|||
_setupEventListeners(); |
|||
Logger.info('播放器重新初始化成功'); |
|||
} catch (e) { |
|||
Logger.error('播放器重新初始化失败: $e'); |
|||
} |
|||
} |
|||
|
|||
void _setupEventListeners() { |
|||
_playerStateSubscription?.cancel(); |
|||
|
|||
_playerStateSubscription = _audioPlayer.playerStateStream.listen( |
|||
(state) { |
|||
if (_isDisposed) return; |
|||
|
|||
// 更新播放状态 - 使用更准确的判断逻辑 |
|||
final isPlayerPlaying = state.playing; |
|||
final hasAudioSource = _playlist.length > 0; |
|||
final actuallyPlaying = isPlayerPlaying && hasAudioSource; |
|||
|
|||
// 只有当实际状态与当前状态不同时才更新 |
|||
if (isPlaying.value != actuallyPlaying) { |
|||
isPlaying.value = actuallyPlaying; |
|||
} |
|||
|
|||
// 如果播放完成,尝试播放下一个 |
|||
if (state.processingState == ProcessingState.completed) { |
|||
_advanceToNextItem(); |
|||
} |
|||
}, |
|||
onError: (error) { |
|||
if (_isDisposed) return; |
|||
Logger.error('播放器错误: $error'); |
|||
_reinitializePlayer(); |
|||
}, |
|||
); |
|||
} |
|||
|
|||
void toggleEnabled() { |
|||
isEnabled.toggle(); |
|||
if (!isEnabled.value) { |
|||
stop(); |
|||
} |
|||
} |
|||
|
|||
Future<void> _cleanupResources() async { |
|||
_isDisposed = true; |
|||
await _playerStateSubscription?.cancel(); |
|||
await stop(); |
|||
await _audioPlayer.dispose(); |
|||
|
|||
// 关闭事件控制器 |
|||
if (!_eventController.isClosed) { |
|||
await _eventController.close(); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
void onClose() async { |
|||
Logger.info('关闭Azure TTS服务'); |
|||
await _cleanupResources(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
/// 使用系统默认音频设备 |
|||
Future<bool> useDefaultAudioDevice() async { |
|||
return await _configureAudioDevice(); |
|||
} |
|||
|
|||
/// 更新语音 |
|||
void updateVoice(Voice voice) { |
|||
_selectedVoice = voice; |
|||
Logger.info('已更新Azure语音: ${voice.shortName}'); |
|||
} |
|||
|
|||
/// 根据语音名称设置语音 |
|||
Future<void> setVoice(String voiceName) async { |
|||
Logger.info('正在设置Azure语音: $voiceName'); |
|||
|
|||
try { |
|||
// 获取可用的语音列表 |
|||
final voicesResponse = await FlutterAzureTts.getAvailableVoices(); |
|||
|
|||
if (voicesResponse.voices.isEmpty) { |
|||
Logger.error('未找到可用的Azure语音'); |
|||
return; |
|||
} |
|||
|
|||
// 1. 首先尝试精确匹配 |
|||
Voice? matchedVoice; |
|||
for (final voice in voicesResponse.voices) { |
|||
if (voice.shortName == voiceName) { |
|||
matchedVoice = voice; |
|||
break; |
|||
} |
|||
} |
|||
|
|||
// 2. 如果没有精确匹配,尝试部分匹配 |
|||
if (matchedVoice == null) { |
|||
for (final voice in voicesResponse.voices) { |
|||
if (voice.shortName.contains(voiceName) || voiceName.contains(voice.shortName)) { |
|||
matchedVoice = voice; |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 3. 如果仍然没有匹配,尝试匹配语言区域 |
|||
if (matchedVoice == null) { |
|||
final localePrefix = voiceName.split('-').take(2).join('-'); |
|||
for (final voice in voicesResponse.voices) { |
|||
if (voice.locale.startsWith(localePrefix)) { |
|||
matchedVoice = voice; |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 4. 如果所有尝试都失败,使用第一个可用的语音 |
|||
if (matchedVoice == null && voicesResponse.voices.isNotEmpty) { |
|||
matchedVoice = voicesResponse.voices.first; |
|||
} |
|||
|
|||
// 设置选中的语音 |
|||
if (matchedVoice != null) { |
|||
_selectedVoice = matchedVoice; |
|||
Logger.info('已设置Azure语音: ${matchedVoice.shortName}'); |
|||
} else { |
|||
Logger.error('未能找到合适的语音'); |
|||
} |
|||
} catch (e) { |
|||
Logger.error('设置Azure语音失败: $e'); |
|||
// 如果获取语音列表失败,创建一个简化的Voice对象 |
|||
_selectedVoice = Voice( |
|||
shortName: voiceName, |
|||
displayName: voiceName, |
|||
locale: _voiceLocale, |
|||
name: voiceName, |
|||
localName: voiceName, |
|||
gender: 'Female', |
|||
voiceType: 'Neural', |
|||
status: 'GA', |
|||
sampleRateHertz: '24000', |
|||
styles: [], |
|||
roles: [], |
|||
); |
|||
Logger.info('使用简化的Voice对象: $voiceName'); |
|||
} |
|||
} |
|||
|
|||
/// 通知事件 |
|||
void _notifyEvent(String eventType, String message) { |
|||
if (!_eventController.isClosed) { |
|||
_eventController.add({ |
|||
'eventType': eventType, |
|||
'message': message, |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,403 @@ |
|||
import 'dart:async'; |
|||
import 'package:flutter/services.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|||
import '../../core/utils/logger.dart'; |
|||
|
|||
/// 微软 Text-to-Speech 服务异常 |
|||
class AzureTtsException implements Exception { |
|||
final String message; |
|||
AzureTtsException(this.message); |
|||
|
|||
@override |
|||
String toString() => message; |
|||
} |
|||
|
|||
/// 微软 Text-to-Speech 服务 |
|||
/// |
|||
/// 该服务通过平台通道与 Android 上的 Microsoft Speech SDK 交互, |
|||
/// 提供文本转语音功能。 |
|||
class AzureTtsService extends GetxService { |
|||
static const MethodChannel _channel = MethodChannel('com.deep_voice.azure_tts'); |
|||
|
|||
bool _isInitialized = false; |
|||
late final String _subscriptionKey; |
|||
late final String _serviceRegion; |
|||
|
|||
// 当前设置 |
|||
String _currentVoice = 'zh-CN-XiaoxiaoNeural'; |
|||
int _currentRate = 0; |
|||
int _currentPitch = 0; |
|||
int _currentVolume = 100; |
|||
|
|||
// 语音合成队列 |
|||
final List<_SpeechItem> _textQueue = []; |
|||
bool _isProcessingQueue = false; |
|||
bool _isSpeaking = false; |
|||
|
|||
// 流式输出缓冲区 |
|||
String _streamBuffer = ''; |
|||
|
|||
// 可观察状态 |
|||
final isEnabled = true.obs; |
|||
final isSpeaking = false.obs; |
|||
|
|||
String get currentVoice => _currentVoice; |
|||
|
|||
AzureTtsService() { |
|||
_loadConfig(); |
|||
} |
|||
|
|||
/// 从环境变量加载配置 |
|||
void _loadConfig() { |
|||
_subscriptionKey = dotenv.env['AZURE_ASR_SUBSCRIPTION_KEY'] ?? ''; |
|||
_serviceRegion = dotenv.env['AZURE_ASR_SERVICE_REGION'] ?? ''; |
|||
} |
|||
|
|||
/// 初始化TTS服务 |
|||
Future<bool> initialize({String language = 'zh-CN'}) async { |
|||
if (_isInitialized) return true; |
|||
|
|||
try { |
|||
if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) { |
|||
Logger.error('未找到Azure语音服务配置'); |
|||
return false; |
|||
} |
|||
|
|||
final result = await _channel.invokeMethod('initialize', { |
|||
'subscriptionKey': _subscriptionKey, |
|||
'serviceRegion': _serviceRegion, |
|||
'language': language, |
|||
}); |
|||
|
|||
_isInitialized = result; |
|||
return result; |
|||
} catch (e) { |
|||
Logger.error('初始化失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 设置语音 |
|||
Future<bool> setVoice(String voiceName) async { |
|||
if (!_isInitialized) await initialize(); |
|||
|
|||
try { |
|||
final result = await _channel.invokeMethod('setVoice', { |
|||
'voiceName': voiceName, |
|||
}); |
|||
|
|||
if (result) _currentVoice = voiceName; |
|||
return result; |
|||
} catch (e) { |
|||
Logger.error('设置语音失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 设置语音参数 |
|||
Future<bool> setSpeechParams({int rate = 0, int pitch = 0, int volume = 100}) async { |
|||
if (!_isInitialized) await initialize(); |
|||
|
|||
try { |
|||
final result = await _channel.invokeMethod('setSpeechParams', { |
|||
'rate': rate, |
|||
'pitch': pitch, |
|||
'volume': volume, |
|||
}); |
|||
|
|||
if (result) { |
|||
_currentRate = rate; |
|||
_currentPitch = pitch; |
|||
_currentVolume = volume; |
|||
} |
|||
|
|||
return result; |
|||
} catch (e) { |
|||
Logger.error('设置语音参数失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 播放文本 |
|||
Future<String> speakText(String text) async { |
|||
if (!_isInitialized) await initialize(); |
|||
if (!isEnabled.value) return "TTS服务已禁用"; |
|||
|
|||
try { |
|||
_isSpeaking = true; |
|||
isSpeaking.value = true; |
|||
|
|||
final result = await _channel.invokeMethod('speakText', {'text': text}); |
|||
|
|||
_isSpeaking = false; |
|||
isSpeaking.value = false; |
|||
|
|||
return result; |
|||
} catch (e) { |
|||
_isSpeaking = false; |
|||
isSpeaking.value = false; |
|||
Logger.error('语音合成失败: $e'); |
|||
return "语音合成失败: $e"; |
|||
} |
|||
} |
|||
|
|||
/// 播放SSML |
|||
Future<String> speakSsml(String ssml) async { |
|||
if (!_isInitialized) await initialize(); |
|||
if (!isEnabled.value) return "TTS服务已禁用"; |
|||
|
|||
try { |
|||
_isSpeaking = true; |
|||
isSpeaking.value = true; |
|||
|
|||
final result = await _channel.invokeMethod('speakSsml', {'ssml': ssml}); |
|||
|
|||
_isSpeaking = false; |
|||
isSpeaking.value = false; |
|||
|
|||
return result; |
|||
} catch (e) { |
|||
_isSpeaking = false; |
|||
isSpeaking.value = false; |
|||
Logger.error('SSML语音合成失败: $e'); |
|||
return "SSML语音合成失败: $e"; |
|||
} |
|||
} |
|||
|
|||
/// 添加文本到队列并播放 |
|||
Future<bool> speak(String text, {int? rate, int? pitch, int? volume}) async { |
|||
if (!isEnabled.value || text.isEmpty) return false; |
|||
|
|||
_textQueue.add(_SpeechItem( |
|||
text: text, |
|||
rate: rate ?? _currentRate, |
|||
pitch: pitch ?? _currentPitch, |
|||
volume: volume ?? _currentVolume, |
|||
)); |
|||
|
|||
if (!_isProcessingQueue) _processQueue(); |
|||
return true; |
|||
} |
|||
|
|||
/// 处理AI流式输出文本 |
|||
/// |
|||
/// 将流式输出的文本片段添加到缓冲区,并在检测到完整句子时将其送入播放队列 |
|||
Future<bool> processStreamText(String textChunk) async { |
|||
if (!isEnabled.value || textChunk.isEmpty) return false; |
|||
|
|||
// 添加文本到缓冲区 |
|||
_streamBuffer += textChunk; |
|||
|
|||
// 如果缓冲区为空,直接返回 |
|||
if (_streamBuffer.isEmpty) return true; |
|||
|
|||
// 使用正则表达式匹配句子,包括结束符号 |
|||
// 匹配任意字符,直到遇到句子结束符号 |
|||
final sentenceRegex = RegExp( |
|||
r'([^。.!!??;;:\n\r]+[。.!!??;;::\n\r]|[^。.!!??;;:\n\r]+(?:\.{3,}|…)|[^。.!!??;;:\n\r]+["」』])' |
|||
); |
|||
|
|||
bool hasProcessed = false; |
|||
|
|||
// 查找所有完整句子 |
|||
final matches = sentenceRegex.allMatches(_streamBuffer); |
|||
final List<String> sentences = []; |
|||
int lastMatchEnd = 0; |
|||
|
|||
for (final match in matches) { |
|||
// 提取完整句子(包含结束符号) |
|||
final sentence = match.group(1)?.trim(); |
|||
if (sentence != null && sentence.isNotEmpty) { |
|||
sentences.add(sentence); |
|||
lastMatchEnd = match.end; |
|||
} |
|||
} |
|||
|
|||
// 处理找到的句子 |
|||
for (final sentence in sentences) { |
|||
_textQueue.add(_SpeechItem( |
|||
text: sentence, |
|||
rate: _currentRate, |
|||
pitch: _currentPitch, |
|||
volume: _currentVolume, |
|||
)); |
|||
hasProcessed = true; |
|||
} |
|||
|
|||
// 更新缓冲区,只保留未完成的部分 |
|||
if (lastMatchEnd > 0) { |
|||
_streamBuffer = _streamBuffer.substring(lastMatchEnd); |
|||
} |
|||
|
|||
// 如果处理了文本并且队列未在处理中,开始处理队列 |
|||
if (hasProcessed && !_isProcessingQueue) { |
|||
_processQueue(); |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
/// 强制处理缓冲区中的剩余文本 |
|||
/// |
|||
/// 在流式输出结束时调用,确保缓冲区中的所有文本都被处理 |
|||
Future<bool> flushStreamBuffer() async { |
|||
if (!isEnabled.value || _streamBuffer.isEmpty) return false; |
|||
|
|||
// 先尝试使用processStreamText处理可能的完整句子 |
|||
await processStreamText(''); |
|||
|
|||
// 如果缓冲区仍有剩余文本,将其添加到播放队列 |
|||
if (_streamBuffer.isNotEmpty && _streamBuffer.trim().isNotEmpty) { |
|||
_textQueue.add(_SpeechItem( |
|||
text: _streamBuffer, |
|||
rate: _currentRate, |
|||
pitch: _currentPitch, |
|||
volume: _currentVolume, |
|||
)); |
|||
|
|||
// 清空缓冲区 |
|||
_streamBuffer = ''; |
|||
|
|||
// 如果队列未在处理中,开始处理队列 |
|||
if (!_isProcessingQueue) { |
|||
_processQueue(); |
|||
} |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
/// 连续播放多段文本 |
|||
Future<bool> speakMultiple(List<String> texts, {int? rate, int? pitch, int? volume}) async { |
|||
if (!isEnabled.value || texts.isEmpty) return false; |
|||
|
|||
for (final text in texts) { |
|||
if (text.isNotEmpty) { |
|||
_textQueue.add(_SpeechItem( |
|||
text: text, |
|||
rate: rate ?? _currentRate, |
|||
pitch: pitch ?? _currentPitch, |
|||
volume: volume ?? _currentVolume, |
|||
)); |
|||
} |
|||
} |
|||
|
|||
if (!_isProcessingQueue) _processQueue(); |
|||
return true; |
|||
} |
|||
|
|||
/// 处理语音合成队列 |
|||
Future<void> _processQueue() async { |
|||
if (_textQueue.isEmpty || _isProcessingQueue) return; |
|||
|
|||
_isProcessingQueue = true; |
|||
|
|||
try { |
|||
while (_textQueue.isNotEmpty) { |
|||
if (!isEnabled.value) { |
|||
_textQueue.clear(); |
|||
break; |
|||
} |
|||
|
|||
final item = _textQueue.removeAt(0); |
|||
|
|||
if (item.rate != _currentRate || item.pitch != _currentPitch || item.volume != _currentVolume) { |
|||
await setSpeechParams( |
|||
rate: item.rate, |
|||
pitch: item.pitch, |
|||
volume: item.volume, |
|||
); |
|||
} |
|||
|
|||
await speakText(item.text); |
|||
} |
|||
} catch (e) { |
|||
Logger.error('处理语音队列出错: $e'); |
|||
} finally { |
|||
_isProcessingQueue = false; |
|||
} |
|||
} |
|||
|
|||
/// 停止当前语音合成并清空队列 |
|||
Future<bool> stop() async { |
|||
_textQueue.clear(); |
|||
_streamBuffer = ''; // 清空流缓冲区 |
|||
|
|||
if (_isSpeaking) { |
|||
try { |
|||
final result = await _channel.invokeMethod('stopSpeaking'); |
|||
_isSpeaking = false; |
|||
isSpeaking.value = false; |
|||
return result; |
|||
} catch (e) { |
|||
Logger.error('停止语音合成出错: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
/// 生成SSML文本 |
|||
String generateSsml({ |
|||
required String text, |
|||
String? voiceName, |
|||
int? rate, |
|||
int? pitch, |
|||
int? volume, |
|||
}) { |
|||
final voice = voiceName ?? _currentVoice; |
|||
final rateValue = (rate ?? _currentRate).clamp(-100, 100); |
|||
final pitchValue = (pitch ?? _currentPitch).clamp(-100, 100); |
|||
final volumeValue = (volume ?? _currentVolume).clamp(0, 100); |
|||
|
|||
final rateStr = rateValue == 0 ? '0%' : rateValue < 0 ? '${(rateValue * 0.9).round()}%' : '$rateValue%'; |
|||
final pitchStr = pitchValue == 0 ? '0%' : '${(pitchValue * 0.5).round()}%'; |
|||
final volumeStr = "$volumeValue%"; |
|||
|
|||
return ''' |
|||
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts" xml:lang="zh-CN"> |
|||
<voice name="$voice"> |
|||
<prosody rate="$rateStr" pitch="$pitchStr" volume="$volumeStr"> |
|||
$text |
|||
</prosody> |
|||
</voice> |
|||
</speak> |
|||
'''; |
|||
} |
|||
|
|||
/// 切换启用状态 |
|||
void toggleEnabled() { |
|||
isEnabled.toggle(); |
|||
if (!isEnabled.value) stop(); |
|||
} |
|||
|
|||
/// 释放资源 |
|||
Future<void> dispose() async { |
|||
if (!_isInitialized) return; |
|||
|
|||
try { |
|||
await stop(); |
|||
await _channel.invokeMethod('dispose'); |
|||
_isInitialized = false; |
|||
} catch (e) { |
|||
Logger.error('释放资源失败: $e'); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// 语音合成项目 |
|||
class _SpeechItem { |
|||
final String text; |
|||
final int rate; |
|||
final int pitch; |
|||
final int volume; |
|||
|
|||
_SpeechItem({ |
|||
required this.text, |
|||
this.rate = 0, |
|||
this.pitch = 0, |
|||
this.volume = 100, |
|||
}); |
|||
} |
|||
@ -0,0 +1,743 @@ |
|||
import 'dart:async'; |
|||
import 'dart:convert'; |
|||
import 'dart:typed_data'; |
|||
import 'dart:io'; |
|||
import 'dart:math' as math; |
|||
import 'package:get/get.dart'; |
|||
import 'package:just_audio/just_audio.dart'; |
|||
import 'package:web_socket_channel/web_socket_channel.dart'; |
|||
import 'package:audio_session/audio_session.dart'; |
|||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|||
import '../../core/utils/logger.dart'; |
|||
|
|||
/// 火山语音服务异常 |
|||
class VolcanoTtsException implements Exception { |
|||
final String message; |
|||
VolcanoTtsException(this.message); |
|||
|
|||
@override |
|||
String toString() => message; |
|||
} |
|||
|
|||
/// 自定义音频源,用于从字节数组读取音频数据 |
|||
class BytesAudioSource extends StreamAudioSource { |
|||
final Uint8List _bytes; |
|||
static const int _bufferSize = 4096; // 4KB buffer size for better Android compatibility |
|||
|
|||
BytesAudioSource(this._bytes); |
|||
|
|||
@override |
|||
Future<StreamAudioResponse> request([int? start, int? end]) async { |
|||
start = start ?? 0; |
|||
end = end ?? _bytes.length; |
|||
|
|||
try { |
|||
final subData = _bytes.sublist(start, end); |
|||
|
|||
// 使用固定大小的块进行流式传输 |
|||
final chunks = <List<int>>[]; |
|||
var offset = 0; |
|||
while (offset < subData.length) { |
|||
final chunkSize = math.min(_bufferSize, subData.length - offset); |
|||
chunks.add(subData.sublist(offset, offset + chunkSize)); |
|||
offset += chunkSize; |
|||
} |
|||
|
|||
return StreamAudioResponse( |
|||
sourceLength: _bytes.length, |
|||
contentLength: subData.length, |
|||
offset: start, |
|||
stream: Stream.fromIterable(chunks), |
|||
contentType: 'audio/mpeg', |
|||
); |
|||
} catch (e) { |
|||
print('BytesAudioSource request error: $e'); |
|||
rethrow; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<int> get length => Future.value(_bytes.length); |
|||
} |
|||
|
|||
/// 火山语音合成服务 |
|||
class VolcanoTtsService extends GetxService { |
|||
static const String _host = 'openspeech.bytedance.com'; |
|||
static const String _apiUrl = 'wss://$_host/api/v1/tts/ws_binary'; |
|||
|
|||
// 配置参数 |
|||
final String _appId; |
|||
final String _token; |
|||
final String _cluster; |
|||
final String _voiceType; |
|||
|
|||
// 音频播放器 |
|||
late AudioPlayer _audioPlayer; |
|||
final ConcatenatingAudioSource _playlist = ConcatenatingAudioSource(children: []); |
|||
final isEnabled = true.obs; // 默认启用 |
|||
final isPlaying = false.obs; |
|||
StreamSubscription? _playbackEventSubscription; |
|||
StreamSubscription? _playerStateSubscription; |
|||
AudioSession? _audioSession; |
|||
|
|||
// WebSocket 相关 |
|||
WebSocketChannel? _channel; |
|||
bool _isDisposed = false; |
|||
|
|||
// 句子管理 |
|||
String _pendingText = ''; |
|||
final List<String> _sentenceQueue = []; |
|||
static final _sentenceBreaks = RegExp(r'[。!?.!?,,、]'); // 添加更多标点符号 |
|||
bool _isFetching = false; |
|||
static const int _maxSegmentLength = 100; // 最大分段长度 |
|||
static const int _optimalSegmentLength = 50; // 最佳分段长度 |
|||
|
|||
// WebSocket连接配置 |
|||
static final Map<String, String> _wsHeaders = { |
|||
'User-Agent': 'DeepVoice/1.0', |
|||
'Accept': '*/*', |
|||
'Accept-Encoding': 'gzip, deflate, br', |
|||
'Connection': 'Upgrade', |
|||
'Upgrade': 'websocket', |
|||
'Sec-WebSocket-Version': '13', |
|||
'Sec-WebSocket-Extensions': 'permessage-deflate', |
|||
'Origin': 'https://openspeech.bytedance.com', |
|||
}; |
|||
|
|||
// 二进制协议头部字段定义 |
|||
static const int PROTOCOL_VERSION = 0x1; // 0b0001 - 版本1 |
|||
static const int HEADER_SIZE = 0x1; // 0b0001 - 4字节 |
|||
static const int MESSAGE_TYPE = 0x1; // 0b0001 - full client request |
|||
static const int MESSAGE_FLAGS = 0x0; // 0b0000 - 无特殊标记 |
|||
static const int SERIALIZATION = 0x1; // 0b0001 - JSON |
|||
static const int COMPRESSION = 0x0; // 0b0000 - 无压缩 |
|||
static const int RESERVED = 0x0; // 0b0000 - 保留字段 |
|||
|
|||
// 构建二进制协议头 |
|||
static Uint8List _buildHeader() { |
|||
final header = ByteData(4); // 4字节的头部 |
|||
|
|||
// 第一个字节: [协议版本(4位) | 报头大小(4位)] |
|||
header.setUint8(0, (PROTOCOL_VERSION << 4) | HEADER_SIZE); |
|||
|
|||
// 第二个字节: [消息类型(4位) | 消息标记(4位)] |
|||
header.setUint8(1, (MESSAGE_TYPE << 4) | MESSAGE_FLAGS); |
|||
|
|||
// 第三个字节: [序列化方法(4位) | 压缩方法(4位)] |
|||
header.setUint8(2, (SERIALIZATION << 4) | COMPRESSION); |
|||
|
|||
// 第四个字节: [保留字段(8位)] |
|||
header.setUint8(3, RESERVED); |
|||
|
|||
return header.buffer.asUint8List(); |
|||
} |
|||
|
|||
VolcanoTtsService() : |
|||
_appId = dotenv.env['VOLCANO_TTS_APP_ID'] ?? '', |
|||
_token = dotenv.env['VOLCANO_TTS_APP_TOKEN'] ?? '', |
|||
_cluster = dotenv.env['VOLCANO_TTS_CLUSTER'] ?? '', |
|||
_voiceType = dotenv.env['VOLCANO_TTS_VOICE_TYPE'] ?? 'zh_male_M392_conversation_wvae_bigtts' { |
|||
if (_appId.isEmpty || _token.isEmpty || _cluster.isEmpty) { |
|||
throw Exception('火山语音配置信息不完整,请检查环境变量'); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<void> onInit() async { |
|||
super.onInit(); |
|||
try { |
|||
await _initAudioSession(); |
|||
await _initAudioPlayer(); |
|||
await useDefaultAudioDevice(); |
|||
} catch (e, stackTrace) { |
|||
print('初始化TTS服务失败: $e'); |
|||
print('Stack trace: $stackTrace'); |
|||
} |
|||
} |
|||
|
|||
Future<void> _initAudioSession() async { |
|||
_audioSession = await AudioSession.instance; |
|||
await _audioSession?.configure(AudioSessionConfiguration( |
|||
avAudioSessionCategory: AVAudioSessionCategory.playback, |
|||
androidAudioAttributes: const AndroidAudioAttributes( |
|||
contentType: AndroidAudioContentType.speech, |
|||
usage: AndroidAudioUsage.media, |
|||
flags: AndroidAudioFlags.audibilityEnforced, |
|||
), |
|||
androidAudioFocusGainType: AndroidAudioFocusGainType.gain, |
|||
androidWillPauseWhenDucked: true, |
|||
)); |
|||
|
|||
// 确保音频会话激活并设置正确的音频模式 |
|||
if (Platform.isAndroid) { |
|||
await _audioSession?.setActive(true, avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.notifyOthersOnDeactivation); |
|||
} else { |
|||
await _audioSession?.setActive(true); |
|||
} |
|||
} |
|||
|
|||
Future<void> _initAudioPlayer() async { |
|||
if (_isDisposed) return; |
|||
|
|||
_audioPlayer = AudioPlayer(); |
|||
|
|||
// 配置Android特定的播放参数 |
|||
if (Platform.isAndroid) { |
|||
await _audioPlayer.setAndroidAudioAttributes(const AndroidAudioAttributes( |
|||
contentType: AndroidAudioContentType.speech, |
|||
usage: AndroidAudioUsage.media, |
|||
flags: AndroidAudioFlags.audibilityEnforced, |
|||
)); |
|||
} |
|||
|
|||
// 基本播放器配置 |
|||
await _audioPlayer.setVolume(1.0); |
|||
await _audioPlayer.setLoopMode(LoopMode.off); |
|||
await _audioPlayer.setSpeed(1.0); |
|||
|
|||
// 设置音频缓冲配置,启用预加载以减少延迟 |
|||
await _audioPlayer.setAudioSource( |
|||
_playlist, |
|||
initialPosition: Duration.zero, |
|||
preload: true, |
|||
); |
|||
|
|||
_setupEventListeners(); |
|||
} |
|||
|
|||
Future<void> speak(String text) async { |
|||
if (!isEnabled.value || text.trim().isEmpty) return; |
|||
|
|||
try { |
|||
_pendingText += text; |
|||
_extractFullSentences(); |
|||
await _startPreloadIfNeeded(); |
|||
} catch (e, stack) { |
|||
print('语音合成失败: $e'); |
|||
await _reinitializePlayer(); |
|||
} |
|||
} |
|||
|
|||
void _extractFullSentences() { |
|||
if (_pendingText.isEmpty) return; |
|||
|
|||
final matches = _sentenceBreaks.allMatches(_pendingText).toList(); |
|||
if (matches.isEmpty) { |
|||
// 如果没有找到句子分隔符,按长度分割 |
|||
_splitByLength(_pendingText); |
|||
_pendingText = ''; |
|||
return; |
|||
} |
|||
|
|||
int lastPos = 0; |
|||
String currentBatch = ''; |
|||
|
|||
for (final match in matches) { |
|||
final end = match.end; |
|||
final sentence = _pendingText.substring(lastPos, end).trim(); |
|||
|
|||
if (sentence.isEmpty) { |
|||
lastPos = end; |
|||
continue; |
|||
} |
|||
|
|||
// 如果当前批次为空,直接添加句子 |
|||
if (currentBatch.isEmpty) { |
|||
currentBatch = sentence; |
|||
} |
|||
// 如果当前批次加上新句子不超过30字符,则合并 |
|||
else if ((currentBatch + sentence).length <= 30) { |
|||
currentBatch += sentence; |
|||
} |
|||
// 如果超过30字符,将当前批次加入队列,开始新批次 |
|||
else { |
|||
if (currentBatch.isNotEmpty) { |
|||
_sentenceQueue.add(currentBatch); |
|||
} |
|||
currentBatch = sentence; |
|||
} |
|||
|
|||
lastPos = end; |
|||
} |
|||
|
|||
// 处理最后一个批次 |
|||
if (currentBatch.isNotEmpty) { |
|||
_sentenceQueue.add(currentBatch); |
|||
} |
|||
|
|||
// 处理剩余的文本 |
|||
if (lastPos < _pendingText.length) { |
|||
final remaining = _pendingText.substring(lastPos).trim(); |
|||
if (remaining.isNotEmpty) { |
|||
if (remaining.length <= 30) { |
|||
// 尝试与最后一个批次合并 |
|||
if (_sentenceQueue.isNotEmpty && |
|||
(_sentenceQueue.last + remaining).length <= 30) { |
|||
_sentenceQueue[_sentenceQueue.length - 1] += remaining; |
|||
} else { |
|||
_sentenceQueue.add(remaining); |
|||
} |
|||
} else { |
|||
_splitByLength(remaining); |
|||
} |
|||
} |
|||
} |
|||
|
|||
_pendingText = ''; |
|||
} |
|||
|
|||
void _splitByLength(String text) { |
|||
if (text.isEmpty) return; |
|||
|
|||
int start = 0; |
|||
while (start < text.length) { |
|||
int end = math.min(start + 30, text.length); |
|||
|
|||
// 尝试在标点或空格处分割 |
|||
if (end < text.length) { |
|||
final spacePos = text.lastIndexOf(' ', end); |
|||
final breakPos = _sentenceBreaks.firstMatch( |
|||
text.substring(start, end) |
|||
)?.end; |
|||
|
|||
if (breakPos != null && breakPos > start) { |
|||
end = start + breakPos; |
|||
} else if (spacePos > start) { |
|||
end = spacePos; |
|||
} |
|||
} |
|||
|
|||
final segment = text.substring(start, end).trim(); |
|||
if (segment.isNotEmpty) { |
|||
// 尝试与前一个批次合并 |
|||
if (_sentenceQueue.isNotEmpty && |
|||
(_sentenceQueue.last + segment).length <= 30) { |
|||
_sentenceQueue[_sentenceQueue.length - 1] += segment; |
|||
} else { |
|||
_sentenceQueue.add(segment); |
|||
} |
|||
} |
|||
start = end; |
|||
} |
|||
} |
|||
|
|||
Future<void> _startPreloadIfNeeded() async { |
|||
if (_isFetching || _sentenceQueue.isEmpty) return; |
|||
_isFetching = true; |
|||
await _fetchNextSegment(); |
|||
} |
|||
|
|||
Future<void> _fetchNextSegment() async { |
|||
if (_sentenceQueue.isEmpty) { |
|||
_isFetching = false; |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
// 预加载多个片段以实现流畅播放 |
|||
final segments = <String>[]; |
|||
while (_sentenceQueue.isNotEmpty && segments.length < 2) { // 减少预加载数量以降低内存压力 |
|||
segments.add(_sentenceQueue.removeAt(0)); |
|||
} |
|||
|
|||
for (final segment in segments) { |
|||
print('获取音频片段: $segment'); |
|||
final audioData = await _synthesize(segment); |
|||
|
|||
if (audioData != null) { |
|||
print('成功获取音频数据: ${audioData.length} bytes'); |
|||
try { |
|||
final audioSource = BytesAudioSource(audioData); |
|||
await _playlist.add(audioSource); |
|||
print('音频片段已添加到播放列表'); |
|||
|
|||
// 如果是第一个片段且播放器未在播放,开始播放 |
|||
if (!_audioPlayer.playing && _playlist.length == 1) { |
|||
// 确保音频会话处于激活状态 |
|||
await _audioSession?.setActive(true); |
|||
|
|||
// 设置播放参数 |
|||
await _audioPlayer.setVolume(1.0); |
|||
await _audioPlayer.seek(Duration.zero, index: 0); |
|||
|
|||
// 开始播放 |
|||
await _audioPlayer.play(); |
|||
} |
|||
} catch (e) { |
|||
print('添加音频源失败: $e'); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 如果队列还有内容,继续获取下一批 |
|||
if (_sentenceQueue.isNotEmpty) { |
|||
await Future.delayed(const Duration(milliseconds: 100)); // 添加延迟以避免过快请求 |
|||
await _fetchNextSegment(); |
|||
} else { |
|||
_isFetching = false; |
|||
// 检查是否还有未处理的文本 |
|||
_extractFullSentences(); |
|||
if (_sentenceQueue.isNotEmpty) { |
|||
await _startPreloadIfNeeded(); |
|||
} |
|||
} |
|||
} catch (e, stackTrace) { |
|||
print('获取音频失败: $e'); |
|||
print('Stack trace: $stackTrace'); |
|||
_isFetching = false; |
|||
} |
|||
} |
|||
|
|||
Future<Uint8List?> _synthesize(String text) async { |
|||
WebSocket? socket; |
|||
StreamSubscription? subscription; |
|||
|
|||
try { |
|||
final requestJson = { |
|||
'app': { |
|||
'appid': _appId, |
|||
'token': _token, |
|||
'cluster': _cluster, |
|||
}, |
|||
'user': { |
|||
'uid': '${DateTime.now().millisecondsSinceEpoch}', |
|||
}, |
|||
'audio': { |
|||
'voice_type': _voiceType, |
|||
'encoding': 'mp3', |
|||
'speed_ratio': 1.0, |
|||
'volume_ratio': 1.0, |
|||
'pitch_ratio': 1.0, |
|||
'language': 'zh', |
|||
}, |
|||
'request': { |
|||
'reqid': '${DateTime.now().millisecondsSinceEpoch}', |
|||
'text': text, |
|||
'text_type': 'plain', |
|||
'operation': 'query', |
|||
}, |
|||
}; |
|||
|
|||
final jsonBytes = utf8.encode(json.encode(requestJson)); |
|||
final header = _buildHeader(); |
|||
final fullRequest = Uint8List(header.length + 4 + jsonBytes.length); |
|||
|
|||
fullRequest.setAll(0, header); |
|||
final lengthBytes = ByteData(4)..setInt32(0, jsonBytes.length, Endian.big); |
|||
fullRequest.setAll(header.length, lengthBytes.buffer.asUint8List()); |
|||
fullRequest.setAll(header.length + 4, jsonBytes); |
|||
|
|||
final wsUrl = Uri.parse(_apiUrl); |
|||
final completer = Completer<Uint8List?>(); |
|||
|
|||
socket = await WebSocket.connect( |
|||
wsUrl.toString(), |
|||
headers: { |
|||
..._wsHeaders, |
|||
'Authorization': 'Bearer;$_token', |
|||
}, |
|||
).timeout( |
|||
const Duration(seconds: 5), |
|||
onTimeout: () { |
|||
throw TimeoutException('WebSocket连接超时'); |
|||
}, |
|||
); |
|||
|
|||
subscription = socket.listen( |
|||
(response) { |
|||
if (!completer.isCompleted && response is Uint8List) { |
|||
if (response.length < 2) return; |
|||
|
|||
final messageType = response[1] >> 4; |
|||
|
|||
if (messageType == 0x0B) { |
|||
final headerSize = response[0] & 0x0F; |
|||
final payloadStart = headerSize * 4; |
|||
|
|||
if (response.length > payloadStart) { |
|||
final audioData = response.sublist(payloadStart); |
|||
completer.complete(audioData); |
|||
} |
|||
} else if (messageType == 0x0F) { |
|||
try { |
|||
final headerSize = response[0] & 0x0F; |
|||
final payloadStart = headerSize * 4; |
|||
|
|||
if (response.length < payloadStart + 4) { |
|||
throw VolcanoTtsException('错误响应数据不完整'); |
|||
} |
|||
|
|||
final jsonData = response.sublist(payloadStart + 4); |
|||
final jsonString = utf8.decode(jsonData, allowMalformed: true); |
|||
|
|||
if (jsonString.trim().startsWith('{')) { |
|||
final errorMap = json.decode(jsonString); |
|||
throw VolcanoTtsException(errorMap['error'] as String? ?? '未知错误'); |
|||
} |
|||
} catch (e) { |
|||
if (e is VolcanoTtsException) rethrow; |
|||
throw VolcanoTtsException('服务返回错误: $e'); |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
onError: (error) { |
|||
if (!completer.isCompleted) { |
|||
completer.completeError(error); |
|||
} |
|||
}, |
|||
onDone: () { |
|||
if (!completer.isCompleted) { |
|||
completer.complete(null); |
|||
} |
|||
}, |
|||
cancelOnError: false, |
|||
); |
|||
|
|||
socket.add(fullRequest); |
|||
|
|||
return await completer.future.timeout( |
|||
const Duration(seconds: 10), |
|||
onTimeout: () => null, |
|||
); |
|||
} catch (e) { |
|||
print('合成请求失败: $e'); |
|||
return null; |
|||
} finally { |
|||
await subscription?.cancel(); |
|||
await socket?.close(); |
|||
} |
|||
} |
|||
|
|||
Future<void> _playAudio(Uint8List audioData) async { |
|||
if (_isDisposed) { |
|||
print('TTS服务已销毁,跳过播放'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
print('准备播放音频数据: ${audioData.length} bytes'); |
|||
final audioSource = BytesAudioSource(audioData); |
|||
|
|||
print('清除播放列表'); |
|||
await _playlist.clear(); |
|||
|
|||
print('添加新的音频源到播放列表'); |
|||
await _playlist.add(audioSource); |
|||
|
|||
if (!_audioPlayer.playing) { |
|||
print('设置音量和播放位置'); |
|||
await _audioPlayer.setVolume(1.0); |
|||
await _audioPlayer.seek(Duration.zero, index: 0); |
|||
|
|||
// 确保音频会话处于激活状态 |
|||
await _audioSession?.setActive(true); |
|||
|
|||
print('等待50ms确保音频准备就绪'); |
|||
await Future.delayed(const Duration(milliseconds: 50)); |
|||
|
|||
print('开始播放'); |
|||
await _audioPlayer.play(); |
|||
print('播放命令已发送'); |
|||
} else { |
|||
print('播放器已在播放中'); |
|||
} |
|||
} catch (e, stack) { |
|||
print('播放音频失败: $e'); |
|||
print('错误堆栈: $stack'); |
|||
await _reinitializePlayer(); |
|||
} |
|||
} |
|||
|
|||
Future<void> stop() async { |
|||
try { |
|||
print('停止播放'); |
|||
await _audioPlayer.stop(); |
|||
await _playlist.clear(); |
|||
_pendingText = ''; |
|||
_sentenceQueue.clear(); |
|||
_isFetching = false; |
|||
isPlaying.value = false; |
|||
|
|||
// 确保清空所有待处理的音频数据 |
|||
try { |
|||
// 取消所有正在进行的WebSocket连接 |
|||
await _channel?.sink.close(); |
|||
_channel = null; |
|||
|
|||
// 重置播放器状态 |
|||
await _audioPlayer.pause(); |
|||
await _audioPlayer.seek(Duration.zero); |
|||
|
|||
print('已清空所有待播放的文字和音频'); |
|||
} catch (e) { |
|||
print('清空音频缓存时出错: $e'); |
|||
} |
|||
} catch (e) { |
|||
print('停止播放失败: $e'); |
|||
} |
|||
} |
|||
|
|||
// 添加一个方法来检查音频播放器是否真正在播放 |
|||
bool isActuallyPlaying() { |
|||
try { |
|||
// 检查播放器状态 |
|||
final isPlayerPlaying = _audioPlayer.playing; |
|||
|
|||
// 检查播放列表是否为空 |
|||
final hasAudioSource = _playlist.length > 0; |
|||
|
|||
// 只有当播放器正在播放且播放列表不为空时,才认为真正在播放 |
|||
final actuallyPlaying = isPlayerPlaying && hasAudioSource; |
|||
|
|||
// 如果状态不一致,记录日志 |
|||
if (isPlaying.value != actuallyPlaying) { |
|||
print('音频播放状态不一致: isPlaying.value=${isPlaying.value}, 实际播放状态=$actuallyPlaying (播放器状态=$isPlayerPlaying, 播放列表长度=${_playlist.length})'); |
|||
} |
|||
|
|||
return actuallyPlaying; |
|||
} catch (e) { |
|||
print('检查实际播放状态失败: $e'); |
|||
// 如果检查失败,返回当前的 isPlaying 值 |
|||
return isPlaying.value; |
|||
} |
|||
} |
|||
|
|||
Future<void> _reinitializePlayer() async { |
|||
try { |
|||
await stop(); |
|||
await _audioPlayer.dispose(); |
|||
|
|||
// 重新初始化音频会话 |
|||
await _initAudioSession(); |
|||
|
|||
_audioPlayer = AudioPlayer(); |
|||
await _audioPlayer.setVolume(1.0); |
|||
await _audioPlayer.setLoopMode(LoopMode.off); |
|||
|
|||
// 重新配置Android特定的播放参数 |
|||
await _audioPlayer.setAndroidAudioAttributes(const AndroidAudioAttributes( |
|||
contentType: AndroidAudioContentType.speech, |
|||
usage: AndroidAudioUsage.media, |
|||
flags: AndroidAudioFlags.audibilityEnforced, |
|||
)); |
|||
|
|||
await _audioPlayer.setAudioSource( |
|||
_playlist, |
|||
initialPosition: Duration.zero, |
|||
preload: true, |
|||
); |
|||
|
|||
_setupEventListeners(); |
|||
print('播放器重新初始化成功'); |
|||
} catch (e) { |
|||
print('播放器重新初始化失败: $e'); |
|||
} |
|||
} |
|||
|
|||
void _setupEventListeners() { |
|||
_playbackEventSubscription?.cancel(); |
|||
_playerStateSubscription?.cancel(); |
|||
|
|||
_playbackEventSubscription = _audioPlayer.playbackEventStream.listen( |
|||
(event) { |
|||
if (_isDisposed) return; |
|||
if (event.processingState == ProcessingState.completed) { |
|||
_tryPlayNext(); |
|||
} |
|||
}, |
|||
onError: (error) { |
|||
if (_isDisposed) return; |
|||
_reinitializePlayer(); |
|||
}, |
|||
); |
|||
|
|||
_playerStateSubscription = _audioPlayer.playerStateStream.listen( |
|||
(state) { |
|||
if (_isDisposed) return; |
|||
isPlaying.value = state.playing; |
|||
|
|||
if (state.processingState == ProcessingState.completed) { |
|||
_tryPlayNext(); |
|||
} |
|||
}, |
|||
onError: (error) { |
|||
if (_isDisposed) return; |
|||
_reinitializePlayer(); |
|||
}, |
|||
); |
|||
} |
|||
|
|||
Future<void> _tryPlayNext() async { |
|||
if (_isDisposed || _playlist.length <= 0) return; |
|||
|
|||
try { |
|||
if (!_audioPlayer.playing) { |
|||
// 确保音频会话处于激活状态 |
|||
await _audioSession?.setActive(true); |
|||
|
|||
// 减少延迟时间 |
|||
await Future.delayed(const Duration(milliseconds: 30)); |
|||
|
|||
if (!_isDisposed && !_audioPlayer.playing) { |
|||
await _audioPlayer.seek(Duration.zero, index: 0); |
|||
await _audioPlayer.play(); |
|||
} |
|||
} |
|||
} catch (e) { |
|||
if (e is! StateError) { |
|||
print('播放错误: $e'); |
|||
await _reinitializePlayer(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
void toggleEnabled() { |
|||
isEnabled.toggle(); |
|||
if (!isEnabled.value) { |
|||
stop(); |
|||
} |
|||
} |
|||
|
|||
Future<void> _cleanupResources() async { |
|||
_isDisposed = true; |
|||
await _playbackEventSubscription?.cancel(); |
|||
await _playerStateSubscription?.cancel(); |
|||
await _audioSession?.setActive(false); |
|||
await _channel?.sink.close(); |
|||
await stop(); |
|||
await _audioPlayer.dispose(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() async { |
|||
print('关闭TTS服务'); |
|||
await _cleanupResources(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
/// 使用系统默认音频设备 |
|||
Future<bool> useDefaultAudioDevice() async { |
|||
try { |
|||
if (Platform.isAndroid) { |
|||
// Android 平台 - 重置为正常模式,让系统自动选择 |
|||
final audioManager = AndroidAudioManager(); |
|||
await audioManager.setMode(AndroidAudioHardwareMode.normal); |
|||
await audioManager.stopBluetoothSco(); |
|||
await audioManager.setBluetoothScoOn(false); |
|||
// 不显式设置扬声器状态,让系统自动选择 |
|||
await audioManager.setSpeakerphoneOn(false); |
|||
return true; |
|||
} else if (Platform.isIOS) { |
|||
// iOS 平台 - 清除任何输出端口覆盖 |
|||
final avAudioSession = AVAudioSession(); |
|||
await avAudioSession.overrideOutputAudioPort(AVAudioSessionPortOverride.none); |
|||
return true; |
|||
} |
|||
return false; |
|||
} catch (e) { |
|||
Logger.error('设置默认音频设备失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
} |
|||
@ -1,9 +1,31 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/chat_controller.dart'; |
|||
import '../../../data/services/azure_asr_service.dart'; |
|||
import '../../../data/services/azure_tts_service.dart'; |
|||
import '../../../data/services/volcano_ai_service.dart'; |
|||
import '../../../data/services/chat_history_service.dart'; |
|||
|
|||
class ChatBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
// 注册服务 |
|||
if (!Get.isRegistered<AzureAsrService>()) { |
|||
Get.put(AzureAsrService(), permanent: true); |
|||
} |
|||
|
|||
if (!Get.isRegistered<AzureTtsService>()) { |
|||
Get.put(AzureTtsService(), permanent: true); |
|||
} |
|||
|
|||
if (!Get.isRegistered<VolcanoAIService>()) { |
|||
Get.put(VolcanoAIService(), permanent: true); |
|||
} |
|||
|
|||
if (!Get.isRegistered<ChatHistoryService>()) { |
|||
Get.put(ChatHistoryService(), permanent: true); |
|||
} |
|||
|
|||
// 注册控制器 |
|||
Get.lazyPut<ChatController>(() => ChatController()); |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/azure_asr_test_controller.dart'; |
|||
import '../../../data/services/azure_asr_service.dart'; |
|||
|
|||
class AzureAsrTestBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
// 注册Azure ASR服务(如果尚未注册) |
|||
if (!Get.isRegistered<AzureAsrService>()) { |
|||
Get.lazyPut<AzureAsrService>(() => AzureAsrService()); |
|||
} |
|||
|
|||
// 注册控制器 |
|||
Get.lazyPut<AzureAsrTestController>(() => AzureAsrTestController()); |
|||
} |
|||
} |
|||
@ -1,11 +1,18 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/translation_controller.dart'; |
|||
import '../../../data/services/azure_asr_service.dart'; |
|||
import '../../../data/services/azure_tts_service.dart'; |
|||
import '../../../data/services/volcano_translation_service.dart'; |
|||
|
|||
class TranslationBinding implements Bindings { |
|||
@override |
|||
void dependencies() { |
|||
// 注册服务 |
|||
Get.put(AzureAsrService(), permanent: true); |
|||
Get.put(AzureTtsService(), permanent: true); |
|||
Get.put(VolcanoTranslationService(), permanent: true); |
|||
|
|||
// Register the controller |
|||
// 注册控制器 |
|||
Get.put(TranslationController()); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue