You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
604 lines
17 KiB
604 lines
17 KiB
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
import '../../../data/models/appconfig.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:get_storage/get_storage.dart';
|
|
import '../../../core/utils/logger.dart';
|
|
import 'package:get/get.dart';
|
|
import '../asr_service.dart';
|
|
|
|
/// 音频源类型
|
|
enum AudioSourceType {
|
|
microphone, // 使用设备麦克风
|
|
external // 使用外部提供的音频数据
|
|
}
|
|
|
|
/// Azure 语音识别服务
|
|
///
|
|
/// 该服务提供了通过平台通道与原生 Microsoft Speech SDK 交互的接口
|
|
class AzureAsrService extends GetxService implements AsrService {
|
|
static final AzureAsrService to = Get.put(AzureAsrService());
|
|
static const MethodChannel _channel = MethodChannel('azure_speech/asr');
|
|
static const EventChannel _eventChannel =
|
|
EventChannel('azure_speech/asr_events');
|
|
// 音频数据事件通道
|
|
static const EventChannel _audioDataEventChannel =
|
|
EventChannel('azure_speech/audio_data_events');
|
|
|
|
// final GetStorage _storage = GetStorage();
|
|
bool _isInitialized = false;
|
|
late final String _subscriptionKey;
|
|
late final String _serviceRegion;
|
|
|
|
final List<String> _defaultSupportedLanguages = ['zh-CN', 'en-US'];
|
|
@override
|
|
List<String> get supportedLanguages => _defaultSupportedLanguages;
|
|
|
|
// 连续识别相关
|
|
bool _isContinuousRecognitionActive = false;
|
|
StreamController<RecognitionEvent>? _eventStreamController;
|
|
StreamSubscription? _eventSubscription;
|
|
|
|
// 最新的识别结果
|
|
String _latestRecognizedText = '';
|
|
String get latestRecognizedText => _latestRecognizedText;
|
|
|
|
// 最新检测到的语言
|
|
String _latestDetectedLanguage = '';
|
|
String get latestDetectedLanguage => _latestDetectedLanguage;
|
|
|
|
// 音频数据事件订阅
|
|
StreamSubscription? _audioDataEventSubscription;
|
|
// 音频数据流控制器
|
|
StreamController<Uint8List>? _audioDataStreamController;
|
|
|
|
// 当前音频源类型
|
|
AudioSourceType _audioSourceType = AudioSourceType.microphone;
|
|
|
|
// 防抖时间戳与间隔(实例级)
|
|
DateTime? _lastStartAt;
|
|
DateTime? _lastStopAt;
|
|
static const Duration _debounce = Duration(milliseconds: 600);
|
|
|
|
AzureAsrService() {
|
|
_loadConfig();
|
|
}
|
|
|
|
/// 从环境变量加载配置
|
|
void _loadConfig() {
|
|
// final _env = _storage.read("ENV") as Map<String, String>;
|
|
_subscriptionKey = AppConfig.env('AZURE_SPEECH_KEY') ?? '';
|
|
_serviceRegion = AppConfig.env('AZURE_SPEECH_REGION') ?? '';
|
|
|
|
if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) {
|
|
throw Exception(
|
|
'未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION');
|
|
}
|
|
}
|
|
|
|
/// 设置事件通道
|
|
void _setupEventChannel() {
|
|
_eventSubscription?.cancel();
|
|
_eventSubscription = _eventChannel.receiveBroadcastStream().listen((event) {
|
|
if (event is Map) {
|
|
_handleRecognitionEvent(event);
|
|
}
|
|
}, onError: _handleRecognitionError);
|
|
}
|
|
|
|
/// 设置音频数据事件通道
|
|
void _setupAudioDataEventChannel() {
|
|
_audioDataEventSubscription?.cancel();
|
|
_audioDataEventSubscription =
|
|
_audioDataEventChannel.receiveBroadcastStream().listen((event) {
|
|
if (event is Map) {
|
|
_handleAudioDataEvent(event);
|
|
}
|
|
}, onError: (error) {
|
|
Logger.error('音频数据事件流错误: ${error.toString()}');
|
|
});
|
|
}
|
|
|
|
@override
|
|
Future<bool> initialize({
|
|
List<String>? supportedLanguages,
|
|
bool useExternalAudio = false,
|
|
bool useEchoCancellation = false,
|
|
}) async {
|
|
try {
|
|
final List<String> languages =
|
|
supportedLanguages ?? _defaultSupportedLanguages;
|
|
//底层会初始化前释放
|
|
// // 检查是否需要重新初始化
|
|
// if (_isInitialized) {
|
|
// await dispose();
|
|
// }
|
|
|
|
// 设置音频源类型
|
|
_audioSourceType = useExternalAudio
|
|
? AudioSourceType.external
|
|
: AudioSourceType.microphone;
|
|
|
|
final bool result = await _channel.invokeMethod('initialize', {
|
|
'subscriptionKey': _subscriptionKey,
|
|
'region': _serviceRegion,
|
|
'supportedLanguages': languages,
|
|
'audioSourceType': _audioSourceType.toString().split('.').last,
|
|
'useEchoCancellation': useEchoCancellation,
|
|
});
|
|
|
|
_setupEventChannel();
|
|
|
|
_isInitialized = result;
|
|
Logger.info('Azure 语音识别服务初始化${result ? '成功' : '失败'}');
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('Azure 语音识别服务初始化失败: ${e.toString()}');
|
|
_isInitialized = false;
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<RecognitionEvent> recognizeOnce() async {
|
|
if (!_isInitialized) {
|
|
await initialize();
|
|
}
|
|
|
|
try {
|
|
// 调用同步的recognizeOnce方法
|
|
final result = await _channel.invokeMethod('recognizeOnce');
|
|
|
|
if (result is Map) {
|
|
// 处理新格式返回
|
|
final String text = result['text'] as String? ?? '';
|
|
final String detectedLanguage =
|
|
result['detectedLanguage'] as String? ?? '';
|
|
|
|
// 更新最新识别结果
|
|
_latestRecognizedText = text;
|
|
_latestDetectedLanguage = detectedLanguage;
|
|
|
|
return RecognitionEvent.finalResult(
|
|
text: text,
|
|
detectedLanguage: detectedLanguage,
|
|
);
|
|
} else if (result is String) {
|
|
// 兼容旧版本
|
|
_latestRecognizedText = result;
|
|
return RecognitionEvent.finalResult(text: result);
|
|
}
|
|
|
|
throw Exception('无效的识别结果格式');
|
|
} catch (e) {
|
|
Logger.error('语音识别失败: ${e.toString()}');
|
|
return RecognitionEvent.error(e.toString());
|
|
}
|
|
}
|
|
|
|
@override
|
|
|
|
/// 开始连续语音识别(带防抖)
|
|
///
|
|
/// 行为说明:
|
|
/// - 在 600ms 的冷却时间内再次调用会被防抖拦截并返回 false;
|
|
/// - 首次或冷却期外调用会继续启动连续识别;
|
|
/// - 若当前已处于连续识别中,会先调用停止后再启动。
|
|
Future<bool> startContinuousRecognition(bool audioSourceType) async {
|
|
// 防抖判断:短时间内重复调用直接拦截
|
|
final now = DateTime.now();
|
|
if (_lastStartAt != null && now.difference(_lastStartAt!) < _debounce) {
|
|
return false;
|
|
}
|
|
_lastStartAt = now;
|
|
|
|
if (!_isInitialized) {
|
|
await initialize();
|
|
}
|
|
|
|
if (_isContinuousRecognitionActive) {
|
|
await stopContinuousRecognition();
|
|
}
|
|
|
|
try {
|
|
// 开始连续识别
|
|
final bool result =
|
|
await _channel.invokeMethod('startContinuousRecognition', {
|
|
'audioSourceType': audioSourceType,
|
|
});
|
|
|
|
if (!result) {
|
|
throw Exception('启动连续识别失败');
|
|
}
|
|
|
|
_isContinuousRecognitionActive = true;
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('开始连续语音识别失败: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
|
|
/// 停止连续语音识别(带防抖)
|
|
///
|
|
/// 行为说明:
|
|
/// - 在 600ms 的冷却时间内再次调用会被防抖拦截并返回 false;
|
|
/// - 若当前不在识别中,直接返回 true;
|
|
/// - 调用成功后立即将内部状态置为 false(同时事件通道回调仍会再次校正)。
|
|
Future<bool> stopContinuousRecognition() async {
|
|
// 防抖判断:短时间内重复调用直接拦截
|
|
final now = DateTime.now();
|
|
if (_lastStopAt != null && now.difference(_lastStopAt!) < _debounce) {
|
|
return false;
|
|
}
|
|
_lastStopAt = now;
|
|
|
|
if (!_isInitialized || !_isContinuousRecognitionActive) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
final bool result =
|
|
await _channel.invokeMethod('stopContinuousRecognition');
|
|
if (result) {
|
|
_isContinuousRecognitionActive = false;
|
|
}
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('停止连续语音识别失败: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<Stream<RecognitionEvent>> recognizeCallback() async {
|
|
if (!_isInitialized) {
|
|
await initialize();
|
|
}
|
|
try {
|
|
_eventStreamController = StreamController<RecognitionEvent>.broadcast();
|
|
|
|
// 开始连续识别
|
|
final bool result = await _channel.invokeMethod('recognizeCallback');
|
|
if (!result) {
|
|
_cleanupEventStream();
|
|
}
|
|
|
|
return _eventStreamController!.stream;
|
|
} catch (e) {
|
|
Logger.error('开始连续语音识别失败: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool isContinuousRecognitionActive() {
|
|
return _isContinuousRecognitionActive;
|
|
}
|
|
|
|
/// 处理音频数据事件
|
|
void _handleAudioDataEvent(dynamic event) {
|
|
if (event is! Map) return;
|
|
|
|
final Map<dynamic, dynamic> eventMap = event;
|
|
final String eventType = eventMap['type'] as String? ?? '';
|
|
|
|
switch (eventType) {
|
|
case 'audioData':
|
|
final Uint8List data = eventMap['data'] as Uint8List? ?? Uint8List(0);
|
|
final double timestamp =
|
|
(eventMap['timestamp'] as num?)?.toDouble() ?? 0.0;
|
|
final int size = eventMap['size'] as int? ?? 0;
|
|
print('接收到音频数据: 大小=${size}字节, 时间戳=${timestamp}');
|
|
Logger.debug('接收到音频数据: 大小=${size}字节, 时间戳=${timestamp}');
|
|
|
|
// 发送到音频数据流
|
|
_audioDataStreamController?.add(data);
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// 获取音频数据流
|
|
Stream<Uint8List>? getAudioDataStream() {
|
|
print("getAudioDataStream");
|
|
_audioDataStreamController ??= StreamController<Uint8List>.broadcast();
|
|
return _audioDataStreamController?.stream;
|
|
}
|
|
|
|
/// 处理来自原生端的识别事件
|
|
void _handleRecognitionEvent(dynamic event) {
|
|
if (event is! Map || _eventStreamController == null) return;
|
|
|
|
final Map<dynamic, dynamic> eventMap = event;
|
|
final String eventType = eventMap['type'] as String? ?? '';
|
|
|
|
// 添加日志帮助调试
|
|
|
|
switch (eventType) {
|
|
case 'result':
|
|
final String text = eventMap['text'] as String? ?? '';
|
|
final String detectedLanguage =
|
|
eventMap['detectedLanguage'] as String? ?? '';
|
|
_latestRecognizedText = text;
|
|
_latestDetectedLanguage = detectedLanguage;
|
|
_eventStreamController?.add(RecognitionEvent(
|
|
type: RecognitionEventType.finalResult,
|
|
text: text,
|
|
detectedLanguage: detectedLanguage,
|
|
));
|
|
break;
|
|
case 'result1':
|
|
final String text = eventMap['text'] as String? ?? '';
|
|
final String detectedLanguage =
|
|
eventMap['detectedLanguage'] as String? ?? '';
|
|
_latestRecognizedText = text;
|
|
_latestDetectedLanguage = detectedLanguage;
|
|
_eventStreamController?.add(RecognitionEvent(
|
|
type: RecognitionEventType.finalResult1,
|
|
text: text,
|
|
detectedLanguage: detectedLanguage,
|
|
));
|
|
break;
|
|
|
|
case 'recognizing':
|
|
final String text = eventMap['text'] as String? ?? '';
|
|
final String detectedLanguage =
|
|
eventMap['detectedLanguage'] as String? ?? '';
|
|
_eventStreamController?.add(RecognitionEvent(
|
|
type: RecognitionEventType.intermediateResult,
|
|
text: text,
|
|
detectedLanguage: detectedLanguage,
|
|
));
|
|
break;
|
|
case 'sessionStarted':
|
|
_eventStreamController?.add(RecognitionEvent(
|
|
type: RecognitionEventType.sessionStarted,
|
|
));
|
|
break;
|
|
|
|
case 'sessionStopped':
|
|
_isContinuousRecognitionActive = false;
|
|
_eventStreamController?.add(RecognitionEvent(
|
|
type: RecognitionEventType.sessionStopped,
|
|
));
|
|
break;
|
|
|
|
case 'canceled':
|
|
_isContinuousRecognitionActive = false;
|
|
final String reason = eventMap['reason'] as String? ?? '';
|
|
final String errorDetails = eventMap['errorDetails'] as String? ?? '';
|
|
|
|
if (reason.isNotEmpty || errorDetails.isNotEmpty) {
|
|
Logger.error('识别取消: $reason - ${errorDetails.toString()}');
|
|
}
|
|
|
|
_eventStreamController?.add(RecognitionEvent(
|
|
type: RecognitionEventType.canceled,
|
|
error: '$reason: $errorDetails',
|
|
));
|
|
break;
|
|
|
|
case 'error':
|
|
final String error = eventMap['message'] as String? ?? '';
|
|
Logger.error('识别错误: ${error.toString()}');
|
|
_eventStreamController?.add(RecognitionEvent(
|
|
type: RecognitionEventType.error,
|
|
error: error,
|
|
));
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// 处理识别事件流错误
|
|
void _handleRecognitionError(Object error) {
|
|
Logger.error('识别事件流错误: ${error.toString()}');
|
|
_eventStreamController?.addError(error);
|
|
_cleanupEventStream();
|
|
}
|
|
|
|
/// 清理事件流资源
|
|
void _cleanupEventStream() {
|
|
_eventStreamController?.close();
|
|
_eventStreamController = null;
|
|
_isContinuousRecognitionActive = false;
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
try {
|
|
if (_isContinuousRecognitionActive) {
|
|
await stopContinuousRecognition();
|
|
}
|
|
|
|
// 取消事件订阅
|
|
await _eventSubscription?.cancel();
|
|
_eventSubscription = null;
|
|
|
|
// 取消音频数据事件订阅
|
|
await _audioDataEventSubscription?.cancel();
|
|
_audioDataEventSubscription = null;
|
|
|
|
// 关闭音频数据流
|
|
await _audioDataStreamController?.close();
|
|
_audioDataStreamController = null;
|
|
|
|
// 通知原生端释放资源
|
|
await _channel.invokeMethod('dispose');
|
|
_isInitialized = false;
|
|
|
|
Logger.info('Azure 语音识别资源已释放');
|
|
} catch (e) {
|
|
Logger.error('释放语音识别资源失败: ${e.toString()}');
|
|
_cleanupEventStream();
|
|
_isInitialized = false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> pushAudioData(Uint8List data) async {
|
|
if (!_isInitialized) {
|
|
await initialize(useExternalAudio: true);
|
|
}
|
|
|
|
if (_audioSourceType != AudioSourceType.external) {
|
|
Logger.error('当前非外部音频模式,不能推送音频数据');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
final bool result = await _channel.invokeMethod('pushAudioData', {
|
|
'audioData': data // Flutter 会自动将 Uint8List 转换为 ByteBuffer
|
|
});
|
|
|
|
if (!result) {
|
|
throw Exception('推送音频数据失败');
|
|
}
|
|
|
|
return true;
|
|
} catch (e) {
|
|
Logger.error('推送音频数据失败: ${e.toString()}');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> enableRecord(bool audioSourceType, String filePath,
|
|
{bool acceptAudioData = false}) async {
|
|
try {
|
|
final bool result = await _channel.invokeMethod('enableRecord', {
|
|
'audioSourceType': audioSourceType,
|
|
'filePath': filePath,
|
|
'acceptAudioData': acceptAudioData, // 新增参数
|
|
});
|
|
if (acceptAudioData) {
|
|
_setupAudioDataEventChannel();
|
|
}
|
|
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('开始录音: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> stopRecord(bool isSave) async {
|
|
try {
|
|
final bool result = await _channel.invokeMethod('stopRecord', {
|
|
'isSave': isSave,
|
|
});
|
|
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('停止录音: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> pauseRecord() async {
|
|
try {
|
|
final bool result = await _channel.invokeMethod('pauseRecord');
|
|
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('暂停录音: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// 设置音频配置
|
|
///
|
|
/// [sampleRate] 采样率,默认16000
|
|
/// [channels] 声道数,默认1(单声道)
|
|
Future<bool> setAudioConfig({
|
|
int sampleRate = 16000,
|
|
int channels = 1,
|
|
}) async {
|
|
try {
|
|
final bool result = await _channel.invokeMethod('setAudioConfig', {
|
|
'sampleRate': sampleRate,
|
|
'channels': channels,
|
|
});
|
|
|
|
Logger.info(
|
|
'音频配置设置${result ? '成功' : '失败'}: 采样率=$sampleRate, 声道数=$channels');
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('设置音频配置失败: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> moveFile(String sourcePath, String destPath) async {
|
|
// TODO: implement moveFile
|
|
try {
|
|
final bool result = await _channel.invokeMethod('moveFile', {
|
|
'sourcePath': sourcePath,
|
|
'destPath': destPath,
|
|
});
|
|
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('开始录音: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> renameFile(String filePath, String newName) async {
|
|
// TODO: implement renameFile
|
|
try {
|
|
final bool result = await _channel.invokeMethod('renameFile', {
|
|
'filePath': filePath,
|
|
'newName': newName,
|
|
});
|
|
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('开始录音: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> disableBluetoothAudio() async {
|
|
// TODO: implement disableBluetoothAudio
|
|
try {
|
|
final bool result = await _channel.invokeMethod('disableBluetoothAudio');
|
|
return;
|
|
} catch (e) {
|
|
Logger.error('开始录音: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> restoreOriginalAudioState() async {
|
|
// TODO: implement restoreOriginalAudioState
|
|
try {
|
|
final bool result =
|
|
await _channel.invokeMethod('restoreOriginalAudioState');
|
|
return;
|
|
} catch (e) {
|
|
Logger.error('开始录音: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> resumeRecord() async {
|
|
try {
|
|
final bool result = await _channel.invokeMethod('resumeRecord');
|
|
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('继续录音: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
}
|
|
|