From a3c1722415fa8ac35c4a9b46cf24a55d6b849fbc Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Mon, 15 Jun 2026 11:15:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=9A=E8=AF=9D=E4=BC=9A?= =?UTF-8?q?=E8=AE=AE=E8=AE=B0=E5=BD=95=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/services/ast_service.dart | 15 ++ .../speech_impl/azure_ast_service.dart | 44 +++++ .../controllers/translation_controller.dart | 48 +++-- .../azure_speech/AzureSpeechPlugin.kt | 40 +++++ .../azure_speech/tools/AstStereoRecorder.kt | 160 +++++++++++++++++ .../azure_speech/AzureSpeechPlugin.swift | 49 ++++-- .../Sources/tools/AstStereoRecorder.swift | 165 ++++++++++++++++++ 7 files changed, 488 insertions(+), 33 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/lib/data/services/ast_service.dart b/lib/data/services/ast_service.dart index 5f2bc7e0e..b406b1766 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/speech_impl/azure_ast_service.dart b/lib/data/services/speech_impl/azure_ast_service.dart index 889c2e193..4f7cb85de 100644 --- a/lib/data/services/speech_impl/azure_ast_service.dart +++ b/lib/data/services/speech_impl/azure_ast_service.dart @@ -300,6 +300,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/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 550647be4..204bd35da 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -1046,36 +1046,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('录音已停止,计时器已重置'); } @@ -1083,7 +1087,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(' 录音已暂停'); } @@ -1091,7 +1099,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 0bbeca1dd..1f2453105 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 @@ -80,6 +81,9 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, // 设备产品配置 broadcastpeertranslate:true=B 路同时也走外放(耳机一路总会走,外放是叠加)。 @Volatile private var broadcastPeerTranslate: Boolean = false + // 通话翻译立体声录音器:左=本端麦克风原音(A 路 pushExternalAudio),右=对方翻译合成音(B 路 AST TTS) + private val astStereoRecorder = AstStereoRecorder() + private fun emitAstTtsFrame(leg: String, data: ByteArray, isFinal: Boolean = false) { val sink = astTtsEventSink ?: return // EventChannel 在主线程 send,确保有效;TTS 段一段一段过来,量级在 KB,主线程开销可忽略 @@ -107,6 +111,8 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, private val bPcmWriter: AudioWriter = object : AudioWriter { override fun write(data: ByteArray) { emitAstTtsFrame("B", data) + // 录音不受 broadcastPeerTranslate 影响:即便不本地播放,也要捕获 B 路翻译音频作为右声道 + if (astStereoRecorder.isActive) astStereoRecorder.enqueueRightChannel(data) if (broadcastPeerTranslate) callBPcmPlayer.feed(data) } override fun markEnd() { @@ -209,6 +215,8 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, if (astAudioPushCountA % 100 == 1L) { FileLogger.d(tag, "pushAstAudioToA: provider=$currentAstProvider, size=${data.size}, count=$astAudioPushCountA") } + // 立体声录音左声道=本端麦克风原音(取未经回环 gate 处理的原始 PCM),由它驱动录音时钟 + if (astStereoRecorder.isActive) astStereoRecorder.writeLeftChannel(data) val payload = gateAgainstBPlayback(data) if (currentAstProvider == "iflytek") { iflytekAstHelperA?.pushAudioData(payload) @@ -1229,6 +1237,8 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, // 让所有等待 delay(1200L) 的旧 init 协程在恢复时直接 return,避免落到已 dispose 的 helper 上 nextAstInitGeneration() FileLogger.d(tag, "释放双向 AST 资源(异步)") + // dispose 时若仍在录音则保存现有文件,避免泄漏 + astStereoRecorder.stop(true) callBPcmPlayer.stop() GlobalScope.launch(Dispatchers.IO) { try { @@ -1298,6 +1308,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) + } + } + /** Dart 把杰理 runtime 解码后的 SCO PCM 推进来: * leg = 'A' / 'uplink' = 本机麦克风 / 己方 * leg = 'B' / 'downlink' = 对端通话音 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..eb3f5b5df --- /dev/null +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AstStereoRecorder.kt @@ -0,0 +1,160 @@ +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 + // 左声道:本端麦克风原音,应用 LEFT_GAIN 拉响 + val lOutU = gainSoftClip(mono[srcIdx], mono[srcIdx + 1], LEFT_GAIN) + stereo[dstIdx] = (lOutU and 0xFF).toByte() + stereo[dstIdx + 1] = ((lOutU shr 8) and 0xFF).toByte() + // 右声道:B 路 TTS 合成音,应用 RIGHT_GAIN 压低 + val rOutU = gainSoftClip(right[srcIdx], right[srcIdx + 1], RIGHT_GAIN) + stereo[dstIdx + 2] = (rOutU and 0xFF).toByte() + stereo[dstIdx + 3] = ((rOutU shr 8) and 0xFF).toByte() + } + fileToWrite = file + } + + fileToWrite?.saveAudioDataToWav(stereo) + } + + /** + * 对一个 16-bit little-endian PCM 样本施加线性增益并 tanh 软削波。 + * 返回 [0, 0xFFFF] 区间的 Int(即 UInt16 的 bit pattern),交给调用方拆成两个字节。 + * gain==1.0 时短路返回原始 bit pattern,避免无谓浮点。 + */ + private fun gainSoftClip(lo: Byte, hi: Byte, gain: Float): Int { + val unsigned = (lo.toInt() and 0xFF) or ((hi.toInt() and 0xFF) shl 8) + if (gain == 1.0f) return unsigned + val s = unsigned.toShort().toInt() // 还原为带符号 Int16 + val x = (s.toFloat() / 32767.0f) * gain + val y = Math.tanh(x.toDouble()).toFloat() + var v = y * 32767.0f + if (v > 32767.0f) v = 32767.0f + if (v < -32768.0f) v = -32768.0f + return v.toInt() and 0xFFFF + } + + companion object { + private const val TAG = "AstStereoRecorder" + + // 通话翻译录音中麦克风原音偏小、对方 TTS 偏响,对两路分别做线性增益再 tanh 软削波。 + // 左(麦克风) +12dB ≈ 3.98x,右(TTS) -4dB ≈ 0.63x,与 iOS 端 AstStereoRecorder 保持一致。 + private const val LEFT_GAIN = 3.98f + private const val RIGHT_GAIN = 0.63f + } +} 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 ae619c7da..edc817187 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 @@ -136,6 +136,9 @@ import ble_service // 设备产品配置 broadcastpeertranslate:true=播报并启用 gate;false/未下发=不播报且 gate 永不触发。默认 false。 private var broadcastPeerTranslate: Bool = false + // 通话翻译立体声录音器:左=本端麦克风原音(A 路 pushExternalAudio),右=对方翻译合成音(B 路 AST TTS) + private let astStereoRecorder = AstStereoRecorder() + // 杰理 SCO PCM 推送计数(每 100 帧打一次日志,避免刷屏) private var astAudioPushCountA: Int64 = 0 private var astAudioPushCountB: Int64 = 0 @@ -166,6 +169,8 @@ import ble_service os_log("pushAstAudioToA: provider=%{public}@ size=%d count=%lld rms=%.1f head=%{public}@", log: ctLog, type: .info, currentAstProvider, data.count, astAudioPushCountA, rms, head) } + // 立体声录音左声道=本端麦克风原音(取未经回环 gate 处理的原始 PCM),由它驱动录音时钟 + if astStereoRecorder.isActive { astStereoRecorder.writeLeftChannel(data) } let payload = gateAgainstBPlayback(data) switch currentAstProvider { case "volcano": @@ -886,21 +891,29 @@ 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) + os_log("[AST-REC] enableRecord: %{public}@ ok=%{public}@", log: ctLog, type: .info, filePath, ok ? "true" : "false") + 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 + let ok = astStereoRecorder.stop(isSave: isSave) + os_log("[AST-REC] stopRecord: isSave=%{public}@ ok=%{public}@", log: ctLog, type: .info, isSave ? "true" : "false", ok ? "true" : "false") + result(ok) + case "startContinuousTranslation": os_log("[AST-START] provider=%{public}@", log: ctLog, type: .info, currentAstProvider) if currentAstProvider == "volcano" { @@ -997,6 +1010,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) @@ -1200,6 +1215,10 @@ private func sendAudioDataEvent(_ event: [String: Any]) { /// - pcm: 16bit、单声道、16kHz PCM;段尾帧允许传空 /// - isFinal: 是否是该段 utterance 的段尾(与 Android 合约一致,native 依靠 isFinal=true 触发整段下发) internal func pushAstTtsFrame(leg: String, pcm: Data, isFinal: Bool) { + // 录音右声道=对方翻译合成音(B 路 TTS),不受 broadcastPeerTranslate 影响:即便不本地播放也要捕获 + if leg == "B", !pcm.isEmpty, astStereoRecorder.isActive { + astStereoRecorder.enqueueRightChannel(pcm) + } guard let sink = astTtsEventSink else { // 调试:报警一次,避免刷屏 astTtsEmitCount &+= 1 diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/AstStereoRecorder.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/AstStereoRecorder.swift new file mode 100644 index 000000000..29f08f7bb --- /dev/null +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/AstStereoRecorder.swift @@ -0,0 +1,165 @@ +import Foundation +import os.log + +/// 通话翻译模式立体声录音器 +/// +/// 由本端麦克风 PCM(来自 BLE 输入的左声道)作为时钟,按 16k/16bit 的节奏 +/// 持续写入录音文件;对方翻译后的合成 PCM(B 路 AST 输出)异步排入右声道 FIFO, +/// 与左声道按相同样本数交错合成立体声后写出。 +/// +/// 输出格式:16 kHz / 16-bit PCM / 2 channels WAV。 +public final class AstStereoRecorder { + private let log = OSLog(subsystem: "com.azure.speech", category: "AstStereoRecorder") + + /// 通话翻译录音中麦克风原音偏小、对方 TTS 偏响,对两路分别做线性增益再 tanh 软削波。 + /// 左(麦克风) +12dB ≈ 3.98x,右(TTS) -4dB ≈ 0.63x,与 Android 端 AstStereoRecorder 保持一致。 + private static let leftGain: Float = 3.98 + private static let rightGain: Float = 0.63 + + /// 受写入队列保护的状态 + private let stateQueue = DispatchQueue(label: "ast.stereo.recorder.state") + private var recordFile: RecordFile? + private var rightFifo = Data() + private var isRunningFlag = false + private var isPausedFlag = false + + /// 调用点用的轻量级开关,避免每帧都进 stateQueue.sync。 + /// 在 start/pause/resume/stop 内同步更新,读时直接走原子 Bool(接受 start/stop 瞬间的一两帧延迟)。 + private var _active: Bool = false + public var isActive: Bool { _active } + + public init() {} + + /// 开始录音 + /// - Parameter filePath: 输出文件路径(.wav) + public func start(filePath: String) -> 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.. (UInt8, UInt8) { + if gain == 1.0 { return (lo, hi) } + let unsigned = UInt16(lo) | (UInt16(hi) << 8) + let s = Int16(bitPattern: unsigned) + let x = (Float(s) / 32767.0) * gain + let y = tanh(x) + var v = y * 32767.0 + if v > 32767.0 { v = 32767.0 } + if v < -32768.0 { v = -32768.0 } + let outU = UInt16(bitPattern: Int16(v)) + return (UInt8(outU & 0xFF), UInt8((outU >> 8) & 0xFF)) + } +}