From 19dd3ee0f5390264b24b3b0db2abe87bd632d1ba Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Thu, 8 Jan 2026 16:45:38 +0800 Subject: [PATCH] =?UTF-8?q?refactor(meeting):=20=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E6=B3=A2=E5=BD=A2=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=B8=BAaudio=5Fwaveforms=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除自定义波形动画和数据处理逻辑,改用audio_waveforms库实现 简化控制器代码,移除未使用的变量和动画控制器 更新视图层以适配新的波形显示组件 --- .../meeting_record_controller.dart | 225 +++-------- .../meeting/views/meeting_record_view.dart | 349 ++++++------------ 2 files changed, 175 insertions(+), 399 deletions(-) diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 792618710..277c1c998 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -5,6 +5,7 @@ import 'dart:math'; import 'dart:typed_data'; import '/core/utils/logger.dart'; import '/core/utils/permission_util.dart'; +import 'package:audio_waveforms/audio_waveforms.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; @@ -40,7 +41,9 @@ class MeetingRecordController extends GetxController final RxBool showTextContent = false.obs; final RxBool isRecording = false.obs; //final RxBool isVoiceRecognitionActive = false.obs; - final RxList waveData = [].obs; + final RxDouble rms = 0.0.obs; + + final RecorderController waveformController = RecorderController(); // Transcription content final RxString intermediateContent = ''.obs; @@ -61,46 +64,23 @@ class MeetingRecordController extends GetxController // 移除了未使用的变量和TabController StreamSubscription? _audioDataSubscription; - // 简化的音波数据管理 - final int frameIntervalMs = 300; // 300ms间隔,更稳定的帧率 - int maxVisibleBars = 60; // 最大可见波形条数量 - Timer? _audioFrameTimer; // 音频帧定时器 - double _currentFrameAmplitude = 0.0; // 当前帧累积振幅 - int _currentFrameSamples = 0; // 当前帧样本数量 - - // 性能优化:音频处理采样控制 - int _audioProcessCounter = 0; - static const int _audioProcessInterval = 1; // 每1个音频包处理1次 - - // 波形流动动画控制器 - late AnimationController waveformAnimationController; - late Animation waveformAnimation; - - // 数据更新控制器 - 统一管理数据更新和动画 - late AnimationController _dataUpdateController; - late Animation _dataUpdateAnimation; - // 设置最大可见波形条数量(根据屏幕宽度) - void setMaxVisibleBars(double screenWidth) { - const barWidth = 3.0; // 波形条宽度 - const barSpacing = 1.0; // 波形条间距 - maxVisibleBars = (screenWidth / (barWidth + barSpacing)).floor(); - } + double _smoothedRms = 0.0; + double _latestRms = 0.0; + Timer? _waveformTimer; + static const Duration _waveformTick = Duration(milliseconds: 40); + static const int _maxWaveSamples = 1200; @override void onInit() { super.onInit(); _initializeComponents(); _setupFromArguments(); - _initializeWaveformAnimation(); //generateWaveData(); } @override void onClose() { _cleanupResources(); - // 清理两个AnimationController - waveformAnimationController.dispose(); - _dataUpdateController.dispose(); super.onClose(); } @@ -134,56 +114,6 @@ class MeetingRecordController extends GetxController titleFocusNode.addListener(_handleTitleFocusChange); } - /// 初始化波形流动动画和数据更新控制器 - void _initializeWaveformAnimation() { - // 1. 数据更新控制器 - 控制数据提交频率 - _dataUpdateController = AnimationController( - duration: Duration(milliseconds: frameIntervalMs), // 250ms 一次数据更新 - vsync: this, - ); - - _dataUpdateAnimation = - Tween(begin: 0.0, end: 1.0).animate(_dataUpdateController); - - // 监听数据更新动画,每个周期结束时提交数据 - _dataUpdateAnimation.addListener(() { - if (!isRecording.value) return; - - // 每个动画周期结束时,才提交一次数据 - if (_dataUpdateController.value >= 1.0) { - _commitAudioFrame(); - - // 重置动画,继续循环 - _dataUpdateController.reset(); - if (isRecording.value) { - _dataUpdateController.forward(); - } - } - }); - - // 2. 波形流动动画控制器 - 控制视觉流动效果 - waveformAnimationController = AnimationController( - duration: Duration(milliseconds: frameIntervalMs), // 与数据更新同步 - vsync: this, - ); - - waveformAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: waveformAnimationController, - curve: Curves.linear, // 线性动画确保匀速流动 - )); - - // 监听动画完成,实现连续循环 - waveformAnimationController.addStatusListener((status) { - if (status == AnimationStatus.completed && isRecording.value) { - waveformAnimationController.reset(); - waveformAnimationController.forward(); - } - }); - } - /// Sets up initial state from navigation arguments void _setupFromArguments() { final Map args = Get.arguments ?? {}; @@ -207,8 +137,7 @@ class MeetingRecordController extends GetxController /// Cleans up resources when controller is disposed void _cleanupResources() { _timer?.cancel(); - _audioFrameTimer?.cancel(); // 清理音频帧定时器 - // AnimationController在onClose中统一清理 + _stopWaveformTimer(); _recognitionSubscription?.cancel(); _recognitionSubscription = null; _audioDataSubscription?.cancel(); @@ -220,6 +149,11 @@ class MeetingRecordController extends GetxController } _asrService.stopRecord(true); isRecording.value = false; + rms.value = 0.0; + _smoothedRms = 0.0; + _latestRms = 0.0; + waveformController.reset(); + waveformController.dispose(); } /// Handles focus changes in title text field @@ -321,8 +255,6 @@ class MeetingRecordController extends GetxController // 1. 实时音频可视化 // 2. 音频质量检测 // 3. 发送到其他服务 - Logger.d("AudioData", "接收到音频数据: ${audioData.length} 字节"); - // 处理音频数据的逻辑 _processAudioData(audioData); }); @@ -333,12 +265,10 @@ class MeetingRecordController extends GetxController newName.value = fullFileName; titleEditingController.text = fullFileName; - // 启动波形动画和音频帧定时器 - _startWaveformAnimation(); - _startAudioFrameTimer(); - // Start hardware components _startHardwareServices(); + _resetWaveform(); + _startWaveformTimer(); startTimer(); } @@ -362,55 +292,59 @@ class MeetingRecordController extends GetxController /// Pauses current recording session Future _pauseRecording() async { await _asrService.pauseRecord(); - _stopWaveformAnimation(); // 暂停时停止动画 - _stopAudioFrameTimer(); // 停止音频帧定时器 + _stopWaveformTimer(); + rms.value = 0.0; pauseTimer(); } /// Resumes paused recording session Future _resumeRecording() async { await _asrService.resumeRecord(); - _startWaveformAnimation(); // 恢复时启动动画 - _startAudioFrameTimer(); // 重启音频帧定时器 + _startWaveformTimer(); startTimer(); } - /// 处理原始Uint8List音频数据 - 只累积,不更新UI,性能优化 + /// 处理原始Uint8List音频数据 void _processAudioData(Uint8List audioData) { if (!isRecording.value) return; - - // 性能优化:不是每个音频包都处理,减少CPU占用 - _audioProcessCounter++; - if (_audioProcessCounter % _audioProcessInterval != 0) { - return; // 跳过这次处理 + final next = _calculateAmplitude(audioData); + if (_smoothedRms == 0.0) { + _smoothedRms = next; + } else { + _smoothedRms = _smoothedRms * 0.8 + next * 0.2; } + rms.value = _smoothedRms; + _latestRms = _smoothedRms; + } - // 将音频数据转换为振幅值并累积到当前帧 - final amplitude = _calculateAmplitude(audioData); - _currentFrameAmplitude += amplitude; - _currentFrameSamples++; + void _resetWaveform() { + waveformController.reset(); + waveformController.updateFrequency = _waveformTick; } - /// 提交音频帧数据到波形显示 - void _commitAudioFrame() { - // 计算当前帧的平均振幅 - double frameAmplitude = 0.0; - if (_currentFrameSamples > 0) { - frameAmplitude = _currentFrameAmplitude / _currentFrameSamples; - } + void _startWaveformTimer() { + _waveformTimer?.cancel(); + _waveformTimer = Timer.periodic(_waveformTick, (_) { + if (!isRecording.value) return; + if (timerStatus.value != TimerStatus.running) return; + _appendWaveSample(_latestRms); + }); + } - // 转换为显示数据 - final newAmplitude = frameAmplitude * 800.0; + void _stopWaveformTimer() { + _waveformTimer?.cancel(); + _waveformTimer = null; + } - // 更新波形数据 - if (waveData.length >= maxVisibleBars) { - waveData.removeAt(0); + void _appendWaveSample(double value) { + final normalized = value.clamp(0.0, 1.0); + final eased = sqrt(normalized); + final data = waveformController.waveData; + data.add(eased); + if (data.length > _maxWaveSamples) { + data.removeRange(0, data.length - _maxWaveSamples); } - waveData.add(newAmplitude); - - // 重置当前帧数据 - _currentFrameAmplitude = 0.0; - _currentFrameSamples = 0; + waveformController.notifyListeners(); } /// 计算音频数据的振幅 @@ -436,48 +370,10 @@ class MeetingRecordController extends GetxController return (rms / 32768.0).clamp(0.0, 1.0); } - /// 启动数据更新控制器 - 替代Timer - void _startAudioFrameTimer() { - _audioFrameTimer?.cancel(); // 清理旧的Timer(如果有) - _audioFrameTimer = null; - - // 启动数据更新控制器 - _dataUpdateController.forward(); - } - - /// 停止数据更新控制器 - void _stopAudioFrameTimer() { - _audioFrameTimer?.cancel(); - _audioFrameTimer = null; - - // 停止数据更新控制器 - _dataUpdateController.stop(); - _dataUpdateController.reset(); - - // 重置当前帧数据和计数器 - _currentFrameAmplitude = 0.0; - _currentFrameSamples = 0; - _audioProcessCounter = 0; // 重置音频处理计数器 - } - - /// 启动波形流动动画 - void _startWaveformAnimation() { - if (isRecording.value && !waveformAnimationController.isAnimating) { - waveformAnimationController.forward(); - } - } - - /// 停止波形动画 - void _stopWaveformAnimation() { - waveformAnimationController.stop(); - waveformAnimationController.reset(); - } - /// Saves recording and cleans up resources Future saveRecording() async { resetTimer(); - _stopWaveformAnimation(); // 停止波形动画 - _stopAudioFrameTimer(); // 停止音频帧定时器 + _stopWaveformTimer(); if (audioType.value != 0) { _bleManager.closeCodec(); } @@ -506,6 +402,10 @@ class MeetingRecordController extends GetxController _resetToDefaultState(); isRecording.value = false; + rms.value = 0.0; + _smoothedRms = 0.0; + _latestRms = 0.0; + _resetWaveform(); } /// Resets controller to initial state @@ -571,15 +471,4 @@ class MeetingRecordController extends GetxController seconds.value = 0; timerStatus.value = TimerStatus.stopped; } - - /// UI HELPERS SECTION /// - - /// Generates mock waveform data for visualization - 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; - }); - } } diff --git a/lib/modules/meeting/views/meeting_record_view.dart b/lib/modules/meeting/views/meeting_record_view.dart index d0128b749..8456c1fd6 100644 --- a/lib/modules/meeting/views/meeting_record_view.dart +++ b/lib/modules/meeting/views/meeting_record_view.dart @@ -1,5 +1,5 @@ -import 'dart:async'; -import 'dart:math' as math; +import 'dart:ui' as ui; +import 'package:audio_waveforms/audio_waveforms.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; @@ -108,16 +108,21 @@ class MeetingRecordView extends GetView { // 使用LayoutBuilder获取准确的屏幕宽度 final screenWidth = constraints.maxWidth; - // 初始化最大可见波形条数量 - controller.setMaxVisibleBars(screenWidth); - return Column( children: [ - SizedBox(height: 24.h), + SizedBox(height: 16.h), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Align( + alignment: Alignment.centerLeft, + child: _buildRecordTypeBadge(isDarkMode), + ), + ), + SizedBox(height: 12.h), // 实时流动波形显示区域 Container( height: 200.h, - margin: EdgeInsets.symmetric(horizontal: 16.w), + margin: EdgeInsets.zero, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -155,12 +160,49 @@ class MeetingRecordView extends GetView { borderRadius: BorderRadius.circular(12.r), child: Stack( children: [ - // 实时流动波形绘制组件 - _WaveformWidget( - controller: controller, - isDarkMode: isDarkMode, - size: Size(screenWidth - 32.w, 200.h), - ), + Obx(() { + final size = Size(screenWidth, 200.h); + final waveColor = + isDarkMode ? Colors.white : Colors.black87; + final shader = ui.Gradient.linear( + const Offset(0, 0), + Offset(size.width, 0), + [ + waveColor.withValues(alpha: 0.0), + waveColor.withValues(alpha: 0.25), + waveColor.withValues(alpha: 0.6), + waveColor.withValues(alpha: 0.95), + ], + const [0.0, 0.55, 0.8, 1.0], + ); + + return Center( + child: AudioWaveforms( + size: size, + recorderController: controller.waveformController, + backgroundColor: Colors.transparent, + waveStyle: WaveStyle( + waveThickness: 1.w, + spacing: 2.w, + waveCap: StrokeCap.round, + showMiddleLine: true, + middleLineColor: Colors.redAccent.withValues( + alpha: controller.timerStatus.value == + TimerStatus.running + ? 0.9 + : 0.0, + ), + middleLineThickness: 1.w, + showTop: true, + showBottom: true, + extendWaveform: true, + // gradient: shader, + waveColor: waveColor, + scaleFactor: 90, + ), + ), + ); + }), // 录音状态指示器 Obx(() { @@ -217,7 +259,7 @@ class MeetingRecordView extends GetView { fontWeight: FontWeight.normal, color: isDarkMode ? Colors.white : Colors.black87, fontFeatures: const [ - FontFeature.tabularFigures() + ui.FontFeature.tabularFigures() ], // 使用等宽数字,避免跳动 ), )), @@ -250,6 +292,7 @@ class MeetingRecordView extends GetView { tooltip: controller.isMarked.value ? 'unmarkFavorite'.tr : 'markFavorite'.tr, + isDarkMode: isDarkMode, ), // 录音按钮 _buildActionButton( @@ -265,6 +308,7 @@ class MeetingRecordView extends GetView { tooltip: controller.timerStatus.value == TimerStatus.running ? 'pauseRecording'.tr : 'startRecording'.tr, + isDarkMode: isDarkMode, ), // 完成按钮 _buildActionButton( @@ -276,6 +320,7 @@ class MeetingRecordView extends GetView { ), radius: 32.w, tooltip: 'finishRecording'.tr, + isDarkMode: isDarkMode, ), ], ), @@ -288,11 +333,14 @@ class MeetingRecordView extends GetView { required Widget child, required double radius, required String tooltip, + required bool isDarkMode, }) { + final Color bgColor = isDarkMode ? const Color(0xFF1E1E1E) : Colors.white; + return Tooltip( message: tooltip, child: Material( - color: Colors.white, + color: bgColor, shape: const CircleBorder(), elevation: 4, child: InkWell( @@ -300,7 +348,7 @@ class MeetingRecordView extends GetView { customBorder: const CircleBorder(), child: CircleAvatar( radius: radius, - backgroundColor: Colors.white, + backgroundColor: bgColor, child: child, ), ), @@ -308,6 +356,60 @@ class MeetingRecordView extends GetView { ); } + Widget _buildRecordTypeBadge(bool isDarkMode) { + return Obx(() { + String label; + IconData icon; + Color color; + + switch (controller.audioType.value) { + case 0: + label = 'liveRecording'.tr; + icon = Icons.mic_rounded; + color = Colors.red; + break; + case 1: + label = 'audioVideoRecording'.tr; + icon = Icons.videocam_rounded; + color = Colors.green; + break; + case 2: + default: + label = 'callRecording'.tr; + icon = Icons.phone_in_talk_rounded; + color = Colors.orange; + break; + } + + return Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.h), + decoration: BoxDecoration( + color: color.withValues(alpha: isDarkMode ? 0.25 : 0.12), + borderRadius: BorderRadius.circular(999.r), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 18.sp, + color: isDarkMode ? Colors.white : color, + ), + SizedBox(width: 6.w), + Text( + label, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w500, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + ], + ), + ); + }); + } + // 底部编辑弹窗 void _bottomSheet(BuildContext context, bool isDarkMode) { Get.bottomSheet( @@ -450,218 +552,3 @@ class MeetingRecordView extends GetView { ); } } - -/// 简化的实时流动波形绘制器 -class RealTimeWaveformPainter extends CustomPainter { - final List waveData; - final bool isDarkMode; - final bool isRecording; - final double animationValue; // 动画值,用于流动效果 - - RealTimeWaveformPainter({ - required this.waveData, - required this.isDarkMode, - required this.isRecording, - this.animationValue = 0.0, - }); - - @override - void paint(Canvas canvas, Size size) { - final centerY = size.height / 2; - - // 绘制中央基准线 - _drawCenterLine(canvas, size, centerY); - - // 只在录音状态下且有波形数据时才绘制波形 - if (isRecording && waveData.isNotEmpty) { - _drawWaveform(canvas, size, centerY); - } - } - - /// 绘制中央基准线 - void _drawCenterLine(Canvas canvas, Size size, double centerY) { - final linePaint = Paint() - ..color = - (isDarkMode ? Colors.white : Colors.black).withValues(alpha: 0.15) - ..strokeWidth = 1.0; - - canvas.drawLine( - Offset(0, centerY), - Offset(size.width, centerY), - linePaint, - ); - } - - /// 绘制简化的流动波形 - void _drawWaveform(Canvas canvas, Size size, double centerY) { - const double barWidth = 3.0; // 波形条宽度 - const double barSpacing = 1.0; // 波形条间距 - const double totalBarWidth = barWidth + barSpacing; - - // 计算可显示的波形条数量 - final maxBars = (size.width / totalBarWidth).floor(); - final visibleBars = waveData.length > maxBars ? maxBars : waveData.length; - - // 计算流动偏移量(从右向左移动) - final flowOffset = animationValue * totalBarWidth; - - // 从右到左绘制波形条 - for (int i = 0; i < visibleBars; i++) { - final dataIndex = waveData.length - 1 - i; - final amplitude = waveData[dataIndex]; - - // 计算波形条的x位置(从右侧开始,向左排列) - final x = size.width - (i + 1) * totalBarWidth + flowOffset; - - // 跳过屏幕外的波形条 - if (x < -barWidth || x > size.width) continue; - - _drawWaveBar( - canvas, x, centerY, amplitude, i.toDouble(), visibleBars.toDouble()); - } - } - - /// 绘制单个波形条 - void _drawWaveBar(Canvas canvas, double x, double centerY, double amplitude, - double index, double totalBars) { - const double barWidth = 2.0; - const double maxHeight = 200.0; - const double minHeight = 4.0; // 最小基础高度 - const double containerPadding = 10.0; // 容器内边距,防止超出边界 - - // 计算可用的最大高度(考虑容器边界) - final availableHeight = (centerY - containerPadding) * 2; // 上下对称 - final effectiveMaxHeight = math.min(maxHeight, availableHeight); - - // 优化波形条高度计算:确保有基础高度 + 振幅变化 - final normalizedAmplitude = (amplitude / 100.0).clamp(0.0, 1.0); - final dynamicHeight = - normalizedAmplitude * (effectiveMaxHeight - minHeight); - final barHeight = minHeight + dynamicHeight; - - // 计算透明度(越新的波形条越不透明) - //final opacity = (1.0 - (index / totalBars) * 0.6).clamp(0.3, 1.0); - - // 创建波形条矩形 - final rect = RRect.fromRectAndRadius( - Rect.fromCenter( - center: Offset(x + barWidth / 2, centerY), - width: barWidth, - height: barHeight, // 使用优化后的高度 - ), - const Radius.circular(1.5), - ); - - // 创建渐变颜色 - final Color baseColor = isDarkMode - ? Colors.grey[500]! // 深灰色 - : Colors.grey[600]!; // 更深灰色 - - final paint = Paint() - ..color = baseColor - ..style = PaintingStyle.fill; - - canvas.drawRRect(rect, paint); - } - - @override - bool shouldRepaint(covariant RealTimeWaveformPainter oldDelegate) { - // 只有在以下情况才重绘 - if (oldDelegate.isDarkMode != isDarkMode || - oldDelegate.isRecording != isRecording) { - return true; - } - - // 数据长度变化才重绘 - if (oldDelegate.waveData.length != waveData.length) { - return true; - } - - // 数据内容变化(比较最后一个值) - if (waveData.isNotEmpty && - oldDelegate.waveData.isNotEmpty && - (waveData.last - oldDelegate.waveData.last).abs() > 0.1) { - return true; - } - - // 动画值每 0.1 变化才重绘(避免每帧都重绘) - return (animationValue - oldDelegate.animationValue).abs() > 0.1; - } -} - -/// 优化的波形Widget,避免双重响应式更新 -class _WaveformWidget extends StatefulWidget { - final MeetingRecordController controller; - final bool isDarkMode; - final Size size; - - const _WaveformWidget({ - required this.controller, - required this.isDarkMode, - required this.size, - }); - - @override - State<_WaveformWidget> createState() => _WaveformWidgetState(); -} - -class _WaveformWidgetState extends State<_WaveformWidget> { - late List _waveData; - late bool _isRecording; - StreamSubscription? _waveDataSubscription; - StreamSubscription? _isRecordingSubscription; - - @override - void initState() { - super.initState(); - _waveData = List.from(widget.controller.waveData); - _isRecording = widget.controller.isRecording.value; - - // 监听 waveData 变化 - _waveDataSubscription = widget.controller.waveData.listen((data) { - if (mounted) { - setState(() { - _waveData = List.from(data); - }); - } - }); - - // 监听 isRecording 变化 - _isRecordingSubscription = widget.controller.isRecording.listen((value) { - if (mounted) { - setState(() { - _isRecording = value; - }); - } - }); - } - - @override - void dispose() { - _waveDataSubscription?.cancel(); - _isRecordingSubscription?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return RepaintBoundary( - child: AnimatedBuilder( - animation: widget.controller.waveformAnimation, - builder: (context, child) { - return CustomPaint( - size: widget.size, - painter: RealTimeWaveformPainter( - waveData: _waveData, - isDarkMode: widget.isDarkMode, - isRecording: _isRecording, - animationValue: widget.controller.waveformAnimation.value, - ), - isComplex: true, // 告诉 Flutter 这个绘制较复杂,可缓存 - willChange: false, // 数据不会频繁变化(我们已优化) - ); - }, - ), - ); - } -}