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/),
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

View File

@ -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<RecordingButton> createState() => _RecordingButtonState();
}
class _RecordingButtonState extends State<RecordingButton>
with TickerProviderStateMixin {
class _RecordingButtonState extends State<RecordingButton> with TickerProviderStateMixin {
late SpeechRecognitionService _speechService;
late String _sessionId; // 🆕 ID
bool _isListening = false;
bool _isInitialized = false;
bool _isProcessing = false; //
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
// 🆕
StreamSubscription<SpeechRecognitionResult>? _resultSubscription;
StreamSubscription<bool>? _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<RecordingButton>
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 1.1,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut));
}
Future<void> _initializeService() async {
@ -117,46 +130,80 @@ class _RecordingButtonState extends State<RecordingButton>
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<RecordingButton>
_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<RecordingButton>
height: widget.size,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
)
),
],
),
),
@ -269,10 +326,7 @@ class _RecordingButtonState extends State<RecordingButton>
);
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<RecordingButton>
@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();
}

View File

@ -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<String, dynamic> 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<SpeechRecognitionResult> _resultController =
StreamController<SpeechRecognitionResult>.broadcast();
final StreamController<SpeechRecognitionError> _errorController =
StreamController<SpeechRecognitionError>.broadcast();
final StreamController<bool> _statusController =
StreamController<bool>.broadcast();
final StreamController<bool> _statusController = StreamController<bool>.broadcast();
//
Timer? _recognitionTimer;
StreamSubscription<Uint8List>? _audioSubscription;
// 🆕
///
final Map<String, _SessionState> _sessions = {};
/// ID
String? _activeSessionId;
///
final Map<String, StreamController<SpeechRecognitionResult>> _sessionResultControllers = {};
///
final Map<String, StreamController<bool>> _sessionStatusControllers = {};
///
Future<bool> 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<void> startListening({
bool partialResults = true,
}) async {
Future<void> 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<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;
@ -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<void> 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] 服务已完全清理');
}
}

View File

@ -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: