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