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:
parent
cd6d31457f
commit
5e61eed30f
|
|
@ -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.
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":2,"entries":[{"package":"yx_asr","rootUri":"../","packageUri":"lib/"}]}
|
||||
45
CHANGELOG.md
45
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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] 服务已完全清理');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue