Browse Source

上传iOS 服务调整优化

newdev_shunjiawei
liwei1dao 6 months ago
parent
commit
917fd97a46
  1. 2
      .metadata
  2. 7
      lib/core/bindings/initial_binding.dart
  3. 3
      lib/data/services/asr_service.dart
  4. 170
      lib/data/services/microsoft_translation_service.dart
  5. 15
      lib/data/services/speech_impl/azure_asr_service.dart
  6. 1
      lib/data/services/speech_impl/volcano_asr_api_service.dart
  7. 1
      lib/data/services/speech_impl/volcano_asr_service.dart
  8. 13
      lib/data/services/volcano_translation_service.dart
  9. 22
      lib/modules/meeting/controllers/meeting_details_controller.dart
  10. 33
      lib/modules/translation/controllers/translation_controller.dart
  11. 16
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift
  12. 12
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  13. 44
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift
  14. 10
      local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift

2
.metadata

@ -15,7 +15,7 @@ migration:
- platform: root - platform: root
create_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e create_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e
base_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e base_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e
- platform: android - platform: ios
create_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e create_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e
base_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e base_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e

7
lib/core/bindings/initial_binding.dart

@ -14,6 +14,7 @@ import '../../data/services/meeting/meeting_upload_service.dart';
import '../../data/services/novice_guide_service.dart'; import '../../data/services/novice_guide_service.dart';
import '../../data/services/speech_factory.dart'; import '../../data/services/speech_factory.dart';
import '../../data/services/volcano_translation_service.dart'; import '../../data/services/volcano_translation_service.dart';
import '../../data/services/microsoft_translation_service.dart';
import '../../data/services/volcano_ai_service.dart'; import '../../data/services/volcano_ai_service.dart';
import '../../data/services/language_manager.dart'; import '../../data/services/language_manager.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
@ -43,9 +44,13 @@ class InitialBinding extends Bindings {
// 注册 AST 服务 // 注册 AST 服务
Get.lazyPut<AstService>(() => AzureAstService(), fenix: true); Get.lazyPut<AstService>(() => AzureAstService(), fenix: true);
// 火山翻译服务 // 火山翻译服务(图片翻译 / 文本翻译模块仍在使用)
Get.lazyPut<VolcanoTranslationService>(() => VolcanoTranslationService(), Get.lazyPut<VolcanoTranslationService>(() => VolcanoTranslationService(),
fenix: true); fenix: true);
// 微软翻译服务(同声翻译 / 面对面翻译 / 音视频翻译 使用)
Get.lazyPut<MicrosoftTranslationService>(
() => MicrosoftTranslationService(),
fenix: true);
// 蓝牙服务 // 蓝牙服务
Get.lazyPut<BluetoothManager>(() => BluetoothManager(), fenix: true); Get.lazyPut<BluetoothManager>(() => BluetoothManager(), fenix: true);

3
lib/data/services/asr_service.dart

@ -27,10 +27,13 @@ abstract class AsrService {
/// 开始连续语音识别 /// 开始连续语音识别
/// ///
/// 返回一个包含识别事件的流 /// 返回一个包含识别事件的流
/// [skipAsrForCallMode] true 时仅占位,不真正启动本地 ASR(用于通话翻译 call 模式:
/// AST 端到端服务已自行处理识别+翻译+TTS)。多媒体翻译等 external 场景保持 false。
Future<bool> startContinuousRecognition( Future<bool> startContinuousRecognition(
bool audioSourceType, { bool audioSourceType, {
bool isRemoveFirstPunctuation = true, bool isRemoveFirstPunctuation = true,
String mode = "normal", String mode = "normal",
bool skipAsrForCallMode = false,
}); });
/// 停止连续语音识别 /// 停止连续语音识别

170
lib/data/services/microsoft_translation_service.dart

@ -0,0 +1,170 @@
import 'dart:convert';
import 'package:get/get.dart';
import 'package:http/http.dart' as http;
import '../../core/utils/logger.dart';
import '../../data/models/appconfig.dart';
import 'language_configs/azure_language_config.dart';
import 'language_manager.dart';
/// 微软 Azure Translator REST 翻译服务
/// 端点:https://api.cognitive.microsofttranslator.com
/// 文档:https://learn.microsoft.com/azure/ai-services/translator/reference/v3-0-translate
class MicrosoftTranslationService extends GetxService {
static const String _defaultEndpoint =
'https://api.cognitive.microsofttranslator.com';
static const String _apiPath = '/translate';
static const String _apiVersion = '3.0';
late final String _subscriptionKey;
late final String _region;
late final String _endpoint;
bool _isInitialized = false;
bool get isInitialized => _isInitialized;
final LanguageManager _languageManager = Get.find<LanguageManager>();
/// shortCode -> 微软 translationCode 缓存(由 getAzureTranslationLanguageSpecs 构建)
final Map<String, String> _shortCodeToTranslationCode = {};
@override
Future<void> onInit() async {
await initialize();
super.onInit();
}
Future<bool> initialize() async {
if (_isInitialized) return true;
try {
_subscriptionKey = AppConfig.env('AZURE_TRANSLATION_KEY') ?? '';
_region = AppConfig.env('AZURE_TRANSLATION_REGION') ?? '';
_endpoint =
AppConfig.env('AZURE_TRANSLATION_ENDPOINT') ?? _defaultEndpoint;
if (_subscriptionKey.isEmpty) {
print(
'🟢[MS-TRANS-DBG] AZURE_TRANSLATION_KEY 未配置,服务无法初始化');
Logger.error('微软翻译服务初始化失败:AZURE_TRANSLATION_KEY 为空');
return false;
}
for (final spec in getAzureTranslationLanguageSpecs()) {
final short = spec['shortCode'];
final code = spec['translationCode'];
if (short != null && code != null) {
_shortCodeToTranslationCode[short] = code;
}
}
_isInitialized = true;
print(
'🟢[MS-TRANS-DBG] 微软翻译服务初始化成功: endpoint=$_endpoint, region=$_region, keyLen=${_subscriptionKey.length}, specCount=${_shortCodeToTranslationCode.length}');
Logger.info('微软翻译服务初始化成功 region=$_region');
return true;
} catch (e) {
print('🟢[MS-TRANS-DBG] 微软翻译服务初始化异常: $e');
Logger.error('微软翻译服务初始化异常: $e');
return false;
}
}
/// 把 ASR 代码(如 `zh-CN`)映射为微软 Translator 语言码(如 `zh-Hans`)
/// 找不到返回 null
String? _toTranslationCode(String asrCode) {
if (asrCode.isEmpty) return null;
final short = _languageManager.getShortCodeByAsrCode(asrCode);
if (short == null) return null;
return _shortCodeToTranslationCode[short];
}
/// 文本翻译
/// [text] 待翻译文本
/// [sourceLanguageCode] 源语言 ASR 码(传空串则让微软自动检测)
/// [targetLanguageCode] 目标语言 ASR 码
Future<String?> translateText({
required String text,
required String sourceLanguageCode,
required String targetLanguageCode,
}) async {
if (!_isInitialized) {
print('🟢[MS-TRANS-DBG] 未初始化,返回 null');
Logger.error('微软翻译服务未初始化');
return null;
}
if (text.isEmpty) return null;
try {
final String? targetCode = _toTranslationCode(targetLanguageCode);
if (targetCode == null) {
print(
'🟢[MS-TRANS-DBG] 目标语言码映射失败: targetAsr=$targetLanguageCode,返回 null');
Logger.error('微软翻译失败:目标语言 $targetLanguageCode 不在映射表');
return null;
}
final String? sourceCode = _toTranslationCode(sourceLanguageCode);
final queryParams = <String, String>{
'api-version': _apiVersion,
'to': targetCode,
};
if (sourceCode != null && sourceCode.isNotEmpty) {
queryParams['from'] = sourceCode;
}
final uri = Uri.parse('$_endpoint$_apiPath')
.replace(queryParameters: queryParams);
final body = json.encode([
{'text': text}
]);
final headers = {
'Content-Type': 'application/json',
'Ocp-Apim-Subscription-Key': _subscriptionKey,
'Ocp-Apim-Subscription-Region': _region,
'X-ClientTraceId': _newTraceId(),
};
print(
'🟢[MS-TRANS-DBG] 发起翻译请求: uri=$uri, from=$sourceCode, to=$targetCode, text=$text');
final response =
await http.post(uri, headers: headers, body: body).timeout(
const Duration(seconds: 10),
onTimeout: () => http.Response('{"error":"timeout"}', 408),
);
print(
'🟢[MS-TRANS-DBG] 翻译响应: status=${response.statusCode}, bodyLen=${response.body.length}, body=${response.body.length > 500 ? response.body.substring(0, 500) : response.body}');
if (response.statusCode != 200) {
Logger.error(
'微软翻译 HTTP 错误: status=${response.statusCode}, body=${response.body}');
return null;
}
final decoded = json.decode(response.body);
if (decoded is! List || decoded.isEmpty) {
print('🟢[MS-TRANS-DBG] 响应非 List 或为空,解析失败');
return null;
}
final first = decoded.first;
if (first is! Map) return null;
final translations = first['translations'];
if (translations is! List || translations.isEmpty) return null;
final translated = translations.first;
if (translated is! Map) return null;
final result = translated['text'];
if (result is! String) return null;
print('🟢[MS-TRANS-DBG] 翻译成功: $result');
return result;
} catch (e, st) {
print('🟢[MS-TRANS-DBG] 翻译请求异常: $e\n$st');
Logger.error('微软翻译请求异常: $e');
return null;
}
}
String _newTraceId() {
final now = DateTime.now().microsecondsSinceEpoch;
return 'ms-trans-$now';
}
}

15
lib/data/services/speech_impl/azure_asr_service.dart

@ -202,6 +202,7 @@ class AzureAsrService extends GetxService implements AsrService {
bool audioSourceType, { bool audioSourceType, {
bool isRemoveFirstPunctuation = true, bool isRemoveFirstPunctuation = true,
String mode = "normal", String mode = "normal",
bool skipAsrForCallMode = false,
}) async { }) async {
// 防抖判断:短时间内重复调用直接拦截 // 防抖判断:短时间内重复调用直接拦截
final now = DateTime.now(); final now = DateTime.now();
@ -225,6 +226,7 @@ class AzureAsrService extends GetxService implements AsrService {
'audioSourceType': audioSourceType, 'audioSourceType': audioSourceType,
'isRemoveFirstPunctuation': isRemoveFirstPunctuation, 'isRemoveFirstPunctuation': isRemoveFirstPunctuation,
'mode': mode, 'mode': mode,
'skipAsrForCallMode': skipAsrForCallMode,
}); });
if (!result) { if (!result) {
@ -327,11 +329,22 @@ class AzureAsrService extends GetxService implements AsrService {
/// 处理来自原生端的识别事件 /// 处理来自原生端的识别事件
void _handleRecognitionEvent(dynamic event) { void _handleRecognitionEvent(dynamic event) {
if (event is! Map || _eventStreamController == null) return; if (event is! Map) {
print('🔴[ASR-DBG] 非 Map 事件被丢弃: ${event.runtimeType}');
return;
}
if (_eventStreamController == null) {
print('🔴[ASR-DBG] _eventStreamController 为 null,事件被丢弃: $event');
return;
}
final Map<dynamic, dynamic> eventMap = event; final Map<dynamic, dynamic> eventMap = event;
final String eventType = eventMap['type'] as String? ?? ''; final String eventType = eventMap['type'] as String? ?? '';
final String provider = eventMap['provider'] as String? ?? ''; final String provider = eventMap['provider'] as String? ?? '';
if (eventType != 'nativeLog' && eventType != 'audioData') {
print(
'🔴[ASR-DBG] 原生事件抵达 azure_asr_service: type=$eventType, provider=$provider, text=${eventMap['text']}');
}
if (provider.isNotEmpty) { if (provider.isNotEmpty) {
if (provider != _latestAsrProvider) { if (provider != _latestAsrProvider) {
_latestAsrProvider = provider; _latestAsrProvider = provider;

1
lib/data/services/speech_impl/volcano_asr_api_service.dart

@ -869,6 +869,7 @@ class VolcanoAsrApiService implements AsrService {
bool audioSourceType, { bool audioSourceType, {
bool isRemoveFirstPunctuation = true, bool isRemoveFirstPunctuation = true,
String mode = "normal", String mode = "normal",
bool skipAsrForCallMode = false,
}) { }) {
// TODO: implement startContinuousRecognition // TODO: implement startContinuousRecognition
throw UnimplementedError(); throw UnimplementedError();

1
lib/data/services/speech_impl/volcano_asr_service.dart

@ -404,6 +404,7 @@ class VolcanoAsrService extends GetxService implements AsrService {
bool audioSourceType, { bool audioSourceType, {
bool isRemoveFirstPunctuation = true, bool isRemoveFirstPunctuation = true,
String mode = "normal", String mode = "normal",
bool skipAsrForCallMode = false,
}) { }) {
// TODO: implement startContinuousRecognition // TODO: implement startContinuousRecognition
throw UnimplementedError(); throw UnimplementedError();

13
lib/data/services/volcano_translation_service.dart

@ -177,6 +177,7 @@ class VolcanoTranslationService extends GetxService {
required String targetLanguageCode, required String targetLanguageCode,
}) async { }) async {
if (!_isInitialized) { if (!_isInitialized) {
print('🟡[VOLC-DBG] 翻译服务未初始化,返回 null');
Logger.error('翻译服务未初始化'); Logger.error('翻译服务未初始化');
return null; return null;
} }
@ -200,6 +201,7 @@ class VolcanoTranslationService extends GetxService {
if (targetShortCode != null) { if (targetShortCode != null) {
targetCode = targetShortCode; targetCode = targetShortCode;
} }
print('🟡[VOLC-DBG] translateText: srcAsr=$sourceLanguageCode->srcShort=$sourceCode, tgtAsr=$targetLanguageCode->tgtShort=$targetCode, text=$text');
// 构建请求体 // 构建请求体
final requestBody = { final requestBody = {
@ -223,11 +225,13 @@ class VolcanoTranslationService extends GetxService {
final queryString = Uri(queryParameters: queryParams).query; final queryString = Uri(queryParameters: queryParams).query;
final url = '$_baseUrl/?$queryString'; final url = '$_baseUrl/?$queryString';
print('🟡[VOLC-DBG] 发起翻译请求: url=$url, body=${json.encode(requestBody)}');
final response = await http.post( final response = await http.post(
Uri.parse(url), Uri.parse(url),
headers: headers, headers: headers,
body: json.encode(requestBody), body: json.encode(requestBody),
); );
print('🟡[VOLC-DBG] 翻译响应: status=${response.statusCode}, bodyLen=${response.body.length}, body=${response.body.length > 500 ? response.body.substring(0, 500) : response.body}');
if (response.statusCode == 200) { if (response.statusCode == 200) {
final responseData = json.decode(response.body); final responseData = json.decode(response.body);
@ -279,13 +283,20 @@ class VolcanoTranslationService extends GetxService {
} }
// 返回提取到的翻译结果 // 返回提取到的翻译结果
if (translation == null) {
print('🟡[VOLC-DBG] HTTP 200 但未能解析出翻译字段,responseData=$responseData');
} else {
print('🟡[VOLC-DBG] 翻译成功: $translation');
}
return translation; return translation;
} }
print('🟡[VOLC-DBG] HTTP 错误: status=${response.statusCode}, body=${response.body}');
Logger.error( Logger.error(
'翻译HTTP错误: status=${response.statusCode}, body=${response.body}'); '翻译HTTP错误: status=${response.statusCode}, body=${response.body}');
return null; return null;
} catch (e) { } catch (e, st) {
print('🟡[VOLC-DBG] 翻译请求异常: $e\n$st');
Logger.error('翻译请求异常: $e'); Logger.error('翻译请求异常: $e');
return null; return null;
} }

22
lib/modules/meeting/controllers/meeting_details_controller.dart

@ -170,12 +170,22 @@ class MeetingDetailsController extends GetxController
if (!await file.exists()) { if (!await file.exists()) {
Logger.error('音频文件不存在: $filepath'); Logger.error('音频文件不存在: $filepath');
isPlayerReady.value = false; isPlayerReady.value = false;
Get.snackbar( // 本地文件缺失:若云端存在音频,清空本地路径并持久化,
'error'.tr, // 让 UI 自动切换为"下载"按钮,下载完成后即可播放;
'audioFileNotFound'.tr, // 音频文件不存在 // 仅当云端也没有音频时才提示文件不存在
snackPosition: SnackPosition.BOTTOM, if (meetingData.value.audiourl.isNotEmpty) {
duration: const Duration(seconds: 3), Logger.d('MeetingDetails', '本地音频缺失,切换为下载态(云端URL可用)');
); meetingData.value.filepath = '';
meetingData.refresh();
await SqfliteApi.editMeetingTitle(id, {'filePath': ''});
} else {
Get.snackbar(
'error'.tr,
'audioFileNotFound'.tr, // 音频文件不存在
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 3),
);
}
return; return;
} }

33
lib/modules/translation/controllers/translation_controller.dart

@ -46,6 +46,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// ==================== 服务依赖 ==================== // ==================== 服务依赖 ====================
final AsrService _asrService = Get.find<AsrService>(); final AsrService _asrService = Get.find<AsrService>();
// 同声翻译 / 面对面翻译 / 音视频翻译走火山翻译(REST)
// 通话翻译(call 模式)不使用该字段,改走 _astService(AST 端到端)
final VolcanoTranslationService _translationService = final VolcanoTranslationService _translationService =
Get.find<VolcanoTranslationService>(); Get.find<VolcanoTranslationService>();
final TtsService _ttsService = Get.find<TtsService>(); final TtsService _ttsService = Get.find<TtsService>();
@ -1172,7 +1174,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 处理语音识别事件 /// 处理语音识别事件
/// [event] 识别事件 /// [event] 识别事件
void _handleRecognitionEvent(RecognitionEvent event) { void _handleRecognitionEvent(RecognitionEvent event) {
// Logger.info("处理识别事件:${event}"); print(
'🟣[TRANS-DBG] _handleRecognitionEvent: type=${event.type}, text=${event.text}, provider=${event.provider}, detectedLang=${event.detectedLanguage}, currentMode=${currentMode.value}');
final String asrProvider = event.provider; final String asrProvider = event.provider;
if (asrProvider.isNotEmpty) Logger.info('ASR provider: $asrProvider'); if (asrProvider.isNotEmpty) Logger.info('ASR provider: $asrProvider');
switch (event.type) { switch (event.type) {
@ -1531,12 +1534,17 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// 防抖处理翻译请求 // 防抖处理翻译请求
_translationDebounceTimer?.cancel(); _translationDebounceTimer?.cancel();
_translationDebounceTimer = Timer(const Duration(milliseconds: 300), () { _translationDebounceTimer = Timer(const Duration(milliseconds: 300), () {
final lastIsInter = translationHistory.isNotEmpty ? translationHistory.last.isIntermediate : null;
final lastSrc = translationHistory.isNotEmpty ? translationHistory.last.sourceText : '';
print('🟣[TRANS-DBG] 中间结果防抖触发: histEmpty=${translationHistory.isEmpty}, lastIsInter=$lastIsInter, lastSrcLen=${lastSrc.length}');
if (translationHistory.isNotEmpty && if (translationHistory.isNotEmpty &&
translationHistory.last.isIntermediate && translationHistory.last.isIntermediate &&
translationHistory.last.sourceText.isNotEmpty) { translationHistory.last.sourceText.isNotEmpty) {
translateText(translationHistory.last.sourceText, translateText(translationHistory.last.sourceText,
translationHistory.last.timestamp, translationHistory.last.timestamp,
isFinal: false); isFinal: false);
} else {
print('🟣[TRANS-DBG] 中间结果防抖未调用 translateText(条件不满足)');
} }
}); });
} }
@ -1544,7 +1552,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 处理最终识别结果 /// 处理最终识别结果
/// [text] 识别的文本 /// [text] 识别的文本
Future<void> handleFinalResult(String text) async { Future<void> handleFinalResult(String text) async {
if (text.isEmpty) return; print('🟣[TRANS-DBG] handleFinalResult 入口: text=$text, mode=${currentMode.value}, historyLen=${translationHistory.length}, lastIntermediate=${translationHistory.isNotEmpty ? translationHistory.last.isIntermediate : "N/A"}');
if (text.isEmpty) {
print('🟣[TRANS-DBG] handleFinalResult 因 text 为空直接返回');
return;
}
// 更新当前项 // 更新当前项
if (translationHistory.isNotEmpty && if (translationHistory.isNotEmpty &&
@ -1594,22 +1606,30 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 返回翻译结果 /// 返回翻译结果
Future<String?> translateText(String sourceText, DateTime timestamp, Future<String?> translateText(String sourceText, DateTime timestamp,
{bool isFinal = false}) async { {bool isFinal = false}) async {
if (sourceText.isEmpty) return null; print('🟣[TRANS-DBG] translateText 入口: text=$sourceText, isFinal=$isFinal, mode=${currentMode.value}, src=${sourceLanguageCode.value}, tgt=${targetLanguageCode.value}, detected=$detectedLanguageCode');
if (sourceText.isEmpty) {
print('🟣[TRANS-DBG] translateText 因 sourceText 为空直接返回');
return null;
}
isTranslating.value = true; isTranslating.value = true;
String? translationResult; String? translationResult;
try { try {
final languageCodes = _determineLanguageCodes(); final languageCodes = _determineLanguageCodes();
print('🟣[TRANS-DBG] 准备调用翻译 API: src=${languageCodes['source']} tgt=${languageCodes['target']}');
translationResult = await _performTranslation( translationResult = await _performTranslation(
sourceText, languageCodes['source']!, languageCodes['target']!); sourceText, languageCodes['source']!, languageCodes['target']!);
print('🟣[TRANS-DBG] 翻译 API 返回: result=$translationResult');
if (translationResult != null) { if (translationResult != null) {
await _updateTranslationHistory( await _updateTranslationHistory(
sourceText, translationResult, timestamp, languageCodes, isFinal); sourceText, translationResult, timestamp, languageCodes, isFinal);
} else {
print('🟣[TRANS-DBG] 翻译 API 返回 null,不更新历史也不播报');
} }
} catch (e) { } catch (e, st) {
Logger.error('翻译失败: ${e.toString()}'); print('🟣[TRANS-DBG] 翻译失败: ${e.toString()}\n$st');
} finally { } finally {
isTranslating.value = false; isTranslating.value = false;
} }
@ -1785,8 +1805,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 处理TTS播放 /// 处理TTS播放
/// [translationResult] 翻译结果 /// [translationResult] 翻译结果
Future<void> _handleTtsPlayback(String translationResult) async { Future<void> _handleTtsPlayback(String translationResult) async {
print('🟣[TRANS-DBG] _handleTtsPlayback 入口: isTtsEnabled=${isTtsEnabled.value}, text=$translationResult');
if (isTtsEnabled.value) { if (isTtsEnabled.value) {
await playTranslatedText(translationResult); await playTranslatedText(translationResult);
} else {
print('🟣[TRANS-DBG] TTS 已禁用,跳过播报');
} }
} }

16
local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift

@ -2623,18 +2623,20 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback {
sendEvent(name: "recognition_stopped", data: ["sessionid":currsessionAsrId,"status": "stopped"]) sendEvent(name: "recognition_stopped", data: ["sessionid":currsessionAsrId,"status": "stopped"])
}else{ }else{
sendEvent(name: "recognition_stopped", data: ["sessionid":sessionid,"status": "stopped"]) sendEvent(name: "recognition_stopped", data: ["sessionid":sessionid,"status": "stopped"])
isRecognizing = false
hasSpeechDetected = false
stopIdleCheck()
if (currentRecognitionMode == "ble_wakeup") { if (currentRecognitionMode == "ble_wakeup") {
audioPlayer?.playStopSound() audioPlayer?.playStopSound()
} }
tryResumeExternalMusicIfIdle(reason: "onSessionStopped")
} }
// 统一复位 ASR 状态 + 触发外部音乐恢复判断:
// 之前 PTT 分支漏掉了这几步,isRecognizing 卡在 true,
// 后续 AI/TTS 完成回调里 tryResumeExternalMusicIfIdle 的 recogBlocking 守卫永远为 true,
// 导致 scheduleDebouncedDeactivate 永远进不去、外部音乐收不到 notifyOthersOnDeactivation。
isRecognizing = false
hasSpeechDetected = false
stopIdleCheck()
_log("INFO", "[Agent] onSessionStopped mode=\(currentRecognitionMode) tryResume")
tryResumeExternalMusicIfIdle(reason: "onSessionStopped")
} }
func onCanceled(sessionid:String ,_ reason: String, _ errorDetails: String) { func onCanceled(sessionid:String ,_ reason: String, _ errorDetails: String) {

12
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift

@ -415,17 +415,21 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
let removeFirstPunctuation = args["isRemoveFirstPunctuation"] as? Bool ?? true let removeFirstPunctuation = args["isRemoveFirstPunctuation"] as? Bool ?? true
let mode = args["mode"] as? String ?? "normal" let mode = args["mode"] as? String ?? "normal"
// 通话翻译 call 模式由 Dart 侧显式传 true:AST 端到端服务已跑,占位跳过 ASR。
// 缺省 false,保证同声/面对面/多媒体翻译(external 音频)都能正常启动 ASR。
let skipAsrForCallMode = args["skipAsrForCallMode"] as? Bool ?? false
// 根据参数确定音频源类型 // 根据参数确定音频源类型
let audioSourceType = isExternalActive ? let audioSourceType = isExternalActive ?
AzureAsrHelper.AudioSourceType.external : AzureAsrHelper.AudioSourceType.external :
AzureAsrHelper.AudioSourceType.microphone AzureAsrHelper.AudioSourceType.microphone
os_log("startContinuousRecognition: mode=%@, audioSourceType=%@, isExternal=%d, astProvider=%@", os_log("startContinuousRecognition: mode=%@, audioSourceType=%@, isExternal=%d, skipAsr=%d, astProvider=%@",
log: ctLog, type: .info, mode, String(describing: audioSourceType), isExternalActive ? 1 : 0, currentAstProvider) log: ctLog, type: .info, mode, String(describing: audioSourceType), isExternalActive ? 1 : 0, skipAsrForCallMode ? 1 : 0, currentAstProvider)
// 通话翻译模式(external):AST 端到端服务已处理识别+翻译+TTS,无需启动 ASR // 仅当 Dart 显式标记为 call 模式下的占位调用时才跳过 ASR;
if isExternalActive { // 之前仅凭 isExternalActive 判断会误伤多媒体翻译(external 音频但需要启动 ASR)。
if isExternalActive && skipAsrForCallMode {
os_log("startContinuousRecognition: 通话模式,AST端到端服务已激活(provider=%@),跳过ASR", log: ctLog, type: .info, currentAstProvider) os_log("startContinuousRecognition: 通话模式,AST端到端服务已激活(provider=%@),跳过ASR", log: ctLog, type: .info, currentAstProvider)
result(true) result(true)
} else { } else {

44
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift

@ -1581,8 +1581,8 @@ private func applyAudioSessionForTTS() -> Bool {
|| currentRecognitionMode == "normal" || currentRecognitionMode == "normal"
|| currentRecognitionMode == "ble_wakeup") || currentRecognitionMode == "ble_wakeup")
if internalSessionActive && !audioInterrupted && isBlePlaybackMode if internalSessionActive && !audioInterrupted && isBlePlaybackMode
&& sessionSnapshot.category == .playback && sessionSnapshot.category == .playAndRecord
&& sessionSnapshot.mode == .spokenAudio { && sessionSnapshot.mode == .default {
return true return true
} }
dlog("INFO", "applyAudioSessionForTTS 入口 mode=\(currentRecognitionMode) otherAudioPlaying=\(sessionSnapshot.isOtherAudioPlaying) category=\(sessionSnapshot.category.rawValue) sessionMode=\(sessionSnapshot.mode.rawValue) internalActive=\(internalSessionActive)") dlog("INFO", "applyAudioSessionForTTS 入口 mode=\(currentRecognitionMode) otherAudioPlaying=\(sessionSnapshot.isOtherAudioPlaying) category=\(sessionSnapshot.category.rawValue) sessionMode=\(sessionSnapshot.mode.rawValue) internalActive=\(internalSessionActive)")
@ -1628,9 +1628,13 @@ private func applyAudioSessionForTTS() -> Bool {
self.micCapture?.stopCapture() self.micCapture?.stopCapture()
Thread.sleep(forTimeInterval: 0.05) Thread.sleep(forTimeInterval: 0.05)
} }
desiredCategory = .playback // 和 ASR 阶段保持同 category(.playAndRecord):
desiredMode = .spokenAudio // 换成 .playback + .spokenAudio 会让 iOS 给外部 App 发一次 "interruption ended (no shouldResume)",
// 不再 mixWithOthers:唤醒/TTS 期间需要打断外部和内部其他音频 // 最终 setActive(false, notifyOthers) 也没办法让它恢复。
// 整轮对话只用一种 category,iOS 视角就是单一 interruption,释放时才能正确触发 auto-resume。
desiredCategory = .playAndRecord
desiredMode = .default
desiredOptions = [.defaultToSpeaker, .allowBluetoothA2DP]
} else if isRecording { } else if isRecording {
desiredCategory = .playAndRecord desiredCategory = .playAndRecord
desiredMode = .default desiredMode = .default
@ -1755,14 +1759,15 @@ private func deactivateAudioSessionAfterTTSIfIdle() {
dlog("INFO", "deactivateAudioSessionAfterTTSIfIdle 入口") dlog("INFO", "deactivateAudioSessionAfterTTSIfIdle 入口")
let work = { [weak self] in let work = { [weak self] in
guard let self = self else { return } guard let self = self else { return }
// 注:这里刻意不再用 externalBusy skip。 // AI/ASR 仍忙时 skip deactivate:
// 之前试过"AI 还在 streaming 时段间 skip"——会导致整轮对话只在最终发一次 // 实测 PTT 模式多轮工具调用(tool_call → TTS → tool_call → TTS)里,如果段间放行
// notifyOthersOnDeactivation,有些外部 App(尤其第三方音乐)一次信号来不 // deactivate,外部 App(Apple Music/Spotify 等)会在 1s 内经历 resume→interrupt→resume
// 及/会被吃掉,表现为"完全不恢复"。 // 的快速抖动,最后一次 resume 会被部分 App 判为异常并忽略,表现为"彻底不恢复"。
// 放行后段间会反复 deactivate(notifyOthers),外部 App 能持续收到 ended, // 直接 skip 后等 aiOnComplete/onSessionStopped 的 tryResumeExternalMusicIfIdle
// 虽然有段间短暂冒音的副作用,但能稳定恢复。 // 重新 scheduleDebouncedDeactivate,保证整轮对话只经历一次 pause→resume。
if self.externalBusyProvider?() == true { if self.externalBusyProvider?() == true {
self.dlog("INFO", "external busy (AI/ASR 仍忙)——仍允许 deactivate,外部 App 稳定恢复优先") self.dlog("INFO", "deactivate skip: external busy (AI/ASR 仍忙),等回合结束再释放")
return
} }
if self.micCapture?.isCapturing == true { if self.micCapture?.isCapturing == true {
self.dlog("INFO", "deactivate skip: mic capturing,排重试") self.dlog("INFO", "deactivate skip: mic capturing,排重试")
@ -1833,6 +1838,21 @@ private func deactivateAudioSessionAfterTTSIfIdle() {
// "setActive 成功后清零 + 下轮 startRecognition 读到 isOtherAudioPlaying=false 覆盖" // "setActive 成功后清零 + 下轮 startRecognition 读到 isOtherAudioPlaying=false 覆盖"
// 组合攻击擦掉,导致外部音乐恢复不了 // 组合攻击擦掉,导致外部音乐恢复不了
let opts: AVAudioSession.SetActiveOptions = .notifyOthersOnDeactivation let opts: AVAudioSession.SetActiveOptions = .notifyOthersOnDeactivation
// 释放前先把 category 切到 .ambient:让 iOS 明确收到"我们彻底不占 session 了"的信号,
// shouldResume 提示才能可靠送达 Apple Music / Spotify 等外部 App。
// 多个外部 App 对"仍处于 .playAndRecord 的 deactivate"会默认不恢复(视为潜在录音/通话),
// 切到 .ambient 后再 deactivate 是 Siri 以外多数语音助手实测通用的套路。
let prevCategory = session.category
let prevMode = session.mode
let prevOptions = session.categoryOptions
if prevCategory != .ambient {
do {
try session.setCategory(.ambient, mode: .default, options: [])
self.dlog("INFO", "deactivate 前先切到 .ambient(原 category=\(prevCategory.rawValue) mode=\(prevMode.rawValue))")
} catch {
self.dlog("WARNING", "切到 .ambient 失败: \(error.localizedDescription),继续原 category 释放")
}
}
self.dlog("INFO", "准备 setActive(false) flagHint=\(self.notifyOthersOnNextDeactivate) otherAudioPlaying=\(otherAudioPlaying) category=\(session.category.rawValue) mode=\(session.mode.rawValue)") self.dlog("INFO", "准备 setActive(false) flagHint=\(self.notifyOthersOnNextDeactivate) otherAudioPlaying=\(otherAudioPlaying) category=\(session.category.rawValue) mode=\(session.mode.rawValue)")
do { do {
try session.setActive(false, options: opts) try session.setActive(false, options: opts)

10
local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift

@ -177,14 +177,18 @@ public class SimpleAudioReceiver: NSObject {
desiredOptions = [.allowBluetooth, .defaultToSpeaker] desiredOptions = [.allowBluetooth, .defaultToSpeaker]
} }
} else { // normal, push_to_talk, ble_wakeup 等:仅录音,不需要同时播报 } else { // normal, push_to_talk, ble_wakeup 等:仅录音,不需要同时播报
desiredCategory = .record // 不使用 .record:该 category 的 interruption 被外部音乐 App(Apple Music/Spotify 等)
// 归为"Siri/录音类打断",.notifyOthersOnDeactivation 拿不到 auto-resume。
// 改用 .playAndRecord,和 phone_call 对齐,iOS 会把这类打断归为"媒体打断",
// 外部 App 在 TTS 结束 session 释放时才能正常恢复。
desiredCategory = .playAndRecord
desiredMode = .default desiredMode = .default
desiredOptions = [] desiredOptions = [.defaultToSpeaker, .allowBluetoothA2DP]
} }
// 如果在真实通话中,强制使用更适合的录音设置 // 如果在真实通话中,强制使用更适合的录音设置
if isTelephonyActive() { if isTelephonyActive() {
desiredCategory = .record desiredCategory = .playAndRecord
desiredMode = .voiceChat desiredMode = .voiceChat
desiredOptions = [.allowBluetooth] desiredOptions = [.allowBluetooth]
} }

Loading…
Cancel
Save