Merge branch 'release/1.0.5'

This commit is contained in:
Max 2026-01-18 00:10:04 +08:00
commit 9ae0344757
14 changed files with 894 additions and 225 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# macOS metadata files
._*
.DS_Store

View File

@ -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/), 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). 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 ## [1.0.4] - 2025-01-23
### 🎉 Added - 多实例会话管理支持 ### 🎉 Added - 多实例会话管理支持

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:yx_asr/yx_asr.dart'; import 'package:yx_asr/yx_asr.dart';
void main() { void main() {
@ -82,17 +81,19 @@ class _SpeechRecognitionPageState extends State<SpeechRecognitionPage> {
); );
// - 使2023 // - 使2023
final success = final success = await _speechService.initializeWithDefaultModel(
await _speechService.initializeWithDefaultModel(ModelConfig.zh2023); ModelConfig.zh2023,
true,
);
if (success) { if (success) {
// //
_speechService.onResult.listen((result) { _speechService.onResult.listen((result) {
print('📱 [Example] 接收到识别结果: "${result.recognizedWords}"'); debugPrint('📱 [Example] 接收到识别结果: "${result.recognizedWords}"');
setState(() { setState(() {
// //
if (result.recognizedWords.isNotEmpty) { if (result.recognizedWords.isNotEmpty) {
print('📱 [Example] 实时识别: ${result.recognizedWords}'); debugPrint('📱 [Example] 实时识别: ${result.recognizedWords}');
_currentText = result.recognizedWords; _currentText = result.recognizedWords;
} }
}); });
@ -402,12 +403,12 @@ class _SpeechRecognitionPageState extends State<SpeechRecognitionPage> {
speechService: _speechService, speechService: _speechService,
size: 80, size: 80,
onResult: (result) { onResult: (result) {
print( debugPrint(
'📱 [Example] RecordingButton 接收到识别结果: "${result.recognizedWords}"'); '📱 [Example] RecordingButton 接收到识别结果: "${result.recognizedWords}"');
setState(() { setState(() {
if (result.recognizedWords.isNotEmpty) { if (result.recognizedWords.isNotEmpty) {
// //
print('📱 [Example] 实时识别,更新输入框: ${result.recognizedWords}'); debugPrint('📱 [Example] 实时识别,更新输入框: ${result.recognizedWords}');
_currentText = result.recognizedWords; _currentText = result.recognizedWords;
// = + // = +

View File

@ -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:flutter_test/flutter_test.dart';
import 'package:yx_asr_example/main.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('example 占位测试', (WidgetTester tester) async {
// // Build our app and trigger a frame. // / CI
// await tester.pumpWidget(const MyApp()); expect(true, isTrue);
// // 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);
}); });
} }

View File

@ -3,7 +3,7 @@ import '../models/speech_recognition_result.dart';
import '../models/speech_recognition_error.dart'; import '../models/speech_recognition_error.dart';
/// ///
/// ///
/// ///
/// sherpa_onnxAPI等 /// sherpa_onnxAPI等
abstract class SpeechRecognitionService { abstract class SpeechRecognitionService {
@ -17,12 +17,12 @@ abstract class SpeechRecognitionService {
Future<bool> hasPermission(); Future<bool> hasPermission();
/// ///
/// ///
/// [config] - /// [config] -
Future<bool> initialize(Map<String, dynamic> config); Future<bool> initialize(Map<String, dynamic> config);
/// ///
/// ///
/// [partialResults] - /// [partialResults] -
Future<void> startListening({bool partialResults = true}); Future<void> startListening({bool partialResults = true});
@ -52,16 +52,16 @@ abstract class SpeechRecognitionService {
class SpeechRecognitionConfig { class SpeechRecognitionConfig {
/// 线 /// 线
final String? modelPath; final String? modelPath;
/// ///
final String? localeId; final String? localeId;
/// ///
final int sampleRate; final int sampleRate;
/// 使 /// 使
final bool onDevice; final bool onDevice;
/// ///
final Map<String, dynamic> customConfig; final Map<String, dynamic> customConfig;

View File

@ -85,18 +85,29 @@ class RecordingButton extends StatefulWidget {
State<RecordingButton> createState() => _RecordingButtonState(); State<RecordingButton> createState() => _RecordingButtonState();
} }
class _RecordingButtonState extends State<RecordingButton> with TickerProviderStateMixin { class _RecordingButtonState extends State<RecordingButton>
late SpeechRecognitionService _speechService; with TickerProviderStateMixin {
// 🔧 nullable dispose 访 late
SpeechRecognitionService? _speechService;
late String _sessionId; // 🆕 ID late String _sessionId; // 🆕 ID
bool _isListening = false; bool _isListening = false;
bool _isInitialized = false; bool _isInitialized = false;
bool _isInitializing = false; // 🆕
bool _isProcessing = false; // bool _isProcessing = false; //
late AnimationController _animationController; late AnimationController _animationController;
late Animation<double> _scaleAnimation; late Animation<double> _scaleAnimation;
// 🆕 -
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
// 🆕 // 🆕
StreamSubscription<SpeechRecognitionResult>? _resultSubscription; StreamSubscription<SpeechRecognitionResult>? _resultSubscription;
StreamSubscription<bool>? _statusSubscription; StreamSubscription<bool>? _statusSubscription;
StreamSubscription<SpeechRecognitionError>? _errorSubscription;
// _isProcessing
Timer? _processingResetTimer;
@override @override
void initState() { void initState() {
@ -107,7 +118,8 @@ class _RecordingButtonState extends State<RecordingButton> with TickerProviderSt
'btn_${widget.key?.toString() ?? DateTime.now().millisecondsSinceEpoch}'; 'btn_${widget.key?.toString() ?? DateTime.now().millisecondsSinceEpoch}';
_setupAnimation(); _setupAnimation();
_initializeService(); // 🔧 -
// _initializeService(); // initState
} }
void _setupAnimation() { void _setupAnimation() {
@ -118,17 +130,44 @@ class _RecordingButtonState extends State<RecordingButton> with TickerProviderSt
_scaleAnimation = Tween<double>( _scaleAnimation = Tween<double>(
begin: 1.0, begin: 1.0,
end: 1.1, 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 { 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(); _speechService = widget.speechService ?? YxAsrService();
final service = _speechService!; // 使
try { try {
// //
if (!await _speechService.hasPermission()) { if (!await service.hasPermission()) {
final granted = await _speechService.requestPermission(); final granted = await service.requestPermission();
if (!granted) { if (!granted) {
widget.onError?.call( widget.onError?.call(
SpeechRecognitionError( SpeechRecognitionError(
@ -137,34 +176,44 @@ class _RecordingButtonState extends State<RecordingButton> with TickerProviderSt
errorCode: null, errorCode: null,
), ),
); );
if (mounted) {
setState(() {
_isInitialized = false;
});
}
return; 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 (success) {
// 🆕 使 // 🆕 使
if (_speechService is YxAsrService) { if (service is YxAsrService) {
final service = _speechService as YxAsrService;
// //
service.registerSession(sessionId: _sessionId); service.registerSession(sessionId: _sessionId);
debugPrint('🎤 [RecordingButton] 注册会话: $_sessionId'); debugPrint('🎤 [RecordingButton] 注册会话: $_sessionId');
// 🆕 // 🆕
_resultSubscription = service.getResultStreamForSession(_sessionId).listen((result) { _resultSubscription =
debugPrint('🎤 [RecordingButton $_sessionId] 收到结果: ${result.recognizedWords}'); service.getResultStreamForSession(_sessionId).listen((result) {
debugPrint(
'🎤 [RecordingButton $_sessionId] 收到结果: ${result.recognizedWords}');
widget.onResult?.call(result); widget.onResult?.call(result);
}); });
// 🆕 // 🆕
_statusSubscription = service.getStatusStreamForSession(_sessionId).listen((isListening) { _statusSubscription = service
.getStatusStreamForSession(_sessionId)
.listen((isListening) {
debugPrint('🎤 [RecordingButton $_sessionId] 状态变化: $isListening'); debugPrint('🎤 [RecordingButton $_sessionId] 状态变化: $isListening');
setState(() { if (!mounted) return;
_isListening = isListening; setState(() => _isListening = isListening);
});
if (isListening) { if (isListening) {
_animationController.forward(); _animationController.forward();
} else { } else {
@ -174,14 +223,15 @@ class _RecordingButtonState extends State<RecordingButton> with TickerProviderSt
}); });
// //
service.onError.listen(widget.onError ?? (_) {}); _errorSubscription = service.onError.listen(widget.onError ?? (_) {});
} else { } else {
// 使 // 使
_resultSubscription = _speechService.onResult.listen(widget.onResult ?? (_) {}); _resultSubscription =
_statusSubscription = _speechService.onListeningStatusChanged.listen((isListening) { service.onResult.listen(widget.onResult ?? (_) {});
setState(() { _statusSubscription =
_isListening = isListening; service.onListeningStatusChanged.listen((isListening) {
}); if (!mounted) return;
setState(() => _isListening = isListening);
if (isListening) { if (isListening) {
_animationController.forward(); _animationController.forward();
} else { } else {
@ -189,13 +239,22 @@ class _RecordingButtonState extends State<RecordingButton> with TickerProviderSt
} }
widget.onListeningStatusChanged?.call(isListening); 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(() { if (mounted) {
_isInitialized = success; setState(() => _isInitialized = success);
}); }
} catch (e) { } catch (e) {
widget.onError?.call( widget.onError?.call(
SpeechRecognitionError( SpeechRecognitionError(
@ -204,21 +263,70 @@ class _RecordingButtonState extends State<RecordingButton> with TickerProviderSt
errorCode: null, errorCode: null,
), ),
); );
if (mounted) {
setState(() {
_isInitialized = false;
});
}
} }
} }
Future<void> _toggleRecording() async { 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(() { if (mounted) {
_isProcessing = true; setState(() {
}); _isProcessing = true;
});
}
try { try {
// // //
await HapticFeedback.lightImpact(); try {
unawaited(HapticFeedback.lightImpact());
} catch (_) {
// ignore
}
// //
_animationController.forward().then((_) { _animationController.forward().then((_) {
@ -226,19 +334,20 @@ class _RecordingButtonState extends State<RecordingButton> with TickerProviderSt
}); });
// 🆕 使 // 🆕 使
if (_speechService is YxAsrService) { final service = _speechService!; // _isInitialized
final service = _speechService as YxAsrService; if (service is YxAsrService) {
if (_isListening) { if (_isListening) {
await service.stopListeningForSession(_sessionId); await service.stopListeningForSession(_sessionId);
} else { } else {
await service.startListeningForSession(_sessionId, partialResults: widget.partialResults); await service.startListeningForSession(_sessionId,
partialResults: widget.partialResults);
} }
} else { } else {
// 使 // 使
if (_isListening) { if (_isListening) {
await _speechService.stopListening(); await service.stopListening();
} else { } else {
await _speechService.startListening(partialResults: widget.partialResults); await service.startListening(partialResults: widget.partialResults);
} }
} }
} catch (e) { } catch (e) {
@ -250,8 +359,9 @@ class _RecordingButtonState extends State<RecordingButton> with TickerProviderSt
), ),
); );
} finally { } finally {
// // dispose
Future.delayed(const Duration(milliseconds: 300), () { _processingResetTimer?.cancel();
_processingResetTimer = Timer(const Duration(milliseconds: 300), () {
if (mounted) { if (mounted) {
setState(() { setState(() {
_isProcessing = false; _isProcessing = false;
@ -263,23 +373,29 @@ class _RecordingButtonState extends State<RecordingButton> with TickerProviderSt
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 🔧
Color iconColor; Color iconColor;
if (!widget.enabled || !_isInitialized) { if (!widget.enabled) {
iconColor = widget.disabledColor ?? Colors.grey[850]!; 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 { } else {
iconColor = _isListening iconColor = widget.idleColor ?? const Color(0xFF2196F3);
? (widget.recordingColor ?? const Color(0xFFFF5252))
: (widget.idleColor ?? const Color(0xFF2196F3));
} }
Widget button = AnimatedBuilder( Widget buttonContent = AnimatedBuilder(
animation: _scaleAnimation, animation: _scaleAnimation,
builder: (context, child) { builder: (context, child) {
return Container( return Container(
width: widget.size, width: widget.size,
height: widget.size, height: widget.size,
decoration: BoxDecoration( 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, shape: BoxShape.circle,
), ),
child: Material( child: Material(
@ -313,7 +429,8 @@ class _RecordingButtonState extends State<RecordingButton> with TickerProviderSt
height: widget.size, height: widget.size,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, 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) { if (widget.tooltip != null) {
button = Tooltip(message: widget.tooltip!, child: button); button = Tooltip(message: widget.tooltip!, child: button);
@ -337,15 +464,18 @@ class _RecordingButtonState extends State<RecordingButton> with TickerProviderSt
// 🆕 // 🆕
_resultSubscription?.cancel(); _resultSubscription?.cancel();
_statusSubscription?.cancel(); _statusSubscription?.cancel();
_errorSubscription?.cancel();
_processingResetTimer?.cancel();
// 🆕 // 🆕 ()
if (_speechService is YxAsrService) { if (_speechService != null && _speechService is YxAsrService) {
final service = _speechService as YxAsrService; final service = _speechService as YxAsrService;
service.unregisterSession(_sessionId); service.unregisterSession(_sessionId);
debugPrint('🎤 [RecordingButton] 注销会话: $_sessionId'); debugPrint('🎤 [RecordingButton] 注销会话: $_sessionId');
} }
_animationController.dispose(); _animationController.dispose();
_pulseController.dispose(); // 🆕
super.dispose(); super.dispose();
} }
} }

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:isolate';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.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, decodingMethod: DecodingMethod.modifiedBeamSearch,
maxActivePaths: 8, maxActivePaths: 8,
enableEndpoint: true, enableEndpoint: true,
@ -179,13 +181,14 @@ class RecognitionResult {
final double confidence; final double confidence;
final DateTime timestamp; 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() => { Map<String, dynamic> toJson() => {
'text': text, 'text': text,
'confidence': confidence, 'confidence': confidence,
'timestamp': timestamp.toIso8601String(), 'timestamp': timestamp.toIso8601String(),
}; };
} }
/// 🆕 /// 🆕
@ -204,12 +207,13 @@ class _SessionState {
final DateTime createdAt; final DateTime createdAt;
_SessionState(this.sessionId) _SessionState(this.sessionId)
: isActive = false, : isActive = false,
lastRecognizedText = '', lastRecognizedText = '',
createdAt = DateTime.now(); createdAt = DateTime.now();
@override @override
String toString() => '_SessionState(id: $sessionId, active: $isActive, created: $createdAt)'; String toString() =>
'_SessionState(id: $sessionId, active: $isActive, created: $createdAt)';
} }
/// sherpa_onnx /// sherpa_onnx
@ -244,19 +248,30 @@ class YxAsrService implements SpeechRecognitionService {
SampleRate _sampleRate = SampleRate.standard; SampleRate _sampleRate = SampleRate.standard;
// //
AdvancedRecognitionConfig _advancedConfig = AdvancedRecognitionConfig.balanced; AdvancedRecognitionConfig _advancedConfig =
AdvancedRecognitionConfig.balanced;
// //
final StreamController<SpeechRecognitionResult> _resultController = final StreamController<SpeechRecognitionResult> _resultController =
StreamController<SpeechRecognitionResult>.broadcast(); StreamController<SpeechRecognitionResult>.broadcast();
final StreamController<SpeechRecognitionError> _errorController = final StreamController<SpeechRecognitionError> _errorController =
StreamController<SpeechRecognitionError>.broadcast(); StreamController<SpeechRecognitionError>.broadcast();
final StreamController<bool> _statusController = StreamController<bool>.broadcast(); final StreamController<bool> _statusController =
StreamController<bool>.broadcast();
// //
Timer? _recognitionTimer; Timer? _recognitionTimer;
StreamSubscription<Uint8List>? _audioSubscription; 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 = {}; final Map<String, _SessionState> _sessions = {};
@ -265,7 +280,8 @@ class YxAsrService implements SpeechRecognitionService {
String? _activeSessionId; String? _activeSessionId;
/// ///
final Map<String, StreamController<SpeechRecognitionResult>> _sessionResultControllers = {}; final Map<String, StreamController<SpeechRecognitionResult>>
_sessionResultControllers = {};
/// ///
final Map<String, StreamController<bool>> _sessionStatusControllers = {}; final Map<String, StreamController<bool>> _sessionStatusControllers = {};
@ -312,7 +328,8 @@ class YxAsrService implements SpeechRecognitionService {
/// [speed] - /// [speed] -
void setRecognitionSpeed(RecognitionSpeed speed) { void setRecognitionSpeed(RecognitionSpeed speed) {
_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) { void setSampleRate(SampleRate sampleRate) {
_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] 构建识别器配置...'); print('🔍 [YxAsr] 构建识别器配置...');
debugPrint('🔧 [YxAsr] 使用采样率: ${_sampleRate.description}'); debugPrint('🔧 [YxAsr] 使用采样率: ${_sampleRate.description}');
debugPrint('🔧 [YxAsr] 解码方法: ${_advancedConfig.decodingMethod.description}'); debugPrint(
'🔧 [YxAsr] 解码方法: ${_advancedConfig.decodingMethod.description}');
debugPrint('🔧 [YxAsr] 最大活跃路径: ${_advancedConfig.maxActivePaths}'); debugPrint('🔧 [YxAsr] 最大活跃路径: ${_advancedConfig.maxActivePaths}');
debugPrint('🔧 [YxAsr] 端点检测: ${_advancedConfig.enableEndpoint ? "启用" : "禁用"}'); debugPrint(
'🔧 [YxAsr] 端点检测: ${_advancedConfig.enableEndpoint ? "启用" : "禁用"}');
final config = OnlineRecognizerConfig( final config = OnlineRecognizerConfig(
feat: FeatureConfig(sampleRate: _sampleRate.hz, featureDim: _advancedConfig.featureDim), feat: FeatureConfig(
sampleRate: _sampleRate.hz, featureDim: _advancedConfig.featureDim),
model: OnlineModelConfig( model: OnlineModelConfig(
transducer: OnlineTransducerModelConfig( transducer: OnlineTransducerModelConfig(
encoder: encoderPath, encoder: encoderPath,
@ -468,18 +489,41 @@ class YxAsrService implements SpeechRecognitionService {
blankPenalty: _advancedConfig.blankPenalty, blankPenalty: _advancedConfig.blankPenalty,
); );
// sherpa-onnx if (_useRecognizerIsolate) {
print('🔍 [YxAsr] 初始化 sherpa-onnx 绑定...'); // initBindings + OnlineRecognizer isolate UI 线
initBindings(); 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] 创建在线识别器实例...'); print('🔍 [YxAsr] 创建在线识别器实例...');
try { try {
_recognizer = OnlineRecognizer(config); _recognizer = OnlineRecognizer(config);
print('🔍 [YxAsr] 在线识别器创建成功'); print('🔍 [YxAsr] 在线识别器创建成功');
} catch (e) { } catch (e) {
print('❌ [YxAsr] 在线识别器创建失败: $e'); print('❌ [YxAsr] 在线识别器创建失败: $e');
throw e; throw e;
}
} }
_currentModelPath = modelPath; _currentModelPath = modelPath;
@ -500,7 +544,7 @@ class YxAsrService implements SpeechRecognitionService {
/// 使 setSampleRate() /// 使 setSampleRate()
Future<void> startListening({bool partialResults = true}) async { Future<void> startListening({bool partialResults = true}) async {
try { try {
if (!_isInitialized || _recognizer == null) { if (!_isInitialized || (!_useRecognizerIsolate && _recognizer == null)) {
throw Exception('识别器未初始化,请先调用 initializeWithModel()'); throw Exception('识别器未初始化,请先调用 initializeWithModel()');
} }
@ -518,21 +562,32 @@ class YxAsrService implements SpeechRecognitionService {
_isStartingRecording = true; _isStartingRecording = true;
try { try {
// if (_useRecognizerIsolate) {
_stream = _recognizer!.createStream(); final started = await _recognizerIsolateStart(
debugPrint('🔧 [YxAsr] 音频流已创建: ${_stream != null}'); partialResults: partialResults,
speedMs: _recognitionSpeed.milliseconds,
sampleRate: _sampleRate.hz,
);
if (!started) {
throw Exception('后台 isolate 启动识别失败');
}
} else {
//
_stream = _recognizer!.createStream();
debugPrint('🔧 [YxAsr] 音频流已创建: ${_stream != null}');
// //
if (_stream == null) { if (_stream == null) {
throw Exception('音频流创建失败'); throw Exception('音频流创建失败');
} }
// //
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
// //
if (_stream == null) { if (_stream == null) {
throw Exception('音频流在等待后变为null'); throw Exception('音频流在等待后变为null');
}
} }
// //
@ -542,8 +597,10 @@ class YxAsrService implements SpeechRecognitionService {
_lastRecognizedText = ''; // _lastRecognizedText = ''; //
_statusController.add(true); _statusController.add(true);
// if (!_useRecognizerIsolate) {
_startRecognitionLoop(partialResults); // isolate
_startRecognitionLoop(partialResults);
}
debugPrint('✅ [YxAsr] 录音启动成功'); debugPrint('✅ [YxAsr] 录音启动成功');
} finally { } finally {
@ -592,17 +649,19 @@ class YxAsrService implements SpeechRecognitionService {
_isListening = false; _isListening = false;
_statusController.add(false); _statusController.add(false);
// // isolate / isolate
_recognitionTimer?.cancel(); if (_useRecognizerIsolate) {
_recognitionTimer = null; await _recognizerIsolateStop();
} else {
_recognitionTimer?.cancel();
_recognitionTimer = null;
}
// //
await _stopAudioRecording(); await _stopAudioRecording();
// //
if (_stream != null) { _stream = null;
_stream = null;
}
debugPrint('✅ [YxAsr] 识别已停止'); debugPrint('✅ [YxAsr] 识别已停止');
} catch (e) { } catch (e) {
@ -616,8 +675,12 @@ class YxAsrService implements SpeechRecognitionService {
/// ///
Future<void> cancel() async { Future<void> cancel() async {
await _stopAudioRecording(); await _stopAudioRecording();
_recognitionTimer?.cancel(); if (_useRecognizerIsolate) {
_recognitionTimer = null; await _recognizerIsolateStop();
} else {
_recognitionTimer?.cancel();
_recognitionTimer = null;
}
_isListening = false; _isListening = false;
_statusController.add(false); _statusController.add(false);
} }
@ -634,7 +697,8 @@ class YxAsrService implements SpeechRecognitionService {
if (!_sessions.containsKey(id)) { if (!_sessions.containsKey(id)) {
_sessions[id] = _SessionState(id); _sessions[id] = _SessionState(id);
_sessionResultControllers[id] = StreamController<SpeechRecognitionResult>.broadcast(); _sessionResultControllers[id] =
StreamController<SpeechRecognitionResult>.broadcast();
_sessionStatusControllers[id] = StreamController<bool>.broadcast(); _sessionStatusControllers[id] = StreamController<bool>.broadcast();
debugPrint('📝 [YxAsr] 注册会话: $id (总计: ${_sessions.length} 个会话)'); debugPrint('📝 [YxAsr] 注册会话: $id (总计: ${_sessions.length} 个会话)');
} else { } else {
@ -676,7 +740,8 @@ class YxAsrService implements SpeechRecognitionService {
/// ///
/// [sessionId] - ID /// [sessionId] - ID
/// [partialResults] - /// [partialResults] -
Future<void> startListeningForSession(String sessionId, {bool partialResults = true}) async { Future<void> startListeningForSession(String sessionId,
{bool partialResults = true}) async {
try { try {
// //
if (!_sessions.containsKey(sessionId)) { 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'); debugPrint('⚠️ [YxAsr] 停止其他会话 $_activeSessionId,切换到 $sessionId');
await stopListeningForSession(_activeSessionId!); await stopListeningForSession(_activeSessionId!);
} }
@ -700,7 +767,8 @@ class YxAsrService implements SpeechRecognitionService {
_sessions[sessionId]!.isActive = true; _sessions[sessionId]!.isActive = true;
_sessions[sessionId]!.lastRecognizedText = ''; _sessions[sessionId]!.lastRecognizedText = '';
debugPrint('🎤 [YxAsr] 会话 $sessionId 开始录音 (partialResults: $partialResults)'); debugPrint(
'🎤 [YxAsr] 会话 $sessionId 开始录音 (partialResults: $partialResults)');
// startListening // startListening
await startListening(partialResults: partialResults); 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; rethrow;
} }
} }
@ -738,7 +807,8 @@ class YxAsrService implements SpeechRecognitionService {
// //
if (_activeSessionId != sessionId) { if (_activeSessionId != sessionId) {
debugPrint('⚠️ [YxAsr] 会话 $sessionId 不是活跃会话(活跃: $_activeSessionId),忽略停止请求'); debugPrint(
'⚠️ [YxAsr] 会话 $sessionId 不是活跃会话(活跃: $_activeSessionId),忽略停止请求');
return; return;
} }
@ -758,7 +828,8 @@ class YxAsrService implements SpeechRecognitionService {
debugPrint('✅ [YxAsr] 会话 $sessionId 录音已停止'); debugPrint('✅ [YxAsr] 会话 $sessionId 录音已停止');
} catch (e) { } catch (e) {
debugPrint('❌ [YxAsr] 会话 $sessionId 停止录音失败: $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 (_sessionResultControllers.containsKey(sessionId)) {
// //
if (_sessions[sessionId]?.lastRecognizedText == recognizedWords) { if (_sessions[sessionId]?.lastRecognizedText == recognizedWords) {
@ -857,6 +929,9 @@ class YxAsrService implements SpeechRecognitionService {
return false; return false;
} }
// recognizer / isolate UI
_useRecognizerIsolate = config['useRecognizerIsolate'] == true;
// 使 // 使
final modelPath = config['modelPath'] as String? ?? 'assets/models'; final modelPath = config['modelPath'] as String? ?? 'assets/models';
print('🔍 [YxAsr] 使用模型路径: $modelPath'); 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'; final defaultPath = modelPath ?? 'assets/models';
print('🔍 [YxAsr] initializeWithDefaultModel() 被调用,使用路径: $defaultPath'); 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( _audioSubscription = stream.listen(
(audioData) { (audioData) {
// //
if (_stream != null && _recognizer != null && _isListening && !_isStartingRecording) { if (_isListening && !_isStartingRecording) {
// Float32List if (_useRecognizerIsolate) {
final samples = _convertToFloat32(audioData); final port = _recognizerCommandPort;
debugPrint('🎵 [YxAsr] 接收音频数据: ${audioData.length} 字节, ${samples.length} 样本'); if (port != null) {
// port.send(<String, dynamic>{
_stream!.acceptWaveform(sampleRate: sampleRate, samples: samples); '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 { } else {
debugPrint( debugPrint(
'❌ [YxAsr] 音频数据丢弃: stream=${_stream != null}, recognizer=${_recognizer != null}, listening=$_isListening, starting=$_isStartingRecording', '❌ [YxAsr] 音频数据丢弃: stream=${_stream != null}, recognizer=${_recognizer != null}, listening=$_isListening, starting=$_isStartingRecording',
@ -958,7 +1059,8 @@ class YxAsrService implements SpeechRecognitionService {
debugPrint( debugPrint(
'🔄 [YxAsr] 开始识别循环, partialResults: $partialResults, 速度: ${_recognitionSpeed.description} (${_recognitionSpeed.milliseconds}ms)', '🔄 [YxAsr] 开始识别循环, partialResults: $partialResults, 速度: ${_recognitionSpeed.description} (${_recognitionSpeed.milliseconds}ms)',
); );
_recognitionTimer = Timer.periodic(Duration(milliseconds: _recognitionSpeed.milliseconds), ( _recognitionTimer =
Timer.periodic(Duration(milliseconds: _recognitionSpeed.milliseconds), (
timer, timer,
) { ) {
if (!_isListening || _stream == null || _recognizer == null) { if (!_isListening || _stream == null || _recognizer == null) {
@ -981,7 +1083,9 @@ class YxAsrService implements SpeechRecognitionService {
debugPrint('🔍 [YxAsr] 获取识别结果: "${result.text}"'); debugPrint('🔍 [YxAsr] 获取识别结果: "${result.text}"');
// //
if (result.text.isNotEmpty && partialResults && result.text != _lastRecognizedText) { if (result.text.isNotEmpty &&
partialResults &&
result.text != _lastRecognizedText) {
debugPrint('🎤 [YxAsr] 发送实时识别结果: ${result.text}'); debugPrint('🎤 [YxAsr] 发送实时识别结果: ${result.text}');
_lastRecognizedText = result.text; // _lastRecognizedText = result.text; //
@ -990,9 +1094,11 @@ class YxAsrService implements SpeechRecognitionService {
// 🆕 // 🆕
if (_activeSessionId != null) { 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}"'); 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( final error = SpeechRecognitionError(
errorType: errorType, errorType: errorType,
errorMsg: errorMsg, errorMsg: errorMsg,
@ -1026,14 +1133,313 @@ class YxAsrService implements SpeechRecognitionService {
/// ///
Future<void> _cleanup() async { Future<void> _cleanup() async {
await _stopAudioRecording(); await _stopAudioRecording();
_recognitionTimer?.cancel(); if (_useRecognizerIsolate) {
_recognitionTimer = null; await _recognizerIsolateStop();
_stream = null; await _disposeRecognizerIsolate();
_recognizer = null; _stream = null;
_recognizer = null;
} else {
_recognitionTimer?.cancel();
_recognitionTimer = null;
_stream = null;
_recognizer = null;
}
_isListening = false; _isListening = false;
_isInitialized = 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 /// assets
Future<String> _prepareModelFiles(String assetPath) async { Future<String> _prepareModelFiles(String assetPath) async {
try { try {

View File

@ -1,6 +1,6 @@
name: yx_asr name: yx_asr
description: 基于 sherpa_onnx 的 Flutter 语音识别插件,提供完全离线的实时语音转文字功能。支持多实例会话管理。 description: 基于 sherpa_onnx 的 Flutter 语音识别插件,提供完全离线的实时语音转文字功能。支持多实例会话管理。
version: 1.0.4 version: 1.0.5
homepage: https://github.com/yuanxuan/yx_asr homepage: https://github.com/yuanxuan/yx_asr
environment: environment:

View File

@ -1,7 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:yx_asr/yx_asr.dart';
import '../mocks/mock_speech_service.dart'; import '../mocks/mock_speech_service.dart';
void main() { void main() {
@ -27,11 +26,11 @@ void main() {
for (final filePath in audioFiles) { for (final filePath in audioFiles) {
final file = File(filePath); final file = File(filePath);
expect(file.existsSync(), true, reason: '音频文件 $filePath 应该存在'); expect(file.existsSync(), true, reason: '音频文件 $filePath 应该存在');
// //
final fileSize = await file.length(); final fileSize = await file.length();
expect(fileSize, greaterThan(0), reason: '音频文件 $filePath 不应该为空'); expect(fileSize, greaterThan(0), reason: '音频文件 $filePath 不应该为空');
print('$filePath: ${fileSize} bytes'); print('$filePath: ${fileSize} bytes');
} }
}); });
@ -62,7 +61,7 @@ void main() {
// WAV文件头 // WAV文件头
final wavInfo = _parseWavHeader(audioData); final wavInfo = _parseWavHeader(audioData);
expect(wavInfo['sampleRate'], 16000, reason: '采样率应该是16kHz'); expect(wavInfo['sampleRate'], 16000, reason: '采样率应该是16kHz');
expect(wavInfo['channels'], 1, reason: '应该是单声道'); expect(wavInfo['channels'], 1, reason: '应该是单声道');
expect(wavInfo['bitsPerSample'], 16, reason: '应该是16位'); expect(wavInfo['bitsPerSample'], 16, reason: '应该是16位');
@ -84,14 +83,14 @@ void main() {
for (final entry in testFiles.entries) { for (final entry in testFiles.entries) {
final file = File(entry.key); final file = File(entry.key);
final expectedSampleRate = entry.value; final expectedSampleRate = entry.value;
if (file.existsSync()) { if (file.existsSync()) {
final audioData = await file.readAsBytes(); final audioData = await file.readAsBytes();
final wavInfo = _parseWavHeader(audioData); final wavInfo = _parseWavHeader(audioData);
expect(wavInfo['sampleRate'], expectedSampleRate, expect(wavInfo['sampleRate'], expectedSampleRate,
reason: '${entry.key} 的采样率应该是 ${expectedSampleRate}Hz'); reason: '${entry.key} 的采样率应该是 ${expectedSampleRate}Hz');
print('${entry.key}: ${wavInfo['sampleRate']}Hz'); print('${entry.key}: ${wavInfo['sampleRate']}Hz');
} }
} }
@ -100,20 +99,20 @@ void main() {
test('应该能够提取音频PCM数据', () async { test('应该能够提取音频PCM数据', () async {
final file = File('test/test_wavs/0.wav'); final file = File('test/test_wavs/0.wav');
final audioData = await file.readAsBytes(); final audioData = await file.readAsBytes();
// PCM数据 // PCM数据
final pcmData = _extractPcmData(audioData); final pcmData = _extractPcmData(audioData);
expect(pcmData.length, greaterThan(0), reason: 'PCM数据不应该为空'); expect(pcmData.length, greaterThan(0), reason: 'PCM数据不应该为空');
// Float32格式sherpa_onnx需要的格式 // Float32格式sherpa_onnx需要的格式
final float32Data = _convertToFloat32(pcmData); final float32Data = _convertToFloat32(pcmData);
expect(float32Data.length, pcmData.length ~/ 2, expect(float32Data.length, pcmData.length ~/ 2,
reason: 'Float32数据长度应该是Int16数据长度的一半'); reason: 'Float32数据长度应该是Int16数据长度的一半');
print('✅ PCM数据提取成功:'); print('✅ PCM数据提取成功:');
print(' - 原始数据: ${pcmData.length} bytes'); print(' - 原始数据: ${pcmData.length} bytes');
print(' - Float32数据: ${float32Data.length} samples'); print(' - Float32数据: ${float32Data.length} samples');
// //
bool validRange = true; bool validRange = true;
for (final sample in float32Data) { for (final sample in float32Data) {
@ -130,29 +129,29 @@ void main() {
final audioData = await file.readAsBytes(); final audioData = await file.readAsBytes();
final pcmData = _extractPcmData(audioData); final pcmData = _extractPcmData(audioData);
final float32Data = _convertToFloat32(pcmData); final float32Data = _convertToFloat32(pcmData);
// //
bool resultReceived = false; bool resultReceived = false;
String recognizedText = ''; String recognizedText = '';
mockService.onResult.listen((result) { mockService.onResult.listen((result) {
resultReceived = true; resultReceived = true;
recognizedText = result.recognizedWords; recognizedText = result.recognizedWords;
}); });
// //
await mockService.startListening(); await mockService.startListening();
expect(mockService.isListening, true); expect(mockService.isListening, true);
// sherpa_onnx处理的 // sherpa_onnx处理的
// //
mockService.mockResult('测试音频识别结果'); mockService.mockResult('测试音频识别结果');
// //
await Future.delayed(const Duration(milliseconds: 10)); await Future.delayed(const Duration(milliseconds: 10));
expect(resultReceived, true, reason: '应该接收到识别结果'); expect(resultReceived, true, reason: '应该接收到识别结果');
expect(recognizedText, '测试音频识别结果'); expect(recognizedText, '测试音频识别结果');
print('✅ 音频识别模拟测试通过'); print('✅ 音频识别模拟测试通过');
print(' - 音频数据: ${float32Data.length} samples'); print(' - 音频数据: ${float32Data.length} samples');
print(' - 识别结果: $recognizedText'); print(' - 识别结果: $recognizedText');
@ -163,22 +162,23 @@ void main() {
/// WAV文件头信息 /// WAV文件头信息
Map<String, int> _parseWavHeader(Uint8List data) { Map<String, int> _parseWavHeader(Uint8List data) {
final view = ByteData.sublistView(data); final view = ByteData.sublistView(data);
// RIFF头部fmt chunk // RIFF头部fmt chunk
int offset = 12; int offset = 12;
while (offset < data.length - 8) { while (offset < data.length - 8) {
final chunkId = String.fromCharCodes(data.sublist(offset, offset + 4)); final chunkId = String.fromCharCodes(data.sublist(offset, offset + 4));
final chunkSize = view.getUint32(offset + 4, Endian.little); final chunkSize = view.getUint32(offset + 4, Endian.little);
if (chunkId == 'fmt ') { if (chunkId == 'fmt ') {
final sampleRate = view.getUint32(offset + 12, Endian.little); final sampleRate = view.getUint32(offset + 12, Endian.little);
final channels = view.getUint16(offset + 10, Endian.little); final channels = view.getUint16(offset + 10, Endian.little);
final bitsPerSample = view.getUint16(offset + 22, Endian.little); final bitsPerSample = view.getUint16(offset + 22, Endian.little);
// data chunk // data chunk
int dataOffset = offset + 8 + chunkSize; int dataOffset = offset + 8 + chunkSize;
while (dataOffset < data.length - 8) { 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') { if (dataChunkId == 'data') {
final dataLength = view.getUint32(dataOffset + 4, Endian.little); final dataLength = view.getUint32(dataOffset + 4, Endian.little);
return { return {
@ -195,7 +195,7 @@ Map<String, int> _parseWavHeader(Uint8List data) {
} }
offset += 8 + chunkSize; offset += 8 + chunkSize;
} }
throw Exception('无法解析WAV文件头'); throw Exception('无法解析WAV文件头');
} }
@ -204,7 +204,7 @@ Uint8List _extractPcmData(Uint8List wavData) {
final wavInfo = _parseWavHeader(wavData); final wavInfo = _parseWavHeader(wavData);
final dataOffset = wavInfo['dataOffset']!; final dataOffset = wavInfo['dataOffset']!;
final dataLength = wavInfo['dataLength']!; final dataLength = wavInfo['dataLength']!;
return wavData.sublist(dataOffset, dataOffset + dataLength); return wavData.sublist(dataOffset, dataOffset + dataLength);
} }
@ -212,10 +212,10 @@ Uint8List _extractPcmData(Uint8List wavData) {
Float32List _convertToFloat32(Uint8List pcmData) { Float32List _convertToFloat32(Uint8List pcmData) {
final int16Data = Int16List.view(pcmData.buffer); final int16Data = Int16List.view(pcmData.buffer);
final float32Data = Float32List(int16Data.length); final float32Data = Float32List(int16Data.length);
for (int i = 0; i < int16Data.length; i++) { for (int i = 0; i < int16Data.length; i++) {
float32Data[i] = int16Data[i] / 32768.0; float32Data[i] = int16Data[i] / 32768.0;
} }
return float32Data; return float32Data;
} }

View File

@ -1,8 +1,75 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:yx_asr/yx_asr.dart'; import 'package:yx_asr/yx_asr.dart';
import '../mocks/mock_speech_service.dart'; import '../mocks/mock_speech_service.dart';
void main() { 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('语音识别性能测试', () { group('语音识别性能测试', () {
late MockSpeechService mockService; late MockSpeechService mockService;

View File

@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';

View File

@ -18,13 +18,15 @@ void main() {
final map = result.toMap(); final map = result.toMap();
expect(map['recognizedWords'], '测试'); expect(map['recognizedWords'], '测试');
expect(map['confidence'], 0.8); // SpeechRecognitionResult recognizedWords
expect(map['alternatives'], []); expect(map.containsKey('confidence'), false);
expect(map.containsKey('alternatives'), false);
}); });
test('应该正确从 Map 创建', () { test('应该正确从 Map 创建', () {
final map = { final map = {
'recognizedWords': '从Map创建', 'recognizedWords': '从Map创建',
//
'finalResult': true, 'finalResult': true,
'confidence': 0.9, 'confidence': 0.9,
'alternatives': ['备选'], 'alternatives': ['备选'],

View File

@ -1,7 +1,64 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:yx_asr/yx_asr.dart'; import 'package:yx_asr/yx_asr.dart';
void main() { 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 单元测试', () { group('YxAsrService 单元测试', () {
late YxAsrService service; late YxAsrService service;
@ -100,13 +157,15 @@ void main() {
final map = result.toMap(); final map = result.toMap();
expect(map['recognizedWords'], '测试'); expect(map['recognizedWords'], '测试');
expect(map['confidence'], 0.8); // SpeechRecognitionResult recognizedWords
expect(map['alternatives'], []); expect(map.containsKey('confidence'), false);
expect(map.containsKey('alternatives'), false);
}); });
test('应该正确从 Map 创建', () { test('应该正确从 Map 创建', () {
final map = { final map = {
'recognizedWords': '从Map创建', 'recognizedWords': '从Map创建',
//
'finalResult': true, 'finalResult': true,
'confidence': 0.9, 'confidence': 0.9,
'alternatives': ['备选'], 'alternatives': ['备选'],

View File

@ -27,17 +27,17 @@ void main() {
), ),
); );
// // build
await tester.pumpAndSettle(); await tester.pump();
// //
expect(find.byType(RecordingButton), findsOneWidget); expect(find.byType(RecordingButton), findsOneWidget);
// // rounded
expect(find.byIcon(Icons.mic), findsOneWidget); expect(find.byIcon(Icons.mic_rounded), findsOneWidget);
// //
expect(find.byIcon(Icons.stop), findsNothing); expect(find.byIcon(Icons.stop_rounded), findsNothing);
}); });
testWidgets('应该正确处理点击事件', (WidgetTester tester) async { testWidgets('应该正确处理点击事件', (WidgetTester tester) async {
@ -58,10 +58,12 @@ void main() {
), ),
); );
await tester.pumpAndSettle(); await tester.pump();
// //
await tester.tap(find.byType(RecordingButton), warnIfMissed: false); await tester.tap(find.byType(RecordingButton), warnIfMissed: false);
// MockSpeechService.initialize 100ms
await tester.pump(const Duration(milliseconds: 150));
await tester.pump(); 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.pump();
// //
await tester.pumpAndSettle(); expect(find.byIcon(Icons.mic_rounded), findsOneWidget);
//
expect(find.byType(RecordingButton), 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); mockService.mockStatusChange(false);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// // rounded
expect(find.byType(RecordingButton), findsOneWidget); expect(find.byIcon(Icons.mic_rounded), findsOneWidget);
}); });
testWidgets('应该正确处理错误', (WidgetTester tester) async { 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( mockService.mockError(
@ -160,12 +167,13 @@ void main() {
), ),
); );
await tester.pumpAndSettle();
//
mockService.mockStatusChange(true);
await tester.pump(); 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(statusChanged, true);
expect(lastStatus, 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); expect(errorReceived, true);