diff --git a/.dart_tool/extension_discovery/README.md b/.dart_tool/extension_discovery/README.md new file mode 100644 index 0000000..9dc6757 --- /dev/null +++ b/.dart_tool/extension_discovery/README.md @@ -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. diff --git a/.dart_tool/extension_discovery/vs_code.json b/.dart_tool/extension_discovery/vs_code.json new file mode 100644 index 0000000..bbc8664 --- /dev/null +++ b/.dart_tool/extension_discovery/vs_code.json @@ -0,0 +1 @@ +{"version":2,"entries":[{"package":"yx_asr","rootUri":"../","packageUri":"lib/"}]} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a15ac..e4cc120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Added diff --git a/lib/src/widgets/recording_button.dart b/lib/src/widgets/recording_button.dart index 534a384..205f99d 100644 --- a/lib/src/widgets/recording_button.dart +++ b/lib/src/widgets/recording_button.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../interfaces/speech_recognition_service.dart'; @@ -13,6 +15,10 @@ class RecordingButton extends StatefulWidget { /// 语音识别服务实例(可选,默认创建 YxAsrService) final SpeechRecognitionService? speechService; + /// 🆕 会话ID(用于多实例场景,支持同一页面多个录音按钮独立工作) + /// 如果不提供,将自动生成 + final String? sessionId; + /// 模型路径(默认 'assets/models') final String modelPath; @@ -58,6 +64,7 @@ class RecordingButton extends StatefulWidget { const RecordingButton({ super.key, this.speechService, + this.sessionId, // 🆕 会话ID this.modelPath = 'assets/models', this.onResult, this.onError, @@ -78,18 +85,27 @@ class RecordingButton extends StatefulWidget { State createState() => _RecordingButtonState(); } -class _RecordingButtonState extends State - with TickerProviderStateMixin { +class _RecordingButtonState extends State with TickerProviderStateMixin { late SpeechRecognitionService _speechService; + late String _sessionId; // 🆕 会话ID bool _isListening = false; bool _isInitialized = false; bool _isProcessing = false; // 防抖标志 late AnimationController _animationController; late Animation _scaleAnimation; + // 🆕 会话流订阅 + StreamSubscription? _resultSubscription; + StreamSubscription? _statusSubscription; + @override void initState() { super.initState(); + + // 🆕 生成或使用提供的会话ID + _sessionId = widget.sessionId ?? + 'btn_${widget.key?.toString() ?? DateTime.now().millisecondsSinceEpoch}'; + _setupAnimation(); _initializeService(); } @@ -102,10 +118,7 @@ class _RecordingButtonState extends State _scaleAnimation = Tween( begin: 1.0, end: 1.1, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeInOut, - )); + ).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut)); } Future _initializeService() async { @@ -117,46 +130,80 @@ class _RecordingButtonState extends State if (!await _speechService.hasPermission()) { final granted = await _speechService.requestPermission(); if (!granted) { - widget.onError?.call(SpeechRecognitionError( - errorType: SpeechRecognitionErrorType.permissionDenied, - errorMsg: '麦克风权限被拒绝', - errorCode: null, - )); + widget.onError?.call( + SpeechRecognitionError( + errorType: SpeechRecognitionErrorType.permissionDenied, + errorMsg: '麦克风权限被拒绝', + errorCode: null, + ), + ); return; } } // 初始化服务 - final success = await _speechService.initialize({ - 'modelPath': widget.modelPath, - }); + final success = await _speechService.initialize({'modelPath': widget.modelPath}); if (success) { - // 监听事件 - _speechService.onResult.listen(widget.onResult ?? (_) {}); - _speechService.onError.listen(widget.onError ?? (_) {}); - _speechService.onListeningStatusChanged.listen((isListening) { - setState(() { - _isListening = isListening; + // 🆕 如果服务支持会话管理,使用会话流 + if (_speechService is YxAsrService) { + final service = _speechService as YxAsrService; + + // 注册会话 + 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 { - _animationController.reverse(); - } - widget.onListeningStatusChanged?.call(isListening); - }); + + // 🆕 监听该会话的状态流 + _statusSubscription = service.getStatusStreamForSession(_sessionId).listen((isListening) { + debugPrint('🎤 [RecordingButton $_sessionId] 状态变化: $isListening'); + setState(() { + _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(() { _isInitialized = success; }); } catch (e) { - widget.onError?.call(SpeechRecognitionError( - errorType: SpeechRecognitionErrorType.service, - errorMsg: '初始化失败: $e', - errorCode: null, - )); + widget.onError?.call( + SpeechRecognitionError( + errorType: SpeechRecognitionErrorType.service, + errorMsg: '初始化失败: $e', + errorCode: null, + ), + ); } } @@ -178,19 +225,30 @@ class _RecordingButtonState extends State _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 { - await _speechService.startListening( - partialResults: widget.partialResults, - ); + // 向后兼容:使用全局方法 + if (_isListening) { + await _speechService.stopListening(); + } else { + await _speechService.startListening(partialResults: widget.partialResults); + } } } catch (e) { - widget.onError?.call(SpeechRecognitionError( - errorType: SpeechRecognitionErrorType.service, - errorMsg: '切换录音状态失败: $e', - errorCode: null, - )); + widget.onError?.call( + SpeechRecognitionError( + errorType: SpeechRecognitionErrorType.service, + errorMsg: '切换录音状态失败: $e', + errorCode: null, + ), + ); } finally { // 延迟重置处理标志,确保有足够的防抖时间 Future.delayed(const Duration(milliseconds: 300), () { @@ -255,11 +313,10 @@ class _RecordingButtonState extends State height: widget.size, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation(Colors.white), + valueColor: AlwaysStoppedAnimation(Colors.white), ), ), - ) + ), ], ), ), @@ -269,10 +326,7 @@ class _RecordingButtonState extends State ); if (widget.tooltip != null) { - button = Tooltip( - message: widget.tooltip!, - child: button, - ); + button = Tooltip(message: widget.tooltip!, child: button); } return button; @@ -280,6 +334,17 @@ class _RecordingButtonState extends State @override void dispose() { + // 🆕 取消流订阅 + _resultSubscription?.cancel(); + _statusSubscription?.cancel(); + + // 🆕 注销会话 + if (_speechService is YxAsrService) { + final service = _speechService as YxAsrService; + service.unregisterSession(_sessionId); + debugPrint('🎤 [RecordingButton] 注销会话: $_sessionId'); + } + _animationController.dispose(); super.dispose(); } diff --git a/lib/src/yx_asr_service.dart b/lib/src/yx_asr_service.dart index d8da4a1..8942c01 100644 --- a/lib/src/yx_asr_service.dart +++ b/lib/src/yx_asr_service.dart @@ -158,8 +158,7 @@ class AdvancedRecognitionConfig { ); /// 高质量模式预设 - static const AdvancedRecognitionConfig highQuality = - AdvancedRecognitionConfig( + static const AdvancedRecognitionConfig highQuality = AdvancedRecognitionConfig( decodingMethod: DecodingMethod.modifiedBeamSearch, maxActivePaths: 8, enableEndpoint: true, @@ -172,12 +171,7 @@ class AdvancedRecognitionConfig { } /// 识别状态枚举 -enum RecognitionState { - idle, - processing, - listening, - error, -} +enum RecognitionState { idle, processing, listening, error } /// 识别结果类 class RecognitionResult { @@ -185,17 +179,37 @@ class RecognitionResult { final double confidence; final DateTime timestamp; - RecognitionResult({ - required this.text, - required this.confidence, - required this.timestamp, - }); + RecognitionResult({required this.text, required this.confidence, required this.timestamp}); Map toJson() => { - 'text': text, - 'confidence': confidence, - 'timestamp': timestamp.toIso8601String(), - }; + 'text': text, + 'confidence': confidence, + 'timestamp': timestamp.toIso8601String(), + }; +} + +/// 🆕 单个会话的状态管理 +/// 用于支持同一页面多个独立的录音会话 +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 的完整语音识别实现 @@ -230,21 +244,32 @@ class YxAsrService implements SpeechRecognitionService { SampleRate _sampleRate = SampleRate.standard; // 高级识别配置 - AdvancedRecognitionConfig _advancedConfig = - AdvancedRecognitionConfig.balanced; + AdvancedRecognitionConfig _advancedConfig = AdvancedRecognitionConfig.balanced; // 事件流控制器 final StreamController _resultController = StreamController.broadcast(); final StreamController _errorController = StreamController.broadcast(); - final StreamController _statusController = - StreamController.broadcast(); + final StreamController _statusController = StreamController.broadcast(); // 定时器和订阅 Timer? _recognitionTimer; StreamSubscription? _audioSubscription; + // 🆕 会话管理相关字段 + /// 所有已注册的会话状态 + final Map _sessions = {}; + + /// 当前活跃的会话ID(同一时间只能有一个会话录音) + String? _activeSessionId; + + /// 每个会话的结果流控制器 + final Map> _sessionResultControllers = {}; + + /// 每个会话的状态流控制器 + final Map> _sessionStatusControllers = {}; + /// 检查语音识别是否可用 Future isAvailable() async { try { @@ -287,8 +312,7 @@ 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)'); } /// 获取当前识别速度 @@ -300,8 +324,7 @@ class YxAsrService implements SpeechRecognitionService { /// 注意:需要在初始化之前设置,或重新初始化后生效 void setSampleRate(SampleRate sampleRate) { _sampleRate = sampleRate; - debugPrint( - '🔧 [YxAsr] 采样率设置为: ${sampleRate.description} (${sampleRate.hz}Hz)'); + debugPrint('🔧 [YxAsr] 采样率设置为: ${sampleRate.description} (${sampleRate.hz}Hz)'); } /// 获取当前采样率 @@ -364,22 +387,31 @@ class YxAsrService implements SpeechRecognitionService { if (!permissionInfo['isGranted']) { if (permissionInfo['isPermanentlyDenied']) { print('❌ [YxAsr] 麦克风权限被永久拒绝,需要用户手动在设置中开启'); - _sendError(SpeechRecognitionErrorType.permissionDenied, - '麦克风权限被永久拒绝,请在设置中手动开启麦克风权限', 'PERMISSION_PERMANENTLY_DENIED'); + _sendError( + SpeechRecognitionErrorType.permissionDenied, + '麦克风权限被永久拒绝,请在设置中手动开启麦克风权限', + 'PERMISSION_PERMANENTLY_DENIED', + ); return false; } else if (permissionInfo['canRequest']) { print('⚠️ [YxAsr] 麦克风权限未授予,尝试请求权限...'); final granted = await requestPermission(); if (!granted) { print('❌ [YxAsr] 用户拒绝了麦克风权限'); - _sendError(SpeechRecognitionErrorType.permissionDenied, - '需要麦克风权限才能进行语音识别,请允许应用访问麦克风', 'PERMISSION_DENIED'); + _sendError( + SpeechRecognitionErrorType.permissionDenied, + '需要麦克风权限才能进行语音识别,请允许应用访问麦克风', + 'PERMISSION_DENIED', + ); return false; } } else { print('❌ [YxAsr] 麦克风权限受限'); - _sendError(SpeechRecognitionErrorType.permissionDenied, - '麦克风权限受限,无法进行语音识别', 'PERMISSION_RESTRICTED'); + _sendError( + SpeechRecognitionErrorType.permissionDenied, + '麦克风权限受限,无法进行语音识别', + 'PERMISSION_RESTRICTED', + ); return false; } } @@ -408,17 +440,12 @@ 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, @@ -471,9 +498,7 @@ class YxAsrService implements SpeechRecognitionService { /// /// [partialResults] - 是否返回部分结果 /// 采样率使用 setSampleRate() 设置的值 - Future startListening({ - bool partialResults = true, - }) async { + Future startListening({bool partialResults = true}) async { try { if (!_isInitialized || _recognizer == null) { throw Exception('识别器未初始化,请先调用 initializeWithModel()'); @@ -597,6 +622,219 @@ class YxAsrService implements SpeechRecognitionService { _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.broadcast(); + _sessionStatusControllers[id] = StreamController.broadcast(); + debugPrint('📝 [YxAsr] 注册会话: $id (总计: ${_sessions.length} 个会话)'); + } else { + debugPrint('⚠️ [YxAsr] 会话 $id 已存在,跳过注册'); + } + + return id; + } + + /// 🆕 注销会话 + /// + /// 释放会话相关的所有资源 + /// [sessionId] - 要注销的会话ID + Future 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 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 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 getResultStreamForSession(String sessionId) { + if (!_sessionResultControllers.containsKey(sessionId)) { + debugPrint('⚠️ [YxAsr] 会话 $sessionId 不存在,自动注册'); + registerSession(sessionId: sessionId); + } + return _sessionResultControllers[sessionId]!.stream; + } + + /// 🆕 获取特定会话的状态流 + /// + /// [sessionId] - 会话ID + /// 返回该会话的状态变化流 + Stream 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 getRegisteredSessions() { + return _sessions.keys.toList(); + } + + /// 🆕 获取当前活跃的会话ID + String? getActiveSessionId() { + return _activeSessionId; + } + + // ==================== 原有方法 ==================== + /// 是否正在监听 bool get isListening => _isListening; @@ -652,19 +890,16 @@ class YxAsrService implements SpeechRecognitionService { _audioSubscription = stream.listen( (audioData) { // 多重状态检查,确保所有条件都满足 - if (_stream != null && - _recognizer != null && - _isListening && - !_isStartingRecording) { + if (_stream != null && _recognizer != null && _isListening && !_isStartingRecording) { // 将音频数据转换为 Float32List 格式 final samples = _convertToFloat32(audioData); - debugPrint( - '🎵 [YxAsr] 接收音频数据: ${audioData.length} 字节, ${samples.length} 样本'); + debugPrint('🎵 [YxAsr] 接收音频数据: ${audioData.length} 字节, ${samples.length} 样本'); // 发送音频数据到识别器进行处理 _stream!.acceptWaveform(sampleRate: sampleRate, samples: samples); } else { 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) { @@ -721,12 +956,15 @@ class YxAsrService implements SpeechRecognitionService { /// 开始识别循环处理 void _startRecognitionLoop(bool partialResults) { debugPrint( - '🔄 [YxAsr] 开始识别循环, partialResults: $partialResults, 速度: ${_recognitionSpeed.description} (${_recognitionSpeed.milliseconds}ms)'); - _recognitionTimer = Timer.periodic( - Duration(milliseconds: _recognitionSpeed.milliseconds), (timer) { + '🔄 [YxAsr] 开始识别循环, partialResults: $partialResults, 速度: ${_recognitionSpeed.description} (${_recognitionSpeed.milliseconds}ms)', + ); + _recognitionTimer = Timer.periodic(Duration(milliseconds: _recognitionSpeed.milliseconds), ( + timer, + ) { if (!_isListening || _stream == null || _recognizer == null) { debugPrint( - '🛑 [YxAsr] 识别循环停止: listening=$_isListening, stream=${_stream != null}, recognizer=${_recognizer != null}'); + '🛑 [YxAsr] 识别循环停止: listening=$_isListening, stream=${_stream != null}, recognizer=${_recognizer != null}', + ); timer.cancel(); return; } @@ -743,16 +981,18 @@ 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; // 更新最后识别的文本 - _sendResult( - recognizedWords: result.text, - ); - } else if (result.text.isNotEmpty && - result.text == _lastRecognizedText) { + + // 发送到全局流(向后兼容) + _sendResult(recognizedWords: result.text); + + // 🆕 如果有活跃会话,同时发送到该会话的流 + if (_activeSessionId != null) { + _sendResultForSession(_activeSessionId!, recognizedWords: result.text); + } + } else if (result.text.isNotEmpty && result.text == _lastRecognizedText) { debugPrint('🔄 [YxAsr] 跳过重复识别结果: "${result.text}"'); } @@ -767,19 +1007,14 @@ class YxAsrService implements SpeechRecognitionService { } /// 发送识别结果到结果流 - void _sendResult({ - required String recognizedWords, - }) { + void _sendResult({required String recognizedWords}) { debugPrint('📤 [YxAsr] 发送识别结果: "$recognizedWords"'); - final result = SpeechRecognitionResult( - recognizedWords: recognizedWords, - ); + final result = SpeechRecognitionResult(recognizedWords: recognizedWords); _resultController.add(result); } /// 发送错误信息到错误流 - void _sendError(SpeechRecognitionErrorType errorType, String errorMsg, - String? errorCode) { + void _sendError(SpeechRecognitionErrorType errorType, String errorMsg, String? errorCode) { final error = SpeechRecognitionError( errorType: errorType, errorMsg: errorMsg, @@ -836,10 +1071,12 @@ class YxAsrService implements SpeechRecognitionService { } catch (e) { debugPrint('❌ [YxAsr] 无法复制模型文件 $fileName: $e'); // 提供更详细的错误信息 - throw Exception('模型文件复制失败: $fileName\n' - '请确保模型文件存在于: $assetFile\n' - '当前模型路径: $assetPath\n' - '支持的新模型: sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30'); + throw Exception( + '模型文件复制失败: $fileName\n' + '请确保模型文件存在于: $assetFile\n' + '当前模型路径: $assetPath\n' + '支持的新模型: sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30', + ); } } else { debugPrint('⏭️ [YxAsr] 文件已存在,跳过: $fileName'); @@ -856,9 +1093,18 @@ class YxAsrService implements SpeechRecognitionService { /// 释放所有资源并关闭流 Future dispose() async { + // 🆕 清理所有会话 + final sessionIds = _sessions.keys.toList(); + for (final sessionId in sessionIds) { + await unregisterSession(sessionId); + } + + // 清理原有资源 await _cleanup(); await _resultController.close(); await _errorController.close(); await _statusController.close(); + + debugPrint('🗑️ [YxAsr] 服务已完全清理'); } } diff --git a/pubspec.yaml b/pubspec.yaml index 056c035..b4e5c36 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,10 +1,10 @@ name: yx_asr -description: 基于 sherpa_onnx 的 Flutter 语音识别插件,提供完全离线的实时语音转文字功能。 -version: 1.0.2 +description: 基于 sherpa_onnx 的 Flutter 语音识别插件,提供完全离线的实时语音转文字功能。支持多实例会话管理。 +version: 1.0.4 homepage: https://github.com/yuanxuan/yx_asr environment: - sdk: '>=3.0.0 <4.0.0' + sdk: ">=3.0.0 <4.0.0" flutter: ">=3.0.0" dependencies: @@ -22,5 +22,3 @@ dev_dependencies: integration_test: sdk: flutter flutter_lints: ^3.0.0 - -flutter: