11 changed files with 520 additions and 68 deletions
@ -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" |
||||
|
} |
||||
|
} |
||||
@ -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…
Reference in new issue