diff --git a/lib/data/services/asr_service.dart b/lib/data/services/asr_service.dart index 8e18e3433..02c25283e 100644 --- a/lib/data/services/asr_service.dart +++ b/lib/data/services/asr_service.dart @@ -4,28 +4,28 @@ import 'dart:async'; abstract class AsrService { /// 支持的语言 List get supportedLanguages; - + /// 初始化语音识别服务 Future initialize({ List? supportedLanguages, }); - + /// 执行一次性语音识别 - /// + /// /// 返回识别的最终结果 Future recognizeOnce(); - + /// 开始连续语音识别 - /// + /// /// 返回一个包含识别事件的流 Future> startContinuousRecognition(); - + /// 停止连续语音识别 Future stopContinuousRecognition(); - + /// 检查连续识别是否活跃 bool isContinuousRecognitionActive(); - + /// 释放资源 Future dispose(); } @@ -34,19 +34,19 @@ abstract class AsrService { enum RecognitionEventType { /// 最终识别结果 finalResult, - + /// 中间识别结果(实时反馈) intermediateResult, - + /// 会话开始 sessionStarted, - + /// 会话结束 sessionStopped, - + /// 识别取消 canceled, - + /// 识别错误 error, } @@ -55,23 +55,27 @@ enum RecognitionEventType { class RecognitionEvent { /// 事件类型 final RecognitionEventType type; - + /// 识别文本(仅在 finalResult 和 intermediateResult 类型中有效) final String text; - + /// 检测到的语言 final String detectedLanguage; - + + /// 角色 + final String role; + /// 错误信息(仅在 error 和 canceled 类型中有效) final String error; - + RecognitionEvent({ required this.type, this.text = '', this.detectedLanguage = '', + this.role = '', this.error = '', }); - + /// 创建最终结果事件的快捷构造函数 factory RecognitionEvent.finalResult({ required String text, @@ -83,7 +87,7 @@ class RecognitionEvent { detectedLanguage: detectedLanguage, ); } - + /// 创建错误事件的快捷构造函数 factory RecognitionEvent.error(String errorMessage) { return RecognitionEvent( @@ -91,15 +95,17 @@ class RecognitionEvent { error: errorMessage, ); } - + /// 检查是否为最终结果 bool get isFinalResult => type == RecognitionEventType.finalResult; - + /// 检查是否为错误 - bool get isError => type == RecognitionEventType.error || type == RecognitionEventType.canceled; - + bool get isError => + type == RecognitionEventType.error || + type == RecognitionEventType.canceled; + @override String toString() { return 'RecognitionEvent{type: $type, text: $text, detectedLanguage: $detectedLanguage, error: $error}'; } -} \ No newline at end of file +} diff --git a/lib/data/services/speech_impl/xunfei_asr_service.dart b/lib/data/services/speech_impl/xunfei_asr_service.dart new file mode 100644 index 000000000..94d4d72dc --- /dev/null +++ b/lib/data/services/speech_impl/xunfei_asr_service.dart @@ -0,0 +1,270 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'package:record/record.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import '../../../core/utils/logger.dart'; +import 'package:get/get.dart'; +import '../asr_service.dart'; + +/// 讯飞 语音识别服务 +/// +class XunfeiAsrService extends GetxService implements AsrService { + final String baseUrl = 'wss://rtasr.xfyun.cn/v1/ws'; + final int _chunkSize = 1280; // 每次发送的数据大小 + + final String appId = '43d87d22'; + final String apiKey = '83f063755e8d342670002f50a4471d42'; + + AudioRecorder? recorder; + Timer? _timer; + + WebSocketChannel? _channel; + bool isConnected = false; //是否链接 + + bool _isInitialized = false; + + final List _defaultSupportedLanguages = ['zh-CN']; + String _languageStr = 'cn'; + + // 连续识别相关 + bool _isContinuousRecognitionActive = false; + StreamController? _eventStreamController; + + @override + List get supportedLanguages => _defaultSupportedLanguages; + + Uint8List _buff = Uint8List(0); //音频缓存区 + BytesBuilder _originaBytes = BytesBuilder(); //原始音频 + bool isRecording = false; + + @override + Future initialize({ + List? supportedLanguages, + }) async { + try { + final List languages = + supportedLanguages ?? _defaultSupportedLanguages; + + String languagetoStr = languages.first.split('-')[1].toLowerCase(); + _languageStr = languagetoStr; + + _isInitialized = true; + Logger.info('Azure 语音识别服务初始化成功'); + return true; + } catch (e) { + Logger.error('Azure 语音识别服务初始化失败: $e'); + _isInitialized = false; + rethrow; + } + } + + @override + Future recognizeOnce() async { + if (!_isInitialized) { + await initialize(); + } + + try { + throw Exception('无效的识别结果格式'); + } catch (e) { + Logger.error('语音识别失败: $e'); + return RecognitionEvent.error(e.toString()); + } + } + + @override + Future> startContinuousRecognition() async { + if (!_isInitialized) { + await initialize(); + } + + try { + _eventStreamController = StreamController.broadcast(); + + await recorder?.dispose(); + recorder = AudioRecorder(); + Stream? stream = await recorder?.startStream( + const RecordConfig( + sampleRate: 16000, + encoder: AudioEncoder.pcm16bits, + numChannels: 1, + ), + ); + isRecording = true; + stream?.listen((data) { + if (isRecording) { + _buff = _appendToBuffer(data); + _originaBytes.add(data); + } + }); + await _tryConnecting(); + _timer = Timer.periodic(const Duration(milliseconds: 40), (timer) async { + //每40毫秒读取一次数据 + var frame = _getAudioData(); + if (frame.isNotEmpty) { + _channel?.sink.add(frame); + } + }); + + _isContinuousRecognitionActive = true; + return _eventStreamController!.stream; + } catch (e) { + Logger.error('开始连续语音识别失败: $e'); + rethrow; + } + } + + //写入音频数据到缓存区中 + Uint8List _appendToBuffer(Uint8List newData) { + var newBuffer = Uint8List(_buff.length + newData.length); + newBuffer.setAll(0, _buff); + newBuffer.setAll(_buff.length, newData); + return newBuffer; + } + + //读取缓存区中一帧数据 + Uint8List _getAudioData() { + if (_buff.length >= _chunkSize) { + // 从缓冲区中读取1280字节的数据 + var data = _buff.sublist(0, _chunkSize); + // 移除已读取的数据 + _buff = _buff.sublist(_chunkSize); + return data; + } else { + return _buff; + } + } + + //尝试连接 + Future _tryConnecting() async { + try { + final url = _geturl(); + _channel = WebSocketChannel.connect(Uri.parse(url)); + _channel?.stream.timeout(const Duration(seconds: 10)); //设置超时时间 + await _channel?.ready; + isConnected = true; + _channel?.stream.listen( + (message) { + _handle(message); + }, + onError: (error) { + isConnected = false; + Logger.error('连接失败: $error'); + }, + onDone: () { + isConnected = false; + Logger.error('WebSocket 连接已关闭!'); + }, + cancelOnError: true, + ); + } catch (e) { + Logger.error("初始化异常----------------$e"); + } + } + + //处理数据 + void _handle(data) { + try { + final result = jsonDecode(data); + if (result['code'] == "0" && result['data'] != "") { + final data = jsonDecode(result['data']); + String msg = ""; + String role = ''; + + List rtList = data['cn']['st']['rt']; + for (var rt in rtList) { + for (var ws in rt['ws']) { + for (var ws in ws['cw']) { + msg += ws['w']; + if (ws['rl'] != "0") { + role = ws['rl']; + } + } + } + } + Logger.info( + "识别到的内容 type:${data['cn']['st']['type']} msg:$msg rl:$role"); + if (msg.isNotEmpty) { + if (data['cn']['st']['type'] == "0") { + _eventStreamController?.add(RecognitionEvent( + type: RecognitionEventType.finalResult, + text: msg, + role: role, + )); + } else { + _eventStreamController?.add(RecognitionEvent( + type: RecognitionEventType.intermediateResult, + text: msg, + role: role, + )); + } + } + } + } catch (e) { + Logger.error("接受消息异常 $e"); + } + } + + ///获取链接地址 + String _geturl() { + // 生成时间戳 + String ts = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); + // 计算 baseString + String baseString = md5.convert(utf8.encode(appId + ts)).toString(); + // 计算 signa + var hmacSha1 = Hmac(sha1, utf8.encode(apiKey)); + var signa = hmacSha1.convert(utf8.encode(baseString)); + String signaBase64 = base64Encode(signa.bytes); + String url = ""; + // 构建 WebSocket URL + url = + '$baseUrl?appid=$appId&ts=$ts&lang=$_languageStr&roleType=2&signa=${Uri.encodeComponent(signaBase64)}'; + return url; + } + + @override + Future stopContinuousRecognition() async { + try { + await recorder?.stop(); + isRecording = false; + _timer?.cancel(); + _buff = Uint8List(0); + _originaBytes = BytesBuilder(); + if (isConnected) { + _channel?.sink.add(utf8.encode('{"end": true}')); //结束 + } + + _isContinuousRecognitionActive = false; + return true; + } catch (e) { + Logger.error('停止连续语音识别失败: $e'); + rethrow; + } + } + + @override + bool isContinuousRecognitionActive() { + return _isContinuousRecognitionActive; + } + + @override + Future dispose() async { + try { + await stopContinuousRecognition(); + recorder?.dispose(); + _channel?.sink.close(); + + _eventStreamController?.close(); + _eventStreamController = null; + + _isInitialized = false; + + Logger.info('Azure 语音识别资源已释放'); + } catch (e) { + Logger.error('释放语音识别资源失败: $e'); + _isInitialized = false; + } + } +} diff --git a/lib/modules/meeting/controllers/meeting_edit_controller.dart b/lib/modules/meeting/controllers/meeting_edit_controller.dart index d24308441..87af7ec73 100644 --- a/lib/modules/meeting/controllers/meeting_edit_controller.dart +++ b/lib/modules/meeting/controllers/meeting_edit_controller.dart @@ -62,13 +62,13 @@ class MeetingEditController extends GetxController void confirm() async { String content = contentController.text; String firstContent = - content.length >= 20 ? content.substring(0, 20) : content; + content.length >= 30 ? content.substring(0, 30) : content; String title = titleController.text.isNotEmpty ? titleController.text : meetingDetailsController.meetingData.value.title; Map data = { 'title': title, - 'firstContent': firstContent, + 'firstContent': firstContent.replaceAll("\n", ""), 'seconds': meetingDetailsController.seconds, 'content': contentController.text, }; @@ -81,7 +81,7 @@ class MeetingEditController extends GetxController final dataMap = Map.from(meetingController.dataList[index]); dataMap['title'] = title; - dataMap['content'] = firstContent; + dataMap['content'] = firstContent.replaceAll("\n", ""); dataMap['seconds'] = meetingDetailsController.seconds; meetingController.dataList[index] = dataMap; } diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 1e569bfd9..dfd364675 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -5,6 +5,7 @@ import 'package:get/get.dart'; import '../../../data/services/asr_service.dart'; import '../../../data/services/db/sqflite_api.dart'; +import '../../../data/services/speech_impl/xunfei_asr_service.dart'; import 'meeting_controller.dart'; class MeetingRecordController extends GetxController @@ -31,7 +32,8 @@ class MeetingRecordController extends GetxController void onInit() { super.onInit(); lottieController = AnimationController(vsync: this); - _asrService = Get.find(); + Get.put(XunfeiAsrService()); + _asrService = Get.find(); final Map args = Get.arguments ?? {}; title = args['title']; creationTime = DateTime.now().millisecondsSinceEpoch; @@ -41,7 +43,7 @@ class MeetingRecordController extends GetxController void onClose() { lottieController.dispose(); timer?.cancel(); - _asrService.stopContinuousRecognition(); + _asrService.dispose(); _recognitionSubscription?.cancel(); super.onClose(); } @@ -82,7 +84,6 @@ class MeetingRecordController extends GetxController //结束录音 Future _stopRecorder() async { await _asrService.stopContinuousRecognition(); - _recognitionSubscription?.cancel(); } // 处理语音识别事件 @@ -92,7 +93,7 @@ class MeetingRecordController extends GetxController intermediateContent.value = event.text; break; case RecognitionEventType.finalResult: - finalContent.value += event.text; + finalContent.value += _spliceText(event); intermediateContent.value = ''; break; default: @@ -101,14 +102,49 @@ class MeetingRecordController extends GetxController } } + String _spliceText(RecognitionEvent event) { + String eventText = event.text; + String text = ''; + if (finalContent.value.isNotEmpty) { + if (event.role != '') { + final punctuations = { + ',', + '。', + '!', + '?', + ';', + ':', + ',', + '.', + ':', + ';', + '!', + '?' + }; + final firstChar = eventText[0]; + if (punctuations.contains(firstChar)) { + text += '$firstChar\n'; + eventText = eventText.substring(1); + } else { + text += '\n'; + } + text += '说话人${event.role}:'; + } + } else { + text += '说话人${event.role}:'; + } + text += eventText; + return text; + } + // 完成 void complete() async { - String firstContent = finalContent.value.length >= 20 - ? finalContent.value.substring(0, 20) + String firstContent = finalContent.value.length >= 30 + ? finalContent.value.substring(0, 30) : finalContent.value; Map data = { 'title': title, - 'firstContent': firstContent, + 'firstContent': firstContent.replaceAll("\n", ""), 'seconds': seconds.value, 'creationTime': creationTime, 'audio': [], diff --git a/lib/modules/meeting/views/meeting_edit_view.dart b/lib/modules/meeting/views/meeting_edit_view.dart index b85e95c7f..73de47a76 100644 --- a/lib/modules/meeting/views/meeting_edit_view.dart +++ b/lib/modules/meeting/views/meeting_edit_view.dart @@ -237,14 +237,14 @@ class MeetingEditView extends GetView { controller: controller.contentController, style: TextStyle( color: isDarkMode ? Colors.white : Colors.black87, - fontSize: 16.sp, + fontSize: 14.sp, ), maxLines: 50, decoration: InputDecoration( hintText: '编辑记录内容', hintStyle: TextStyle( color: isDarkMode ? Colors.white70 : Colors.black54, - fontSize: 16.sp, + fontSize: 14.sp, ), fillColor: Colors.transparent, border: InputBorder.none,