From e511beb291c7917533ddfaee80cced502e94b8af Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Tue, 25 Mar 2025 14:09:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=80=89=E6=8B=A9=E9=9F=B3?= =?UTF-8?q?=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/services/speech_factory.dart | 55 ++- .../speech_impl/voice_clone_tts_service.dart | 393 ++++++++++++++++++ .../home/controllers/home_controller.dart | 3 +- .../timbre/controllers/timbre_controller.dart | 281 ++++++++++++- lib/modules/timbre/views/timbre_view.dart | 66 ++- 5 files changed, 769 insertions(+), 29 deletions(-) create mode 100644 lib/data/services/speech_impl/voice_clone_tts_service.dart diff --git a/lib/data/services/speech_factory.dart b/lib/data/services/speech_factory.dart index a6f1e4d50..93b0850d1 100644 --- a/lib/data/services/speech_factory.dart +++ b/lib/data/services/speech_factory.dart @@ -1,5 +1,7 @@ import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; import 'asr_service.dart'; +import 'speech_impl/voice_clone_tts_service.dart'; import 'tts_service.dart'; import 'speech_impl/azure_asr_service.dart'; import 'speech_impl/azure_tts_service.dart'; @@ -11,60 +13,70 @@ import '../../core/utils/logger.dart'; enum SpeechServiceType { /// Azure 语音服务 azure, - + /// Flutter 本地语音服务 flutter, } /// 语音服务工厂类 -/// +/// /// 用于根据配置信息获取不同的TTS和ASR实现 class SpeechFactory extends GetxService { static SpeechFactory get to => Get.find(); - /// 当前使用的语音服务类型 SpeechServiceType _currentServiceType = SpeechServiceType.azure; - + /// 获取当前使用的语音服务类型 SpeechServiceType get currentServiceType => _currentServiceType; - + /// 设置语音服务类型 void setServiceType(SpeechServiceType type) { if (_currentServiceType != type) { _currentServiceType = type; Logger.info('语音服务类型已切换为: ${type.toString().split('.').last}'); - + // 重新注册服务 _registerServices(); } } - + /// 注册语音服务 void _registerServices() { // 注销现有服务 if (Get.isRegistered()) { Get.delete(force: true); } - + if (Get.isRegistered()) { Get.delete(force: true); } - + + final GetStorage storage = GetStorage(); + String timbre = storage.read('timbre') ?? '1'; + // 根据类型注册新服务 switch (_currentServiceType) { case SpeechServiceType.azure: Get.lazyPut(() => AzureAsrService()); - Get.lazyPut(() => AzureTtsService()); + if (timbre == '1') { + Get.lazyPut(() => AzureTtsService()); + } else { + Get.lazyPut(() => VoiceCloneTtsService()); + } break; - + case SpeechServiceType.flutter: Get.lazyPut(() => FlutterAsrService()); - Get.lazyPut(() => FlutterTtsService()); + if (timbre == '1') { + Get.lazyPut(() => FlutterTtsService()); + } else { + Get.lazyPut(() => VoiceCloneTtsService()); + } break; } } - + /// 初始化语音服务 Future initialize({SpeechServiceType? initialType}) async { try { @@ -72,24 +84,25 @@ class SpeechFactory extends GetxService { if (initialType != null) { _currentServiceType = initialType; } - + // 注册服务 _registerServices(); - + // // 初始化服务 // final asrService = Get.find(); // final ttsService = Get.find(); - + // await asrService.initialize(); // await ttsService.initialize(); - - Logger.info('语音服务初始化完成,当前类型: ${_currentServiceType.toString().split('.').last}'); + + Logger.info( + '语音服务初始化完成,当前类型: ${_currentServiceType.toString().split('.').last}'); } catch (e) { Logger.error('语音服务初始化失败: $e'); rethrow; } } - + /// 获取ASR服务 AsrService getAsrService() { if (!Get.isRegistered()) { @@ -97,7 +110,7 @@ class SpeechFactory extends GetxService { } return Get.find(); } - + /// 获取TTS服务 TtsService getTtsService() { if (!Get.isRegistered()) { @@ -105,4 +118,4 @@ class SpeechFactory extends GetxService { } return Get.find(); } -} \ No newline at end of file +} diff --git a/lib/data/services/speech_impl/voice_clone_tts_service.dart b/lib/data/services/speech_impl/voice_clone_tts_service.dart new file mode 100644 index 000000000..483102adf --- /dev/null +++ b/lib/data/services/speech_impl/voice_clone_tts_service.dart @@ -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 _eventController = + StreamController.broadcast(); + + @override + Stream get onEvent => _eventController.stream; + + bool _isInitialized = false; + String _resId = ''; + + // 语音合成队列 + final List _textQueue = []; + bool _isProcessingQueue = false; + + BytesBuilder _audioBytes = BytesBuilder(); + + Completer _completer = Completer(); + + final isEnabled = true.obs; + + // 流式文本缓冲区 + String _streamBuffer = ''; + + @override + Future initialize({ + List? 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 setVoice(String voiceName) async { + if (!_isInitialized) await initialize(); + try { + Logger.info('设置TTS语音: $voiceName'); + return true; + } catch (e) { + Logger.error('设置TTS语音失败: $e'); + return false; + } + } + + // 语音合成 + Future _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 _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 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 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 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 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 _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 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 dispose() async { + if (!_isInitialized) return; + try { + await stop(); + // 关闭事件流控制器 + await _eventController.close(); + _isInitialized = false; + _player.dispose(); + } catch (e) { + Logger.error('释放资源失败: $e'); + } + } +} diff --git a/lib/modules/home/controllers/home_controller.dart b/lib/modules/home/controllers/home_controller.dart index 5e5ea4223..bf5fc0bf3 100644 --- a/lib/modules/home/controllers/home_controller.dart +++ b/lib/modules/home/controllers/home_controller.dart @@ -113,7 +113,6 @@ class HomeController extends GetxController { // 设置蓝牙监听器 void _setupBluetoothListeners() { - // 监听设备连接 _bluetoothService.onDeviceConnected((device) { Logger.info('监听到蓝牙设备连接: ${device.name}'); @@ -188,7 +187,7 @@ class HomeController extends GetxController { // 打开AI伴侣功能 void openAIGirlfriend() { - exploreController.onAIFeatureTap(2);//进入AI女友页面 + exploreController.onAIFeatureTap(2); //进入AI女友页面 } // 展示AI功能 diff --git a/lib/modules/timbre/controllers/timbre_controller.dart b/lib/modules/timbre/controllers/timbre_controller.dart index 3fd12cf53..f85156764 100644 --- a/lib/modules/timbre/controllers/timbre_controller.dart +++ b/lib/modules/timbre/controllers/timbre_controller.dart @@ -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 _getToken() async { + int timeStamp = DateTime.now().millisecondsSinceEpoch; + String data = + '{"base":{"appid":"$_appId","version":"v1","timestamp":"$timeStamp"},"model":"remote"}'; + String keySignInput = _appKey + timeStamp.toString(); + List keySignBytes = utf8.encode(keySignInput); + Digest keySignDigest = md5.convert(keySignBytes); + String keySign = keySignDigest.toString(); + String signInput = keySign + data; + List 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 _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 _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 keySignBytes = utf8.encode(keySignInput); + Digest keySignDigest = md5.convert(keySignBytes); + String keySign = keySignDigest.toString(); + String signInput = keySign + data; + List signBytes = utf8.encode(signInput); + Digest signDigest = md5.convert(signBytes); + String sign = signDigest.toString(); + return {'sign': sign, 'timeStamp': timeStamp.toString()}; + } + + String _decodeCorruptedText(String corruptedText) { + List 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 _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']); } } diff --git a/lib/modules/timbre/views/timbre_view.dart b/lib/modules/timbre/views/timbre_view.dart index 37bdd8d45..2f67131be 100644 --- a/lib/modules/timbre/views/timbre_view.dart +++ b/lib/modules/timbre/views/timbre_view.dart @@ -60,7 +60,69 @@ class TimbreView extends GetView { ), ), GestureDetector( - onTap: () {}, + onTap: () { + controller.startRecorder(); + Get.dialog( + barrierDismissible: false, + AlertDialog( + backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, + title: Text( + '请朗读', + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + content: Obx( + () => controller.text.value.isNotEmpty + ? Text( + controller.text.value, + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ) + : Container( + width: 40.w, + height: 40.w, + alignment: Alignment.center, + child: CircularProgressIndicator( + strokeWidth: 2.w, + valueColor: const AlwaysStoppedAnimation( + Colors.blue), + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + controller.cancel(); + }, + child: Text( + 'cancel'.tr, // 取消 + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ), + ), + Obx(() => controller.text.value.isNotEmpty + ? TextButton( + onPressed: () { + Get.back(); + controller.completeRecorder(); + }, + child: Text( + '完成录制', + style: TextStyle( + color: isDarkMode + ? Colors.red[300] + : Colors.red[600], + ), + ), + ) + : const SizedBox()), + ], + ), + ); + }, child: Container( width: 130.w, height: 48.w, @@ -89,7 +151,7 @@ class TimbreView extends GetView { Widget _item(bool isDarkMode, Map data) { return InkWell( onTap: () { - controller.type.value = data['type']; + controller.selectTone(data); }, child: Obx( () => Container(