From 7dc7af472719171d10f0ff6e87cd4f85db8f9b20 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Mon, 25 May 2026 14:21:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=20iOS=20=E9=80=9A=E8=AF=9D?= =?UTF-8?q?=E7=BF=BB=E8=AF=91=E8=AF=AD=E9=9F=B3=E8=AE=B0=E5=BD=95=E4=B8=8D?= =?UTF-8?q?=E5=AE=8C=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/Runner.xcodeproj/project.pbxproj | 12 +- lib/data/services/ast_service.dart | 15 ++ .../meeting/meeting_task_service.dart | 9 +- .../speech_impl/azure_ast_service.dart | 44 ++++++ .../meeting_details_controller.dart | 15 +- .../bottomSheet/generate_bottom_sheet.dart | 52 +++---- .../controllers/translation_controller.dart | 48 +++--- .../azure_speech/AzureSpeechPlugin.kt | 47 ++++++ .../azure_speech/tools/AstStereoRecorder.kt | 136 +++++++++++++++++ .../azure_speech/AzureSpeechPlugin.swift | 70 ++++++--- .../Sources/tools/AstStereoRecorder.swift | 140 ++++++++++++++++++ 11 files changed, 520 insertions(+), 68 deletions(-) create mode 100644 local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AstStereoRecorder.kt create mode 100644 local_plugins/azure_speech/ios/azure_speech/Sources/tools/AstStereoRecorder.swift diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index fcad18058..6f823e086 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -346,10 +346,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -363,10 +367,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -506,7 +514,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 537SFY28XS; + DEVELOPMENT_TEAM = K39FB49XKY; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; diff --git a/lib/data/services/ast_service.dart b/lib/data/services/ast_service.dart index 992ab3545..5c83ef7d5 100644 --- a/lib/data/services/ast_service.dart +++ b/lib/data/services/ast_service.dart @@ -16,8 +16,23 @@ abstract class AstService { void setLowVolumeThreshold(int threshold); /// 开始录音 + /// + /// 通话翻译模式专用:以立体声 WAV 形式同时记录 + /// - 左声道:本端麦克风原音(来自 BLE 输入) + /// - 右声道:对方语音的翻译音频(B 路 AST 合成的 PCM) Future enableRecord(String filePath); + /// 暂停通话翻译录音 + Future pauseRecord(); + + /// 继续通话翻译录音 + Future resumeRecord(); + + /// 结束通话翻译录音 + /// + /// [isSave] 为 false 时丢弃当前文件 + Future stopRecord(bool isSave); + /// 开始连续翻译 Future startContinuousTranslation(); diff --git a/lib/data/services/meeting/meeting_task_service.dart b/lib/data/services/meeting/meeting_task_service.dart index b984179d5..fb748070b 100644 --- a/lib/data/services/meeting/meeting_task_service.dart +++ b/lib/data/services/meeting/meeting_task_service.dart @@ -135,13 +135,20 @@ class MeetingTaskService extends GetxService { _executeTask(); } - Future refreshTask(int id, int templateid, String tid, String tolanguage) async { + Future refreshTask( + int id, + int templateid, + String tid, + String tolanguage, + TranscribeService serviceType, + ) async { tasktype.value = 3; await Api.refreshTaskEchomeet({ 'id': id, 'templateid': templateid, 'tid': tid, 'tolanguage': tolanguage, + 'service_type': serviceType.value, }); _taskList.add({ 'id': id, diff --git a/lib/data/services/speech_impl/azure_ast_service.dart b/lib/data/services/speech_impl/azure_ast_service.dart index e75215a59..b264922e9 100644 --- a/lib/data/services/speech_impl/azure_ast_service.dart +++ b/lib/data/services/speech_impl/azure_ast_service.dart @@ -296,6 +296,50 @@ class AzureAstService extends GetxService implements AstService { } } + @override + Future pauseRecord() async { + try { + final bool result = await _channel.invokeMethod('pauseRecord'); + return result; + } on MissingPluginException { + Logger.warning('[STS] pauseRecord: 原生未实现'); + return false; + } catch (e) { + Logger.error('暂停录音失败: ${e.toString()}'); + return false; + } + } + + @override + Future resumeRecord() async { + try { + final bool result = await _channel.invokeMethod('resumeRecord'); + return result; + } on MissingPluginException { + Logger.warning('[STS] resumeRecord: 原生未实现'); + return false; + } catch (e) { + Logger.error('继续录音失败: ${e.toString()}'); + return false; + } + } + + @override + Future stopRecord(bool isSave) async { + try { + final bool result = await _channel.invokeMethod('stopRecord', { + 'isSave': isSave, + }); + return result; + } on MissingPluginException { + Logger.warning('[STS] stopRecord: 原生未实现'); + return false; + } catch (e) { + Logger.error('停止录音失败: ${e.toString()}'); + return false; + } + } + @override Future path(String filePath) async { try { diff --git a/lib/modules/meeting/controllers/meeting_details_controller.dart b/lib/modules/meeting/controllers/meeting_details_controller.dart index ae0f15803..930f797cb 100644 --- a/lib/modules/meeting/controllers/meeting_details_controller.dart +++ b/lib/modules/meeting/controllers/meeting_details_controller.dart @@ -428,10 +428,21 @@ class MeetingDetailsController extends GetxController EasyLoading.dismiss(); } - void refreshSummary(int templateid, String tid, String tolanguage) async { + void refreshSummary( + int templateid, + String tid, + String tolanguage, + TranscribeService serviceType, + ) async { EasyLoading.show(); _everType = 4; - await _meetingTaskService.refreshTask(id, templateid, tid, tolanguage); + await _meetingTaskService.refreshTask( + id, + templateid, + tid, + tolanguage, + serviceType, + ); meetingDetails.value.tasktype = 3; meetingDetails.refresh(); EasyLoading.dismiss(); diff --git a/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart b/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart index 949f9c427..d312ecfb0 100644 --- a/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart +++ b/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart @@ -334,33 +334,32 @@ class _GenerateBottomSheetState extends State { }); }, ), - if (!widget.isSummarizeAgain) - _row( - 'transcribeService'.tr, // 对应中文:转写服务 - value: _serviceLabel(_serviceType), - isBorder: false, - isDarkMode: isDarkMode, - onTap: () { - Get.bottomSheet( - TranscribeServiceBottomSheet(_serviceType), - isScrollControlled: true, - backgroundColor: isDarkMode - ? Colors.grey[850] - : Colors.grey[100], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(12.r), - ), + _row( + 'transcribeService'.tr, // 对应中文:转写服务 + value: _serviceLabel(_serviceType), + isBorder: false, + isDarkMode: isDarkMode, + onTap: () { + Get.bottomSheet( + TranscribeServiceBottomSheet(_serviceType), + isScrollControlled: true, + backgroundColor: isDarkMode + ? Colors.grey[850] + : Colors.grey[100], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), ), - ).then((value) { - if (value is TranscribeService) { - setState(() { - _serviceType = value; - }); - } - }); - }, - ), + ), + ).then((value) { + if (value is TranscribeService) { + setState(() { + _serviceType = value; + }); + } + }); + }, + ), ], ), ), @@ -388,6 +387,7 @@ class _GenerateBottomSheetState extends State { _templateData['id'], _templateData['tid'] ?? '', _tolanguage, + _serviceType, ); } else { _controller.summaryCreate( diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 316f0f492..69eb4e961 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -1019,36 +1019,40 @@ class TranslationController extends GetxController with WidgetsBindingObserver { } /// 开始录音 + /// + /// - call 模式:走 [_astService],原生将录制立体声 WAV + /// (左=本端麦克风原音,右=对方翻译后的合成音)。 + /// - 其他模式:保持原有 [_asrService] 录制逻辑。 Future startRecording() async { // 等待服务初始化完成,确保 dir 已赋值 await _initServicesFuture; _timerManager.startTimer(); final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); - AudioSourceType audioSourceType; + final String filePath = + "${dir.path}/${currentModeTitle.value.tr}_$formattedTime.wav"; + if (currentMode.value == 'call') { - audioSourceType = AudioSourceType.external; - } else if (currentMode.value == 'audioVideo') { - audioSourceType = AudioSourceType.systemAudio; + await _astService.enableRecord(filePath); } else { - audioSourceType = AudioSourceType.microphone; + AudioSourceType audioSourceType; + if (currentMode.value == 'audioVideo') { + audioSourceType = AudioSourceType.systemAudio; + } else { + audioSourceType = AudioSourceType.microphone; + } + await _asrService.enableRecord(audioSourceType, filePath, false); } - await _asrService.enableRecord( - audioSourceType, - "${dir.path}/${currentModeTitle.value.tr}_$formattedTime.wav", - false, - ); - - // if (currentMode.value == 'call') { - // await _astService.enableRecord( - // "${dir.path}/${currentModeTitle.value.tr}_${formattedTime}_mic.wav"); - // } isCreateRecord.value = true; } /// 停止录音 Future stopRecording() async { _timerManager.stopTimer(); - _asrService.stopRecord(true); + if (currentMode.value == 'call') { + await _astService.stopRecord(true); + } else { + await _asrService.stopRecord(true); + } isCreateRecord.value = false; Logger.info('录音已停止,计时器已重置'); } @@ -1056,7 +1060,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 暂停录音 Future pauseRecording() async { _timerManager.pauseTimer(); - _asrService.pauseRecord(); + if (currentMode.value == 'call') { + await _astService.pauseRecord(); + } else { + await _asrService.pauseRecord(); + } Logger.info(' 录音已暂停'); } @@ -1064,7 +1072,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 继续录音 Future continueRecording() async { _timerManager.resumeTimer(); - _asrService.resumeRecord(); + if (currentMode.value == 'call') { + await _astService.resumeRecord(); + } else { + await _asrService.resumeRecord(); + } Logger.info(' 录音已继续'); } diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index b13da804d..fecb459ff 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import com.yunqiinnovation.azure_speech.tools.RecordFile +import com.yunqiinnovation.azure_speech.tools.AstStereoRecorder import com.example.astclient.DoubaoE2ETranslateHelper import com.example.astclient.Config import java.util.concurrent.atomic.AtomicInteger @@ -75,7 +76,14 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, private val callBPcmPlayer = CallBPcmPlayer() // 设备产品配置 broadcastpeertranslate:true=播报并启用 gate;false/未下发=不播报且 gate 永不触发。默认 false。 @Volatile private var broadcastPeerTranslate: Boolean = false + // 通话翻译立体声录音器:左=本端麦克风原音(BLE leftBuffer),右=对方翻译合成音(B 路 AST PCM) + private val astStereoRecorder = AstStereoRecorder() private val bPcmWriter: AudioWriter = AudioWriter { data -> + // 录音不受 broadcastPeerTranslate 影响:即便不本地播放,也要捕获 B 路翻译音频 + // 只在录音开启时才进 FIFO,避免未录音时堆积内存 + if (astStereoRecorder.isActive) { + astStereoRecorder.enqueueRightChannel(data) + } if (broadcastPeerTranslate) callBPcmPlayer.feed(data) } @@ -1148,6 +1156,8 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, nextAstInitGeneration() FileLogger.d(tag, "释放双向 AST 资源(异步)") callBPcmPlayer.stop() + // dispose 时若仍在录音则保存现有文件,避免泄漏 + astStereoRecorder.stop(true) GlobalScope.launch(Dispatchers.IO) { try { if (currentAstProvider == "iflytek" ) { @@ -1216,6 +1226,36 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, result.success(true) } + "enableRecord" -> { + val filePath = call.argument("filePath") ?: "" + try { + val ok = astStereoRecorder.start(filePath) + FileLogger.d(tag, "[AST-REC] enableRecord: filePath=$filePath ok=$ok") + result.success(ok) + } catch (e: Exception) { + result.error("AST_ENABLE_RECORD_ERROR", e.message, null) + } + } + + "pauseRecord" -> { + result.success(astStereoRecorder.pause()) + } + + "resumeRecord" -> { + result.success(astStereoRecorder.resume()) + } + + "stopRecord" -> { + val isSave = call.argument("isSave") ?: true + try { + val ok = astStereoRecorder.stop(isSave) + FileLogger.d(tag, "[AST-REC] stopRecord: isSave=$isSave ok=$ok") + result.success(ok) + } catch (e: Exception) { + result.error("AST_STOP_RECORD_ERROR", e.message, null) + } + } + "initialize" -> { val initGeneration = nextAstInitGeneration() val provider = call.argument("provider") ?: "" @@ -1892,6 +1932,13 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, if (rAbs > rightPeak) rightPeak = rAbs } + // 通话翻译立体声录音:左声道=麦克风原音(leftBuffer),右声道由 B 路 TTS PCM + // 在 AstStereoRecorder 内部按相同样本数从 FIFO 取出对齐 + // 录音未开启时直接跳过,避免每帧无谓的函数调用 + if (astStereoRecorder.isActive) { + astStereoRecorder.writeLeftChannel(leftBuffer) + } + // 更新静音连续帧计数 val leftWasMuted = leftSilentStreak >= silenceFramesToMute val rightWasMuted = rightSilentStreak >= silenceFramesToMute diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AstStereoRecorder.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AstStereoRecorder.kt new file mode 100644 index 000000000..cc96c65c5 --- /dev/null +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AstStereoRecorder.kt @@ -0,0 +1,136 @@ +package com.yunqiinnovation.azure_speech.tools + +import android.util.Log + +/** + * 通话翻译模式立体声录音器 + * + * 由本端麦克风 PCM(来自 BLE 输入的左声道)作为时钟,按 16k/16bit 的节奏 + * 持续写入录音文件;对方翻译后的合成 PCM(B 路 AST 输出)异步排入右声道 FIFO, + * 与左声道按相同样本数交错合成立体声后写出。 + * + * 输出格式:16 kHz / 16-bit PCM / 2 channels WAV。 + */ +class AstStereoRecorder { + + private val lock = Any() + private var recordFile: RecordFile? = null + private val rightFifo = ArrayDeque() + + @Volatile + private var running = false + + @Volatile + private var paused = false + + /** + * 调用点用的轻量级开关:调用方可在不加锁的情况下快速判定是否需要把音频灌进录音器。 + * 在 start/pause/resume/stop 中同步更新;接受 start/stop 瞬间一两帧的偏差。 + */ + val isActive: Boolean + get() = running && !paused + + /** + * 开始录音 + * @param filePath 输出文件路径(.wav) + */ + fun start(filePath: String): Boolean { + synchronized(lock) { + // 已在录音,先停掉旧的 + recordFile?.closeFile(false) + recordFile = null + rightFifo.clear() + paused = false + + val file = RecordFile() + file.setAudioConfig(16000, 2) + file.creatingFiles(filePath) + recordFile = file + running = true + Log.d(TAG, "AstStereoRecorder start: $filePath") + return true + } + } + + fun pause(): Boolean { + paused = true + return true + } + + fun resume(): Boolean { + paused = false + return true + } + + fun stop(isSave: Boolean): Boolean { + synchronized(lock) { + val file = recordFile ?: return false + running = false + paused = false + val ok = file.closeFile(isSave) + recordFile = null + rightFifo.clear() + Log.d(TAG, "AstStereoRecorder stop: saved=$ok") + return ok + } + } + + /** + * 排入右声道(B 路 AST 合成出的对方翻译 PCM) + * @param pcm 16 kHz / 16-bit PCM mono + */ + fun enqueueRightChannel(pcm: ByteArray) { + if (pcm.isEmpty()) return + synchronized(lock) { + if (!running || paused) return + for (b in pcm) { + rightFifo.addLast(b) + } + } + } + + /** + * 写入一帧左声道(本端麦克风 PCM)并消费等量右声道字节,交错后落盘 + * @param mono 16 kHz / 16-bit PCM mono(来自 BLE 输入解出的 leftBuffer) + */ + fun writeLeftChannel(mono: ByteArray) { + if (mono.isEmpty()) return + // 左声道字节数必须是偶数(16-bit 对齐) + val leftBytes = mono.size - (mono.size % 2) + if (leftBytes == 0) return + + val stereo: ByteArray + val fileToWrite: RecordFile? + synchronized(lock) { + if (!running || paused) return + val file = recordFile ?: return + + val right = ByteArray(leftBytes) + val popCount = minOf(rightFifo.size, leftBytes) + for (i in 0 until popCount) { + right[i] = rightFifo.removeFirst() + } + // 不足部分 right 数组初始为 0(静音填充) + + stereo = ByteArray(leftBytes * 2) + val sampleCount = leftBytes / 2 + for (i in 0 until sampleCount) { + val srcIdx = i * 2 + val dstIdx = i * 4 + // 左声道 + stereo[dstIdx] = mono[srcIdx] + stereo[dstIdx + 1] = mono[srcIdx + 1] + // 右声道 + stereo[dstIdx + 2] = right[srcIdx] + stereo[dstIdx + 3] = right[srcIdx + 1] + } + fileToWrite = file + } + + fileToWrite?.saveAudioDataToWav(stereo) + } + + companion object { + private const val TAG = "AstStereoRecorder" + } +} diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift index ec7560fd8..3de58607b 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift @@ -128,11 +128,25 @@ import ble_service // 设备产品配置 broadcastpeertranslate:true=播报并启用 gate;false/未下发=不播报且 gate 永不触发。默认 false。 private var broadcastPeerTranslate: Bool = false + // 通话翻译立体声录音器:左=本端麦克风原音(BLE leftBuffer),右=对方翻译合成音(B 路 AST PCM) + private let astStereoRecorder = AstStereoRecorder() + fileprivate func pushCallBPcm(_ data: Data) { + // 录音不受 broadcastPeerTranslate 影响:即便不本地播放,也要捕获 B 路翻译音频 + // 只在录音开启时才进 FIFO,避免未录音时堆积内存 + if astStereoRecorder.isActive { + astStereoRecorder.enqueueRightChannel(data) + } guard broadcastPeerTranslate else { return } callBPcmPlayer.feed(data) } + /// Azure B 路 TTS 合成回调中调用:把 B 的翻译音频灌入录音右声道 + fileprivate func recordAzureBTtsPcm(_ data: Data) { + guard astStereoRecorder.isActive else { return } + astStereoRecorder.enqueueRightChannel(data) + } + fileprivate func resetCallBPlayer() { callBPcmPlayer.stop() } @@ -800,21 +814,26 @@ private func sendAudioDataEvent(_ event: [String: Any]) { // MARK: - AST 方法处理 private func handleAstMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { - // case "enableRecord": - // guard let args = call.arguments as? [String: Any], - // let filePath = args["filePath"] as? String else { - // result(FlutterError(code: "INVALID_ARGUMENTS", message: "Missing filePath", details: nil)) - // return - // } - - // do { - // os_log("音频文件名称为: %@", log: log, type: .info, filePath) - // try azureAstHelper.enableRecord(filePath: filePath) - // result(true) - // } catch { - // result(FlutterError(code: "ENABLERECORD_ERROR", message: error.localizedDescription, details: nil)) - // } - + case "enableRecord": + guard let args = call.arguments as? [String: Any], + let filePath = args["filePath"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Missing filePath", details: nil)) + return + } + let ok = astStereoRecorder.start(filePath: filePath) + result(ok) + + case "pauseRecord": + result(astStereoRecorder.pause()) + + case "resumeRecord": + result(astStereoRecorder.resume()) + + case "stopRecord": + let args = call.arguments as? [String: Any] + let isSave = (args?["isSave"] as? Bool) ?? true + result(astStereoRecorder.stop(isSave: isSave)) + case "startContinuousTranslation": os_log("[AST-START] provider=%{public}@", log: ctLog, type: .info, currentAstProvider) if currentAstProvider == "volcano" { @@ -886,6 +905,8 @@ private func sendAudioDataEvent(_ event: [String: Any]) { bailianAstHelperA.dispose() bailianAstHelperB.dispose() resetCallBPlayer() + // dispose 时若仍在录音则保存现有文件,避免泄漏 + _ = astStereoRecorder.stop(isSave: true) os_log("[AST-DISPOSE] All AST resources released", log: ctLog, type: .info) result(true) @@ -1262,6 +1283,10 @@ private class AstEventCallback: IntegratedSpeechTranslationService.ServiceEventC let pcmData = audioData print("[\(serviceId)] 合成音频生成,PCM大小=\(pcmData.count),合成ID=\(utteranceId)") if pcmData.count > 0 { + // B 路:对方翻译后的音频,灌入立体声录音器右声道(仅在录音开启时) + if serviceId == "B" { + plugin?.recordAzureBTtsPcm(pcmData) + } BleService.shared.writeExternalAudioData(data: pcmData) } } @@ -1318,20 +1343,27 @@ extension AzureSpeechPlugin: BleService.Callback { data.withUnsafeBytes { bytes in let uint8Ptr = bytes.bindMemory(to: UInt8.self) - + for i in 0.. Bool { + return stateQueue.sync { + // 已在录音,先停掉旧的 + if recordFile != nil { + _ = recordFile?.closeFile(isSave: false) + recordFile = nil + } + rightFifo.removeAll(keepingCapacity: false) + isPausedFlag = false + + let file = RecordFile() + file.setAudioConfig(sampleRate: 16000, channels: 2) + file.creatingFiles(atPath: filePath) + recordFile = file + isRunningFlag = true + _active = true + os_log("AstStereoRecorder start: %{public}@", log: log, type: .info, filePath) + return true + } + } + + public func pause() -> Bool { + return stateQueue.sync { + isPausedFlag = true + _active = false + return true + } + } + + public func resume() -> Bool { + return stateQueue.sync { + isPausedFlag = false + _active = isRunningFlag + return true + } + } + + public func stop(isSave: Bool) -> Bool { + return stateQueue.sync { + guard let file = recordFile else { + _active = false + return false + } + isRunningFlag = false + isPausedFlag = false + _active = false + let ok = file.closeFile(isSave: isSave) + recordFile = nil + rightFifo.removeAll(keepingCapacity: false) + os_log("AstStereoRecorder stop: saved=%{public}@", log: log, type: .info, ok ? "true" : "false") + return ok + } + } + + /// 排入右声道(B 路 AST 合成出的对方翻译 PCM) + /// - Parameter pcm: 16 kHz / 16-bit PCM mono + public func enqueueRightChannel(_ pcm: Data) { + guard !pcm.isEmpty else { return } + stateQueue.sync { + guard isRunningFlag, !isPausedFlag else { return } + rightFifo.append(pcm) + } + } + + /// 写入一帧左声道(本端麦克风 PCM)并消费等量右声道字节,交错后落盘 + /// - Parameter mono: 16 kHz / 16-bit PCM mono(来自 BLE 输入解出的 leftBuffer) + public func writeLeftChannel(_ mono: Data) { + guard !mono.isEmpty else { return } + // 左声道字节数必须是偶数(16-bit 对齐) + let leftBytes = mono.count - (mono.count % 2) + if leftBytes == 0 { return } + + var stereo = Data() + var fileToWrite: RecordFile? + stateQueue.sync { + guard isRunningFlag, !isPausedFlag, let file = recordFile else { return } + + // 从右声道 FIFO 中取出与左声道等长的字节,不足部分补零 + let popCount = min(rightFifo.count, leftBytes) + var right = Data(capacity: leftBytes) + if popCount > 0 { + right.append(rightFifo.prefix(popCount)) + rightFifo.removeFirst(popCount) + } + if popCount < leftBytes { + right.append(Data(count: leftBytes - popCount)) + } + + stereo = Data(capacity: leftBytes * 2) + let leftPrefix = mono.prefix(leftBytes) + leftPrefix.withUnsafeBytes { (lPtr: UnsafeRawBufferPointer) in + right.withUnsafeBytes { (rPtr: UnsafeRawBufferPointer) in + let lBase = lPtr.bindMemory(to: UInt8.self).baseAddress! + let rBase = rPtr.bindMemory(to: UInt8.self).baseAddress! + let sampleCount = leftBytes / 2 + for i in 0..