23 changed files with 1265 additions and 43 deletions
@ -0,0 +1,11 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/meeting_record_controller.dart'; |
||||
|
|
||||
|
/// 录音模块依赖绑定类 |
||||
|
class MeetingRecordBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
// 延迟注册 MeetingRecordController 控制器 |
||||
|
Get.lazyPut<MeetingRecordController>(() => MeetingRecordController()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,254 @@ |
|||||
|
// meeting_record_controller.dart |
||||
|
import 'dart:async'; |
||||
|
import 'dart:math'; |
||||
|
import 'dart:typed_data'; |
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../../../data/services/asr_service.dart'; |
||||
|
import '../../../data/services/ble_manager.dart'; |
||||
|
|
||||
|
// 新增计时器状态枚举 |
||||
|
enum TimerStatus { |
||||
|
stopped, // 已停止/未开始 |
||||
|
running, // 运行中 |
||||
|
paused, // 已暂停 |
||||
|
} |
||||
|
|
||||
|
/// 控制器:处理录音状态、波形、文本转录等逻辑 |
||||
|
class MeetingRecordController extends GetxController |
||||
|
with SingleGetTickerProviderMixin { |
||||
|
//语言服务 |
||||
|
final AsrService _asrService = Get.find<AsrService>(); |
||||
|
//语言服务监听 |
||||
|
StreamSubscription? _recognitionSubscription; |
||||
|
//是否开启识别 |
||||
|
final RxBool isVoiceRecognitionActive = false.obs; |
||||
|
// 音频输入源 |
||||
|
bool _audioSourceType = false; |
||||
|
|
||||
|
//识别文本 |
||||
|
RxString intermediateContent = ''.obs; |
||||
|
RxString finalContent = ''.obs; |
||||
|
// final RxBool isRecording = false.obs; // 是否正在录音 |
||||
|
// 蓝牙服务 |
||||
|
final _bleManager = Get.find<BleManager>(); |
||||
|
final RxInt seconds = 0.obs; // 录音秒数 |
||||
|
final RxInt audioTypes = 0.obs; // 音频类型 |
||||
|
final RxBool isMarked = false.obs; // 是否标记重点 |
||||
|
final RxList<double> waveData = <double>[].obs; // 波形图数据 |
||||
|
RxBool showTextContent = false.obs; // 控制文本显示 |
||||
|
late TabController tabController; |
||||
|
Timer? _timer; |
||||
|
int _currentPosition = 0; // 当前播放位置(用于波形图动画) |
||||
|
|
||||
|
// 新增计时器状态变量 |
||||
|
final Rx<TimerStatus> timerStatus = TimerStatus.stopped.obs; |
||||
|
int _elapsedMilliseconds = 0; // 累计毫秒数 |
||||
|
int? _lastStartTime; // 上次开始计时的时间戳 |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
_asrService.initialize(); |
||||
|
tabController = TabController(length: 2, vsync: this); |
||||
|
generateWaveData(); |
||||
|
// 移除初始化的 startTimer() 调用 |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
_timer?.cancel(); |
||||
|
_asrService.dispose(); |
||||
|
|
||||
|
tabController.dispose(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
|
||||
|
/// 启动/继续计时器 |
||||
|
void startTimer() { |
||||
|
if (timerStatus.value != TimerStatus.running) { |
||||
|
timerStatus.value = TimerStatus.running; |
||||
|
_lastStartTime = DateTime.now().millisecondsSinceEpoch; |
||||
|
|
||||
|
_timer = Timer.periodic(const Duration(milliseconds: 200), (timer) { |
||||
|
if (timerStatus.value == TimerStatus.running) { |
||||
|
// 计算真实经过的时间(毫秒) |
||||
|
final currentTime = DateTime.now().millisecondsSinceEpoch; |
||||
|
_elapsedMilliseconds += currentTime - _lastStartTime!; |
||||
|
_lastStartTime = currentTime; |
||||
|
|
||||
|
// 转换为秒(四舍五入) |
||||
|
seconds.value = (_elapsedMilliseconds / 1000).round(); |
||||
|
update(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 暂停计时器 |
||||
|
void pauseTimer() { |
||||
|
if (timerStatus.value == TimerStatus.running) { |
||||
|
timerStatus.value = TimerStatus.paused; |
||||
|
_timer?.cancel(); |
||||
|
_timer = null; |
||||
|
|
||||
|
// 更新累计时间 |
||||
|
final currentTime = DateTime.now().millisecondsSinceEpoch; |
||||
|
_elapsedMilliseconds += currentTime - _lastStartTime!; |
||||
|
_lastStartTime = null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 重置计时器 |
||||
|
void resetTimer() { |
||||
|
_timer?.cancel(); |
||||
|
_timer = null; |
||||
|
_elapsedMilliseconds = 0; |
||||
|
_lastStartTime = null; |
||||
|
seconds.value = 0; |
||||
|
timerStatus.value = TimerStatus.stopped; |
||||
|
} |
||||
|
|
||||
|
void toggleTextDisplay() { |
||||
|
showTextContent.toggle(); // 点击按钮切换显示状态 |
||||
|
} |
||||
|
|
||||
|
/// 切换录音状态 |
||||
|
Future<void> toggleRecording() async { |
||||
|
if (timerStatus.value == TimerStatus.stopped) { |
||||
|
await _startRecording(); |
||||
|
} else if (timerStatus.value == TimerStatus.running) { |
||||
|
await _pauseRecording(); |
||||
|
} else if (timerStatus.value == TimerStatus.paused) { |
||||
|
await _resumeRecording(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 生成模拟波形图数据 |
||||
|
void generateWaveData() { |
||||
|
waveData.value = List.generate(120, (i) { |
||||
|
final rand = (i % 20) / 20; |
||||
|
final baseHeight = (sin(i / 5) * 0.5 + 0.5) * 25; |
||||
|
return baseHeight + rand * 10; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 处理语音识别事件 |
||||
|
void _handleRecognitionEvent(RecognitionEvent event) { |
||||
|
switch (event.type) { |
||||
|
case RecognitionEventType.intermediateResult: |
||||
|
print( |
||||
|
"_handleRecognitionEvent,intermediateResult,event.text=${event.text}"); |
||||
|
intermediateContent.value = event.text; |
||||
|
break; |
||||
|
case RecognitionEventType.finalResult: |
||||
|
print("_handleRecognitionEvent,finalResult,event.text=${event.text}"); |
||||
|
finalContent.value += _spliceText(event); |
||||
|
intermediateContent.value = ''; |
||||
|
//_originaBytes.add(event.audio!); |
||||
|
break; |
||||
|
default: |
||||
|
// 不做任何处理 |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
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; |
||||
|
} |
||||
|
|
||||
|
Future<void> _startRecording() async { |
||||
|
// 开始录音 |
||||
|
final recognitionStream = |
||||
|
await _asrService.startContinuousRecognition(_audioSourceType); |
||||
|
_recognitionSubscription?.cancel(); |
||||
|
_recognitionSubscription = |
||||
|
recognitionStream.listen(_handleRecognitionEvent); |
||||
|
await _asrService.enableRecord("录音"); |
||||
|
|
||||
|
startTimer(); |
||||
|
|
||||
|
// 蓝牙处理 |
||||
|
if (audioTypes == 0) { |
||||
|
_bleManager.openEncoder(); |
||||
|
} else if (audioTypes == 1) { |
||||
|
_bleManager.openDecoder(); |
||||
|
} else if (audioTypes == 2) { |
||||
|
_bleManager.openA2DPDecoder(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> _pauseRecording() async { |
||||
|
// 暂停录音 |
||||
|
await _asrService.stopContinuousRecognition(); |
||||
|
await _asrService.pauseRecord(); |
||||
|
pauseTimer(); |
||||
|
//await _asrService.pauseRecognition(); // 假设ASR服务有暂停方法 |
||||
|
} |
||||
|
|
||||
|
Future<void> _resumeRecording() async { |
||||
|
// 开始录音 |
||||
|
final recognitionStream = |
||||
|
await _asrService.startContinuousRecognition(_audioSourceType); |
||||
|
_recognitionSubscription?.cancel(); |
||||
|
_recognitionSubscription = |
||||
|
recognitionStream.listen(_handleRecognitionEvent); |
||||
|
startTimer(); |
||||
|
|
||||
|
//await _asrService.resumeRecognition(); // 假设ASR服务有继续方法 |
||||
|
} |
||||
|
|
||||
|
/// 切换是否标记为重点 |
||||
|
void toggleMarked() { |
||||
|
isMarked.toggle(); |
||||
|
} |
||||
|
|
||||
|
/// 格式化时间为 mm:ss |
||||
|
String formatTime(int seconds) { |
||||
|
// 实现你的时间格式化逻辑 |
||||
|
return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}'; |
||||
|
} |
||||
|
|
||||
|
/// 游标在波形图中的位置(0~1) |
||||
|
double get cursorPosition => _currentPosition / waveData.length; |
||||
|
|
||||
|
/// 保存录音(目前模拟为返回上一页) |
||||
|
Future<void> saveRecording() async { |
||||
|
resetTimer(); // 重置计时器 |
||||
|
_bleManager.closeCodec(); |
||||
|
await _asrService.stopRecord(); |
||||
|
await _asrService.stopContinuousRecognition(); |
||||
|
Get.back(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,239 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:lottie/lottie.dart'; |
||||
|
import '../controllers/meeting_record_controller.dart'; |
||||
|
|
||||
|
class MeetingRecordView extends GetView<MeetingRecordController> { |
||||
|
const MeetingRecordView({super.key}); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Scaffold( |
||||
|
appBar: AppBar( |
||||
|
leading: const BackButton(), |
||||
|
title: const Text('新录音 29', style: TextStyle(fontSize: 18)), // 更新为29 |
||||
|
elevation: 0, |
||||
|
backgroundColor: Colors.white, |
||||
|
foregroundColor: Colors.black, |
||||
|
), |
||||
|
body: Column( |
||||
|
children: [ |
||||
|
TabBar( |
||||
|
controller: controller.tabController, |
||||
|
indicatorColor: Colors.red, |
||||
|
labelColor: Colors.black, |
||||
|
unselectedLabelColor: Colors.grey, |
||||
|
tabs: const [ |
||||
|
Tab(text: '音频'), |
||||
|
Tab(text: '文本'), |
||||
|
], |
||||
|
), |
||||
|
Expanded( |
||||
|
child: TabBarView( |
||||
|
controller: controller.tabController, |
||||
|
children: [ |
||||
|
_buildAudioTab(context), |
||||
|
_buildTextTab(context), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildAudioTab(BuildContext context) { |
||||
|
return Obx(() => Column( |
||||
|
children: [ |
||||
|
const SizedBox(height: 24), |
||||
|
SizedBox( |
||||
|
height: 120, |
||||
|
child: Stack( |
||||
|
alignment: Alignment.center, |
||||
|
children: [ |
||||
|
CustomPaint( |
||||
|
painter: WaveformPainter( |
||||
|
controller.waveData, |
||||
|
cursorPosition: controller.cursorPosition, |
||||
|
), |
||||
|
size: Size.infinite, |
||||
|
), |
||||
|
Positioned( |
||||
|
left: MediaQuery.of(context).size.width * |
||||
|
controller.cursorPosition, |
||||
|
child: Container( |
||||
|
width: 2, |
||||
|
height: 120, |
||||
|
color: Colors.red, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
Text( |
||||
|
controller.formatTime(controller.seconds.value), |
||||
|
style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold), |
||||
|
), |
||||
|
const Spacer(), |
||||
|
_buildBottomBar(context), |
||||
|
const SizedBox(height: 32), |
||||
|
], |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
Widget _buildTextTab(BuildContext context) { |
||||
|
return Obx(() => Column( |
||||
|
children: [ |
||||
|
const SizedBox(height: 48), |
||||
|
Center( |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
const Icon(Icons.graphic_eq, color: Colors.red, size: 32), |
||||
|
const SizedBox(height: 8), |
||||
|
Text( |
||||
|
controller.formatTime(controller.seconds.value), |
||||
|
style: const TextStyle( |
||||
|
fontSize: 36, fontWeight: FontWeight.bold), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 24), |
||||
|
// 新增:转文本提示区域 |
||||
|
if (!controller.showTextContent.value) ...[ |
||||
|
const Text('无文本', |
||||
|
style: TextStyle( |
||||
|
color: Colors.grey, |
||||
|
fontSize: 16, |
||||
|
)), |
||||
|
const SizedBox(height: 16), |
||||
|
ElevatedButton( |
||||
|
onPressed: controller.toggleTextDisplay, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.red, |
||||
|
foregroundColor: Colors.white, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(20), |
||||
|
), |
||||
|
padding: const EdgeInsets.symmetric( |
||||
|
horizontal: 24, |
||||
|
vertical: 8, |
||||
|
), |
||||
|
), |
||||
|
child: const Text('转文本'), |
||||
|
), |
||||
|
], |
||||
|
// 文本内容区域(转文本后显示) |
||||
|
if (controller.showTextContent.value) |
||||
|
Expanded( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 24), |
||||
|
child: SingleChildScrollView( |
||||
|
child: Text( |
||||
|
'${controller.finalContent.value}${controller.intermediateContent.value}', |
||||
|
style: const TextStyle(fontSize: 16), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
const Spacer(), |
||||
|
_buildBottomBar(context), |
||||
|
const SizedBox(height: 32), |
||||
|
], |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
Widget _buildBottomBar(BuildContext context) { |
||||
|
return Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
||||
|
children: [ |
||||
|
GestureDetector( |
||||
|
onTap: controller.toggleMarked, |
||||
|
child: Obx(() => CircleAvatar( |
||||
|
radius: 32, |
||||
|
backgroundColor: Colors.white, |
||||
|
child: Icon( |
||||
|
controller.isMarked.value ? Icons.star : Icons.star_border, |
||||
|
size: 36, |
||||
|
color: |
||||
|
controller.isMarked.value ? Colors.orange : Colors.grey, |
||||
|
), |
||||
|
)), |
||||
|
), |
||||
|
GestureDetector( |
||||
|
onTap: controller.toggleRecording, |
||||
|
child: Obx(() => CircleAvatar( |
||||
|
radius: 40, |
||||
|
backgroundColor: Colors.white, |
||||
|
child: Icon( |
||||
|
controller.timerStatus.value == TimerStatus.running |
||||
|
? Icons.pause |
||||
|
: Icons.play_arrow, |
||||
|
size: 44, |
||||
|
color: Colors.red, |
||||
|
), |
||||
|
)), |
||||
|
), |
||||
|
GestureDetector( |
||||
|
onTap: controller.saveRecording, |
||||
|
child: const CircleAvatar( |
||||
|
radius: 32, |
||||
|
backgroundColor: Colors.white, |
||||
|
child: Icon( |
||||
|
Icons.check, |
||||
|
size: 36, |
||||
|
color: Colors.green, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class WaveformPainter extends CustomPainter { |
||||
|
final List<double> data; |
||||
|
final double cursorPosition; |
||||
|
|
||||
|
WaveformPainter(this.data, {required this.cursorPosition}); |
||||
|
|
||||
|
@override |
||||
|
void paint(Canvas canvas, Size size) { |
||||
|
final paint = Paint() |
||||
|
..color = Colors.blue[300]! |
||||
|
..strokeWidth = 2 |
||||
|
..strokeCap = StrokeCap.round; |
||||
|
|
||||
|
final cursorPaint = Paint() |
||||
|
..color = Colors.red |
||||
|
..strokeWidth = 2; |
||||
|
|
||||
|
final double barWidth = size.width / data.length; |
||||
|
final centerY = size.height / 2; |
||||
|
|
||||
|
for (int i = 0; i < data.length; i++) { |
||||
|
final x = i * barWidth + barWidth / 2; |
||||
|
final height = data[i]; |
||||
|
canvas.drawLine( |
||||
|
Offset(x, centerY - height), |
||||
|
Offset(x, centerY + height), |
||||
|
paint, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
final cursorX = size.width * cursorPosition; |
||||
|
canvas.drawLine( |
||||
|
Offset(cursorX, 0), |
||||
|
Offset(cursorX, size.height), |
||||
|
cursorPaint, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
bool shouldRepaint(covariant WaveformPainter oldDelegate) { |
||||
|
return oldDelegate.data != data || |
||||
|
oldDelegate.cursorPosition != cursorPosition; |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue