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.
346 lines
8.8 KiB
346 lines
8.8 KiB
import 'dart:async';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
|
|
/// 微软 Text-to-Speech 服务异常
|
|
class MicrosoftTtsException implements Exception {
|
|
final String message;
|
|
MicrosoftTtsException(this.message);
|
|
|
|
@override
|
|
String toString() => message;
|
|
}
|
|
|
|
/// 微软 Text-to-Speech 服务
|
|
///
|
|
/// 该服务通过平台通道与 Android 上的 Microsoft Speech SDK 交互,
|
|
/// 提供文本转语音功能。
|
|
class MicrosoftTtsService extends GetxService {
|
|
static const MethodChannel _channel = MethodChannel('com.example.deep_voice/text_to_speech');
|
|
|
|
bool _isInitialized = false;
|
|
late final String _subscriptionKey;
|
|
late final String _serviceRegion;
|
|
|
|
// 当前使用的语音
|
|
String _currentVoice = 'zh-CN-XiaoxiaoNeural';
|
|
String get currentVoice => _currentVoice;
|
|
|
|
// 语音合成队列
|
|
final List<String> _textQueue = [];
|
|
bool _isProcessingQueue = false;
|
|
bool _isSpeaking = false;
|
|
|
|
// 可观察状态
|
|
final isEnabled = true.obs;
|
|
final isSpeaking = false.obs;
|
|
|
|
MicrosoftTtsService() {
|
|
_loadConfig();
|
|
}
|
|
|
|
/// 从环境变量加载配置
|
|
void _loadConfig() {
|
|
_subscriptionKey = dotenv.env['AZURE_SPEECH_KEY'] ?? '';
|
|
_serviceRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? '';
|
|
|
|
if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) {
|
|
throw MicrosoftTtsException('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION');
|
|
}
|
|
}
|
|
|
|
/// 初始化微软 TTS SDK
|
|
///
|
|
/// 返回 true 表示初始化成功,否则抛出 PlatformException
|
|
Future<bool> initialize() async {
|
|
if (_isInitialized) return true;
|
|
|
|
try {
|
|
final bool result = await _channel.invokeMethod('initialize', {
|
|
'subscriptionKey': _subscriptionKey,
|
|
'serviceRegion': _serviceRegion,
|
|
});
|
|
|
|
_isInitialized = result;
|
|
return result;
|
|
} on PlatformException catch (e) {
|
|
throw MicrosoftTtsException('初始化失败: ${e.message}');
|
|
}
|
|
}
|
|
|
|
/// 设置语音
|
|
///
|
|
/// [voiceName] 语音名称,例如 "zh-CN-XiaoxiaoNeural"
|
|
///
|
|
/// 返回 true 表示设置成功,否则抛出 PlatformException
|
|
Future<bool> setVoice(String voiceName) async {
|
|
if (!_isInitialized) {
|
|
await initialize();
|
|
}
|
|
|
|
try {
|
|
final bool result = await _channel.invokeMethod('setVoice', {
|
|
'voiceName': voiceName,
|
|
});
|
|
|
|
if (result) {
|
|
_currentVoice = voiceName;
|
|
}
|
|
|
|
return result;
|
|
} on PlatformException catch (e) {
|
|
throw MicrosoftTtsException('设置语音失败: ${e.message}');
|
|
}
|
|
}
|
|
|
|
/// 将文本转换为语音并播放
|
|
///
|
|
/// [text] 要转换的文本
|
|
///
|
|
/// 返回合成结果消息,否则抛出 PlatformException
|
|
Future<String> speakText(String text) async {
|
|
if (!_isInitialized) {
|
|
await initialize();
|
|
}
|
|
|
|
if (!isEnabled.value) {
|
|
return "TTS 服务已禁用";
|
|
}
|
|
|
|
try {
|
|
_isSpeaking = true;
|
|
isSpeaking.value = true;
|
|
|
|
final String result = await _channel.invokeMethod('speakText', {
|
|
'text': text,
|
|
});
|
|
|
|
_isSpeaking = false;
|
|
isSpeaking.value = false;
|
|
|
|
return result;
|
|
} on PlatformException catch (e) {
|
|
_isSpeaking = false;
|
|
isSpeaking.value = false;
|
|
throw MicrosoftTtsException('语音合成失败: ${e.message}');
|
|
}
|
|
}
|
|
|
|
/// 将 SSML 转换为语音并播放
|
|
///
|
|
/// [ssml] SSML 格式的文本
|
|
///
|
|
/// 返回合成结果消息,否则抛出 PlatformException
|
|
Future<String> speakSsml(String ssml) async {
|
|
if (!_isInitialized) {
|
|
await initialize();
|
|
}
|
|
|
|
if (!isEnabled.value) {
|
|
return "TTS 服务已禁用";
|
|
}
|
|
|
|
try {
|
|
_isSpeaking = true;
|
|
isSpeaking.value = true;
|
|
|
|
final String result = await _channel.invokeMethod('speakSsml', {
|
|
'ssml': ssml,
|
|
});
|
|
|
|
_isSpeaking = false;
|
|
isSpeaking.value = false;
|
|
|
|
return result;
|
|
} on PlatformException catch (e) {
|
|
_isSpeaking = false;
|
|
isSpeaking.value = false;
|
|
throw MicrosoftTtsException('SSML 语音合成失败: ${e.message}');
|
|
}
|
|
}
|
|
|
|
/// 添加文本到队列并开始处理
|
|
///
|
|
/// [text] 要添加到队列的文本
|
|
/// [rate] 可选,语速,范围 -100 到 100,默认为 0
|
|
/// [pitch] 可选,音调,范围 -100 到 100,默认为 0
|
|
///
|
|
/// 返回 true 表示成功添加到队列
|
|
Future<bool> speak(String text, {int rate = 0, int pitch = 0}) async {
|
|
if (!isEnabled.value) {
|
|
return false;
|
|
}
|
|
|
|
if (text.isEmpty) {
|
|
return false;
|
|
}
|
|
|
|
// 生成 SSML
|
|
final ssml = generateSsml(
|
|
text: text,
|
|
rate: rate,
|
|
pitch: pitch,
|
|
);
|
|
|
|
// 添加到队列
|
|
_textQueue.add(ssml);
|
|
|
|
// 如果队列未在处理中,开始处理
|
|
if (!_isProcessingQueue) {
|
|
_processQueue();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// 连续播放多段文本
|
|
///
|
|
/// [texts] 要连续播放的文本列表
|
|
/// [rate] 可选,语速,范围 -100 到 100,默认为 0
|
|
/// [pitch] 可选,音调,范围 -100 到 100,默认为 0
|
|
///
|
|
/// 返回 true 表示成功添加到队列
|
|
Future<bool> speakMultiple(List<String> texts, {int rate = 0, int pitch = 0}) async {
|
|
if (!isEnabled.value) {
|
|
return false;
|
|
}
|
|
|
|
if (texts.isEmpty) {
|
|
return false;
|
|
}
|
|
|
|
// 将所有文本添加到队列
|
|
for (final text in texts) {
|
|
if (text.isNotEmpty) {
|
|
final ssml = generateSsml(
|
|
text: text,
|
|
rate: rate,
|
|
pitch: pitch,
|
|
);
|
|
_textQueue.add(ssml);
|
|
}
|
|
}
|
|
|
|
// 如果队列未在处理中,开始处理
|
|
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;
|
|
}
|
|
|
|
// 获取队列中的下一个 SSML
|
|
final ssml = _textQueue.removeAt(0);
|
|
|
|
// 播放 SSML
|
|
await speakSsml(ssml);
|
|
}
|
|
} catch (e) {
|
|
print('处理语音队列时出错: $e');
|
|
} finally {
|
|
_isProcessingQueue = false;
|
|
}
|
|
}
|
|
|
|
/// 停止当前语音合成并清空队列
|
|
Future<void> stop() async {
|
|
// 清空队列
|
|
_textQueue.clear();
|
|
|
|
// 如果当前正在播放,尝试停止
|
|
if (_isSpeaking) {
|
|
try {
|
|
await _channel.invokeMethod('dispose');
|
|
await initialize(); // 重新初始化以确保资源正确释放和重建
|
|
_isSpeaking = false;
|
|
isSpeaking.value = false;
|
|
} catch (e) {
|
|
print('停止语音合成时出错: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 生成 SSML 文本
|
|
///
|
|
/// [text] 要转换的文本
|
|
/// [voiceName] 可选,语音名称,默认使用当前设置的语音
|
|
/// [rate] 可选,语速,范围 -100 到 100,默认为 0
|
|
/// [pitch] 可选,音调,范围 -100 到 100,默认为 0
|
|
///
|
|
/// 返回 SSML 格式的文本
|
|
String generateSsml({
|
|
required String text,
|
|
String? voiceName,
|
|
int rate = 0,
|
|
int pitch = 0,
|
|
}) {
|
|
final voice = voiceName ?? _currentVoice;
|
|
final rateValue = rate.clamp(-100, 100);
|
|
final pitchValue = pitch.clamp(-100, 100);
|
|
|
|
// 将 rate 和 pitch 转换为 SSML 格式的值
|
|
final String rateStr = _convertRateToSsml(rateValue);
|
|
final String pitchStr = _convertPitchToSsml(pitchValue);
|
|
|
|
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">
|
|
$text
|
|
</prosody>
|
|
</voice>
|
|
</speak>
|
|
''';
|
|
}
|
|
|
|
/// 将 rate 值转换为 SSML 格式
|
|
String _convertRateToSsml(int rate) {
|
|
if (rate == 0) return '0%';
|
|
|
|
// 将 -100 到 100 的范围映射到 -90% 到 100%
|
|
if (rate < 0) {
|
|
// 负值映射到 -90% 到 0%
|
|
return '${(rate * 0.9).round()}%';
|
|
} else {
|
|
// 正值映射到 0% 到 100%
|
|
return '${rate}%';
|
|
}
|
|
}
|
|
|
|
/// 将 pitch 值转换为 SSML 格式
|
|
String _convertPitchToSsml(int pitch) {
|
|
if (pitch == 0) return '0%';
|
|
|
|
// 将 -100 到 100 的范围映射到 -50% 到 50%
|
|
return '${(pitch * 0.5).round()}%';
|
|
}
|
|
|
|
/// 释放资源
|
|
Future<void> dispose() async {
|
|
if (!_isInitialized) return;
|
|
|
|
try {
|
|
await _channel.invokeMethod('dispose');
|
|
_isInitialized = false;
|
|
} on PlatformException catch (e) {
|
|
throw MicrosoftTtsException('释放资源失败: ${e.message}');
|
|
}
|
|
}
|
|
}
|