Browse Source

上传 iOS 通话翻译语音记录不完整

newdev_shunjiawei
liwei1dao 5 months ago
parent
commit
7dc7af4727
  1. 12
      ios/Runner.xcodeproj/project.pbxproj
  2. 15
      lib/data/services/ast_service.dart
  3. 9
      lib/data/services/meeting/meeting_task_service.dart
  4. 44
      lib/data/services/speech_impl/azure_ast_service.dart
  5. 15
      lib/modules/meeting/controllers/meeting_details_controller.dart
  6. 2
      lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart
  7. 44
      lib/modules/translation/controllers/translation_controller.dart
  8. 47
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  9. 136
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AstStereoRecorder.kt
  10. 60
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  11. 140
      local_plugins/azure_speech/ios/azure_speech/Sources/tools/AstStereoRecorder.swift

12
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;

15
lib/data/services/ast_service.dart

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

9
lib/data/services/meeting/meeting_task_service.dart

@ -135,13 +135,20 @@ class MeetingTaskService extends GetxService {
_executeTask();
}
Future<void> refreshTask(int id, int templateid, String tid, String tolanguage) async {
Future<void> 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,

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

@ -296,6 +296,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
Future<bool> path(String filePath) async {
try {

15
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();

2
lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart

@ -334,7 +334,6 @@ class _GenerateBottomSheetState extends State<GenerateBottomSheet> {
});
},
),
if (!widget.isSummarizeAgain)
_row(
'transcribeService'.tr, // 对应中文:转写服务
value: _serviceLabel(_serviceType),
@ -388,6 +387,7 @@ class _GenerateBottomSheetState extends State<GenerateBottomSheet> {
_templateData['id'],
_templateData['tid'] ?? '',
_tolanguage,
_serviceType,
);
} else {
_controller.summaryCreate(

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

@ -1019,36 +1019,40 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
}
/// 开始录音
///
/// - call 模式:走 [_astService],原生将录制立体声 WAV
/// (左=本端麦克风原音,右=对方翻译后的合成音)。
/// - 其他模式:保持原有 [_asrService] 录制逻辑。
Future<void> 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') {
await _astService.enableRecord(filePath);
} else {
AudioSourceType audioSourceType;
if (currentMode.value == 'audioVideo') {
audioSourceType = AudioSourceType.systemAudio;
} else {
audioSourceType = AudioSourceType.microphone;
}
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");
// }
await _asrService.enableRecord(audioSourceType, filePath, false);
}
isCreateRecord.value = true;
}
/// 停止录音
Future<void> 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<void> 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<void> continueRecording() async {
_timerManager.resumeTimer();
_asrService.resumeRecord();
if (currentMode.value == 'call') {
await _astService.resumeRecord();
} else {
await _asrService.resumeRecord();
}
Logger.info(' 录音已继续');
}

47
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<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)
}
}
"initialize" -> {
val initGeneration = nextAstInitGeneration()
val provider = call.argument<String>("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

136
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<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
// 左声道
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"
}
}

60
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,20 +814,25 @@ 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)
@ -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)
}
}
@ -1332,6 +1357,13 @@ extension AzureSpeechPlugin: BleService.Callback {
}
}
// 通话翻译立体声录音:左声道=麦克风原音(leftBuffer),右声道由 B 路 TTS PCM
// 在 AstStereoRecorder 内部按相同样本数从 FIFO 取出对齐
// 录音未开启时直接跳过,避免每帧无谓的函数调用
if astStereoRecorder.isActive {
astStereoRecorder.writeLeftChannel(leftBuffer)
}
// 每200帧打印一次声道音量(与 Android 对齐)
if bleAudioRecvCount % 200 == 1 {
var leftRms: Double = 0

140
local_plugins/azure_speech/ios/azure_speech/Sources/tools/AstStereoRecorder.swift

@ -0,0 +1,140 @@
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")
/// 受写入队列保护的状态
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 {
stereo.append(lBase[i * 2])
stereo.append(lBase[i * 2 + 1])
stereo.append(rBase[i * 2])
stereo.append(rBase[i * 2 + 1])
}
}
}
fileToWrite = file
}
if let file = fileToWrite, !stereo.isEmpty {
file.saveAudioDataToWav(stereo)
}
}
}
Loading…
Cancel
Save