diff --git a/lib/data/services/asr_service.dart b/lib/data/services/asr_service.dart index 02c25283e..fee73821c 100644 --- a/lib/data/services/asr_service.dart +++ b/lib/data/services/asr_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; /// 语音识别服务接口 abstract class AsrService { @@ -65,6 +66,9 @@ class RecognitionEvent { /// 角色 final String role; + /// 原始音频 + final Uint8List? audio; + /// 错误信息(仅在 error 和 canceled 类型中有效) final String error; @@ -73,6 +77,7 @@ class RecognitionEvent { this.text = '', this.detectedLanguage = '', this.role = '', + this.audio, this.error = '', }); diff --git a/lib/data/services/speech_impl/xunfei_asr_service.dart b/lib/data/services/speech_impl/xunfei_asr_service.dart index 94d4d72dc..1201f7c66 100644 --- a/lib/data/services/speech_impl/xunfei_asr_service.dart +++ b/lib/data/services/speech_impl/xunfei_asr_service.dart @@ -192,7 +192,9 @@ class XunfeiAsrService extends GetxService implements AsrService { type: RecognitionEventType.finalResult, text: msg, role: role, + audio: _originaBytes.toBytes(), )); + _originaBytes.clear(); } else { _eventStreamController?.add(RecognitionEvent( type: RecognitionEventType.intermediateResult, diff --git a/lib/modules/meeting/controllers/meeting_details_controller.dart b/lib/modules/meeting/controllers/meeting_details_controller.dart index d88b53f28..37c3b3259 100644 --- a/lib/modules/meeting/controllers/meeting_details_controller.dart +++ b/lib/modules/meeting/controllers/meeting_details_controller.dart @@ -1,13 +1,16 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:just_audio/just_audio.dart'; import '../../../data/services/db/sqflite_api.dart'; +import '../../../data/services/speech_impl/volcano_tts_api_service.dart'; import '../model/meeting_details_model.dart'; import '../model/meeting_model.dart'; class MeetingDetailsController extends GetxController with GetSingleTickerProviderStateMixin { late AnimationController lottieController; + final AudioPlayer audioPlayer = AudioPlayer(); int id = 0; @@ -31,6 +34,7 @@ class MeetingDetailsController extends GetxController @override void onClose() { lottieController.dispose(); + audioPlayer.dispose(); super.onClose(); } @@ -38,16 +42,26 @@ class MeetingDetailsController extends GetxController final data = await SqfliteApi.getMeetingDetails(id); meetingData.value = MeetingModel.fromJson(data); dataDetails.value = MeetingDetailsModel.fromJson(data['details']); - seconds = meetingData.value.seconds; + audioPlayer.setAudioSource(BytesAudioSource(dataDetails.value.audio)); + audioPlayer.playerStateStream.listen((state) async { + if (state.processingState == ProcessingState.completed) { + await audioPlayer.seek(Duration.zero); + audioPlayer.pause(); + lottieController.stop(); + isPlay.value = false; + } + }); isLoading.value = false; } - void start() { + void start() async { if (isPlay.value) { lottieController.stop(); + audioPlayer.pause(); isPlay.value = false; } else { lottieController.repeat(); + audioPlayer.play(); isPlay.value = true; } } diff --git a/lib/modules/meeting/controllers/meeting_edit_controller.dart b/lib/modules/meeting/controllers/meeting_edit_controller.dart index 87af7ec73..5ae01a749 100644 --- a/lib/modules/meeting/controllers/meeting_edit_controller.dart +++ b/lib/modules/meeting/controllers/meeting_edit_controller.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:just_audio/just_audio.dart'; import '../../../data/services/db/sqflite_api.dart'; +import '../../../data/services/speech_impl/volcano_tts_api_service.dart'; import 'meeting_controller.dart'; import 'meeting_details_controller.dart'; @@ -16,6 +18,8 @@ class MeetingEditController extends GetxController late TextEditingController titleController; late TextEditingController contentController; + final AudioPlayer audioPlayer = AudioPlayer(); + int creationTime = 0; int id = 0; @@ -36,6 +40,7 @@ class MeetingEditController extends GetxController lottieController.dispose(); titleController.dispose(); contentController.dispose(); + audioPlayer.dispose(); super.onClose(); } @@ -47,14 +52,27 @@ class MeetingEditController extends GetxController text: meetingDetailsController.dataDetails.value.content, ); creationTime = meetingDetailsController.meetingData.value.creationtime; + audioPlayer.setAudioSource( + BytesAudioSource(meetingDetailsController.dataDetails.value.audio)); + audioPlayer.playerStateStream.listen((state) async { + if (state.processingState == ProcessingState.completed) { + await audioPlayer.seek(Duration.zero); + audioPlayer.pause(); + lottieController.stop(); + isPlay.value = false; + } + }); } - void start() { + void start() async { if (isPlay.value) { lottieController.stop(); + audioPlayer.pause(); isPlay.value = false; } else { lottieController.repeat(); + await audioPlayer.seek(Duration.zero); + audioPlayer.play(); isPlay.value = true; } } diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index dfd364675..17c1849fa 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -28,6 +29,8 @@ class MeetingRecordController extends GetxController StreamSubscription? _recognitionSubscription; + BytesBuilder _originaBytes = BytesBuilder(); //原始音频 + @override void onInit() { super.onInit(); @@ -45,6 +48,7 @@ class MeetingRecordController extends GetxController timer?.cancel(); _asrService.dispose(); _recognitionSubscription?.cancel(); + _originaBytes = BytesBuilder(); super.onClose(); } @@ -95,6 +99,7 @@ class MeetingRecordController extends GetxController case RecognitionEventType.finalResult: finalContent.value += _spliceText(event); intermediateContent.value = ''; + _originaBytes.add(event.audio!); break; default: // 不做任何处理 @@ -142,12 +147,20 @@ class MeetingRecordController extends GetxController String firstContent = finalContent.value.length >= 30 ? finalContent.value.substring(0, 30) : finalContent.value; + Uint8List audio = _originaBytes.toBytes(); + Uint8List wavHeader = generateWavHeader( + sampleRate: 16000, + numberOfChannels: 1, + bitsPerSample: 16, + dataLength: audio.length, + ); + Uint8List wavData = Uint8List.fromList([...wavHeader, ...audio]); Map data = { 'title': title, 'firstContent': firstContent.replaceAll("\n", ""), 'seconds': seconds.value, 'creationTime': creationTime, - 'audio': [], + 'audio': wavData, 'content': finalContent.value, }; final result = await SqfliteApi.insertMeeting(data); @@ -162,4 +175,73 @@ class MeetingRecordController extends GetxController Get.back(); } } + + Uint8List generateWavHeader({ + required int sampleRate, + required int numberOfChannels, + required int bitsPerSample, + required int dataLength, + }) { + final byteRate = sampleRate * numberOfChannels * (bitsPerSample ~/ 8); + final blockAlign = numberOfChannels * (bitsPerSample ~/ 8); + final riffSize = 36 + dataLength; + final header = Uint8List(44); + var pos = 0; + // RIFF 块 + header.setRange(pos, pos + 4, [0x52, 0x49, 0x46, 0x46]); // 'RIFF' + pos += 4; + // 写入 RIFF 大小(小端) + header.setRange(pos, pos + 4, [ + riffSize & 0xff, + (riffSize >> 8) & 0xff, + (riffSize >> 16) & 0xff, + (riffSize >> 24) & 0xff, + ]); + pos += 4; + // WAVE 标识 + header.setRange(pos, pos + 4, [0x57, 0x41, 0x56, 0x45]); // 'WAVE' + pos += 4; + // fmt 子块 + header.setRange(pos, pos + 4, [0x66, 0x6d, 0x74, 0x20]); // 'fmt ' + pos += 4; + header.setRange(pos, pos + 4, [16, 0, 0, 0]); // 块大小 + pos += 4; + header.setRange(pos, pos + 2, [1, 0]); // PCM 格式 + pos += 2; + header.setRange(pos, pos + 2, [numberOfChannels, 0]); // 声道数 + pos += 2; + // 采样率(小端) + header.setRange(pos, pos + 4, [ + sampleRate & 0xff, + (sampleRate >> 8) & 0xff, + (sampleRate >> 16) & 0xff, + (sampleRate >> 24) & 0xff, + ]); + pos += 4; + // 字节率(小端) + header.setRange(pos, pos + 4, [ + byteRate & 0xff, + (byteRate >> 8) & 0xff, + (byteRate >> 16) & 0xff, + (byteRate >> 24) & 0xff, + ]); + pos += 4; + // 块对齐 + header.setRange(pos, pos + 2, [blockAlign, 0]); + pos += 2; + // 位深度 + header.setRange(pos, pos + 2, [bitsPerSample, 0]); + pos += 2; + // data 子块 + header.setRange(pos, pos + 4, [0x64, 0x61, 0x74, 0x61]); // 'data' + pos += 4; + // 数据大小(小端) + header.setRange(pos, pos + 4, [ + dataLength & 0xff, + (dataLength >> 8) & 0xff, + (dataLength >> 16) & 0xff, + (dataLength >> 24) & 0xff, + ]); + return header; + } } diff --git a/lib/modules/meeting/views/meeting_details_view.dart b/lib/modules/meeting/views/meeting_details_view.dart index 7423791ef..712af6131 100644 --- a/lib/modules/meeting/views/meeting_details_view.dart +++ b/lib/modules/meeting/views/meeting_details_view.dart @@ -139,12 +139,22 @@ class MeetingDetailsView extends GetView { Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - Text( - '00:00:00', - style: TextStyle( - color: isDarkMode ? Colors.grey[400] : Colors.grey[500], - fontSize: 12.sp, - ), + StreamBuilder( + stream: controller.audioPlayer.positionStream, + builder: (context, snapshot) { + final position = snapshot.data ?? Duration.zero; + return Text( + DateUtil.formatDateMs( + position.inMilliseconds, + format: 'HH:mm:ss', + isUtc: true, + ), + style: TextStyle( + color: isDarkMode ? Colors.grey[400] : Colors.grey[500], + fontSize: 12.sp, + ), + ); + }, ), GestureDetector( onTap: controller.start, @@ -164,12 +174,23 @@ class MeetingDetailsView extends GetView { ), ), ), - Text( - '00:00:00', - style: TextStyle( - color: isDarkMode ? Colors.grey[400] : Colors.grey[500], - fontSize: 12.sp, - ), + StreamBuilder( + stream: controller.audioPlayer.durationStream, + builder: (context, snapshot) { + final duration = snapshot.data ?? Duration.zero; + controller.seconds = duration.inSeconds; + return Text( + DateUtil.formatDateMs( + duration.inMilliseconds, + format: 'HH:mm:ss', + isUtc: true, + ), + style: TextStyle( + color: isDarkMode ? Colors.grey[400] : Colors.grey[500], + fontSize: 12.sp, + ), + ); + }, ), ], ), @@ -202,10 +223,8 @@ class MeetingDetailsView extends GetView { alignment: Alignment.topLeft, child: SingleChildScrollView( reverse: true, - child: Obx( - () => Text( - controller.dataDetails.value.content, - ), + child: Text( + controller.dataDetails.value.content, ), ), ), diff --git a/lib/modules/meeting/views/meeting_edit_view.dart b/lib/modules/meeting/views/meeting_edit_view.dart index 73de47a76..7c65afd10 100644 --- a/lib/modules/meeting/views/meeting_edit_view.dart +++ b/lib/modules/meeting/views/meeting_edit_view.dart @@ -174,12 +174,22 @@ class MeetingEditView extends GetView { Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - Text( - '00:00:00', - style: TextStyle( - color: isDarkMode ? Colors.grey[400] : Colors.grey[500], - fontSize: 12.sp, - ), + StreamBuilder( + stream: controller.audioPlayer.positionStream, + builder: (context, snapshot) { + final position = snapshot.data ?? Duration.zero; + return Text( + DateUtil.formatDateMs( + position.inMilliseconds, + format: 'HH:mm:ss', + isUtc: true, + ), + style: TextStyle( + color: isDarkMode ? Colors.grey[400] : Colors.grey[500], + fontSize: 12.sp, + ), + ); + }, ), GestureDetector( onTap: controller.start, @@ -192,19 +202,31 @@ class MeetingEditView extends GetView { 12.w, ), ), - child: Icon( - controller.isPlay.value ? Icons.pause : Icons.play_arrow, - color: Colors.white, - size: 25.w, + child: Obx( + () => Icon( + controller.isPlay.value ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 25.w, + ), ), ), ), - Text( - '00:00:00', - style: TextStyle( - color: isDarkMode ? Colors.grey[400] : Colors.grey[500], - fontSize: 12.sp, - ), + StreamBuilder( + stream: controller.audioPlayer.durationStream, + builder: (context, snapshot) { + final duration = snapshot.data ?? Duration.zero; + return Text( + DateUtil.formatDateMs( + duration.inMilliseconds, + format: 'HH:mm:ss', + isUtc: true, + ), + style: TextStyle( + color: isDarkMode ? Colors.grey[400] : Colors.grey[500], + fontSize: 12.sp, + ), + ); + }, ), ], ),