You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
471 lines
13 KiB
471 lines
13 KiB
import 'dart:async';
|
|
import 'dart:io';
|
|
import '../../../data/models/appconfig.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:get_storage/get_storage.dart';
|
|
import '../../../core/utils/logger.dart';
|
|
import '../tts_service.dart';
|
|
|
|
/// 微软 Text-to-Speech 服务
|
|
///
|
|
/// 该服务通过平台通道与原生 Microsoft Speech SDK 交互,
|
|
/// 提供文本转语音功能。
|
|
class AzureTtsService extends GetxService implements TtsService {
|
|
static final AzureTtsService to = Get.put(AzureTtsService());
|
|
static const MethodChannel _channel = MethodChannel('azure_speech/tts');
|
|
static const EventChannel _eventChannel =
|
|
EventChannel('azure_speech/tts_events');
|
|
|
|
// final GetStorage _storage = GetStorage();
|
|
bool _isInitialized = false;
|
|
late final String _subscriptionKey;
|
|
late final String _serviceRegion;
|
|
|
|
// 当前设置
|
|
String _currentVoice = 'zh-CN-XiaoxiaoNeural';
|
|
|
|
// 语音合成队列
|
|
final List<String> _textQueue = [];
|
|
bool _isProcessingQueue = false;
|
|
|
|
// 事件流
|
|
final StreamController<TtsEvent> _eventStreamController =
|
|
StreamController<TtsEvent>.broadcast();
|
|
StreamSubscription? _eventSubscription;
|
|
|
|
// 可观察状态
|
|
final isEnabled = true.obs;
|
|
final _isSpeaking = false.obs;
|
|
String _currsessionid = '';
|
|
// 流式文本缓冲区
|
|
String _streamBuffer = '';
|
|
|
|
@override
|
|
String get currentVoice => _currentVoice;
|
|
|
|
@override
|
|
bool get isSpeaking => _isSpeaking.value;
|
|
|
|
@override
|
|
Stream<TtsEvent> get onEvent => _eventStreamController.stream;
|
|
|
|
AzureTtsService() {
|
|
_loadConfig();
|
|
}
|
|
|
|
/// 从环境变量加载配置
|
|
void _loadConfig() {
|
|
// final _env = _storage.read("ENV") as Map<String, String>;
|
|
_subscriptionKey = AppConfig.env('AZURE_SPEECH_KEY') ?? '';
|
|
_serviceRegion = AppConfig.env('AZURE_SPEECH_REGION') ?? '';
|
|
}
|
|
|
|
/// 设置事件通道
|
|
void _setupEventChannel() {
|
|
_eventSubscription?.cancel();
|
|
_eventSubscription = _eventChannel.receiveBroadcastStream().listen((event) {
|
|
if (event is Map) {
|
|
_handleTtsEvent(event);
|
|
}
|
|
}, onError: (error) {
|
|
Logger.error('TTS事件流错误: ${error.toString()}');
|
|
_eventStreamController.addError(error);
|
|
});
|
|
}
|
|
|
|
/// 处理TTS事件
|
|
void _handleTtsEvent(Map<dynamic, dynamic> event) {
|
|
final String eventType = event['type'] as String? ?? '';
|
|
|
|
// Logger.debug('收到TTS事件: ${e.toString()}ventType, 数据: ${e.toString()}vent');
|
|
|
|
switch (eventType) {
|
|
case 'synthesis_started':
|
|
_isSpeaking.value = true;
|
|
_eventStreamController.add(TtsEvent(type: TtsEventType.started));
|
|
break;
|
|
|
|
case 'synthesizing':
|
|
// _isSpeaking.value = true;
|
|
// // 合成中事件暂时映射到started类型,因为TtsEventType中没有synthesizing类型
|
|
// _eventStreamController.add(TtsEvent(type: TtsEventType.started));
|
|
break;
|
|
|
|
case 'synthesis_completed':
|
|
_isSpeaking.value = false;
|
|
_eventStreamController.add(TtsEvent(type: TtsEventType.completed));
|
|
|
|
// 如果有队列在处理中,继续处理下一项
|
|
if (_isProcessingQueue) {
|
|
_processNextInQueue();
|
|
}
|
|
break;
|
|
|
|
case 'synthesis_canceled':
|
|
_isSpeaking.value = false;
|
|
final String reason = event['reason'] as String? ?? '';
|
|
|
|
_eventStreamController.add(TtsEvent.error('合成取消: $reason'));
|
|
|
|
// 如果有队列在处理中,继续处理下一项(即使出错)
|
|
if (_isProcessingQueue) {
|
|
_processNextInQueue();
|
|
}
|
|
break;
|
|
|
|
case 'error':
|
|
_isSpeaking.value = false;
|
|
final String message = event['message'] as String? ?? '未知错误';
|
|
|
|
Logger.error('TTS错误: $message');
|
|
_eventStreamController.add(TtsEvent.error(message));
|
|
|
|
// 如果有队列在处理中,继续处理下一项(即使出错)
|
|
if (_isProcessingQueue) {
|
|
_processNextInQueue();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> initialize({
|
|
List<String>? supportedLanguages,
|
|
}) async {
|
|
// 检查是否需要重新初始化
|
|
if (_isInitialized) {
|
|
await dispose();
|
|
}
|
|
|
|
try {
|
|
if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) {
|
|
Logger.error('未找到Azure语音服务配置');
|
|
return false;
|
|
}
|
|
|
|
// 先设置事件通道,确保在调用initialize方法前已准备好
|
|
_setupEventChannel();
|
|
|
|
// 更新支持的语音列表(如果提供)
|
|
final String language = supportedLanguages?.first ?? 'zh-CN';
|
|
|
|
final result = await _channel.invokeMethod('initialize', {
|
|
'subscriptionKey': _subscriptionKey,
|
|
'region': _serviceRegion,
|
|
'language': language,
|
|
});
|
|
|
|
_isInitialized = result;
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('初始化失败: ${e.toString()}');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> setVoice(String voiceName) async {
|
|
if (!_isInitialized) await initialize();
|
|
if (voiceName == _currentVoice) return true;
|
|
|
|
try {
|
|
final result = await _channel.invokeMethod('setVoice', {
|
|
'voiceName': voiceName,
|
|
});
|
|
|
|
if (result) _currentVoice = voiceName;
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('设置语音失败: ${e.toString()}');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> setTtsMode(String mod) async {
|
|
if (!_isInitialized) await initialize();
|
|
try {
|
|
final result = await _channel.invokeMethod('setTtsMode', {
|
|
'mod': mod,
|
|
});
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('设置TTS模式失败: ${e.toString()}');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> setVoiceFlocking(String speakerProfileId) async {
|
|
final result = await _channel.invokeMethod('setVoiceFlocking', {
|
|
'speakerProfileId': speakerProfileId,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@override
|
|
Future<bool> startspeak(String sessionid) async {
|
|
if (!_isInitialized) await initialize();
|
|
if (!isEnabled.value) return false;
|
|
|
|
try {
|
|
// 开始合成,传递参数
|
|
final result = await _channel.invokeMethod('startspeak', {
|
|
'sessionid': sessionid,
|
|
});
|
|
|
|
return result == true;
|
|
} catch (e) {
|
|
Logger.error('语音合成失败: ${e.toString()}');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> speakOnce(String sessionid, String text) async {
|
|
if (!_isInitialized) await initialize();
|
|
if (!isEnabled.value || text.isEmpty) return false;
|
|
|
|
try {
|
|
// 开始合成,传递参数
|
|
final result = await _channel.invokeMethod('speakText', {
|
|
'sessionid': sessionid,
|
|
'text': text,
|
|
});
|
|
|
|
return result == true;
|
|
} catch (e) {
|
|
Logger.error('语音合成失败: ${e.toString()}');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> speakStream(String sessionid, String text) async {
|
|
if (!isEnabled.value || text.isEmpty) return false;
|
|
|
|
try {
|
|
// 添加文本到缓冲区
|
|
_streamBuffer += text;
|
|
|
|
// 如果缓冲区为空,直接返回
|
|
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(sentence);
|
|
hasProcessed = true;
|
|
}
|
|
|
|
// 更新缓冲区,只保留未完成的部分
|
|
if (lastMatchEnd > 0) {
|
|
_streamBuffer = _streamBuffer.substring(lastMatchEnd);
|
|
}
|
|
|
|
// 如果处理了文本并且队列未在处理中,开始处理队列
|
|
if (hasProcessed && !_isProcessingQueue) {
|
|
_processQueue();
|
|
}
|
|
|
|
return true;
|
|
} catch (e) {
|
|
Logger.error('处理流式文本失败: ${e.toString()}');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> flushStream(String sessionid) async {
|
|
if (_textQueue.isEmpty && _streamBuffer.isEmpty) return true;
|
|
|
|
try {
|
|
// 处理缓冲区中可能的完整句子
|
|
await speakStream(sessionid, '');
|
|
|
|
// 如果缓冲区仍有剩余文本,将其添加到播放队列
|
|
if (_streamBuffer.isNotEmpty && _streamBuffer.trim().isNotEmpty) {
|
|
_textQueue.add(_streamBuffer);
|
|
|
|
// 清空缓冲区
|
|
_streamBuffer = '';
|
|
|
|
// 如果队列未在处理中,开始处理队列
|
|
if (!_isProcessingQueue) {
|
|
_processQueue();
|
|
}
|
|
}
|
|
|
|
Logger.info('等待TTS队列播放完成,剩余${_textQueue.length}条');
|
|
|
|
// 等待队列处理完成
|
|
final completer = Completer<bool>();
|
|
|
|
// 监听事件流检查队列是否完成
|
|
final subscription = onEvent.listen((event) {
|
|
// 当队列为空且不在处理中,且当前非说话状态,完成操作
|
|
if (_textQueue.isEmpty &&
|
|
!_isProcessingQueue &&
|
|
!_isSpeaking.value &&
|
|
!completer.isCompleted) {
|
|
completer.complete(true);
|
|
}
|
|
});
|
|
|
|
// 如果已经处于空闲状态,直接完成
|
|
if (_textQueue.isEmpty && !_isProcessingQueue && !_isSpeaking.value) {
|
|
subscription.cancel();
|
|
return true;
|
|
}
|
|
|
|
// 设置超时
|
|
final timeout = Timer(const Duration(seconds: 60), () {
|
|
if (!completer.isCompleted) {
|
|
Logger.warning('等待TTS队列完成超时');
|
|
completer.complete(false);
|
|
}
|
|
});
|
|
|
|
// 等待完成
|
|
final result = await completer.future;
|
|
|
|
// 清理
|
|
subscription.cancel();
|
|
timeout.cancel();
|
|
|
|
Logger.info('TTS队列播放完成');
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('刷新TTS流失败: ${e.toString()}');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 处理语音合成队列
|
|
Future<void> _processQueue() async {
|
|
if (_textQueue.isEmpty || _isProcessingQueue) return;
|
|
|
|
_isProcessingQueue = true;
|
|
|
|
// 处理队列中的第一项
|
|
await _processNextInQueue();
|
|
}
|
|
|
|
/// 处理队列中的下一项
|
|
Future<void> _processNextInQueue() async {
|
|
// 检查队列是否为空或已禁用
|
|
if (_textQueue.isEmpty || !isEnabled.value) {
|
|
_textQueue.clear();
|
|
_isProcessingQueue = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 获取当前项但暂不从队列中移除
|
|
final text = _textQueue.first;
|
|
|
|
// 开始合成
|
|
final success = await speakOnce(_currsessionid, text);
|
|
|
|
// 仅当合成操作失败时才从队列移除并继续处理下一项
|
|
// 成功的话会等待合成完成事件后自动处理下一项
|
|
if (!success) {
|
|
_textQueue.removeAt(0);
|
|
await _processNextInQueue();
|
|
} else {
|
|
// 成功启动合成,从队列中移除当前项
|
|
// 注意:下一项的处理将在合成完成事件中触发
|
|
_textQueue.removeAt(0);
|
|
}
|
|
} catch (e) {
|
|
Logger.error('处理语音队列出错: ${e.toString()}');
|
|
|
|
// 出错时移除当前项并尝试继续
|
|
if (_textQueue.isNotEmpty) {
|
|
_textQueue.removeAt(0);
|
|
}
|
|
|
|
// 如果队列中还有项目,继续处理
|
|
if (_textQueue.isNotEmpty) {
|
|
await _processNextInQueue();
|
|
} else {
|
|
_isProcessingQueue = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> stop() async {
|
|
try {
|
|
_textQueue.clear();
|
|
|
|
if (_isSpeaking.value) {
|
|
final result = await _channel.invokeMethod('stopSpeaking');
|
|
// 重置缓存状态
|
|
_isSpeaking.value = false;
|
|
_isProcessingQueue = false;
|
|
return result;
|
|
}
|
|
|
|
return true;
|
|
} catch (e) {
|
|
Logger.error('停止语音合成出错: ${e.toString()}');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 切换启用状态
|
|
void toggleEnabled() {
|
|
isEnabled.toggle();
|
|
if (!isEnabled.value) stop();
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
if (!_isInitialized) return;
|
|
|
|
try {
|
|
await stop();
|
|
await _eventSubscription?.cancel();
|
|
await _channel.invokeMethod('dispose');
|
|
_isInitialized = false;
|
|
} catch (e) {
|
|
Logger.error('释放资源失败: ${e.toString()}');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> setAudioOutputDevice(int type) async {
|
|
try {
|
|
// 开始合成,传递参数
|
|
final result = await _channel.invokeMethod('setAudioOutputDevice', {
|
|
'type': type,
|
|
});
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('音频输出设备类型失败: ${e.toString()}');
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|