Merge branch 'release/1.0.5'
This commit is contained in:
commit
9ae0344757
|
|
@ -0,0 +1,3 @@
|
|||
# macOS metadata files
|
||||
._*
|
||||
.DS_Store
|
||||
10
CHANGELOG.md
10
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 - 多实例会话管理支持
|
||||
|
|
|
|||
|
|
@ -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<SpeechRecognitionPage> {
|
|||
);
|
||||
|
||||
// 初始化服务 - 使用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<SpeechRecognitionPage> {
|
|||
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;
|
||||
|
||||
// 实时更新输入框内容 = 基础文本 + 当前识别文本
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<bool> hasPermission();
|
||||
|
||||
/// 初始化语音识别服务
|
||||
///
|
||||
///
|
||||
/// [config] - 初始化配置参数
|
||||
Future<bool> initialize(Map<String, dynamic> config);
|
||||
|
||||
/// 开始语音识别
|
||||
///
|
||||
///
|
||||
/// [partialResults] - 是否返回部分结果
|
||||
Future<void> 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<String, dynamic> customConfig;
|
||||
|
||||
|
|
|
|||
|
|
@ -85,18 +85,29 @@ class RecordingButton extends StatefulWidget {
|
|||
State<RecordingButton> createState() => _RecordingButtonState();
|
||||
}
|
||||
|
||||
class _RecordingButtonState extends State<RecordingButton> with TickerProviderStateMixin {
|
||||
late SpeechRecognitionService _speechService;
|
||||
class _RecordingButtonState extends State<RecordingButton>
|
||||
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<double> _scaleAnimation;
|
||||
|
||||
// 🆕 脉冲动画控制器 - 用于首次加载时的视觉反馈
|
||||
late AnimationController _pulseController;
|
||||
late Animation<double> _pulseAnimation;
|
||||
|
||||
// 🆕 会话流订阅
|
||||
StreamSubscription<SpeechRecognitionResult>? _resultSubscription;
|
||||
StreamSubscription<bool>? _statusSubscription;
|
||||
StreamSubscription<SpeechRecognitionError>? _errorSubscription;
|
||||
|
||||
// 防抖计时器:用于延迟复位 _isProcessing,避免快速连点
|
||||
Timer? _processingResetTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -107,7 +118,8 @@ class _RecordingButtonState extends State<RecordingButton> with TickerProviderSt
|
|||
'btn_${widget.key?.toString() ?? DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
_setupAnimation();
|
||||
_initializeService();
|
||||
// 🔧 移除同步初始化,改为懒加载 - 解决页面进入时的卡顿问题
|
||||
// _initializeService(); // 不再在 initState 中调用
|
||||
}
|
||||
|
||||
void _setupAnimation() {
|
||||
|
|
@ -118,17 +130,44 @@ class _RecordingButtonState extends State<RecordingButton> with TickerProviderSt
|
|||
_scaleAnimation = Tween<double>(
|
||||
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<double>(
|
||||
begin: 0.95,
|
||||
end: 1.05,
|
||||
).animate(
|
||||
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut));
|
||||
}
|
||||
|
||||
Future<void> _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<RecordingButton> 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<RecordingButton> 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<RecordingButton> 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<RecordingButton> with TickerProviderSt
|
|||
errorCode: null,
|
||||
),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInitialized = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void>.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<RecordingButton> 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<RecordingButton> 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<RecordingButton> 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<RecordingButton> with TickerProviderSt
|
|||
height: widget.size,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
_isInitializing ? iconColor : Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -324,6 +441,16 @@ class _RecordingButtonState extends State<RecordingButton> 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<RecordingButton> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> 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<SpeechRecognitionResult> _resultController =
|
||||
StreamController<SpeechRecognitionResult>.broadcast();
|
||||
final StreamController<SpeechRecognitionError> _errorController =
|
||||
StreamController<SpeechRecognitionError>.broadcast();
|
||||
final StreamController<bool> _statusController = StreamController<bool>.broadcast();
|
||||
final StreamController<bool> _statusController =
|
||||
StreamController<bool>.broadcast();
|
||||
|
||||
// 定时器和订阅
|
||||
Timer? _recognitionTimer;
|
||||
StreamSubscription<Uint8List>? _audioSubscription;
|
||||
|
||||
// ==================== 🆕 后台 recognizer isolate(可选) ====================
|
||||
bool _useRecognizerIsolate = false;
|
||||
Isolate? _recognizerIsolate;
|
||||
SendPort? _recognizerCommandPort;
|
||||
StreamSubscription? _recognizerEventSubscription;
|
||||
int _recognizerRequestId = 0;
|
||||
final Map<int, Completer<Map<String, dynamic>>> _recognizerPending =
|
||||
<int, Completer<Map<String, dynamic>>>{};
|
||||
|
||||
// 🆕 会话管理相关字段
|
||||
/// 所有已注册的会话状态
|
||||
final Map<String, _SessionState> _sessions = {};
|
||||
|
|
@ -265,7 +280,8 @@ class YxAsrService implements SpeechRecognitionService {
|
|||
String? _activeSessionId;
|
||||
|
||||
/// 每个会话的结果流控制器
|
||||
final Map<String, StreamController<SpeechRecognitionResult>> _sessionResultControllers = {};
|
||||
final Map<String, StreamController<SpeechRecognitionResult>>
|
||||
_sessionResultControllers = {};
|
||||
|
||||
/// 每个会话的状态流控制器
|
||||
final Map<String, StreamController<bool>> _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<void> 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<void> 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<SpeechRecognitionResult>.broadcast();
|
||||
_sessionResultControllers[id] =
|
||||
StreamController<SpeechRecognitionResult>.broadcast();
|
||||
_sessionStatusControllers[id] = StreamController<bool>.broadcast();
|
||||
debugPrint('📝 [YxAsr] 注册会话: $id (总计: ${_sessions.length} 个会话)');
|
||||
} else {
|
||||
|
|
@ -676,7 +740,8 @@ class YxAsrService implements SpeechRecognitionService {
|
|||
///
|
||||
/// [sessionId] - 会话ID
|
||||
/// [partialResults] - 是否返回部分结果
|
||||
Future<void> startListeningForSession(String sessionId, {bool partialResults = true}) async {
|
||||
Future<void> 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<bool> initializeWithDefaultModel([String? modelPath]) async {
|
||||
///
|
||||
/// [useRecognizerIsolate] 为 true 时会把 sherpa_onnx recognizer 初始化/解码放到后台 isolate,
|
||||
/// 避免 UI 线程卡顿(推荐在 UI 场景开启)。
|
||||
Future<bool> 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(<String, dynamic>{
|
||||
'type': 'audio',
|
||||
'sampleRate': sampleRate,
|
||||
'data':
|
||||
TransferableTypedData.fromList(<Uint8List>[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<void> _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<void> _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(<String, dynamic>{
|
||||
'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<String, dynamic>.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<Map<String, dynamic>> _callRecognizerWorker(
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
await _ensureRecognizerIsolate();
|
||||
final id = ++_recognizerRequestId;
|
||||
final completer = Completer<Map<String, dynamic>>();
|
||||
_recognizerPending[id] = completer;
|
||||
_recognizerCommandPort!.send(<String, dynamic>{...payload, 'id': id});
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<bool> _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(<String, dynamic>{
|
||||
'type': 'init',
|
||||
'config': <String, dynamic>{
|
||||
'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<bool> _recognizerIsolateStart({
|
||||
required bool partialResults,
|
||||
required int speedMs,
|
||||
required int sampleRate,
|
||||
}) async {
|
||||
final resp = await _callRecognizerWorker(<String, dynamic>{
|
||||
'type': 'start',
|
||||
'partialResults': partialResults,
|
||||
'speedMs': speedMs,
|
||||
'sampleRate': sampleRate,
|
||||
});
|
||||
return resp['ok'] == true;
|
||||
}
|
||||
|
||||
Future<void> _recognizerIsolateStop() async {
|
||||
if (_recognizerCommandPort == null) return;
|
||||
await _callRecognizerWorker(<String, dynamic>{'type': 'stop'});
|
||||
}
|
||||
|
||||
Future<void> _disposeRecognizerIsolate() async {
|
||||
final port = _recognizerCommandPort;
|
||||
if (port == null) return;
|
||||
try {
|
||||
await _callRecognizerWorker(<String, dynamic>{'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(<String, dynamic>{
|
||||
'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<String, dynamic>.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(<String, dynamic>{'type': 'result', 'text': text});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
ev.send(<String, dynamic>{
|
||||
'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(<String, dynamic>{
|
||||
'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<String> _prepareModelFiles(String assetPath) async {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<String, int> _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<String, int> _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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <dynamic>[];
|
||||
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<int>();
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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': ['备选'],
|
||||
|
|
|
|||
|
|
@ -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 <dynamic>[];
|
||||
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<int>();
|
||||
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': ['备选'],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue