feat: 新增多实例会话管理支持 v1.0.4

🎉 **重大功能更新**
- 支持同一页面多个独立的录音会话
- 每个 RecordingButton 可以有独立的 sessionId
- 智能会话切换,同一时间只有一个会话活跃

🆕 **YxAsrService 新增API**
- registerSession() - 注册新会话
- unregisterSession() - 注销会话
- startListeningForSession() - 会话级录音控制
- stopListeningForSession() - 会话级停止控制
- getResultStreamForSession() - 获取会话结果流
- getStatusStreamForSession() - 获取会话状态流
- getRegisteredSessions() - 获取所有会话列表
- getActiveSessionId() - 获取当前活跃会话

🔧 **RecordingButton 增强**
- 新增 sessionId 参数支持多实例场景
- 自动会话注册和资源清理
- 独立的流订阅机制
- 完全向后兼容

 **解决的问题**
- 同一页面多个录音按钮状态同步问题
- 识别结果精确分发到对应按钮
- 会话间状态互相干扰问题

📈 **版本升级**
- 版本号: 1.0.2 → 1.0.4
- 无破坏性更改,现有代码无需修改
This commit is contained in:
Max 2025-10-23 18:06:01 +08:00
parent cd6d31457f
commit 5e61eed30f
6 changed files with 513 additions and 127 deletions

View File

@ -0,0 +1,31 @@
Extension Discovery Cache
=========================
This folder is used by `package:extension_discovery` to cache lists of
packages that contains extensions for other packages.
DO NOT USE THIS FOLDER
----------------------
* Do not read (or rely) the contents of this folder.
* Do write to this folder.
If you're interested in the lists of extensions stored in this folder use the
API offered by package `extension_discovery` to get this information.
If this package doesn't work for your use-case, then don't try to read the
contents of this folder. It may change, and will not remain stable.
Use package `extension_discovery`
---------------------------------
If you want to access information from this folder.
Feel free to delete this folder
-------------------------------
Files in this folder act as a cache, and the cache is discarded if the files
are older than the modification time of `.dart_tool/package_config.json`.
Hence, it should never be necessary to clear this cache manually, if you find a
need to do please file a bug.

View File

@ -0,0 +1 @@
{"version":2,"entries":[{"package":"yx_asr","rootUri":"../","packageUri":"lib/"}]}

View File

@ -5,6 +5,51 @@ 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.4] - 2025-01-23
### 🎉 Added - 多实例会话管理支持
- **会话管理系统**:新增完整的会话管理功能,支持同一页面多个独立的录音会话
- **YxAsrService 新方法**
- `registerSession()` - 注册新会话
- `unregisterSession()` - 注销会话
- `startListeningForSession()` - 为特定会话开始录音
- `stopListeningForSession()` - 为特定会话停止录音
- `getResultStreamForSession()` - 获取特定会话的结果流
- `getStatusStreamForSession()` - 获取特定会话的状态流
- `getRegisteredSessions()` - 获取所有已注册的会话列表
- `getActiveSessionId()` - 获取当前活跃的会话ID
- **RecordingButton 新参数**
- `sessionId` - 会话ID参数用于标识不同的录音实例
- **智能会话切换**:自动管理会话切换,同一时间只有一个会话活跃
### 🐛 Fixed
- 修复同一页面多个 RecordingButton 状态同步问题
- 修复录音结果分发到错误按钮的问题
- 修复会话间状态互相干扰的问题
### ⚡ Improved
- 优化会话切换逻辑,无缝切换不同会话
- 改进资源管理,会话注销时自动清理资源
- 增强错误处理,会话级别的错误追踪
### 📝 Changed
- 保持完全向后兼容,现有代码无需修改
- 识别结果同时发送到全局流和会话流(双重分发机制)
### 🎯 Use Cases
此版本主要解决以下场景:
- ✅ 同一页面有多个录音输入框(如:问题反馈 + 本月总结)
- ✅ 多个 RecordingButton 需要独立工作,互不干扰
- ✅ 需要追踪特定录音按钮的状态和结果
### 💡 Breaking Changes
无破坏性更改,完全向后兼容。
### 📚 Documentation
- 新增会话管理API文档
- 更新使用示例,包含多实例场景
- 添加会话管理最佳实践指南
## [1.0.0] - 2025-08-26 ## [1.0.0] - 2025-08-26
### Added ### Added

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../interfaces/speech_recognition_service.dart'; import '../interfaces/speech_recognition_service.dart';
@ -13,6 +15,10 @@ class RecordingButton extends StatefulWidget {
/// YxAsrService /// YxAsrService
final SpeechRecognitionService? speechService; final SpeechRecognitionService? speechService;
/// 🆕 ID
///
final String? sessionId;
/// 'assets/models' /// 'assets/models'
final String modelPath; final String modelPath;
@ -58,6 +64,7 @@ class RecordingButton extends StatefulWidget {
const RecordingButton({ const RecordingButton({
super.key, super.key,
this.speechService, this.speechService,
this.sessionId, // 🆕 ID
this.modelPath = 'assets/models', this.modelPath = 'assets/models',
this.onResult, this.onResult,
this.onError, this.onError,
@ -78,18 +85,27 @@ class RecordingButton extends StatefulWidget {
State<RecordingButton> createState() => _RecordingButtonState(); State<RecordingButton> createState() => _RecordingButtonState();
} }
class _RecordingButtonState extends State<RecordingButton> class _RecordingButtonState extends State<RecordingButton> with TickerProviderStateMixin {
with TickerProviderStateMixin {
late SpeechRecognitionService _speechService; late SpeechRecognitionService _speechService;
late String _sessionId; // 🆕 ID
bool _isListening = false; bool _isListening = false;
bool _isInitialized = false; bool _isInitialized = false;
bool _isProcessing = false; // bool _isProcessing = false; //
late AnimationController _animationController; late AnimationController _animationController;
late Animation<double> _scaleAnimation; late Animation<double> _scaleAnimation;
// 🆕
StreamSubscription<SpeechRecognitionResult>? _resultSubscription;
StreamSubscription<bool>? _statusSubscription;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// 🆕 使ID
_sessionId = widget.sessionId ??
'btn_${widget.key?.toString() ?? DateTime.now().millisecondsSinceEpoch}';
_setupAnimation(); _setupAnimation();
_initializeService(); _initializeService();
} }
@ -102,10 +118,7 @@ class _RecordingButtonState extends State<RecordingButton>
_scaleAnimation = Tween<double>( _scaleAnimation = Tween<double>(
begin: 1.0, begin: 1.0,
end: 1.1, end: 1.1,
).animate(CurvedAnimation( ).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut));
parent: _animationController,
curve: Curves.easeInOut,
));
} }
Future<void> _initializeService() async { Future<void> _initializeService() async {
@ -117,46 +130,80 @@ class _RecordingButtonState extends State<RecordingButton>
if (!await _speechService.hasPermission()) { if (!await _speechService.hasPermission()) {
final granted = await _speechService.requestPermission(); final granted = await _speechService.requestPermission();
if (!granted) { if (!granted) {
widget.onError?.call(SpeechRecognitionError( widget.onError?.call(
errorType: SpeechRecognitionErrorType.permissionDenied, SpeechRecognitionError(
errorMsg: '麦克风权限被拒绝', errorType: SpeechRecognitionErrorType.permissionDenied,
errorCode: null, errorMsg: '麦克风权限被拒绝',
)); errorCode: null,
),
);
return; return;
} }
} }
// //
final success = await _speechService.initialize({ final success = await _speechService.initialize({'modelPath': widget.modelPath});
'modelPath': widget.modelPath,
});
if (success) { if (success) {
// // 🆕 使
_speechService.onResult.listen(widget.onResult ?? (_) {}); if (_speechService is YxAsrService) {
_speechService.onError.listen(widget.onError ?? (_) {}); final service = _speechService as YxAsrService;
_speechService.onListeningStatusChanged.listen((isListening) {
setState(() { //
_isListening = isListening; service.registerSession(sessionId: _sessionId);
debugPrint('🎤 [RecordingButton] 注册会话: $_sessionId');
// 🆕
_resultSubscription = service.getResultStreamForSession(_sessionId).listen((result) {
debugPrint('🎤 [RecordingButton $_sessionId] 收到结果: ${result.recognizedWords}');
widget.onResult?.call(result);
}); });
if (isListening) {
_animationController.forward(); // 🆕
} else { _statusSubscription = service.getStatusStreamForSession(_sessionId).listen((isListening) {
_animationController.reverse(); debugPrint('🎤 [RecordingButton $_sessionId] 状态变化: $isListening');
} setState(() {
widget.onListeningStatusChanged?.call(isListening); _isListening = isListening;
}); });
if (isListening) {
_animationController.forward();
} else {
_animationController.reverse();
}
widget.onListeningStatusChanged?.call(isListening);
});
//
service.onError.listen(widget.onError ?? (_) {});
} else {
// 使
_resultSubscription = _speechService.onResult.listen(widget.onResult ?? (_) {});
_statusSubscription = _speechService.onListeningStatusChanged.listen((isListening) {
setState(() {
_isListening = isListening;
});
if (isListening) {
_animationController.forward();
} else {
_animationController.reverse();
}
widget.onListeningStatusChanged?.call(isListening);
});
_speechService.onError.listen(widget.onError ?? (_) {});
}
} }
setState(() { setState(() {
_isInitialized = success; _isInitialized = success;
}); });
} catch (e) { } catch (e) {
widget.onError?.call(SpeechRecognitionError( widget.onError?.call(
errorType: SpeechRecognitionErrorType.service, SpeechRecognitionError(
errorMsg: '初始化失败: $e', errorType: SpeechRecognitionErrorType.service,
errorCode: null, errorMsg: '初始化失败: $e',
)); errorCode: null,
),
);
} }
} }
@ -178,19 +225,30 @@ class _RecordingButtonState extends State<RecordingButton>
_animationController.reverse(); _animationController.reverse();
}); });
if (_isListening) { // 🆕 使
await _speechService.stopListening(); if (_speechService is YxAsrService) {
final service = _speechService as YxAsrService;
if (_isListening) {
await service.stopListeningForSession(_sessionId);
} else {
await service.startListeningForSession(_sessionId, partialResults: widget.partialResults);
}
} else { } else {
await _speechService.startListening( // 使
partialResults: widget.partialResults, if (_isListening) {
); await _speechService.stopListening();
} else {
await _speechService.startListening(partialResults: widget.partialResults);
}
} }
} catch (e) { } catch (e) {
widget.onError?.call(SpeechRecognitionError( widget.onError?.call(
errorType: SpeechRecognitionErrorType.service, SpeechRecognitionError(
errorMsg: '切换录音状态失败: $e', errorType: SpeechRecognitionErrorType.service,
errorCode: null, errorMsg: '切换录音状态失败: $e',
)); errorCode: null,
),
);
} finally { } finally {
// //
Future.delayed(const Duration(milliseconds: 300), () { Future.delayed(const Duration(milliseconds: 300), () {
@ -255,11 +313,10 @@ class _RecordingButtonState extends State<RecordingButton>
height: widget.size, height: widget.size,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
AlwaysStoppedAnimation<Color>(Colors.white),
), ),
), ),
) ),
], ],
), ),
), ),
@ -269,10 +326,7 @@ class _RecordingButtonState extends State<RecordingButton>
); );
if (widget.tooltip != null) { if (widget.tooltip != null) {
button = Tooltip( button = Tooltip(message: widget.tooltip!, child: button);
message: widget.tooltip!,
child: button,
);
} }
return button; return button;
@ -280,6 +334,17 @@ class _RecordingButtonState extends State<RecordingButton>
@override @override
void dispose() { void dispose() {
// 🆕
_resultSubscription?.cancel();
_statusSubscription?.cancel();
// 🆕
if (_speechService is YxAsrService) {
final service = _speechService as YxAsrService;
service.unregisterSession(_sessionId);
debugPrint('🎤 [RecordingButton] 注销会话: $_sessionId');
}
_animationController.dispose(); _animationController.dispose();
super.dispose(); super.dispose();
} }

View File

@ -158,8 +158,7 @@ class AdvancedRecognitionConfig {
); );
/// ///
static const AdvancedRecognitionConfig highQuality = static const AdvancedRecognitionConfig highQuality = AdvancedRecognitionConfig(
AdvancedRecognitionConfig(
decodingMethod: DecodingMethod.modifiedBeamSearch, decodingMethod: DecodingMethod.modifiedBeamSearch,
maxActivePaths: 8, maxActivePaths: 8,
enableEndpoint: true, enableEndpoint: true,
@ -172,12 +171,7 @@ class AdvancedRecognitionConfig {
} }
/// ///
enum RecognitionState { enum RecognitionState { idle, processing, listening, error }
idle,
processing,
listening,
error,
}
/// ///
class RecognitionResult { class RecognitionResult {
@ -185,17 +179,37 @@ class RecognitionResult {
final double confidence; final double confidence;
final DateTime timestamp; final DateTime timestamp;
RecognitionResult({ RecognitionResult({required this.text, required this.confidence, required this.timestamp});
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(),
}; };
}
/// 🆕
///
class _SessionState {
///
final String sessionId;
///
bool isActive;
///
String lastRecognizedText;
///
final DateTime createdAt;
_SessionState(this.sessionId)
: isActive = false,
lastRecognizedText = '',
createdAt = DateTime.now();
@override
String toString() => '_SessionState(id: $sessionId, active: $isActive, created: $createdAt)';
} }
/// sherpa_onnx /// sherpa_onnx
@ -230,21 +244,32 @@ class YxAsrService implements SpeechRecognitionService {
SampleRate _sampleRate = SampleRate.standard; SampleRate _sampleRate = SampleRate.standard;
// //
AdvancedRecognitionConfig _advancedConfig = AdvancedRecognitionConfig _advancedConfig = AdvancedRecognitionConfig.balanced;
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 = final StreamController<bool> _statusController = StreamController<bool>.broadcast();
StreamController<bool>.broadcast();
// //
Timer? _recognitionTimer; Timer? _recognitionTimer;
StreamSubscription<Uint8List>? _audioSubscription; StreamSubscription<Uint8List>? _audioSubscription;
// 🆕
///
final Map<String, _SessionState> _sessions = {};
/// ID
String? _activeSessionId;
///
final Map<String, StreamController<SpeechRecognitionResult>> _sessionResultControllers = {};
///
final Map<String, StreamController<bool>> _sessionStatusControllers = {};
/// ///
Future<bool> isAvailable() async { Future<bool> isAvailable() async {
try { try {
@ -287,8 +312,7 @@ class YxAsrService implements SpeechRecognitionService {
/// [speed] - /// [speed] -
void setRecognitionSpeed(RecognitionSpeed speed) { void setRecognitionSpeed(RecognitionSpeed speed) {
_recognitionSpeed = speed; _recognitionSpeed = speed;
debugPrint( debugPrint('🔧 [YxAsr] 识别速度设置为: ${speed.description} (${speed.milliseconds}ms)');
'🔧 [YxAsr] 识别速度设置为: ${speed.description} (${speed.milliseconds}ms)');
} }
/// ///
@ -300,8 +324,7 @@ class YxAsrService implements SpeechRecognitionService {
/// ///
void setSampleRate(SampleRate sampleRate) { void setSampleRate(SampleRate sampleRate) {
_sampleRate = sampleRate; _sampleRate = sampleRate;
debugPrint( debugPrint('🔧 [YxAsr] 采样率设置为: ${sampleRate.description} (${sampleRate.hz}Hz)');
'🔧 [YxAsr] 采样率设置为: ${sampleRate.description} (${sampleRate.hz}Hz)');
} }
/// ///
@ -364,22 +387,31 @@ class YxAsrService implements SpeechRecognitionService {
if (!permissionInfo['isGranted']) { if (!permissionInfo['isGranted']) {
if (permissionInfo['isPermanentlyDenied']) { if (permissionInfo['isPermanentlyDenied']) {
print('❌ [YxAsr] 麦克风权限被永久拒绝,需要用户手动在设置中开启'); print('❌ [YxAsr] 麦克风权限被永久拒绝,需要用户手动在设置中开启');
_sendError(SpeechRecognitionErrorType.permissionDenied, _sendError(
'麦克风权限被永久拒绝,请在设置中手动开启麦克风权限', 'PERMISSION_PERMANENTLY_DENIED'); SpeechRecognitionErrorType.permissionDenied,
'麦克风权限被永久拒绝,请在设置中手动开启麦克风权限',
'PERMISSION_PERMANENTLY_DENIED',
);
return false; return false;
} else if (permissionInfo['canRequest']) { } else if (permissionInfo['canRequest']) {
print('⚠️ [YxAsr] 麦克风权限未授予,尝试请求权限...'); print('⚠️ [YxAsr] 麦克风权限未授予,尝试请求权限...');
final granted = await requestPermission(); final granted = await requestPermission();
if (!granted) { if (!granted) {
print('❌ [YxAsr] 用户拒绝了麦克风权限'); print('❌ [YxAsr] 用户拒绝了麦克风权限');
_sendError(SpeechRecognitionErrorType.permissionDenied, _sendError(
'需要麦克风权限才能进行语音识别,请允许应用访问麦克风', 'PERMISSION_DENIED'); SpeechRecognitionErrorType.permissionDenied,
'需要麦克风权限才能进行语音识别,请允许应用访问麦克风',
'PERMISSION_DENIED',
);
return false; return false;
} }
} else { } else {
print('❌ [YxAsr] 麦克风权限受限'); print('❌ [YxAsr] 麦克风权限受限');
_sendError(SpeechRecognitionErrorType.permissionDenied, _sendError(
'麦克风权限受限,无法进行语音识别', 'PERMISSION_RESTRICTED'); SpeechRecognitionErrorType.permissionDenied,
'麦克风权限受限,无法进行语音识别',
'PERMISSION_RESTRICTED',
);
return false; return false;
} }
} }
@ -408,17 +440,12 @@ class YxAsrService implements SpeechRecognitionService {
// //
print('🔍 [YxAsr] 构建识别器配置...'); print('🔍 [YxAsr] 构建识别器配置...');
debugPrint('🔧 [YxAsr] 使用采样率: ${_sampleRate.description}'); debugPrint('🔧 [YxAsr] 使用采样率: ${_sampleRate.description}');
debugPrint( debugPrint('🔧 [YxAsr] 解码方法: ${_advancedConfig.decodingMethod.description}');
'🔧 [YxAsr] 解码方法: ${_advancedConfig.decodingMethod.description}');
debugPrint('🔧 [YxAsr] 最大活跃路径: ${_advancedConfig.maxActivePaths}'); debugPrint('🔧 [YxAsr] 最大活跃路径: ${_advancedConfig.maxActivePaths}');
debugPrint( debugPrint('🔧 [YxAsr] 端点检测: ${_advancedConfig.enableEndpoint ? "启用" : "禁用"}');
'🔧 [YxAsr] 端点检测: ${_advancedConfig.enableEndpoint ? "启用" : "禁用"}');
final config = OnlineRecognizerConfig( final config = OnlineRecognizerConfig(
feat: FeatureConfig( feat: FeatureConfig(sampleRate: _sampleRate.hz, featureDim: _advancedConfig.featureDim),
sampleRate: _sampleRate.hz,
featureDim: _advancedConfig.featureDim,
),
model: OnlineModelConfig( model: OnlineModelConfig(
transducer: OnlineTransducerModelConfig( transducer: OnlineTransducerModelConfig(
encoder: encoderPath, encoder: encoderPath,
@ -471,9 +498,7 @@ class YxAsrService implements SpeechRecognitionService {
/// ///
/// [partialResults] - /// [partialResults] -
/// 使 setSampleRate() /// 使 setSampleRate()
Future<void> startListening({ Future<void> startListening({bool partialResults = true}) async {
bool partialResults = true,
}) async {
try { try {
if (!_isInitialized || _recognizer == null) { if (!_isInitialized || _recognizer == null) {
throw Exception('识别器未初始化,请先调用 initializeWithModel()'); throw Exception('识别器未初始化,请先调用 initializeWithModel()');
@ -597,6 +622,219 @@ class YxAsrService implements SpeechRecognitionService {
_statusController.add(false); _statusController.add(false);
} }
// ==================== 🆕 ====================
/// 🆕
///
/// RecordingButton
/// [sessionId] -
/// 使ID
String registerSession({String? sessionId}) {
final id = sessionId ?? 'session_${DateTime.now().millisecondsSinceEpoch}';
if (!_sessions.containsKey(id)) {
_sessions[id] = _SessionState(id);
_sessionResultControllers[id] = StreamController<SpeechRecognitionResult>.broadcast();
_sessionStatusControllers[id] = StreamController<bool>.broadcast();
debugPrint('📝 [YxAsr] 注册会话: $id (总计: ${_sessions.length} 个会话)');
} else {
debugPrint('⚠️ [YxAsr] 会话 $id 已存在,跳过注册');
}
return id;
}
/// 🆕
///
///
/// [sessionId] - ID
Future<void> unregisterSession(String sessionId) async {
if (!_sessions.containsKey(sessionId)) {
debugPrint('⚠️ [YxAsr] 会话 $sessionId 不存在,无需注销');
return;
}
//
if (_activeSessionId == sessionId && _isListening) {
debugPrint('⚠️ [YxAsr] 注销活跃会话 $sessionId,先停止录音');
await stopListeningForSession(sessionId);
}
//
await _sessionResultControllers[sessionId]?.close();
await _sessionStatusControllers[sessionId]?.close();
//
_sessions.remove(sessionId);
_sessionResultControllers.remove(sessionId);
_sessionStatusControllers.remove(sessionId);
debugPrint('🗑️ [YxAsr] 注销会话: $sessionId (剩余: ${_sessions.length} 个会话)');
}
/// 🆕
///
/// [sessionId] - ID
/// [partialResults] -
Future<void> startListeningForSession(String sessionId, {bool partialResults = true}) async {
try {
//
if (!_sessions.containsKey(sessionId)) {
throw Exception('会话 $sessionId 未注册,请先调用 registerSession()');
}
//
if (_activeSessionId != null && _activeSessionId != sessionId && _isListening) {
debugPrint('⚠️ [YxAsr] 停止其他会话 $_activeSessionId,切换到 $sessionId');
await stopListeningForSession(_activeSessionId!);
}
//
if (_activeSessionId == sessionId && _isListening) {
debugPrint('⚠️ [YxAsr] 会话 $sessionId 已在录音,忽略重复调用');
return;
}
//
_activeSessionId = sessionId;
_sessions[sessionId]!.isActive = true;
_sessions[sessionId]!.lastRecognizedText = '';
debugPrint('🎤 [YxAsr] 会话 $sessionId 开始录音 (partialResults: $partialResults)');
// startListening
await startListening(partialResults: partialResults);
//
_sessionStatusControllers[sessionId]?.add(true);
debugPrint('✅ [YxAsr] 会话 $sessionId 录音启动成功');
} catch (e) {
debugPrint('❌ [YxAsr] 会话 $sessionId 开始录音失败: $e');
//
if (_sessions.containsKey(sessionId)) {
_sessions[sessionId]!.isActive = false;
}
if (_activeSessionId == sessionId) {
_activeSessionId = null;
}
//
_sendErrorForSession(sessionId, SpeechRecognitionErrorType.service, '开始录音失败: $e', null);
rethrow;
}
}
/// 🆕
///
/// [sessionId] - ID
Future<void> stopListeningForSession(String sessionId) async {
//
if (!_sessions.containsKey(sessionId)) {
debugPrint('⚠️ [YxAsr] 会话 $sessionId 不存在,忽略停止请求');
return;
}
//
if (_activeSessionId != sessionId) {
debugPrint('⚠️ [YxAsr] 会话 $sessionId 不是活跃会话(活跃: $_activeSessionId),忽略停止请求');
return;
}
try {
debugPrint('🛑 [YxAsr] 会话 $sessionId 停止录音');
// stopListening
await stopListening();
//
_sessions[sessionId]!.isActive = false;
_activeSessionId = null;
//
_sessionStatusControllers[sessionId]?.add(false);
debugPrint('✅ [YxAsr] 会话 $sessionId 录音已停止');
} catch (e) {
debugPrint('❌ [YxAsr] 会话 $sessionId 停止录音失败: $e');
_sendErrorForSession(sessionId, SpeechRecognitionErrorType.service, '停止录音失败: $e', null);
}
}
/// 🆕
///
/// [sessionId] - ID
///
Stream<SpeechRecognitionResult> getResultStreamForSession(String sessionId) {
if (!_sessionResultControllers.containsKey(sessionId)) {
debugPrint('⚠️ [YxAsr] 会话 $sessionId 不存在,自动注册');
registerSession(sessionId: sessionId);
}
return _sessionResultControllers[sessionId]!.stream;
}
/// 🆕
///
/// [sessionId] - ID
///
Stream<bool> getStatusStreamForSession(String sessionId) {
if (!_sessionStatusControllers.containsKey(sessionId)) {
debugPrint('⚠️ [YxAsr] 会话 $sessionId 不存在,自动注册');
registerSession(sessionId: sessionId);
}
return _sessionStatusControllers[sessionId]!.stream;
}
/// 🆕
void _sendResultForSession(String sessionId, {required String recognizedWords}) {
if (_sessionResultControllers.containsKey(sessionId)) {
//
if (_sessions[sessionId]?.lastRecognizedText == recognizedWords) {
debugPrint('🔄 [YxAsr] 会话 $sessionId 跳过重复结果: "$recognizedWords"');
return;
}
//
if (_sessions.containsKey(sessionId)) {
_sessions[sessionId]!.lastRecognizedText = recognizedWords;
}
final result = SpeechRecognitionResult(recognizedWords: recognizedWords);
_sessionResultControllers[sessionId]!.add(result);
debugPrint('📤 [YxAsr] 会话 $sessionId 发送结果: "$recognizedWords"');
}
}
/// 🆕
void _sendErrorForSession(
String sessionId,
SpeechRecognitionErrorType errorType,
String errorMsg,
String? errorCode,
) {
final error = SpeechRecognitionError(
errorType: errorType,
errorMsg: '[会话 $sessionId] $errorMsg',
errorCode: errorCode,
);
//
_errorController.add(error);
debugPrint('❌ [YxAsr] 会话 $sessionId 错误: $errorMsg');
}
/// 🆕
List<String> getRegisteredSessions() {
return _sessions.keys.toList();
}
/// 🆕 ID
String? getActiveSessionId() {
return _activeSessionId;
}
// ==================== ====================
/// ///
bool get isListening => _isListening; bool get isListening => _isListening;
@ -652,19 +890,16 @@ class YxAsrService implements SpeechRecognitionService {
_audioSubscription = stream.listen( _audioSubscription = stream.listen(
(audioData) { (audioData) {
// //
if (_stream != null && if (_stream != null && _recognizer != null && _isListening && !_isStartingRecording) {
_recognizer != null &&
_isListening &&
!_isStartingRecording) {
// Float32List // Float32List
final samples = _convertToFloat32(audioData); final samples = _convertToFloat32(audioData);
debugPrint( debugPrint('🎵 [YxAsr] 接收音频数据: ${audioData.length} 字节, ${samples.length} 样本');
'🎵 [YxAsr] 接收音频数据: ${audioData.length} 字节, ${samples.length} 样本');
// //
_stream!.acceptWaveform(sampleRate: sampleRate, samples: samples); _stream!.acceptWaveform(sampleRate: sampleRate, samples: samples);
} 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',
);
} }
}, },
onError: (error) { onError: (error) {
@ -721,12 +956,15 @@ class YxAsrService implements SpeechRecognitionService {
/// ///
void _startRecognitionLoop(bool partialResults) { void _startRecognitionLoop(bool partialResults) {
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), (timer) { _recognitionTimer = Timer.periodic(Duration(milliseconds: _recognitionSpeed.milliseconds), (
timer,
) {
if (!_isListening || _stream == null || _recognizer == null) { if (!_isListening || _stream == null || _recognizer == null) {
debugPrint( debugPrint(
'🛑 [YxAsr] 识别循环停止: listening=$_isListening, stream=${_stream != null}, recognizer=${_recognizer != null}'); '🛑 [YxAsr] 识别循环停止: listening=$_isListening, stream=${_stream != null}, recognizer=${_recognizer != null}',
);
timer.cancel(); timer.cancel();
return; return;
} }
@ -743,16 +981,18 @@ class YxAsrService implements SpeechRecognitionService {
debugPrint('🔍 [YxAsr] 获取识别结果: "${result.text}"'); debugPrint('🔍 [YxAsr] 获取识别结果: "${result.text}"');
// //
if (result.text.isNotEmpty && if (result.text.isNotEmpty && partialResults && result.text != _lastRecognizedText) {
partialResults &&
result.text != _lastRecognizedText) {
debugPrint('🎤 [YxAsr] 发送实时识别结果: ${result.text}'); debugPrint('🎤 [YxAsr] 发送实时识别结果: ${result.text}');
_lastRecognizedText = result.text; // _lastRecognizedText = result.text; //
_sendResult(
recognizedWords: result.text, //
); _sendResult(recognizedWords: result.text);
} else if (result.text.isNotEmpty &&
result.text == _lastRecognizedText) { // 🆕
if (_activeSessionId != null) {
_sendResultForSession(_activeSessionId!, recognizedWords: result.text);
}
} else if (result.text.isNotEmpty && result.text == _lastRecognizedText) {
debugPrint('🔄 [YxAsr] 跳过重复识别结果: "${result.text}"'); debugPrint('🔄 [YxAsr] 跳过重复识别结果: "${result.text}"');
} }
@ -767,19 +1007,14 @@ class YxAsrService implements SpeechRecognitionService {
} }
/// ///
void _sendResult({ void _sendResult({required String recognizedWords}) {
required String recognizedWords,
}) {
debugPrint('📤 [YxAsr] 发送识别结果: "$recognizedWords"'); debugPrint('📤 [YxAsr] 发送识别结果: "$recognizedWords"');
final result = SpeechRecognitionResult( final result = SpeechRecognitionResult(recognizedWords: recognizedWords);
recognizedWords: recognizedWords,
);
_resultController.add(result); _resultController.add(result);
} }
/// ///
void _sendError(SpeechRecognitionErrorType errorType, String errorMsg, void _sendError(SpeechRecognitionErrorType errorType, String errorMsg, String? errorCode) {
String? errorCode) {
final error = SpeechRecognitionError( final error = SpeechRecognitionError(
errorType: errorType, errorType: errorType,
errorMsg: errorMsg, errorMsg: errorMsg,
@ -836,10 +1071,12 @@ class YxAsrService implements SpeechRecognitionService {
} catch (e) { } catch (e) {
debugPrint('❌ [YxAsr] 无法复制模型文件 $fileName: $e'); debugPrint('❌ [YxAsr] 无法复制模型文件 $fileName: $e');
// //
throw Exception('模型文件复制失败: $fileName\n' throw Exception(
'请确保模型文件存在于: $assetFile\n' '模型文件复制失败: $fileName\n'
'当前模型路径: $assetPath\n' '请确保模型文件存在于: $assetFile\n'
'支持的新模型: sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30'); '当前模型路径: $assetPath\n'
'支持的新模型: sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30',
);
} }
} else { } else {
debugPrint('⏭️ [YxAsr] 文件已存在,跳过: $fileName'); debugPrint('⏭️ [YxAsr] 文件已存在,跳过: $fileName');
@ -856,9 +1093,18 @@ class YxAsrService implements SpeechRecognitionService {
/// ///
Future<void> dispose() async { Future<void> dispose() async {
// 🆕
final sessionIds = _sessions.keys.toList();
for (final sessionId in sessionIds) {
await unregisterSession(sessionId);
}
//
await _cleanup(); await _cleanup();
await _resultController.close(); await _resultController.close();
await _errorController.close(); await _errorController.close();
await _statusController.close(); await _statusController.close();
debugPrint('🗑️ [YxAsr] 服务已完全清理');
} }
} }

View File

@ -1,10 +1,10 @@
name: yx_asr name: yx_asr
description: 基于 sherpa_onnx 的 Flutter 语音识别插件,提供完全离线的实时语音转文字功能。 description: 基于 sherpa_onnx 的 Flutter 语音识别插件,提供完全离线的实时语音转文字功能。支持多实例会话管理。
version: 1.0.2 version: 1.0.4
homepage: https://github.com/yuanxuan/yx_asr homepage: https://github.com/yuanxuan/yx_asr
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: ">=3.0.0 <4.0.0"
flutter: ">=3.0.0" flutter: ">=3.0.0"
dependencies: dependencies:
@ -22,5 +22,3 @@ dev_dependencies:
integration_test: integration_test:
sdk: flutter sdk: flutter
flutter_lints: ^3.0.0 flutter_lints: ^3.0.0
flutter: