Browse Source

修复通话会议记录的问题

weicu
liwei1dao 4 months ago
parent
commit
a3c1722415
  1. 15
      lib/data/services/ast_service.dart
  2. 44
      lib/data/services/speech_impl/azure_ast_service.dart
  3. 48
      lib/modules/translation/controllers/translation_controller.dart
  4. 40
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  5. 160
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AstStereoRecorder.kt
  6. 49
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  7. 165
      local_plugins/azure_speech/ios/azure_speech/Sources/tools/AstStereoRecorder.swift

15
lib/data/services/ast_service.dart

@ -16,8 +16,23 @@ abstract class AstService {
void setLowVolumeThreshold(int threshold); void setLowVolumeThreshold(int threshold);
/// 开始录音 /// 开始录音
///
/// 通话翻译模式专用:以立体声 WAV 形式同时记录
/// - 左声道:本端麦克风原音(来自 BLE 输入)
/// - 右声道:对方语音的翻译音频(B 路 AST 合成的 PCM)
Future<bool> enableRecord(String filePath); Future<bool> enableRecord(String filePath);
/// 暂停通话翻译录音
Future<bool> pauseRecord();
/// 继续通话翻译录音
Future<bool> resumeRecord();
/// 结束通话翻译录音
///
/// [isSave] 为 false 时丢弃当前文件
Future<bool> stopRecord(bool isSave);
/// 开始连续翻译 /// 开始连续翻译
Future<bool> startContinuousTranslation(); Future<bool> startContinuousTranslation();

44
lib/data/services/speech_impl/azure_ast_service.dart

@ -300,6 +300,50 @@ class AzureAstService extends GetxService implements AstService {
} }
} }
@override
Future<bool> 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<bool> 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<bool> 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 @override
Future<bool> path(String filePath) async { Future<bool> path(String filePath) async {
try { try {

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

@ -1046,36 +1046,40 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
} }
/// 开始录音 /// 开始录音
///
/// - call 模式:走 [_astService],原生将录制立体声 WAV
/// (左=本端麦克风原音,右=对方翻译后的合成音)。
/// - 其他模式:保持原有 [_asrService] 录制逻辑。
Future<void> startRecording() async { Future<void> startRecording() async {
// 等待服务初始化完成,确保 dir 已赋值 // 等待服务初始化完成,确保 dir 已赋值
await _initServicesFuture; await _initServicesFuture;
_timerManager.startTimer(); _timerManager.startTimer();
final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
AudioSourceType audioSourceType; final String filePath =
"${dir.path}/${currentModeTitle.value.tr}_$formattedTime.wav";
if (currentMode.value == 'call') { if (currentMode.value == 'call') {
audioSourceType = AudioSourceType.external; await _astService.enableRecord(filePath);
} else if (currentMode.value == 'audioVideo') {
audioSourceType = AudioSourceType.systemAudio;
} else { } 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; isCreateRecord.value = true;
} }
/// 停止录音 /// 停止录音
Future<void> stopRecording() async { Future<void> stopRecording() async {
_timerManager.stopTimer(); _timerManager.stopTimer();
_asrService.stopRecord(true); if (currentMode.value == 'call') {
await _astService.stopRecord(true);
} else {
await _asrService.stopRecord(true);
}
isCreateRecord.value = false; isCreateRecord.value = false;
Logger.info('录音已停止,计时器已重置'); Logger.info('录音已停止,计时器已重置');
} }
@ -1083,7 +1087,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 暂停录音 /// 暂停录音
Future<void> pauseRecording() async { Future<void> pauseRecording() async {
_timerManager.pauseTimer(); _timerManager.pauseTimer();
_asrService.pauseRecord(); if (currentMode.value == 'call') {
await _astService.pauseRecord();
} else {
await _asrService.pauseRecord();
}
Logger.info(' 录音已暂停'); Logger.info(' 录音已暂停');
} }
@ -1091,7 +1099,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 继续录音 /// 继续录音
Future<void> continueRecording() async { Future<void> continueRecording() async {
_timerManager.resumeTimer(); _timerManager.resumeTimer();
_asrService.resumeRecord(); if (currentMode.value == 'call') {
await _astService.resumeRecord();
} else {
await _asrService.resumeRecord();
}
Logger.info(' 录音已继续'); Logger.info(' 录音已继续');
} }

40
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.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import com.yunqiinnovation.azure_speech.tools.RecordFile import com.yunqiinnovation.azure_speech.tools.RecordFile
import com.yunqiinnovation.azure_speech.tools.AstStereoRecorder
import com.example.astclient.DoubaoE2ETranslateHelper import com.example.astclient.DoubaoE2ETranslateHelper
import com.example.astclient.Config import com.example.astclient.Config
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@ -80,6 +81,9 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
// 设备产品配置 broadcastpeertranslate:true=B 路同时也走外放(耳机一路总会走,外放是叠加)。 // 设备产品配置 broadcastpeertranslate:true=B 路同时也走外放(耳机一路总会走,外放是叠加)。
@Volatile private var broadcastPeerTranslate: Boolean = false @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) { private fun emitAstTtsFrame(leg: String, data: ByteArray, isFinal: Boolean = false) {
val sink = astTtsEventSink ?: return val sink = astTtsEventSink ?: return
// EventChannel 在主线程 send,确保有效;TTS 段一段一段过来,量级在 KB,主线程开销可忽略 // EventChannel 在主线程 send,确保有效;TTS 段一段一段过来,量级在 KB,主线程开销可忽略
@ -107,6 +111,8 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
private val bPcmWriter: AudioWriter = object : AudioWriter { private val bPcmWriter: AudioWriter = object : AudioWriter {
override fun write(data: ByteArray) { override fun write(data: ByteArray) {
emitAstTtsFrame("B", data) emitAstTtsFrame("B", data)
// 录音不受 broadcastPeerTranslate 影响:即便不本地播放,也要捕获 B 路翻译音频作为右声道
if (astStereoRecorder.isActive) astStereoRecorder.enqueueRightChannel(data)
if (broadcastPeerTranslate) callBPcmPlayer.feed(data) if (broadcastPeerTranslate) callBPcmPlayer.feed(data)
} }
override fun markEnd() { override fun markEnd() {
@ -209,6 +215,8 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
if (astAudioPushCountA % 100 == 1L) { if (astAudioPushCountA % 100 == 1L) {
FileLogger.d(tag, "pushAstAudioToA: provider=$currentAstProvider, size=${data.size}, count=$astAudioPushCountA") FileLogger.d(tag, "pushAstAudioToA: provider=$currentAstProvider, size=${data.size}, count=$astAudioPushCountA")
} }
// 立体声录音左声道=本端麦克风原音(取未经回环 gate 处理的原始 PCM),由它驱动录音时钟
if (astStereoRecorder.isActive) astStereoRecorder.writeLeftChannel(data)
val payload = gateAgainstBPlayback(data) val payload = gateAgainstBPlayback(data)
if (currentAstProvider == "iflytek") { if (currentAstProvider == "iflytek") {
iflytekAstHelperA?.pushAudioData(payload) iflytekAstHelperA?.pushAudioData(payload)
@ -1229,6 +1237,8 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
// 让所有等待 delay(1200L) 的旧 init 协程在恢复时直接 return,避免落到已 dispose 的 helper 上 // 让所有等待 delay(1200L) 的旧 init 协程在恢复时直接 return,避免落到已 dispose 的 helper 上
nextAstInitGeneration() nextAstInitGeneration()
FileLogger.d(tag, "释放双向 AST 资源(异步)") FileLogger.d(tag, "释放双向 AST 资源(异步)")
// dispose 时若仍在录音则保存现有文件,避免泄漏
astStereoRecorder.stop(true)
callBPcmPlayer.stop() callBPcmPlayer.stop()
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
try { try {
@ -1298,6 +1308,36 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
result.success(true) result.success(true)
} }
"enableRecord" -> {
val filePath = call.argument<String>("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<Boolean>("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 推进来: /** Dart 把杰理 runtime 解码后的 SCO PCM 推进来:
* leg = 'A' / 'uplink' = 本机麦克风 / 己方 * leg = 'A' / 'uplink' = 本机麦克风 / 己方
* leg = 'B' / 'downlink' = 对端通话音 * leg = 'B' / 'downlink' = 对端通话音

160
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<Byte>()
@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
}
}

49
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。 // 设备产品配置 broadcastpeertranslate:true=播报并启用 gate;false/未下发=不播报且 gate 永不触发。默认 false。
private var broadcastPeerTranslate: Bool = false private var broadcastPeerTranslate: Bool = false
// 通话翻译立体声录音器:左=本端麦克风原音(A 路 pushExternalAudio),右=对方翻译合成音(B 路 AST TTS)
private let astStereoRecorder = AstStereoRecorder()
// 杰理 SCO PCM 推送计数(每 100 帧打一次日志,避免刷屏) // 杰理 SCO PCM 推送计数(每 100 帧打一次日志,避免刷屏)
private var astAudioPushCountA: Int64 = 0 private var astAudioPushCountA: Int64 = 0
private var astAudioPushCountB: 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}@", os_log("pushAstAudioToA: provider=%{public}@ size=%d count=%lld rms=%.1f head=%{public}@",
log: ctLog, type: .info, currentAstProvider, data.count, astAudioPushCountA, rms, head) log: ctLog, type: .info, currentAstProvider, data.count, astAudioPushCountA, rms, head)
} }
// 立体声录音左声道=本端麦克风原音(取未经回环 gate 处理的原始 PCM),由它驱动录音时钟
if astStereoRecorder.isActive { astStereoRecorder.writeLeftChannel(data) }
let payload = gateAgainstBPlayback(data) let payload = gateAgainstBPlayback(data)
switch currentAstProvider { switch currentAstProvider {
case "volcano": case "volcano":
@ -886,21 +891,29 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
// MARK: - AST 方法处理 // MARK: - AST 方法处理
private func handleAstMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { private func handleAstMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method { switch call.method {
// case "enableRecord": case "enableRecord":
// guard let args = call.arguments as? [String: Any], guard let args = call.arguments as? [String: Any],
// let filePath = args["filePath"] as? String else { let filePath = args["filePath"] as? String else {
// result(FlutterError(code: "INVALID_ARGUMENTS", message: "Missing filePath", details: nil)) result(FlutterError(code: "INVALID_ARGUMENTS", message: "Missing filePath", details: nil))
// return return
// } }
let ok = astStereoRecorder.start(filePath: filePath)
// do { os_log("[AST-REC] enableRecord: %{public}@ ok=%{public}@", log: ctLog, type: .info, filePath, ok ? "true" : "false")
// os_log("音频文件名称为: %@", log: log, type: .info, filePath) result(ok)
// try azureAstHelper.enableRecord(filePath: filePath)
// result(true) case "pauseRecord":
// } catch { result(astStereoRecorder.pause())
// result(FlutterError(code: "ENABLERECORD_ERROR", message: error.localizedDescription, details: nil))
// } 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": case "startContinuousTranslation":
os_log("[AST-START] provider=%{public}@", log: ctLog, type: .info, currentAstProvider) os_log("[AST-START] provider=%{public}@", log: ctLog, type: .info, currentAstProvider)
if currentAstProvider == "volcano" { if currentAstProvider == "volcano" {
@ -997,6 +1010,8 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
bailianAstHelperA.dispose() bailianAstHelperA.dispose()
bailianAstHelperB.dispose() bailianAstHelperB.dispose()
resetCallBPlayer() resetCallBPlayer()
// dispose 时若仍在录音则保存现有文件,避免泄漏
_ = astStereoRecorder.stop(isSave: true)
os_log("[AST-DISPOSE] All AST resources released", log: ctLog, type: .info) os_log("[AST-DISPOSE] All AST resources released", log: ctLog, type: .info)
result(true) result(true)
@ -1200,6 +1215,10 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
/// - pcm: 16bit、单声道、16kHz PCM;段尾帧允许传空 /// - pcm: 16bit、单声道、16kHz PCM;段尾帧允许传空
/// - isFinal: 是否是该段 utterance 的段尾(与 Android 合约一致,native 依靠 isFinal=true 触发整段下发) /// - isFinal: 是否是该段 utterance 的段尾(与 Android 合约一致,native 依靠 isFinal=true 触发整段下发)
internal func pushAstTtsFrame(leg: String, pcm: Data, isFinal: Bool) { 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 { guard let sink = astTtsEventSink else {
// 调试:报警一次,避免刷屏 // 调试:报警一次,避免刷屏
astTtsEmitCount &+= 1 astTtsEmitCount &+= 1

165
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..<sampleCount {
let (lLo, lHi) = AstStereoRecorder.gainSoftClip(
lBase[i * 2], lBase[i * 2 + 1], gain: AstStereoRecorder.leftGain)
stereo.append(lLo)
stereo.append(lHi)
let (rLo, rHi) = AstStereoRecorder.gainSoftClip(
rBase[i * 2], rBase[i * 2 + 1], gain: AstStereoRecorder.rightGain)
stereo.append(rLo)
stereo.append(rHi)
}
}
}
fileToWrite = file
}
if let file = fileToWrite, !stereo.isEmpty {
file.saveAudioDataToWav(stereo)
}
}
/// 对一个 16-bit little-endian PCM 样本施加线性增益并 tanh 软削波。
/// gain==1.0 时短路返回原字节,避免无谓浮点。
@inline(__always)
private static func gainSoftClip(_ lo: UInt8, _ hi: UInt8, gain: Float) -> (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))
}
}
Loading…
Cancel
Save