5 changed files with 769 additions and 29 deletions
@ -0,0 +1,393 @@ |
|||
import 'dart:async'; |
|||
import 'dart:convert'; |
|||
import 'dart:io'; |
|||
import 'dart:typed_data'; |
|||
|
|||
import 'package:crypto/crypto.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:get_storage/get_storage.dart'; |
|||
import 'package:just_audio/just_audio.dart'; |
|||
import 'package:web_socket_channel/web_socket_channel.dart'; |
|||
import '../../../core/utils/logger.dart'; |
|||
import '../tts_service.dart'; |
|||
import 'volcano_tts_api_service.dart'; |
|||
|
|||
/// 声音复刻服务,负责基本的文本转语音功能 |
|||
/// |
|||
/// 使用讯飞声音复刻实现TtsService接口 |
|||
class VoiceCloneTtsService extends GetxService implements TtsService { |
|||
final String hostUrl = "wss://cn-huabei-1.xf-yun.com/v1/private/voice_clone"; |
|||
|
|||
final String appId = '43d87d22'; |
|||
final String apiKey = '2bd01ce06ac35957fc0c082f9b6284ec'; |
|||
final String apiSecret = 'ODU3OTUyYjhjMjY5NDhkMmMxYWY2Zjlm'; |
|||
|
|||
WebSocketChannel? _channel; |
|||
final AudioPlayer _player = AudioPlayer(); |
|||
late ConcatenatingAudioSource _playlist; |
|||
|
|||
// 当前语音 |
|||
final RxString _currentVoice = ''.obs; |
|||
|
|||
@override |
|||
String get currentVoice => _currentVoice.value; |
|||
|
|||
// 播放状态 |
|||
final RxBool _isSpeaking = false.obs; |
|||
|
|||
@override |
|||
bool get isSpeaking => _isSpeaking.value; |
|||
|
|||
// 事件流控制器 |
|||
final StreamController<TtsEvent> _eventController = |
|||
StreamController<TtsEvent>.broadcast(); |
|||
|
|||
@override |
|||
Stream<TtsEvent> get onEvent => _eventController.stream; |
|||
|
|||
bool _isInitialized = false; |
|||
String _resId = ''; |
|||
|
|||
// 语音合成队列 |
|||
final List _textQueue = []; |
|||
bool _isProcessingQueue = false; |
|||
|
|||
BytesBuilder _audioBytes = BytesBuilder(); |
|||
|
|||
Completer<void> _completer = Completer(); |
|||
|
|||
final isEnabled = true.obs; |
|||
|
|||
// 流式文本缓冲区 |
|||
String _streamBuffer = ''; |
|||
|
|||
@override |
|||
Future<bool> initialize({ |
|||
List<String>? supportedLanguages, |
|||
}) async { |
|||
if (_isInitialized) return true; |
|||
try { |
|||
final GetStorage storage = GetStorage(); |
|||
String timbre = storage.read('timbre'); |
|||
_resId = timbre; |
|||
_playlist = ConcatenatingAudioSource( |
|||
useLazyPreparation: true, |
|||
children: [], |
|||
); |
|||
return true; |
|||
} catch (e) { |
|||
Logger.error('TTS初始化失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> setVoice(String voiceName) async { |
|||
if (!_isInitialized) await initialize(); |
|||
try { |
|||
Logger.info('设置TTS语音: $voiceName'); |
|||
return true; |
|||
} catch (e) { |
|||
Logger.error('设置TTS语音失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
// 语音合成 |
|||
Future<void> _speechSynthesis(String text) async { |
|||
await _tryConnecting(); |
|||
_channel?.sink.add( |
|||
json.encode({ |
|||
"header": {"app_id": appId, "status": 2, "res_id": _resId}, |
|||
"parameter": { |
|||
"tts": { |
|||
"vcn": "x5_clone", |
|||
"audio": {"encoding": "lame", "sample_rate": 16000} |
|||
} |
|||
}, |
|||
"payload": { |
|||
"text": { |
|||
"status": 2, |
|||
"seq": 0, |
|||
"text": base64Encode(utf8.encode(text)) |
|||
} |
|||
}, |
|||
}), |
|||
); |
|||
} |
|||
|
|||
Future<void> _tryConnecting() async { |
|||
try { |
|||
final url = _geturl(); |
|||
_channel = WebSocketChannel.connect(Uri.parse(url)); |
|||
_channel?.stream.timeout(const Duration(seconds: 10)); //设置超时时间 |
|||
await _channel?.ready; |
|||
_channel?.stream.listen( |
|||
(message) { |
|||
_handle(message); |
|||
}, |
|||
onError: (error) { |
|||
Logger.error('连接失败: $error'); |
|||
}, |
|||
onDone: () { |
|||
Logger.error('WebSocket 连接已关闭!'); |
|||
}, |
|||
cancelOnError: true, |
|||
); |
|||
} catch (e) { |
|||
Logger.error('初始化异常: $e'); |
|||
} |
|||
} |
|||
|
|||
String _geturl() { |
|||
// 解析 URL |
|||
final uri = Uri.parse(hostUrl); |
|||
|
|||
// 获取当前时间并格式化为 RFC1123 格式 |
|||
final date = HttpDate.format(DateTime.now().toUtc()); |
|||
|
|||
// 参与签名的字段 |
|||
final signString = [ |
|||
"host: ${uri.host}", |
|||
"date: $date", |
|||
"GET ${uri.path} HTTP/1.1", |
|||
]; |
|||
|
|||
// 拼接签名字符串 |
|||
final sgin = signString.join("\n"); |
|||
|
|||
// 使用 HMAC-SHA256 签名 |
|||
final key = utf8.encode(apiSecret); |
|||
final bytes = utf8.encode(sgin); |
|||
final hmacSha256 = Hmac(sha256, key); |
|||
final digest = hmacSha256.convert(bytes); |
|||
final sha = base64Encode(digest.bytes); |
|||
|
|||
// 构建请求参数 |
|||
final authUrl = |
|||
'api_key="$apiKey", algorithm="hmac-sha256", headers="host date request-line", signature="$sha"'; |
|||
|
|||
// 将请求参数使用 base64 编码 |
|||
final authorization = base64Encode(utf8.encode(authUrl)); |
|||
|
|||
// 构建查询参数 |
|||
final queryParams = { |
|||
"host": uri.host, |
|||
"date": date, |
|||
"authorization": authorization, |
|||
}; |
|||
|
|||
// 将查询参数编码并拼接到 URL 后面 |
|||
final callurl = |
|||
Uri.parse(hostUrl).replace(queryParameters: queryParams).toString(); |
|||
|
|||
return callurl; |
|||
} |
|||
|
|||
void _handle(data) async { |
|||
final result = jsonDecode(data); |
|||
|
|||
int code = result['header']['code']; |
|||
if (code == 0) { |
|||
int status = result['header']['status']; |
|||
if (status == 1 || status == 2) { |
|||
Uint8List audio = base64Decode(result['payload']['audio']['audio']); |
|||
_audioBytes.add(audio); |
|||
} |
|||
if (status == 2) { |
|||
final newSource = BytesAudioSource(_audioBytes.toBytes()); |
|||
await _playlist.add(newSource); |
|||
if (!_isSpeaking.value) { |
|||
_audioPlay(); |
|||
} |
|||
_completer.complete(); |
|||
_completer = Completer(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
void _audioPlay() async { |
|||
_isSpeaking.value = true; |
|||
// 发送开始事件 |
|||
_eventController.add(TtsEvent(type: TtsEventType.started)); |
|||
await _player.setAudioSource(_playlist); |
|||
await _player.play(); |
|||
} |
|||
|
|||
@override |
|||
Future<bool> speakOnce(String text) async { |
|||
if (!isEnabled.value || text.isEmpty) return false; |
|||
|
|||
try { |
|||
_audioBytes.clear(); |
|||
await _speechSynthesis(text); |
|||
await _completer.future; |
|||
|
|||
return true; |
|||
} catch (e) { |
|||
_isSpeaking.value = false; |
|||
Logger.error('语音合成失败: $e'); |
|||
|
|||
// 发送错误事件 |
|||
_eventController.add(TtsEvent.error('语音合成失败: $e')); |
|||
|
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> speakStream(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'); |
|||
|
|||
// 发送错误事件 |
|||
_eventController.add(TtsEvent.error('处理流式文本失败: $e')); |
|||
|
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> flushStream() async { |
|||
if (_textQueue.isEmpty && _streamBuffer.isEmpty) return true; |
|||
|
|||
try { |
|||
// 处理缓冲区中可能的完整句子 |
|||
await speakStream(''); |
|||
|
|||
// 如果缓冲区仍有剩余文本,将其添加到播放队列 |
|||
if (_streamBuffer.isNotEmpty && _streamBuffer.trim().isNotEmpty) { |
|||
_textQueue.add(_streamBuffer); |
|||
|
|||
// 清空缓冲区 |
|||
_streamBuffer = ''; |
|||
|
|||
// 如果队列未在处理中,开始处理队列 |
|||
if (!_isProcessingQueue) { |
|||
_processQueue(); |
|||
} |
|||
} |
|||
|
|||
Logger.info('等待TTS队列播放完成,剩余${_textQueue.length}条'); |
|||
|
|||
return true; |
|||
} catch (e) { |
|||
Logger.error('刷新TTS流失败: $e'); |
|||
|
|||
// 发送错误事件 |
|||
_eventController.add(TtsEvent.error('刷新TTS流失败: $e')); |
|||
|
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 处理语音合成队列 |
|||
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); |
|||
|
|||
// 使用speakOnce播放当前项 |
|||
await speakOnce(item); |
|||
} |
|||
} catch (e) { |
|||
Logger.error('处理语音队列出错: $e'); |
|||
|
|||
// 发送错误事件 |
|||
_eventController.add(TtsEvent.error('处理语音队列出错: $e')); |
|||
} finally { |
|||
_isProcessingQueue = false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> stop() async { |
|||
try { |
|||
_textQueue.clear(); |
|||
_player.stop(); |
|||
_playlist.clear(); |
|||
_audioBytes = BytesBuilder(); |
|||
if (_isSpeaking.value) { |
|||
_isSpeaking.value = false; |
|||
// 发送取消事件 |
|||
_eventController.add(TtsEvent(type: TtsEventType.canceled)); |
|||
} |
|||
return true; |
|||
} catch (e) { |
|||
Logger.error('停止语音合成出错: $e'); |
|||
// 发送错误事件 |
|||
_eventController.add(TtsEvent.error('停止语音合成出错: $e')); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<void> dispose() async { |
|||
if (!_isInitialized) return; |
|||
try { |
|||
await stop(); |
|||
// 关闭事件流控制器 |
|||
await _eventController.close(); |
|||
_isInitialized = false; |
|||
_player.dispose(); |
|||
} catch (e) { |
|||
Logger.error('释放资源失败: $e'); |
|||
} |
|||
} |
|||
} |
|||
@ -1,14 +1,287 @@ |
|||
import 'dart:convert'; |
|||
|
|||
import 'package:crypto/crypto.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:get_storage/get_storage.dart'; |
|||
import 'package:http/http.dart' as http; |
|||
import 'package:path/path.dart' as path; |
|||
import 'package:path_provider/path_provider.dart'; |
|||
import 'package:record/record.dart'; |
|||
|
|||
class TimbreController extends GetxController { |
|||
RxList dataList = [ |
|||
{'type': '1', 'name': '默认音色', 'description': '微软云端音色服务'}, |
|||
{'type': '2', 'name': '声音复刻', 'description': '复刻本人音色服务'} |
|||
].obs; |
|||
final GetStorage _storage = GetStorage(); |
|||
final _record = AudioRecorder(); |
|||
|
|||
final String _appId = "43d87d22"; |
|||
final String _appKey = "2bd01ce06ac35957fc0c082f9b6284ec"; |
|||
|
|||
RxList dataList = [].obs; |
|||
RxString type = '1'.obs; |
|||
|
|||
RxString text = ''.obs; |
|||
|
|||
String _token = ''; |
|||
|
|||
int _textId = 0; |
|||
int _segId = 0; |
|||
String _taskId = '67e0f5e6025cf871b1de31b2'; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_getDataList(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
_record.dispose(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
void _getDataList() { |
|||
List? timbreList = _storage.read('timbreList'); |
|||
String? timbre = _storage.read('timbre'); |
|||
if (timbreList != null) { |
|||
dataList.value = timbreList; |
|||
} else { |
|||
dataList.add({ |
|||
'type': '1', |
|||
'name': '默认音色', |
|||
'description': '微软云端音色服务', |
|||
'isUsable': true |
|||
}); |
|||
_storage.write('timbreList', [ |
|||
{ |
|||
'type': '1', |
|||
'name': '默认音色', |
|||
'description': '微软云端音色服务', |
|||
'isUsable': true |
|||
} |
|||
]); |
|||
} |
|||
if (timbre != null) { |
|||
type.value = timbre; |
|||
} |
|||
} |
|||
|
|||
// 录制声音 |
|||
void startRecorder() async { |
|||
if (_token.isEmpty) { |
|||
await _getToken(); |
|||
} |
|||
await _getTraintext(); |
|||
await _addTask(); |
|||
final directory = await getApplicationDocumentsDirectory(); |
|||
final path = |
|||
'${directory.path}/recording_${DateTime.now().millisecondsSinceEpoch}.wav'; |
|||
await _record.start( |
|||
const RecordConfig( |
|||
encoder: AudioEncoder.wav, |
|||
sampleRate: 24000, |
|||
numChannels: 1, |
|||
), |
|||
path: path, |
|||
); |
|||
} |
|||
|
|||
// 获取token |
|||
Future<void> _getToken() async { |
|||
int timeStamp = DateTime.now().millisecondsSinceEpoch; |
|||
String data = |
|||
'{"base":{"appid":"$_appId","version":"v1","timestamp":"$timeStamp"},"model":"remote"}'; |
|||
String keySignInput = _appKey + timeStamp.toString(); |
|||
List<int> keySignBytes = utf8.encode(keySignInput); |
|||
Digest keySignDigest = md5.convert(keySignBytes); |
|||
String keySign = keySignDigest.toString(); |
|||
String signInput = keySign + data; |
|||
List<int> signBytes = utf8.encode(signInput); |
|||
Digest signDigest = md5.convert(signBytes); |
|||
String sign = signDigest.toString(); |
|||
|
|||
final response = await http.post( |
|||
Uri.parse('http://avatar-hci.xfyousheng.com/aiauth/v1/token'), |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
'Authorization': sign, |
|||
}, |
|||
body: data, |
|||
); |
|||
if (response.statusCode == 200) { |
|||
final body = json.decode(response.body); |
|||
if (body['retcode'] == '000000') { |
|||
_token = body['accesstoken']; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 获取训练文本 |
|||
Future<void> _getTraintext() async { |
|||
String data = '{"textId":"5001"}'; |
|||
Map sign = _sign(data); |
|||
|
|||
final response = await http.post( |
|||
Uri.parse('http://opentrain.xfyousheng.com/voice_train/task/traintext'), |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
'X-Sign': sign['sign'], |
|||
'X-Token': _token, |
|||
'X-AppId': _appId, |
|||
'X-Time': sign['timeStamp'], |
|||
}, |
|||
body: data, |
|||
); |
|||
if (response.statusCode == 200) { |
|||
final body = json.decode(response.body); |
|||
if (body['code'] == 0) { |
|||
_textId = body['data']['textId']; |
|||
Map textSegs = body['data']['textSegs'].first; |
|||
_segId = textSegs['segId']; |
|||
text.value = _decodeCorruptedText(textSegs['segText']); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 创建训练任务 |
|||
Future<void> _addTask() async { |
|||
String data = '{"resourceType":"12"}'; |
|||
Map sign = _sign(data); |
|||
|
|||
final response = await http.post( |
|||
Uri.parse('http://opentrain.xfyousheng.com/voice_train/task/add'), |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
'X-Sign': sign['sign'], |
|||
'X-Token': _token, |
|||
'X-AppId': _appId, |
|||
'X-Time': sign['timeStamp'], |
|||
}, |
|||
body: data, |
|||
); |
|||
if (response.statusCode == 200) { |
|||
final body = json.decode(response.body); |
|||
if (body['code'] == 0) { |
|||
_taskId = body['data']; |
|||
} |
|||
} |
|||
} |
|||
|
|||
Map _sign(String data) { |
|||
int timeStamp = DateTime.now().millisecondsSinceEpoch; |
|||
String keySignInput = _appKey + timeStamp.toString(); |
|||
List<int> keySignBytes = utf8.encode(keySignInput); |
|||
Digest keySignDigest = md5.convert(keySignBytes); |
|||
String keySign = keySignDigest.toString(); |
|||
String signInput = keySign + data; |
|||
List<int> signBytes = utf8.encode(signInput); |
|||
Digest signDigest = md5.convert(signBytes); |
|||
String sign = signDigest.toString(); |
|||
return {'sign': sign, 'timeStamp': timeStamp.toString()}; |
|||
} |
|||
|
|||
String _decodeCorruptedText(String corruptedText) { |
|||
List<int> latin1Bytes = |
|||
corruptedText.codeUnits.map((ch) => ch & 0xFF).toList(); |
|||
return utf8.decode(latin1Bytes); |
|||
} |
|||
|
|||
// 完成录制 |
|||
void completeRecorder() async { |
|||
final audioPath = await _record.stop(); |
|||
String data = |
|||
'{"taskId":"$_taskId","textId":"$_textId","textSegId":"$_segId"}'; |
|||
Map sign = _sign(data); |
|||
|
|||
final uri = Uri.parse( |
|||
'http://opentrain.xfyousheng.com/voice_train/task/submitWithAudio'); |
|||
final request = http.MultipartRequest('POST', uri); |
|||
request.files.add(await http.MultipartFile.fromPath( |
|||
'file', |
|||
audioPath!, |
|||
filename: path.basename(audioPath), |
|||
)); |
|||
request.fields.addAll({ |
|||
'taskId': _taskId, |
|||
'textId': '$_textId', |
|||
'textSegId': '$_segId', |
|||
}); |
|||
request.headers.addAll({ |
|||
"X-Sign": sign['sign'], |
|||
"X-Token": _token, |
|||
"X-AppId": _appId, |
|||
"X-Time": sign['timeStamp'], |
|||
}); |
|||
final response = await request.send(); |
|||
if (response.statusCode == 200) { |
|||
final responseBody = await response.stream.bytesToString(); |
|||
final body = json.decode(responseBody); |
|||
if (body['code'] == 0) { |
|||
text.value = ''; |
|||
dataList.add({ |
|||
'type': _taskId, |
|||
'name': '声音复刻$_taskId', |
|||
'description': '复刻本人音色服务', |
|||
'isUsable': false |
|||
}); |
|||
await _getTaskResult(_taskId); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 查询训练任务状态 |
|||
Future<void> _getTaskResult(String id) async { |
|||
String data = '{"taskId":"$id"}'; |
|||
Map sign = _sign(data); |
|||
|
|||
final response = await http.post( |
|||
Uri.parse('http://opentrain.xfyousheng.com/voice_train/task/result'), |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
'X-Sign': sign['sign'], |
|||
'X-Token': _token, |
|||
'X-AppId': _appId, |
|||
'X-Time': sign['timeStamp'], |
|||
}, |
|||
body: data, |
|||
); |
|||
if (response.statusCode == 200) { |
|||
final body = json.decode(response.body); |
|||
if (body['code'] == 0) { |
|||
if (body['data']['trainStatus'] == -1) { |
|||
await _getTaskResult(id); |
|||
} |
|||
if (body['data']['trainStatus'] == 1) { |
|||
Map? dataMap = |
|||
dataList.firstWhereOrNull((item) => item['type'] == id); |
|||
if (dataMap != null) { |
|||
dataMap['type'] = body['data']['assetId']; |
|||
dataMap['name'] = '声音复刻${body['data']['assetId']}'; |
|||
dataMap['isUsable'] = true; |
|||
dataList.refresh(); |
|||
_storage.write('timbreList', dataList); |
|||
} |
|||
} |
|||
if (body['data']['trainStatus'] == 0) { |
|||
Get.snackbar( |
|||
'error'.tr, // 错误 |
|||
_decodeCorruptedText(body['data']['failedDesc']), |
|||
snackPosition: SnackPosition.TOP, |
|||
); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 取消 |
|||
void cancel() { |
|||
text.value = ''; |
|||
_record.stop(); |
|||
Get.back(); |
|||
} |
|||
|
|||
// 选择音色 |
|||
void selectTone(Map data) { |
|||
if (!data['isUsable']) return; |
|||
type.value = data['type']; |
|||
_storage.write('timbre', data['type']); |
|||
} |
|||
} |
|||
|
|||
Loading…
Reference in new issue