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.
425 lines
15 KiB
425 lines
15 KiB
import 'dart:async';
|
|
import '../../../data/models/appconfig.dart';
|
|
import 'package:flutter/services.dart';
|
|
import '../../../core/utils/logger.dart';
|
|
import 'package:get/get.dart';
|
|
import '../ast_service.dart';
|
|
|
|
/// Azure 语音识别服务
|
|
///
|
|
/// 该服务提供了通过平台通道与原生 Microsoft Speech SDK 交互的接口
|
|
class AzureAstService extends GetxService implements AstService {
|
|
static final AzureAstService to = Get.put(AzureAstService());
|
|
static const MethodChannel _channel = MethodChannel('azure_speech/ast');
|
|
static const EventChannel _eventChannel =
|
|
EventChannel('azure_speech/ast_events');
|
|
|
|
bool _isInitialized = false;
|
|
late final String _subscriptionKey;
|
|
late final String _serviceRegion;
|
|
late final String _azureTranslationKey;
|
|
late final String _azureTranslationRegion;
|
|
|
|
late final String _volcanoTranslationAccessKey;
|
|
late final String _volcanoTranslationSecretKey;
|
|
late final String _volcanoTranslationRegion;
|
|
|
|
late final String _xunfeiAppId;
|
|
late final String _xunfeiAccessKeyId;
|
|
late final String _xunfeiAccessKeySecret;
|
|
late final String _iflytekHost;
|
|
|
|
/// 豆包语音识别服务相关配置
|
|
late final String _doubaoAppKey;
|
|
late final String _doubaoAccessKey;
|
|
late final String _doubaoResourceId;
|
|
|
|
/// 阿里巴巴语音识别服务相关配置
|
|
late final String _alibabaAppKey;
|
|
late final String _alibabaAppId;
|
|
late final String _alibabaAppURL;
|
|
|
|
final List<String> _defaultSupportedLanguages = ['zh-CN', 'en-US'];
|
|
@override
|
|
List<String> get supportedLanguages => _defaultSupportedLanguages;
|
|
|
|
//连续识别相关
|
|
bool _isContinuousRecognitionActive = false;
|
|
StreamController<ASTEvent>? _eventStreamController;
|
|
StreamSubscription? _eventSubscription;
|
|
|
|
// 最新的识别结果
|
|
String _latestRecognizedText = '';
|
|
String get latestRecognizedText => _latestRecognizedText;
|
|
|
|
// 最新检测到的语言
|
|
String _latestDetectedLanguage = '';
|
|
String get latestDetectedLanguage => _latestDetectedLanguage;
|
|
|
|
AzureAstService() {
|
|
_loadConfig();
|
|
}
|
|
|
|
@override
|
|
void onClose() async {
|
|
// 页面或服务被删除时,确保释放底层资源
|
|
await dispose();
|
|
super.onClose();
|
|
}
|
|
|
|
/// 设置事件通道(只在首次调用时订阅 EventChannel,后续复用)
|
|
void _setupEventChannel() {
|
|
// 确保 _eventStreamController 已就绪,避免丢失早期事件
|
|
_eventStreamController ??= StreamController<ASTEvent>.broadcast();
|
|
// EventChannel.receiveBroadcastStream() 只能活跃一次,复用已有订阅
|
|
if (_eventSubscription != null) {
|
|
Logger.info('[STS] _setupEventChannel: 复用已有EventSubscription, streamController=${_eventStreamController.hashCode}');
|
|
return;
|
|
}
|
|
Logger.info('[STS] _setupEventChannel: 首次订阅EventChannel, streamController=${_eventStreamController.hashCode}');
|
|
_eventSubscription = _eventChannel.receiveBroadcastStream().listen((event) {
|
|
if (event is Map) {
|
|
_handleRecognitionEvent(event);
|
|
}
|
|
}, onError: _handleRecognitionError);
|
|
}
|
|
|
|
/// 处理来自原生端的识别事件(AST事件)
|
|
void _handleRecognitionEvent(dynamic event) {
|
|
if (event is! Map) return;
|
|
if (_eventStreamController == null) {
|
|
Logger.error('[STS] _handleRecognitionEvent: _eventStreamController 为 null,事件被丢弃: ${event['type']}');
|
|
return;
|
|
}
|
|
if (_eventStreamController!.isClosed) {
|
|
Logger.error('[STS] _handleRecognitionEvent: _eventStreamController 已关闭,事件被丢弃: ${event['type']}');
|
|
return;
|
|
}
|
|
|
|
final Map<dynamic, dynamic> eventMap = event;
|
|
final String eventType = eventMap['type'] as String? ?? '';
|
|
|
|
switch (eventType) {
|
|
case 'recognized':
|
|
final String serviceId = eventMap['serviceId'] as String? ?? '';
|
|
final String utteranceId = eventMap['utteranceId'] as String? ?? '';
|
|
final String text = eventMap['text'] as String? ?? '';
|
|
final String detectedLanguage = eventMap['language'] as String? ?? '';
|
|
Logger.info('[STS] 识别完成 [$serviceId]: text=$text, uttId=$utteranceId');
|
|
_latestRecognizedText = text;
|
|
_latestDetectedLanguage = detectedLanguage;
|
|
_eventStreamController?.add(ASTEvent(
|
|
type: ASTEventType.finalResult,
|
|
serviceId: serviceId,
|
|
utteranceId: utteranceId,
|
|
text: text,
|
|
detectedLanguage: detectedLanguage,
|
|
));
|
|
break;
|
|
|
|
case 'recognizing':
|
|
final String serviceId = eventMap['serviceId'] as String? ?? '';
|
|
final String utteranceId = eventMap['utteranceId'] as String? ?? '';
|
|
final String text = eventMap['text'] as String? ?? '';
|
|
final String detectedLanguage = eventMap['language'] as String? ?? '';
|
|
Logger.d('AzureAST', '[STS] 识别中 [$serviceId]: text=$text');
|
|
_eventStreamController?.add(ASTEvent(
|
|
type: ASTEventType.intermediateResult,
|
|
serviceId: serviceId,
|
|
utteranceId: utteranceId,
|
|
text: text,
|
|
detectedLanguage: detectedLanguage,
|
|
));
|
|
break;
|
|
case 'translatedInterim':
|
|
final String serviceId = eventMap['serviceId'] as String? ?? '';
|
|
final String utteranceId = eventMap['utteranceId'] as String? ?? '';
|
|
final String text = eventMap['translatedText'] as String? ?? '';
|
|
final String detectedLanguage =
|
|
eventMap['targetLanguage'] as String? ?? '';
|
|
Logger.d('AzureAST', '[STS] 翻译中 [$serviceId]: text=$text');
|
|
_eventStreamController?.add(ASTEvent(
|
|
type: ASTEventType.translationInterim,
|
|
serviceId: serviceId,
|
|
utteranceId: utteranceId,
|
|
text: text,
|
|
detectedLanguage: detectedLanguage,
|
|
));
|
|
break;
|
|
case 'translated':
|
|
final String serviceId = eventMap['serviceId'] as String? ?? '';
|
|
final String utteranceId = eventMap['utteranceId'] as String? ?? '';
|
|
final String text = eventMap['translatedText'] as String? ?? '';
|
|
final String detectedLanguage =
|
|
eventMap['targetLanguage'] as String? ?? '';
|
|
Logger.info('[STS] 翻译完成 [$serviceId]: text=$text, uttId=$utteranceId');
|
|
_eventStreamController?.add(ASTEvent(
|
|
type: ASTEventType.translationResult,
|
|
serviceId: serviceId,
|
|
utteranceId: utteranceId,
|
|
text: text,
|
|
detectedLanguage: detectedLanguage,
|
|
));
|
|
break;
|
|
|
|
case 'sessionStarted':
|
|
Logger.info('[STS] 会话开始');
|
|
_eventStreamController?.add(ASTEvent(
|
|
type: ASTEventType.sessionStarted,
|
|
));
|
|
break;
|
|
|
|
case 'sessionStopped':
|
|
Logger.info('[STS] 会话停止');
|
|
_isContinuousRecognitionActive = false;
|
|
_eventStreamController?.add(ASTEvent(
|
|
type: ASTEventType.sessionStopped,
|
|
));
|
|
break;
|
|
|
|
case 'canceled':
|
|
_isContinuousRecognitionActive = false;
|
|
final String reason = eventMap['reason'] as String? ?? '';
|
|
final String errorDetails = eventMap['errorDetails'] as String? ?? '';
|
|
Logger.error('[STS] 识别取消: $reason - $errorDetails');
|
|
_eventStreamController?.add(ASTEvent(
|
|
type: ASTEventType.canceled,
|
|
error: '$reason: $errorDetails',
|
|
));
|
|
break;
|
|
|
|
case 'error':
|
|
String error = eventMap['message'] as String? ?? '';
|
|
if (error.isEmpty) {
|
|
error = eventMap['error'] as String? ?? '';
|
|
}
|
|
final String code = eventMap['code']?.toString() ?? '';
|
|
if (code.isNotEmpty) {
|
|
error = '$error (Code: $code)';
|
|
}
|
|
Logger.error('[STS] 识别错误: $error');
|
|
_eventStreamController?.add(ASTEvent(
|
|
type: ASTEventType.error,
|
|
error: error,
|
|
));
|
|
break;
|
|
|
|
case 'serviceInitialized':
|
|
Logger.info('[STS] 服务初始化: ${eventMap['serviceId']} ${eventMap['direction']}');
|
|
break;
|
|
|
|
default:
|
|
Logger.info('[STS] 未处理事件: $eventType');
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// 处理识别事件流错误
|
|
void _handleRecognitionError(Object error) {
|
|
Logger.error('识别事件流错误: ${error.toString()}');
|
|
_eventStreamController?.addError(error);
|
|
_cleanupEventStream();
|
|
}
|
|
|
|
/// 清理事件流资源
|
|
void _cleanupEventStream() {
|
|
_eventStreamController?.close();
|
|
_eventStreamController = null;
|
|
_isContinuousRecognitionActive = false;
|
|
}
|
|
|
|
/// 从环境变量加载配置
|
|
void _loadConfig() {
|
|
_subscriptionKey = AppConfig.env('AZURE_SPEECH_KEY') ?? '';
|
|
_serviceRegion = AppConfig.env('AZURE_SPEECH_REGION') ?? '';
|
|
_azureTranslationKey = AppConfig.env('AZURE_TRANSLATION_KEY') ?? '';
|
|
_azureTranslationRegion = AppConfig.env('AZURE_TRANSLATION_REGION') ?? '';
|
|
_volcanoTranslationAccessKey =
|
|
AppConfig.env('VOLCANO_TRANSLATION_ACCESS_KEY') ?? '';
|
|
_volcanoTranslationSecretKey =
|
|
AppConfig.env('VOLCANO_TRANSLATION_SECRET_KEY') ?? '';
|
|
_volcanoTranslationRegion =
|
|
AppConfig.env('VOLCANO_TRANSLATION_REGION') ?? 'cn-north-1';
|
|
_xunfeiAppId = AppConfig.env('XUNFEI_ASR_APP_ID') ?? '';
|
|
_xunfeiAccessKeyId = AppConfig.env('XUNFEI_ASR_ACCESS_KEY_ID') ?? '';
|
|
_xunfeiAccessKeySecret =
|
|
AppConfig.env('XUNFEI_ASR_ACCESS_KEY_SECRET') ?? '';
|
|
_iflytekHost = AppConfig.env('IFLYTEK_ASR_HOST') ?? '';
|
|
_doubaoAppKey = AppConfig.env('VOLC_OPENSPEECH_APP_ID') ?? '';
|
|
_doubaoAccessKey = AppConfig.env('VOLC_OPENSPEECH_ACCESS_TOKEN') ?? '';
|
|
_doubaoResourceId =
|
|
AppConfig.env('VOLC_OPENSPEECH_TRANSLATION_BIGMODEL') ?? '';
|
|
_alibabaAppKey = AppConfig.env('ALIBABA_OPENSPEECH_APP_KEY') ?? '';
|
|
_alibabaAppId = AppConfig.env('ALIBABA_OPENSPEECH_APP_ID') ?? '';
|
|
_alibabaAppURL = AppConfig.env('ALIBABA_OPENSPEECH_APP_URL') ?? '';
|
|
if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) {
|
|
throw Exception(
|
|
'未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<Stream<ASTEvent>> recognizeCallback() async {
|
|
if (!_isInitialized) {
|
|
Logger.info('[STS] recognizeCallback: 未初始化,先执行initialize');
|
|
await initialize();
|
|
}
|
|
try {
|
|
_eventStreamController ??= StreamController<ASTEvent>.broadcast();
|
|
Logger.info('[STS] recognizeCallback: streamController=${_eventStreamController.hashCode}, hasListener=${_eventStreamController!.hasListener}');
|
|
|
|
// 开始连续识别
|
|
final bool result = await _channel.invokeMethod('recognizeCallback');
|
|
Logger.info('[STS] recognizeCallback: invokeMethod 结果=$result');
|
|
if (!result) {
|
|
_cleanupEventStream();
|
|
}
|
|
|
|
return _eventStreamController!.stream;
|
|
} catch (e) {
|
|
Logger.error('[STS] recognizeCallback 失败: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> enableRecord(String filePath) async {
|
|
try {
|
|
final bool result = await _channel.invokeMethod('enableRecord', {
|
|
'filePath': filePath,
|
|
});
|
|
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('开始录音: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> path(String filePath) async {
|
|
try {
|
|
final bool result = await _channel.invokeMethod('path', {
|
|
'filePath': filePath,
|
|
});
|
|
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('开始录音: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> startContinuousTranslation() async {
|
|
try {
|
|
final bool result =
|
|
await _channel.invokeMethod('startContinuousTranslation');
|
|
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('停止录音: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> stopContinuousTranslation() async {
|
|
try {
|
|
final bool result =
|
|
await _channel.invokeMethod('stopContinuousTranslation');
|
|
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('停止录音: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
try {
|
|
Logger.info('[STS] dispose: 开始释放, streamController=${_eventStreamController?.hashCode}, eventSub=${_eventSubscription?.hashCode}');
|
|
// 不取消 _eventSubscription(EventChannel 只能订阅一次,保持复用)
|
|
// 只关闭 _eventStreamController,下次 initialize 时会重新创建
|
|
await _eventStreamController?.close();
|
|
_eventStreamController = null;
|
|
|
|
await _channel.invokeMethod('dispose');
|
|
_isInitialized = false;
|
|
Logger.info('[STS] dispose: 完成, streamController=null, eventSub保留');
|
|
} catch (e) {
|
|
Logger.error('Azure AST 资源释放失败: ${e.toString()}');
|
|
_isInitialized = false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> initialize({
|
|
List<String>? supportedLanguages,
|
|
String provider = 'azure',
|
|
}) async {
|
|
try {
|
|
final List<String> languages =
|
|
supportedLanguages ?? _defaultSupportedLanguages;
|
|
Logger.info('[STS] initialize: provider=$provider, isInitialized=$_isInitialized');
|
|
// 底层会初始化前释放
|
|
if (_isInitialized) {
|
|
await dispose();
|
|
}
|
|
|
|
// 先建立事件通道,确保原生端发事件时 astEventSink 已就绪
|
|
_setupEventChannel();
|
|
Logger.info('[STS] initialize: eventChannel已建立, streamController=${_eventStreamController?.hashCode}');
|
|
|
|
final bool result = await _channel.invokeMethod('initialize', {
|
|
'provider': provider,
|
|
'subscriptionKey': _subscriptionKey,
|
|
'region': _serviceRegion,
|
|
'supportedLanguages': languages,
|
|
|
|
'volcanoTranslationAccessKey': _volcanoTranslationAccessKey,
|
|
'volcanoTranslationSecretKey': _volcanoTranslationSecretKey,
|
|
'volcanoTranslationRegion': _volcanoTranslationRegion,
|
|
|
|
'azureTranslationKey': _azureTranslationKey,
|
|
'azureTranslationServiceRegion': _azureTranslationRegion,
|
|
|
|
'xfyunAppId': _xunfeiAppId,
|
|
'xfyunAccessKeyId': _xunfeiAccessKeyId,
|
|
'xfyunAccessKeySecret': _xunfeiAccessKeySecret,
|
|
'iflytekHost': _iflytekHost,
|
|
|
|
'appKey': _doubaoAppKey,
|
|
'accessKey': _doubaoAccessKey,
|
|
'resourceId': _doubaoResourceId,
|
|
|
|
'alibabaAppKey': _alibabaAppKey,
|
|
'alibabaAppId': _alibabaAppId,
|
|
'alibabaAppURL': _alibabaAppURL,
|
|
});
|
|
|
|
_isInitialized = result;
|
|
print('Azure 语音识别服务初始化${result ? '成功' : '失败'}');
|
|
Logger.info('Azure 语音识别服务初始化${result ? '成功' : '失败'}');
|
|
return result;
|
|
} catch (e) {
|
|
Logger.error('Azure 语音识别服务初始化失败: ${e.toString()}');
|
|
_isInitialized = false;
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void setLowVolumeThreshold(int threshold) {
|
|
try {
|
|
_channel.invokeMethod('setLowVolumeThreshold', {
|
|
'threshold': threshold,
|
|
});
|
|
} catch (e) {
|
|
Logger.error('设置低音量阈值失败: ${e.toString()}');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
}
|
|
|