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