diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e873c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# macOS metadata files +._* +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index e4cc120..43c1441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.5] - 2026-01-17 + +### ⚡ Improved +- **初始化不卡 UI**:新增 `useRecognizerIsolate` 选项,将 sherpa_onnx recognizer 初始化/解码放到后台 isolate,避免初始化期间 UI 卡顿、进度动画不刷新 +- **RecordingButton 体验优化**:首次点击懒加载初始化时先让出一帧,确保加载动画能立即渲染 + +### 🐛 Fixed +- 修复 `RecordingButton` 中防抖延迟导致的测试 pending timers 问题(改为可取消 Timer 并在 dispose 清理) +- 修复测试环境下部分平台 API 不可用导致的失败(增加必要的 mock/容错) + ## [1.0.4] - 2025-01-23 ### 🎉 Added - 多实例会话管理支持 diff --git a/example/lib/main.dart b/example/lib/main.dart index 73824c2..a03278f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:yx_asr/yx_asr.dart'; void main() { @@ -82,17 +81,19 @@ class _SpeechRecognitionPageState extends State { ); // 初始化服务 - 使用2023年模型 - final success = - await _speechService.initializeWithDefaultModel(ModelConfig.zh2023); + final success = await _speechService.initializeWithDefaultModel( + ModelConfig.zh2023, + true, + ); if (success) { // 监听识别结果 _speechService.onResult.listen((result) { - print('📱 [Example] 接收到识别结果: "${result.recognizedWords}"'); + debugPrint('📱 [Example] 接收到识别结果: "${result.recognizedWords}"'); setState(() { // 更新当前识别的文本(实时显示) if (result.recognizedWords.isNotEmpty) { - print('📱 [Example] 实时识别: ${result.recognizedWords}'); + debugPrint('📱 [Example] 实时识别: ${result.recognizedWords}'); _currentText = result.recognizedWords; } }); @@ -402,12 +403,12 @@ class _SpeechRecognitionPageState extends State { speechService: _speechService, size: 80, onResult: (result) { - print( + debugPrint( '📱 [Example] RecordingButton 接收到识别结果: "${result.recognizedWords}"'); setState(() { if (result.recognizedWords.isNotEmpty) { // 所有结果都实时更新到输入框(移除最终结果的特殊处理) - print('📱 [Example] 实时识别,更新输入框: ${result.recognizedWords}'); + debugPrint('📱 [Example] 实时识别,更新输入框: ${result.recognizedWords}'); _currentText = result.recognizedWords; // 实时更新输入框内容 = 基础文本 + 当前识别文本 diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 2a6fa06..6b2d17d 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,30 +1,8 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:yx_asr_example/main.dart'; - void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // // Build our app and trigger a frame. - // await tester.pumpWidget(const MyApp()); - - // // Verify that our counter starts at 0. - // expect(find.text('0'), findsOneWidget); - // expect(find.text('1'), findsNothing); - - // // Tap the '+' icon and trigger a frame. - // await tester.tap(find.byIcon(Icons.add)); - // await tester.pump(); - - // // Verify that our counter has incremented. - // expect(find.text('0'), findsNothing); - // expect(find.text('1'), findsOneWidget); + testWidgets('example 占位测试', (WidgetTester tester) async { + // 示例工程主要用于手动运行/演示,这里保留一个最小可执行测试用于 CI 冒烟 + expect(true, isTrue); }); } diff --git a/lib/src/interfaces/speech_recognition_service.dart b/lib/src/interfaces/speech_recognition_service.dart index 89f9ef3..086d733 100644 --- a/lib/src/interfaces/speech_recognition_service.dart +++ b/lib/src/interfaces/speech_recognition_service.dart @@ -3,7 +3,7 @@ import '../models/speech_recognition_result.dart'; import '../models/speech_recognition_error.dart'; /// 语音识别服务的抽象接口 -/// +/// /// 定义了语音识别服务必须实现的核心功能, /// 支持不同的语音识别实现(如 sherpa_onnx、原生API等) abstract class SpeechRecognitionService { @@ -17,12 +17,12 @@ abstract class SpeechRecognitionService { Future hasPermission(); /// 初始化语音识别服务 - /// + /// /// [config] - 初始化配置参数 Future initialize(Map config); /// 开始语音识别 - /// + /// /// [partialResults] - 是否返回部分结果 Future startListening({bool partialResults = true}); @@ -52,16 +52,16 @@ abstract class SpeechRecognitionService { class SpeechRecognitionConfig { /// 模型路径(用于离线识别) final String? modelPath; - + /// 语言区域标识 final String? localeId; - + /// 采样率 final int sampleRate; - + /// 是否使用设备端识别 final bool onDevice; - + /// 自定义配置参数 final Map customConfig; diff --git a/lib/src/widgets/recording_button.dart b/lib/src/widgets/recording_button.dart index 205f99d..27918c5 100644 --- a/lib/src/widgets/recording_button.dart +++ b/lib/src/widgets/recording_button.dart @@ -85,18 +85,29 @@ class RecordingButton extends StatefulWidget { State createState() => _RecordingButtonState(); } -class _RecordingButtonState extends State with TickerProviderStateMixin { - late SpeechRecognitionService _speechService; +class _RecordingButtonState extends State + with TickerProviderStateMixin { + // 🔧 改为 nullable,避免 dispose 时访问未初始化的 late 变量 + SpeechRecognitionService? _speechService; late String _sessionId; // 🆕 会话ID bool _isListening = false; bool _isInitialized = false; + bool _isInitializing = false; // 🆕 防止重复初始化 bool _isProcessing = false; // 防抖标志 late AnimationController _animationController; late Animation _scaleAnimation; + // 🆕 脉冲动画控制器 - 用于首次加载时的视觉反馈 + late AnimationController _pulseController; + late Animation _pulseAnimation; + // 🆕 会话流订阅 StreamSubscription? _resultSubscription; StreamSubscription? _statusSubscription; + StreamSubscription? _errorSubscription; + + // 防抖计时器:用于延迟复位 _isProcessing,避免快速连点 + Timer? _processingResetTimer; @override void initState() { @@ -107,7 +118,8 @@ class _RecordingButtonState extends State with TickerProviderSt 'btn_${widget.key?.toString() ?? DateTime.now().millisecondsSinceEpoch}'; _setupAnimation(); - _initializeService(); + // 🔧 移除同步初始化,改为懒加载 - 解决页面进入时的卡顿问题 + // _initializeService(); // 不再在 initState 中调用 } void _setupAnimation() { @@ -118,17 +130,44 @@ class _RecordingButtonState extends State with TickerProviderSt _scaleAnimation = Tween( begin: 1.0, end: 1.1, - ).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut)); + ).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut)); + + // 🆕 脉冲动画 - 首次加载时持续脉冲 + _pulseController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + _pulseAnimation = Tween( + begin: 0.95, + end: 1.05, + ).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut)); } Future _initializeService() async { + // 如果之前初始化过(或初始化失败重试),先清理旧订阅,避免重复回调/泄漏 + await _resultSubscription?.cancel(); + await _statusSubscription?.cancel(); + await _errorSubscription?.cancel(); + _resultSubscription = null; + _statusSubscription = null; + _errorSubscription = null; + + // 如果已有服务且支持会话,先注销旧会话(防止残留) + final oldService = _speechService; + if (oldService is YxAsrService) { + oldService.unregisterSession(_sessionId); + } + // 使用注入的服务或创建默认服务 _speechService = widget.speechService ?? YxAsrService(); + final service = _speechService!; // 后续使用非空引用 try { // 检查权限 - if (!await _speechService.hasPermission()) { - final granted = await _speechService.requestPermission(); + if (!await service.hasPermission()) { + final granted = await service.requestPermission(); if (!granted) { widget.onError?.call( SpeechRecognitionError( @@ -137,34 +176,44 @@ class _RecordingButtonState extends State with TickerProviderSt errorCode: null, ), ); + if (mounted) { + setState(() { + _isInitialized = false; + }); + } return; } } // 初始化服务 - final success = await _speechService.initialize({'modelPath': widget.modelPath}); + final success = await service.initialize({ + 'modelPath': widget.modelPath, + // ✅ 关键:把 sherpa_onnx recognizer 初始化/解码放到后台 isolate,避免 UI 线程卡住导致进度圈不转 + 'useRecognizerIsolate': true, + }); if (success) { // 🆕 如果服务支持会话管理,使用会话流 - if (_speechService is YxAsrService) { - final service = _speechService as YxAsrService; - + if (service is YxAsrService) { // 注册会话 service.registerSession(sessionId: _sessionId); debugPrint('🎤 [RecordingButton] 注册会话: $_sessionId'); // 🆕 监听该会话的结果流 - _resultSubscription = service.getResultStreamForSession(_sessionId).listen((result) { - debugPrint('🎤 [RecordingButton $_sessionId] 收到结果: ${result.recognizedWords}'); + _resultSubscription = + service.getResultStreamForSession(_sessionId).listen((result) { + debugPrint( + '🎤 [RecordingButton $_sessionId] 收到结果: ${result.recognizedWords}'); widget.onResult?.call(result); }); // 🆕 监听该会话的状态流 - _statusSubscription = service.getStatusStreamForSession(_sessionId).listen((isListening) { + _statusSubscription = service + .getStatusStreamForSession(_sessionId) + .listen((isListening) { debugPrint('🎤 [RecordingButton $_sessionId] 状态变化: $isListening'); - setState(() { - _isListening = isListening; - }); + if (!mounted) return; + setState(() => _isListening = isListening); if (isListening) { _animationController.forward(); } else { @@ -174,14 +223,15 @@ class _RecordingButtonState extends State with TickerProviderSt }); // 监听错误流(全局) - service.onError.listen(widget.onError ?? (_) {}); + _errorSubscription = service.onError.listen(widget.onError ?? (_) {}); } else { // 向后兼容:使用全局流 - _resultSubscription = _speechService.onResult.listen(widget.onResult ?? (_) {}); - _statusSubscription = _speechService.onListeningStatusChanged.listen((isListening) { - setState(() { - _isListening = isListening; - }); + _resultSubscription = + service.onResult.listen(widget.onResult ?? (_) {}); + _statusSubscription = + service.onListeningStatusChanged.listen((isListening) { + if (!mounted) return; + setState(() => _isListening = isListening); if (isListening) { _animationController.forward(); } else { @@ -189,13 +239,22 @@ class _RecordingButtonState extends State with TickerProviderSt } widget.onListeningStatusChanged?.call(isListening); }); - _speechService.onError.listen(widget.onError ?? (_) {}); + _errorSubscription = service.onError.listen(widget.onError ?? (_) {}); } + } else { + // 初始化返回 false 时也给出明确错误(否则用户只看到无反应) + widget.onError?.call( + SpeechRecognitionError( + errorType: SpeechRecognitionErrorType.service, + errorMsg: '初始化失败: initialize() 返回 false', + errorCode: null, + ), + ); } - setState(() { - _isInitialized = success; - }); + if (mounted) { + setState(() => _isInitialized = success); + } } catch (e) { widget.onError?.call( SpeechRecognitionError( @@ -204,21 +263,70 @@ class _RecordingButtonState extends State with TickerProviderSt errorCode: null, ), ); + if (mounted) { + setState(() { + _isInitialized = false; + }); + } } } Future _toggleRecording() async { - // 防抖检查:如果正在处理中或未初始化或被禁用,则直接返回 - if (_isProcessing || !_isInitialized || !widget.enabled) return; + // 防抖检查:如果正在处理中或被禁用,则直接返回 + if (_isProcessing || !widget.enabled) return; + + // 🔧 懒加载初始化:首次点击时进行初始化 + if (!_isInitialized && !_isInitializing) { + _isInitializing = true; + if (mounted) { + setState(() { + _isProcessing = true; // 显示加载状态 + }); + } + + // 🆕 启动脉冲动画 - 提供视觉反馈 + _pulseController.repeat(reverse: true); + + // 关键:先让出一次事件循环/渲染帧,确保进度圈能开始转(避免后续初始化同步重任务卡住首帧) + await Future.delayed(Duration.zero); + + debugPrint('🔧 [RecordingButton] 懒加载初始化开始...'); + await _initializeService(); + if (!mounted) return; + debugPrint('🔧 [RecordingButton] 懒加载初始化完成, 状态: $_isInitialized'); + + // 🆕 停止脉冲动画 + _pulseController.stop(); + _pulseController.reset(); + + _isInitializing = false; + + if (!_isInitialized) { + // 初始化失败,重置状态并返回 + setState(() { + _isProcessing = false; + }); + return; + } + } + + // 如果正在初始化中(另一个点击触发的),或初始化失败,直接返回 + if (_isInitializing || !_isInitialized) return; // 设置处理中标志,防止重复点击 - setState(() { - _isProcessing = true; - }); + if (mounted) { + setState(() { + _isProcessing = true; + }); + } try { - // 触觉反馈 - await HapticFeedback.lightImpact(); + // 触觉反馈:不应阻塞/影响核心录音流程(测试环境/部分平台可能无实现或耗时) + try { + unawaited(HapticFeedback.lightImpact()); + } catch (_) { + // ignore + } // 播放点击动画 _animationController.forward().then((_) { @@ -226,19 +334,20 @@ class _RecordingButtonState extends State with TickerProviderSt }); // 🆕 使用会话方法(如果服务支持) - if (_speechService is YxAsrService) { - final service = _speechService as YxAsrService; + final service = _speechService!; // 此处已通过 _isInitialized 检查,保证非空 + if (service is YxAsrService) { if (_isListening) { await service.stopListeningForSession(_sessionId); } else { - await service.startListeningForSession(_sessionId, partialResults: widget.partialResults); + await service.startListeningForSession(_sessionId, + partialResults: widget.partialResults); } } else { // 向后兼容:使用全局方法 if (_isListening) { - await _speechService.stopListening(); + await service.stopListening(); } else { - await _speechService.startListening(partialResults: widget.partialResults); + await service.startListening(partialResults: widget.partialResults); } } } catch (e) { @@ -250,8 +359,9 @@ class _RecordingButtonState extends State with TickerProviderSt ), ); } finally { - // 延迟重置处理标志,确保有足够的防抖时间 - Future.delayed(const Duration(milliseconds: 300), () { + // 延迟重置处理标志,确保有足够的防抖时间(可取消,避免 dispose 后残留计时器) + _processingResetTimer?.cancel(); + _processingResetTimer = Timer(const Duration(milliseconds: 300), () { if (mounted) { setState(() { _isProcessing = false; @@ -263,23 +373,29 @@ class _RecordingButtonState extends State with TickerProviderSt @override Widget build(BuildContext context) { + // 🔧 颜色逻辑优化:初始化中保持蓝色,不变灰 Color iconColor; - if (!widget.enabled || !_isInitialized) { + if (!widget.enabled) { iconColor = widget.disabledColor ?? Colors.grey[850]!; + } else if (_isInitializing) { + // 🆕 初始化中:保持蓝色,表示正在准备 + iconColor = widget.idleColor ?? const Color(0xFF2196F3); + } else if (_isListening) { + iconColor = widget.recordingColor ?? const Color(0xFFFF5252); } else { - iconColor = _isListening - ? (widget.recordingColor ?? const Color(0xFFFF5252)) - : (widget.idleColor ?? const Color(0xFF2196F3)); + iconColor = widget.idleColor ?? const Color(0xFF2196F3); } - Widget button = AnimatedBuilder( + Widget buttonContent = AnimatedBuilder( animation: _scaleAnimation, builder: (context, child) { return Container( width: widget.size, height: widget.size, decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.12), + // 兼容 flutter >= 3.0.0:避免使用较新的 Color.withValues,同时避开 withOpacity 的弃用提示 + // 0.12 * 255 ≈ 31 + color: iconColor.withAlpha(31), shape: BoxShape.circle, ), child: Material( @@ -313,7 +429,8 @@ class _RecordingButtonState extends State with TickerProviderSt height: widget.size, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), + valueColor: AlwaysStoppedAnimation( + _isInitializing ? iconColor : Colors.white), ), ), ), @@ -324,6 +441,16 @@ class _RecordingButtonState extends State with TickerProviderSt ); }, ); + // 🆕 初始化中时包裹脉冲动画 + Widget button; + if (_isInitializing) { + button = ScaleTransition( + scale: _pulseAnimation, + child: buttonContent, + ); + } else { + button = buttonContent; + } if (widget.tooltip != null) { button = Tooltip(message: widget.tooltip!, child: button); @@ -337,15 +464,18 @@ class _RecordingButtonState extends State with TickerProviderSt // 🆕 取消流订阅 _resultSubscription?.cancel(); _statusSubscription?.cancel(); + _errorSubscription?.cancel(); + _processingResetTimer?.cancel(); - // 🆕 注销会话 - if (_speechService is YxAsrService) { + // 🆕 注销会话 (添加空值检查,避免未初始化时崩溃) + if (_speechService != null && _speechService is YxAsrService) { final service = _speechService as YxAsrService; service.unregisterSession(_sessionId); debugPrint('🎤 [RecordingButton] 注销会话: $_sessionId'); } _animationController.dispose(); + _pulseController.dispose(); // 🆕 释放脉冲动画控制器 super.dispose(); } } diff --git a/lib/src/yx_asr_service.dart b/lib/src/yx_asr_service.dart index 8942c01..90509d6 100644 --- a/lib/src/yx_asr_service.dart +++ b/lib/src/yx_asr_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:isolate'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; @@ -158,7 +159,8 @@ class AdvancedRecognitionConfig { ); /// 高质量模式预设 - static const AdvancedRecognitionConfig highQuality = AdvancedRecognitionConfig( + static const AdvancedRecognitionConfig highQuality = + AdvancedRecognitionConfig( decodingMethod: DecodingMethod.modifiedBeamSearch, maxActivePaths: 8, enableEndpoint: true, @@ -179,13 +181,14 @@ class RecognitionResult { final double confidence; final DateTime timestamp; - RecognitionResult({required this.text, required this.confidence, required this.timestamp}); + RecognitionResult( + {required this.text, required this.confidence, required this.timestamp}); Map toJson() => { - 'text': text, - 'confidence': confidence, - 'timestamp': timestamp.toIso8601String(), - }; + 'text': text, + 'confidence': confidence, + 'timestamp': timestamp.toIso8601String(), + }; } /// 🆕 单个会话的状态管理 @@ -204,12 +207,13 @@ class _SessionState { final DateTime createdAt; _SessionState(this.sessionId) - : isActive = false, - lastRecognizedText = '', - createdAt = DateTime.now(); + : isActive = false, + lastRecognizedText = '', + createdAt = DateTime.now(); @override - String toString() => '_SessionState(id: $sessionId, active: $isActive, created: $createdAt)'; + String toString() => + '_SessionState(id: $sessionId, active: $isActive, created: $createdAt)'; } /// 基于 sherpa_onnx 的完整语音识别实现 @@ -244,19 +248,30 @@ class YxAsrService implements SpeechRecognitionService { SampleRate _sampleRate = SampleRate.standard; // 高级识别配置 - AdvancedRecognitionConfig _advancedConfig = AdvancedRecognitionConfig.balanced; + AdvancedRecognitionConfig _advancedConfig = + AdvancedRecognitionConfig.balanced; // 事件流控制器 final StreamController _resultController = StreamController.broadcast(); final StreamController _errorController = StreamController.broadcast(); - final StreamController _statusController = StreamController.broadcast(); + final StreamController _statusController = + StreamController.broadcast(); // 定时器和订阅 Timer? _recognitionTimer; StreamSubscription? _audioSubscription; + // ==================== 🆕 后台 recognizer isolate(可选) ==================== + bool _useRecognizerIsolate = false; + Isolate? _recognizerIsolate; + SendPort? _recognizerCommandPort; + StreamSubscription? _recognizerEventSubscription; + int _recognizerRequestId = 0; + final Map>> _recognizerPending = + >>{}; + // 🆕 会话管理相关字段 /// 所有已注册的会话状态 final Map _sessions = {}; @@ -265,7 +280,8 @@ class YxAsrService implements SpeechRecognitionService { String? _activeSessionId; /// 每个会话的结果流控制器 - final Map> _sessionResultControllers = {}; + final Map> + _sessionResultControllers = {}; /// 每个会话的状态流控制器 final Map> _sessionStatusControllers = {}; @@ -312,7 +328,8 @@ class YxAsrService implements SpeechRecognitionService { /// [speed] - 识别速度配置 void setRecognitionSpeed(RecognitionSpeed speed) { _recognitionSpeed = speed; - debugPrint('🔧 [YxAsr] 识别速度设置为: ${speed.description} (${speed.milliseconds}ms)'); + debugPrint( + '🔧 [YxAsr] 识别速度设置为: ${speed.description} (${speed.milliseconds}ms)'); } /// 获取当前识别速度 @@ -324,7 +341,8 @@ class YxAsrService implements SpeechRecognitionService { /// 注意:需要在初始化之前设置,或重新初始化后生效 void setSampleRate(SampleRate sampleRate) { _sampleRate = sampleRate; - debugPrint('🔧 [YxAsr] 采样率设置为: ${sampleRate.description} (${sampleRate.hz}Hz)'); + debugPrint( + '🔧 [YxAsr] 采样率设置为: ${sampleRate.description} (${sampleRate.hz}Hz)'); } /// 获取当前采样率 @@ -440,12 +458,15 @@ class YxAsrService implements SpeechRecognitionService { // 构建模型配置 print('🔍 [YxAsr] 构建识别器配置...'); debugPrint('🔧 [YxAsr] 使用采样率: ${_sampleRate.description}'); - debugPrint('🔧 [YxAsr] 解码方法: ${_advancedConfig.decodingMethod.description}'); + debugPrint( + '🔧 [YxAsr] 解码方法: ${_advancedConfig.decodingMethod.description}'); debugPrint('🔧 [YxAsr] 最大活跃路径: ${_advancedConfig.maxActivePaths}'); - debugPrint('🔧 [YxAsr] 端点检测: ${_advancedConfig.enableEndpoint ? "启用" : "禁用"}'); + debugPrint( + '🔧 [YxAsr] 端点检测: ${_advancedConfig.enableEndpoint ? "启用" : "禁用"}'); final config = OnlineRecognizerConfig( - feat: FeatureConfig(sampleRate: _sampleRate.hz, featureDim: _advancedConfig.featureDim), + feat: FeatureConfig( + sampleRate: _sampleRate.hz, featureDim: _advancedConfig.featureDim), model: OnlineModelConfig( transducer: OnlineTransducerModelConfig( encoder: encoderPath, @@ -468,18 +489,41 @@ class YxAsrService implements SpeechRecognitionService { blankPenalty: _advancedConfig.blankPenalty, ); - // 初始化 sherpa-onnx 绑定 - print('🔍 [YxAsr] 初始化 sherpa-onnx 绑定...'); - initBindings(); + if (_useRecognizerIsolate) { + // ✅ 关键:把 initBindings + OnlineRecognizer 创建搬到后台 isolate,避免卡住 UI 线程 + print('🔍 [YxAsr] 使用后台 isolate 初始化 recognizer(避免卡 UI)...'); + final ok = await _initializeRecognizerInIsolate( + encoderPath: encoderPath, + decoderPath: decoderPath, + joinerPath: joinerPath, + tokensPath: tokensPath, + sampleRate: _sampleRate.hz, + featureDim: _advancedConfig.featureDim, + decodingMethod: _advancedConfig.decodingMethod.value, + maxActivePaths: _advancedConfig.maxActivePaths, + enableEndpoint: _advancedConfig.enableEndpoint, + rule1MinTrailingSilence: _advancedConfig.rule1MinTrailingSilence, + rule2MinTrailingSilence: _advancedConfig.rule2MinTrailingSilence, + rule3MinUtteranceLength: _advancedConfig.rule3MinUtteranceLength, + blankPenalty: _advancedConfig.blankPenalty, + ); + if (!ok) { + throw Exception('后台 isolate 初始化 recognizer 失败'); + } + } else { + // 初始化 sherpa-onnx 绑定 + print('🔍 [YxAsr] 初始化 sherpa-onnx 绑定...'); + initBindings(); - // 创建在线识别器实例 - print('🔍 [YxAsr] 创建在线识别器实例...'); - try { - _recognizer = OnlineRecognizer(config); - print('🔍 [YxAsr] 在线识别器创建成功'); - } catch (e) { - print('❌ [YxAsr] 在线识别器创建失败: $e'); - throw e; + // 创建在线识别器实例 + print('🔍 [YxAsr] 创建在线识别器实例...'); + try { + _recognizer = OnlineRecognizer(config); + print('🔍 [YxAsr] 在线识别器创建成功'); + } catch (e) { + print('❌ [YxAsr] 在线识别器创建失败: $e'); + throw e; + } } _currentModelPath = modelPath; @@ -500,7 +544,7 @@ class YxAsrService implements SpeechRecognitionService { /// 采样率使用 setSampleRate() 设置的值 Future startListening({bool partialResults = true}) async { try { - if (!_isInitialized || _recognizer == null) { + if (!_isInitialized || (!_useRecognizerIsolate && _recognizer == null)) { throw Exception('识别器未初始化,请先调用 initializeWithModel()'); } @@ -518,21 +562,32 @@ class YxAsrService implements SpeechRecognitionService { _isStartingRecording = true; try { - // 先创建音频流用于识别 - _stream = _recognizer!.createStream(); - debugPrint('🔧 [YxAsr] 音频流已创建: ${_stream != null}'); + if (_useRecognizerIsolate) { + final started = await _recognizerIsolateStart( + partialResults: partialResults, + speedMs: _recognitionSpeed.milliseconds, + sampleRate: _sampleRate.hz, + ); + if (!started) { + throw Exception('后台 isolate 启动识别失败'); + } + } else { + // 先创建音频流用于识别 + _stream = _recognizer!.createStream(); + debugPrint('🔧 [YxAsr] 音频流已创建: ${_stream != null}'); - // 验证音频流创建是否成功 - if (_stream == null) { - throw Exception('音频流创建失败'); - } + // 验证音频流创建是否成功 + if (_stream == null) { + throw Exception('音频流创建失败'); + } - // 等待一小段时间确保流创建完成 - await Future.delayed(const Duration(milliseconds: 100)); + // 等待一小段时间确保流创建完成 + await Future.delayed(const Duration(milliseconds: 100)); - // 再次验证音频流状态 - if (_stream == null) { - throw Exception('音频流在等待后变为null'); + // 再次验证音频流状态 + if (_stream == null) { + throw Exception('音频流在等待后变为null'); + } } // 开始音频录制 @@ -542,8 +597,10 @@ class YxAsrService implements SpeechRecognitionService { _lastRecognizedText = ''; // 重置上次识别的文本 _statusController.add(true); - // 开始识别循环处理 - _startRecognitionLoop(partialResults); + if (!_useRecognizerIsolate) { + // 开始识别循环处理(主 isolate) + _startRecognitionLoop(partialResults); + } debugPrint('✅ [YxAsr] 录音启动成功'); } finally { @@ -592,17 +649,19 @@ class YxAsrService implements SpeechRecognitionService { _isListening = false; _statusController.add(false); - // 停止识别循环定时器 - _recognitionTimer?.cancel(); - _recognitionTimer = null; + // 停止识别循环(主 isolate / 后台 isolate) + if (_useRecognizerIsolate) { + await _recognizerIsolateStop(); + } else { + _recognitionTimer?.cancel(); + _recognitionTimer = null; + } // 停止音频录制 await _stopAudioRecording(); // 重置流,准备下次识别 - if (_stream != null) { - _stream = null; - } + _stream = null; debugPrint('✅ [YxAsr] 识别已停止'); } catch (e) { @@ -616,8 +675,12 @@ class YxAsrService implements SpeechRecognitionService { /// 取消语音识别 Future cancel() async { await _stopAudioRecording(); - _recognitionTimer?.cancel(); - _recognitionTimer = null; + if (_useRecognizerIsolate) { + await _recognizerIsolateStop(); + } else { + _recognitionTimer?.cancel(); + _recognitionTimer = null; + } _isListening = false; _statusController.add(false); } @@ -634,7 +697,8 @@ class YxAsrService implements SpeechRecognitionService { if (!_sessions.containsKey(id)) { _sessions[id] = _SessionState(id); - _sessionResultControllers[id] = StreamController.broadcast(); + _sessionResultControllers[id] = + StreamController.broadcast(); _sessionStatusControllers[id] = StreamController.broadcast(); debugPrint('📝 [YxAsr] 注册会话: $id (总计: ${_sessions.length} 个会话)'); } else { @@ -676,7 +740,8 @@ class YxAsrService implements SpeechRecognitionService { /// /// [sessionId] - 会话ID /// [partialResults] - 是否返回部分结果 - Future startListeningForSession(String sessionId, {bool partialResults = true}) async { + Future startListeningForSession(String sessionId, + {bool partialResults = true}) async { try { // 检查会话是否存在 if (!_sessions.containsKey(sessionId)) { @@ -684,7 +749,9 @@ class YxAsrService implements SpeechRecognitionService { } // 如果有其他会话正在录音,先停止 - if (_activeSessionId != null && _activeSessionId != sessionId && _isListening) { + if (_activeSessionId != null && + _activeSessionId != sessionId && + _isListening) { debugPrint('⚠️ [YxAsr] 停止其他会话 $_activeSessionId,切换到 $sessionId'); await stopListeningForSession(_activeSessionId!); } @@ -700,7 +767,8 @@ class YxAsrService implements SpeechRecognitionService { _sessions[sessionId]!.isActive = true; _sessions[sessionId]!.lastRecognizedText = ''; - debugPrint('🎤 [YxAsr] 会话 $sessionId 开始录音 (partialResults: $partialResults)'); + debugPrint( + '🎤 [YxAsr] 会话 $sessionId 开始录音 (partialResults: $partialResults)'); // 调用原有的 startListening 逻辑 await startListening(partialResults: partialResults); @@ -721,7 +789,8 @@ class YxAsrService implements SpeechRecognitionService { } // 通知错误 - _sendErrorForSession(sessionId, SpeechRecognitionErrorType.service, '开始录音失败: $e', null); + _sendErrorForSession( + sessionId, SpeechRecognitionErrorType.service, '开始录音失败: $e', null); rethrow; } } @@ -738,7 +807,8 @@ class YxAsrService implements SpeechRecognitionService { // 检查是否是活跃会话 if (_activeSessionId != sessionId) { - debugPrint('⚠️ [YxAsr] 会话 $sessionId 不是活跃会话(活跃: $_activeSessionId),忽略停止请求'); + debugPrint( + '⚠️ [YxAsr] 会话 $sessionId 不是活跃会话(活跃: $_activeSessionId),忽略停止请求'); return; } @@ -758,7 +828,8 @@ class YxAsrService implements SpeechRecognitionService { debugPrint('✅ [YxAsr] 会话 $sessionId 录音已停止'); } catch (e) { debugPrint('❌ [YxAsr] 会话 $sessionId 停止录音失败: $e'); - _sendErrorForSession(sessionId, SpeechRecognitionErrorType.service, '停止录音失败: $e', null); + _sendErrorForSession( + sessionId, SpeechRecognitionErrorType.service, '停止录音失败: $e', null); } } @@ -787,7 +858,8 @@ class YxAsrService implements SpeechRecognitionService { } /// 🆕 为特定会话发送识别结果 - void _sendResultForSession(String sessionId, {required String recognizedWords}) { + void _sendResultForSession(String sessionId, + {required String recognizedWords}) { if (_sessionResultControllers.containsKey(sessionId)) { // 检查是否与上次结果相同(去重) if (_sessions[sessionId]?.lastRecognizedText == recognizedWords) { @@ -857,6 +929,9 @@ class YxAsrService implements SpeechRecognitionService { return false; } + // 可选:把 recognizer 初始化/解码搬到后台 isolate,避免 UI 卡顿 + _useRecognizerIsolate = config['useRecognizerIsolate'] == true; + // 使用项目中的模型文件路径 final modelPath = config['modelPath'] as String? ?? 'assets/models'; print('🔍 [YxAsr] 使用模型路径: $modelPath'); @@ -865,11 +940,20 @@ class YxAsrService implements SpeechRecognitionService { } /// 便捷初始化方法 - Future initializeWithDefaultModel([String? modelPath]) async { + /// + /// [useRecognizerIsolate] 为 true 时会把 sherpa_onnx recognizer 初始化/解码放到后台 isolate, + /// 避免 UI 线程卡顿(推荐在 UI 场景开启)。 + Future initializeWithDefaultModel([ + String? modelPath, + bool useRecognizerIsolate = false, + ]) async { // 如果没有指定路径,使用项目中的模型文件 final defaultPath = modelPath ?? 'assets/models'; print('🔍 [YxAsr] initializeWithDefaultModel() 被调用,使用路径: $defaultPath'); - return await initialize({'modelPath': defaultPath}); + return await initialize({ + 'modelPath': defaultPath, + 'useRecognizerIsolate': useRecognizerIsolate, + }); } /// 开始音频录制 @@ -890,12 +974,29 @@ class YxAsrService implements SpeechRecognitionService { _audioSubscription = stream.listen( (audioData) { // 多重状态检查,确保所有条件都满足 - if (_stream != null && _recognizer != null && _isListening && !_isStartingRecording) { - // 将音频数据转换为 Float32List 格式 - final samples = _convertToFloat32(audioData); - debugPrint('🎵 [YxAsr] 接收音频数据: ${audioData.length} 字节, ${samples.length} 样本'); - // 发送音频数据到识别器进行处理 - _stream!.acceptWaveform(sampleRate: sampleRate, samples: samples); + if (_isListening && !_isStartingRecording) { + if (_useRecognizerIsolate) { + final port = _recognizerCommandPort; + if (port != null) { + port.send({ + 'type': 'audio', + 'sampleRate': sampleRate, + 'data': + TransferableTypedData.fromList([audioData]), + }); + } + return; + } + + if (_stream != null && _recognizer != null) { + // 将音频数据转换为 Float32List 格式 + final samples = _convertToFloat32(audioData); + debugPrint( + '🎵 [YxAsr] 接收音频数据: ${audioData.length} 字节, ${samples.length} 样本'); + // 发送音频数据到识别器进行处理 + _stream!.acceptWaveform(sampleRate: sampleRate, samples: samples); + return; + } } else { debugPrint( '❌ [YxAsr] 音频数据丢弃: stream=${_stream != null}, recognizer=${_recognizer != null}, listening=$_isListening, starting=$_isStartingRecording', @@ -958,7 +1059,8 @@ class YxAsrService implements SpeechRecognitionService { debugPrint( '🔄 [YxAsr] 开始识别循环, partialResults: $partialResults, 速度: ${_recognitionSpeed.description} (${_recognitionSpeed.milliseconds}ms)', ); - _recognitionTimer = Timer.periodic(Duration(milliseconds: _recognitionSpeed.milliseconds), ( + _recognitionTimer = + Timer.periodic(Duration(milliseconds: _recognitionSpeed.milliseconds), ( timer, ) { if (!_isListening || _stream == null || _recognizer == null) { @@ -981,7 +1083,9 @@ class YxAsrService implements SpeechRecognitionService { debugPrint('🔍 [YxAsr] 获取识别结果: "${result.text}"'); // 只有当识别结果不为空、启用了部分结果、且与上次结果不同时才发送 - if (result.text.isNotEmpty && partialResults && result.text != _lastRecognizedText) { + if (result.text.isNotEmpty && + partialResults && + result.text != _lastRecognizedText) { debugPrint('🎤 [YxAsr] 发送实时识别结果: ${result.text}'); _lastRecognizedText = result.text; // 更新最后识别的文本 @@ -990,9 +1094,11 @@ class YxAsrService implements SpeechRecognitionService { // 🆕 如果有活跃会话,同时发送到该会话的流 if (_activeSessionId != null) { - _sendResultForSession(_activeSessionId!, recognizedWords: result.text); + _sendResultForSession(_activeSessionId!, + recognizedWords: result.text); } - } else if (result.text.isNotEmpty && result.text == _lastRecognizedText) { + } else if (result.text.isNotEmpty && + result.text == _lastRecognizedText) { debugPrint('🔄 [YxAsr] 跳过重复识别结果: "${result.text}"'); } @@ -1014,7 +1120,8 @@ class YxAsrService implements SpeechRecognitionService { } /// 发送错误信息到错误流 - void _sendError(SpeechRecognitionErrorType errorType, String errorMsg, String? errorCode) { + void _sendError(SpeechRecognitionErrorType errorType, String errorMsg, + String? errorCode) { final error = SpeechRecognitionError( errorType: errorType, errorMsg: errorMsg, @@ -1026,14 +1133,313 @@ class YxAsrService implements SpeechRecognitionService { /// 清理所有资源 Future _cleanup() async { await _stopAudioRecording(); - _recognitionTimer?.cancel(); - _recognitionTimer = null; - _stream = null; - _recognizer = null; + if (_useRecognizerIsolate) { + await _recognizerIsolateStop(); + await _disposeRecognizerIsolate(); + _stream = null; + _recognizer = null; + } else { + _recognitionTimer?.cancel(); + _recognitionTimer = null; + _stream = null; + _recognizer = null; + } _isListening = false; _isInitialized = false; } + // ==================== 🆕 recognizer isolate 实现 ==================== + + Future _ensureRecognizerIsolate() async { + if (_recognizerCommandPort != null) return; + + final readyPort = ReceivePort(); + _recognizerIsolate = await Isolate.spawn( + _yxAsrRecognizerWorkerMain, + readyPort.sendPort, + debugName: 'yx_asr_recognizer_worker', + ); + + final SendPort commandPort = await readyPort.first as SendPort; + _recognizerCommandPort = commandPort; + + final eventPort = ReceivePort(); + commandPort.send({ + 'type': 'bind', + 'events': eventPort.sendPort, + }); + + _recognizerEventSubscription = eventPort.listen((dynamic msg) { + if (msg is! Map) return; + final type = msg['type']; + if (type == 'reply') { + final id = msg['id'] as int?; + if (id == null) return; + final c = _recognizerPending.remove(id); + c?.complete(Map.from(msg)); + return; + } + if (type == 'result') { + final text = msg['text'] as String? ?? ''; + if (text.isNotEmpty) { + _lastRecognizedText = text; + _sendResult(recognizedWords: text); + if (_activeSessionId != null) { + _sendResultForSession(_activeSessionId!, recognizedWords: text); + } + } + return; + } + if (type == 'error') { + final errorMsg = msg['msg'] as String? ?? '后台识别错误'; + _sendError(SpeechRecognitionErrorType.service, errorMsg, null); + } + }); + } + + Future> _callRecognizerWorker( + Map payload, + ) async { + await _ensureRecognizerIsolate(); + final id = ++_recognizerRequestId; + final completer = Completer>(); + _recognizerPending[id] = completer; + _recognizerCommandPort!.send({...payload, 'id': id}); + return completer.future; + } + + Future _initializeRecognizerInIsolate({ + required String encoderPath, + required String decoderPath, + required String joinerPath, + required String tokensPath, + required int sampleRate, + required int featureDim, + required String decodingMethod, + required int maxActivePaths, + required bool enableEndpoint, + required double rule1MinTrailingSilence, + required double rule2MinTrailingSilence, + required double rule3MinUtteranceLength, + required double blankPenalty, + }) async { + final resp = await _callRecognizerWorker({ + 'type': 'init', + 'config': { + 'encoder': encoderPath, + 'decoder': decoderPath, + 'joiner': joinerPath, + 'tokens': tokensPath, + 'sampleRate': sampleRate, + 'featureDim': featureDim, + 'decodingMethod': decodingMethod, + 'maxActivePaths': maxActivePaths, + 'enableEndpoint': enableEndpoint, + 'rule1MinTrailingSilence': rule1MinTrailingSilence, + 'rule2MinTrailingSilence': rule2MinTrailingSilence, + 'rule3MinUtteranceLength': rule3MinUtteranceLength, + 'blankPenalty': blankPenalty, + }, + }); + return resp['ok'] == true; + } + + Future _recognizerIsolateStart({ + required bool partialResults, + required int speedMs, + required int sampleRate, + }) async { + final resp = await _callRecognizerWorker({ + 'type': 'start', + 'partialResults': partialResults, + 'speedMs': speedMs, + 'sampleRate': sampleRate, + }); + return resp['ok'] == true; + } + + Future _recognizerIsolateStop() async { + if (_recognizerCommandPort == null) return; + await _callRecognizerWorker({'type': 'stop'}); + } + + Future _disposeRecognizerIsolate() async { + final port = _recognizerCommandPort; + if (port == null) return; + try { + await _callRecognizerWorker({'type': 'dispose'}); + } catch (_) { + // ignore + } + await _recognizerEventSubscription?.cancel(); + _recognizerEventSubscription = null; + _recognizerCommandPort = null; + _recognizerPending.clear(); + _recognizerIsolate?.kill(priority: Isolate.immediate); + _recognizerIsolate = null; + } + + static void _yxAsrRecognizerWorkerMain(SendPort readyPort) { + final commandPort = ReceivePort(); + readyPort.send(commandPort.sendPort); + + SendPort? events; + OnlineRecognizer? recognizer; + OnlineStream? stream; + Timer? timer; + String lastText = ''; + bool partial = true; + int sampleRate = 16000; + + Float32List convertToFloat32(Uint8List audioData) { + final sampleCount = audioData.length ~/ 2; + final samples = Float32List(sampleCount); + for (int i = 0; i < sampleCount; i++) { + final sample16 = (audioData[i * 2 + 1] << 8) | audioData[i * 2]; + final signedSample = sample16 > 32767 ? sample16 - 65536 : sample16; + samples[i] = signedSample / 32768.0; + } + return samples; + } + + void reply(int id, bool ok, [String? error]) { + events?.send({ + 'type': 'reply', + 'id': id, + 'ok': ok, + if (error != null) 'error': error, + }); + } + + commandPort.listen((dynamic msg) { + if (msg is! Map) return; + final type = msg['type']; + final id = msg['id'] as int?; + + if (type == 'bind') { + events = msg['events'] as SendPort?; + return; + } + + if (type == 'init') { + try { + final c = Map.from(msg['config'] as Map); + final cfg = OnlineRecognizerConfig( + feat: FeatureConfig( + sampleRate: c['sampleRate'] as int, + featureDim: c['featureDim'] as int, + ), + model: OnlineModelConfig( + transducer: OnlineTransducerModelConfig( + encoder: c['encoder'] as String, + decoder: c['decoder'] as String, + joiner: c['joiner'] as String, + ), + tokens: c['tokens'] as String, + ), + decodingMethod: c['decodingMethod'] as String, + maxActivePaths: c['maxActivePaths'] as int, + enableEndpoint: c['enableEndpoint'] as bool, + rule1MinTrailingSilence: + (c['rule1MinTrailingSilence'] as num).toDouble(), + rule2MinTrailingSilence: + (c['rule2MinTrailingSilence'] as num).toDouble(), + rule3MinUtteranceLength: + (c['rule3MinUtteranceLength'] as num).toDouble(), + blankPenalty: (c['blankPenalty'] as num).toDouble(), + ); + + initBindings(); + recognizer = OnlineRecognizer(cfg); + reply(id!, true); + } catch (e) { + reply(id ?? -1, false, '后台 recognizer 初始化失败: $e'); + } + return; + } + + if (type == 'start') { + try { + if (recognizer == null) { + reply(id ?? -1, false, 'recognizer 未初始化'); + return; + } + partial = msg['partialResults'] == true; + sampleRate = msg['sampleRate'] as int? ?? sampleRate; + final speedMs = msg['speedMs'] as int? ?? 100; + + stream = recognizer!.createStream(); + lastText = ''; + timer?.cancel(); + timer = Timer.periodic(Duration(milliseconds: speedMs), (_) { + final r = recognizer; + final s = stream; + final ev = events; + if (r == null || s == null || ev == null) return; + try { + if (r.isReady(s)) { + r.decode(s); + final result = r.getResult(s); + final text = result.text; + if (text.isNotEmpty && partial && text != lastText) { + lastText = text; + ev.send({'type': 'result', 'text': text}); + } + } + } catch (e) { + ev.send({ + 'type': 'error', + 'msg': '后台识别出错: $e', + }); + } + }); + + reply(id!, true); + } catch (e) { + reply(id ?? -1, false, '后台启动识别失败: $e'); + } + return; + } + + if (type == 'audio') { + try { + final s = stream; + if (s == null) return; + final ttd = msg['data'] as TransferableTypedData?; + if (ttd == null) return; + final bytes = ttd.materialize().asUint8List(); + final samples = convertToFloat32(bytes); + s.acceptWaveform(sampleRate: sampleRate, samples: samples); + } catch (e) { + events?.send({ + 'type': 'error', + 'msg': '后台音频处理失败: $e', + }); + } + return; + } + + if (type == 'stop') { + timer?.cancel(); + timer = null; + stream = null; + lastText = ''; + reply(id ?? -1, true); + return; + } + + if (type == 'dispose') { + timer?.cancel(); + timer = null; + stream = null; + recognizer = null; + reply(id ?? -1, true); + commandPort.close(); + return; + } + }); + } + /// 准备模型文件(将 assets 复制到应用文档目录) Future _prepareModelFiles(String assetPath) async { try { diff --git a/pubspec.yaml b/pubspec.yaml index b4e5c36..8994ff4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: yx_asr description: 基于 sherpa_onnx 的 Flutter 语音识别插件,提供完全离线的实时语音转文字功能。支持多实例会话管理。 -version: 1.0.4 +version: 1.0.5 homepage: https://github.com/yuanxuan/yx_asr environment: diff --git a/test/audio/audio_file_test.dart b/test/audio/audio_file_test.dart index faceba8..9fe7aa3 100644 --- a/test/audio/audio_file_test.dart +++ b/test/audio/audio_file_test.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; -import 'package:yx_asr/yx_asr.dart'; import '../mocks/mock_speech_service.dart'; void main() { @@ -27,11 +26,11 @@ void main() { for (final filePath in audioFiles) { final file = File(filePath); expect(file.existsSync(), true, reason: '音频文件 $filePath 应该存在'); - + // 检查文件大小 final fileSize = await file.length(); expect(fileSize, greaterThan(0), reason: '音频文件 $filePath 不应该为空'); - + print('✅ $filePath: ${fileSize} bytes'); } }); @@ -62,7 +61,7 @@ void main() { // 解析WAV文件头 final wavInfo = _parseWavHeader(audioData); - + expect(wavInfo['sampleRate'], 16000, reason: '采样率应该是16kHz'); expect(wavInfo['channels'], 1, reason: '应该是单声道'); expect(wavInfo['bitsPerSample'], 16, reason: '应该是16位'); @@ -84,14 +83,14 @@ void main() { for (final entry in testFiles.entries) { final file = File(entry.key); final expectedSampleRate = entry.value; - + if (file.existsSync()) { final audioData = await file.readAsBytes(); final wavInfo = _parseWavHeader(audioData); - - expect(wavInfo['sampleRate'], expectedSampleRate, - reason: '${entry.key} 的采样率应该是 ${expectedSampleRate}Hz'); - + + expect(wavInfo['sampleRate'], expectedSampleRate, + reason: '${entry.key} 的采样率应该是 ${expectedSampleRate}Hz'); + print('✅ ${entry.key}: ${wavInfo['sampleRate']}Hz'); } } @@ -100,20 +99,20 @@ void main() { test('应该能够提取音频PCM数据', () async { final file = File('test/test_wavs/0.wav'); final audioData = await file.readAsBytes(); - + // 提取PCM数据 final pcmData = _extractPcmData(audioData); expect(pcmData.length, greaterThan(0), reason: 'PCM数据不应该为空'); - + // 转换为Float32格式(模拟sherpa_onnx需要的格式) final float32Data = _convertToFloat32(pcmData); - expect(float32Data.length, pcmData.length ~/ 2, - reason: 'Float32数据长度应该是Int16数据长度的一半'); - + expect(float32Data.length, pcmData.length ~/ 2, + reason: 'Float32数据长度应该是Int16数据长度的一半'); + print('✅ PCM数据提取成功:'); print(' - 原始数据: ${pcmData.length} bytes'); print(' - Float32数据: ${float32Data.length} samples'); - + // 验证数据范围 bool validRange = true; for (final sample in float32Data) { @@ -130,29 +129,29 @@ void main() { final audioData = await file.readAsBytes(); final pcmData = _extractPcmData(audioData); final float32Data = _convertToFloat32(pcmData); - + // 模拟识别过程 bool resultReceived = false; String recognizedText = ''; - + mockService.onResult.listen((result) { resultReceived = true; recognizedText = result.recognizedWords; }); - + // 模拟开始识别 await mockService.startListening(); expect(mockService.isListening, true); - + // 模拟发送音频数据(在真实场景中,这会是sherpa_onnx处理的) // 这里我们直接模拟识别结果 mockService.mockResult('测试音频识别结果'); - + // 验证结果 await Future.delayed(const Duration(milliseconds: 10)); expect(resultReceived, true, reason: '应该接收到识别结果'); expect(recognizedText, '测试音频识别结果'); - + print('✅ 音频识别模拟测试通过'); print(' - 音频数据: ${float32Data.length} samples'); print(' - 识别结果: $recognizedText'); @@ -163,22 +162,23 @@ void main() { /// 解析WAV文件头信息 Map _parseWavHeader(Uint8List data) { final view = ByteData.sublistView(data); - + // 跳过RIFF头部,找到fmt chunk int offset = 12; while (offset < data.length - 8) { final chunkId = String.fromCharCodes(data.sublist(offset, offset + 4)); final chunkSize = view.getUint32(offset + 4, Endian.little); - + if (chunkId == 'fmt ') { final sampleRate = view.getUint32(offset + 12, Endian.little); final channels = view.getUint16(offset + 10, Endian.little); final bitsPerSample = view.getUint16(offset + 22, Endian.little); - + // 找到data chunk int dataOffset = offset + 8 + chunkSize; while (dataOffset < data.length - 8) { - final dataChunkId = String.fromCharCodes(data.sublist(dataOffset, dataOffset + 4)); + final dataChunkId = + String.fromCharCodes(data.sublist(dataOffset, dataOffset + 4)); if (dataChunkId == 'data') { final dataLength = view.getUint32(dataOffset + 4, Endian.little); return { @@ -195,7 +195,7 @@ Map _parseWavHeader(Uint8List data) { } offset += 8 + chunkSize; } - + throw Exception('无法解析WAV文件头'); } @@ -204,7 +204,7 @@ Uint8List _extractPcmData(Uint8List wavData) { final wavInfo = _parseWavHeader(wavData); final dataOffset = wavInfo['dataOffset']!; final dataLength = wavInfo['dataLength']!; - + return wavData.sublist(dataOffset, dataOffset + dataLength); } @@ -212,10 +212,10 @@ Uint8List _extractPcmData(Uint8List wavData) { Float32List _convertToFloat32(Uint8List pcmData) { final int16Data = Int16List.view(pcmData.buffer); final float32Data = Float32List(int16Data.length); - + for (int i = 0; i < int16Data.length; i++) { float32Data[i] = int16Data[i] / 32768.0; } - + return float32Data; } diff --git a/test/performance/speech_recognition_performance_test.dart b/test/performance/speech_recognition_performance_test.dart index 8c4421b..40d658a 100644 --- a/test/performance/speech_recognition_performance_test.dart +++ b/test/performance/speech_recognition_performance_test.dart @@ -1,8 +1,75 @@ +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yx_asr/yx_asr.dart'; import '../mocks/mock_speech_service.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Mock platform channels used by YxAsrService dependencies + const recordChannel = MethodChannel('com.llfbandit.record/messages'); + const permissionsChannel = + MethodChannel('flutter.baseflow.com/permissions/methods'); + const pathProviderChannel = MethodChannel('plugins.flutter.io/path_provider'); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(recordChannel, (call) async { + switch (call.method) { + case 'create': + case 'pause': + case 'resume': + case 'start': + case 'startStream': + case 'stop': + case 'cancel': + case 'dispose': + return null; + case 'hasPermission': + return true; + case 'isPaused': + return false; + case 'isRecording': + return false; + case 'getAmplitude': + return {'current': 0.0, 'max': 0.0}; + case 'isEncoderSupported': + return true; + case 'listInputDevices': + return []; + default: + return null; + } + }); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(permissionsChannel, (call) async { + switch (call.method) { + case 'checkPermissionStatus': + return 1; // PermissionStatus.granted + case 'requestPermissions': + final perms = (call.arguments as List).cast(); + return {for (final p in perms) p: 1}; + case 'checkServiceStatus': + return 1; // ServiceStatus.enabled + case 'openAppSettings': + return true; + default: + return 1; + } + }); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(pathProviderChannel, (call) async { + switch (call.method) { + case 'getApplicationDocumentsDirectory': + case 'getTemporaryDirectory': + case 'getApplicationSupportDirectory': + return '/tmp'; + default: + return '/tmp'; + } + }); + group('语音识别性能测试', () { late MockSpeechService mockService; diff --git a/test/speech_recognition_service.dart b/test/speech_recognition_service.dart index 2a0332a..e593144 100644 --- a/test/speech_recognition_service.dart +++ b/test/speech_recognition_service.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import 'dart:typed_data'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; diff --git a/test/unit/models_test.dart b/test/unit/models_test.dart index b83b36a..488951a 100644 --- a/test/unit/models_test.dart +++ b/test/unit/models_test.dart @@ -18,13 +18,15 @@ void main() { final map = result.toMap(); expect(map['recognizedWords'], '测试'); - expect(map['confidence'], 0.8); - expect(map['alternatives'], []); + // 当前 SpeechRecognitionResult 仅包含 recognizedWords 字段 + expect(map.containsKey('confidence'), false); + expect(map.containsKey('alternatives'), false); }); test('应该正确从 Map 创建', () { final map = { 'recognizedWords': '从Map创建', + // 允许包含其他字段,但当前实现会忽略 'finalResult': true, 'confidence': 0.9, 'alternatives': ['备选'], diff --git a/test/unit/yx_asr_service_test.dart b/test/unit/yx_asr_service_test.dart index a08d10e..db983ef 100644 --- a/test/unit/yx_asr_service_test.dart +++ b/test/unit/yx_asr_service_test.dart @@ -1,7 +1,64 @@ +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yx_asr/yx_asr.dart'; void main() { + // YxAsrService 构造过程会触发 platform channel 相关逻辑,先初始化绑定避免 ServicesBinding 未初始化 + TestWidgetsFlutterBinding.ensureInitialized(); + + // Mock platform channels used by dependencies (record / permission_handler) + const recordChannel = MethodChannel('com.llfbandit.record/messages'); + const permissionsChannel = + MethodChannel('flutter.baseflow.com/permissions/methods'); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(recordChannel, (call) async { + switch (call.method) { + case 'create': + case 'pause': + case 'resume': + case 'start': + case 'startStream': + case 'stop': + case 'cancel': + case 'dispose': + return null; + case 'hasPermission': + return true; + case 'isPaused': + return false; + case 'isRecording': + return false; + case 'getAmplitude': + return {'current': 0.0, 'max': 0.0}; + case 'isEncoderSupported': + return true; + case 'listInputDevices': + return []; + default: + return null; + } + }); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(permissionsChannel, (call) async { + switch (call.method) { + case 'checkPermissionStatus': + // PermissionStatus.granted (int) + return 1; + case 'requestPermissions': + final perms = (call.arguments as List).cast(); + return {for (final p in perms) p: 1}; + case 'checkServiceStatus': + // ServiceStatus.enabled (int) + return 1; + case 'openAppSettings': + return true; + default: + return 1; + } + }); + group('YxAsrService 单元测试', () { late YxAsrService service; @@ -100,13 +157,15 @@ void main() { final map = result.toMap(); expect(map['recognizedWords'], '测试'); - expect(map['confidence'], 0.8); - expect(map['alternatives'], []); + // 当前 SpeechRecognitionResult 仅包含 recognizedWords 字段 + expect(map.containsKey('confidence'), false); + expect(map.containsKey('alternatives'), false); }); test('应该正确从 Map 创建', () { final map = { 'recognizedWords': '从Map创建', + // 允许包含其他字段,但当前实现会忽略 'finalResult': true, 'confidence': 0.9, 'alternatives': ['备选'], diff --git a/test/widget/recording_button_test.dart b/test/widget/recording_button_test.dart index e2c376f..364f72c 100644 --- a/test/widget/recording_button_test.dart +++ b/test/widget/recording_button_test.dart @@ -27,17 +27,17 @@ void main() { ), ); - // 等待初始化完成 - await tester.pumpAndSettle(); + // 当前组件采用懒加载初始化:build 时不初始化服务 + await tester.pump(); // 验证按钮存在 expect(find.byType(RecordingButton), findsOneWidget); - // 验证默认图标(麦克风) - expect(find.byIcon(Icons.mic), findsOneWidget); + // 验证默认图标(麦克风,rounded) + expect(find.byIcon(Icons.mic_rounded), findsOneWidget); // 验证没有停止图标 - expect(find.byIcon(Icons.stop), findsNothing); + expect(find.byIcon(Icons.stop_rounded), findsNothing); }); testWidgets('应该正确处理点击事件', (WidgetTester tester) async { @@ -58,10 +58,12 @@ void main() { ), ); - await tester.pumpAndSettle(); + await tester.pump(); // 点击按钮开始录音 await tester.tap(find.byType(RecordingButton), warnIfMissed: false); + // 等待懒加载初始化(MockSpeechService.initialize 有 100ms 延迟) + await tester.pump(const Duration(milliseconds: 150)); await tester.pump(); // 验证服务状态 @@ -88,26 +90,26 @@ void main() { ), ); - await tester.pumpAndSettle(); - - // 初始状态应该显示麦克风图标 - expect(find.byIcon(Icons.mic), findsOneWidget); - - // 模拟开始录音 - mockService.mockStatusChange(true); await tester.pump(); - // 应该显示停止图标(注意:需要等待状态更新) - await tester.pumpAndSettle(); - // 由于是模拟服务,图标可能不会立即改变,我们验证组件仍然存在 - expect(find.byType(RecordingButton), findsOneWidget); + // 初始状态应该显示麦克风图标 + expect(find.byIcon(Icons.mic_rounded), findsOneWidget); + + // 点击触发懒加载初始化并开始录音 + await tester.tap(find.byType(RecordingButton), warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 150)); + // 注意:CircularProgressIndicator 为持续动画,避免 pumpAndSettle 超时 + await tester.pump(const Duration(milliseconds: 50)); + + // 应该显示停止图标(rounded) + expect(find.byIcon(Icons.stop_rounded), findsOneWidget); // 模拟停止录音 mockService.mockStatusChange(false); await tester.pumpAndSettle(); - // 验证组件仍然正常工作 - expect(find.byType(RecordingButton), findsOneWidget); + // 应该恢复麦克风图标(rounded) + expect(find.byIcon(Icons.mic_rounded), findsOneWidget); }); testWidgets('应该正确处理错误', (WidgetTester tester) async { @@ -128,7 +130,12 @@ void main() { ), ); - await tester.pumpAndSettle(); + await tester.pump(); + + // 懒加载初始化:先点击一次让组件订阅 error 流 + await tester.tap(find.byType(RecordingButton), warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 150)); + await tester.pump(); // 模拟错误 mockService.mockError( @@ -160,12 +167,13 @@ void main() { ), ); - await tester.pumpAndSettle(); - - // 模拟状态变化 - mockService.mockStatusChange(true); await tester.pump(); + // 点击触发懒加载初始化并开始录音,会触发状态回调 + await tester.tap(find.byType(RecordingButton), warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 150)); + await tester.pump(const Duration(milliseconds: 50)); + // 验证状态变化回调 expect(statusChanged, true); expect(lastStatus, true); @@ -235,7 +243,13 @@ void main() { ), ); - await tester.pumpAndSettle(); + await tester.pump(); + + // 懒加载初始化:点击触发初始化失败并回调错误 + await tester.tap(find.byType(RecordingButton), warnIfMissed: false); + // 避免 pumpAndSettle 超时(progress indicator 持续动画) + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(); // 验证错误被正确处理 expect(errorReceived, true);