|
|
@ -16,7 +16,7 @@ import java.io.File |
|
|
import java.io.FileOutputStream |
|
|
import java.io.FileOutputStream |
|
|
import java.text.SimpleDateFormat |
|
|
import java.text.SimpleDateFormat |
|
|
import java.util.Date |
|
|
import java.util.Date |
|
|
import java.util.concurrent.LinkedBlockingQueue |
|
|
import java.util.concurrent.LinkedBlockingDeque |
|
|
import android.os.Handler |
|
|
import android.os.Handler |
|
|
import java.util.concurrent.atomic.AtomicBoolean |
|
|
import java.util.concurrent.atomic.AtomicBoolean |
|
|
import android.util.Log |
|
|
import android.util.Log |
|
|
@ -133,9 +133,16 @@ object BleService { |
|
|
|
|
|
|
|
|
// 音频数据缓存 |
|
|
// 音频数据缓存 |
|
|
//private val audioDataBuffer = mutableListOf<Byte>() |
|
|
//private val audioDataBuffer = mutableListOf<Byte>() |
|
|
// 音频数据发送相关 |
|
|
// 音频数据发送相关:左右声道各一条独立缓存队列 + 独立发送线程,两线程错开 20ms 启动,各自按声道节拍下发。 |
|
|
private val audioSendQueue = LinkedBlockingQueue<ByteArray>() |
|
|
// 缓存兼作重连暂停期间的暂存与 F4/F5 降速积压;LinkedBlockingDeque 线程安全(生产者=编码回调线程,消费者=对应发送线程)。 |
|
|
private var audioSendThread: Thread? = null |
|
|
private val leftSendBuffer = LinkedBlockingDeque<ByteArray>() |
|
|
|
|
|
private val rightSendBuffer = LinkedBlockingDeque<ByteArray>() |
|
|
|
|
|
private var leftSendThread: Thread? = null |
|
|
|
|
|
private var rightSendThread: Thread? = null |
|
|
|
|
|
// 两线程写的是同一个 callWriteChar(共享可变 .value)+同一 gatt,真正的写必须串行化,否则并发写会互相覆盖发错数据。 |
|
|
|
|
|
private val bleWriteLock = Any() |
|
|
|
|
|
// 右声道发送线程相对左声道错开启动的毫秒数,避免两路同一时刻抢 BLE 写、平摊链路占用。 |
|
|
|
|
|
private const val RIGHT_SEND_THREAD_STAGGER_MS = 40L |
|
|
private val audioSendHandler = Handler(Looper.getMainLooper()) |
|
|
private val audioSendHandler = Handler(Looper.getMainLooper()) |
|
|
private val isAudioSending = AtomicBoolean(false) |
|
|
private val isAudioSending = AtomicBoolean(false) |
|
|
// 上一包音频实际发送的时间戳(ms),仅用于发送节拍日志统计间隔;-1 表示尚未发送过 |
|
|
// 上一包音频实际发送的时间戳(ms),仅用于发送节拍日志统计间隔;-1 表示尚未发送过 |
|
|
@ -196,24 +203,28 @@ object BleService { |
|
|
// 音频下行发送间隔(ms),调试界面可在运行时调整;通过 setCallTranslationDebugParams() 修改 |
|
|
// 音频下行发送间隔(ms),调试界面可在运行时调整;通过 setCallTranslationDebugParams() 修改 |
|
|
@Volatile |
|
|
@Volatile |
|
|
private var audioSendIntervalNormal = DEFAULT_AUDIO_SEND_INTERVAL_NORMAL |
|
|
private var audioSendIntervalNormal = DEFAULT_AUDIO_SEND_INTERVAL_NORMAL |
|
|
// 左/右声道下发节拍倍数(以一个 audioSendIntervalNormal=40ms 拍为单位): |
|
|
// 左/右声道下发节拍倍数(以一个 audioSendIntervalNormal 拍为单位): |
|
|
// 1 拍=40ms/包(全速)、4 拍=160ms/包、8 拍=320ms/包(三档)。 |
|
|
// 1 拍=1×拍长/包(全速)、4 拍、8 拍(三档)。由设备 F4(左)/F5(右) 上报的解码空余动态切换:空余越少越降速。 |
|
|
// 由设备 F4(左)/F5(右) 上报的解码缓存空余字节数动态切换:空余越少越降速,缓解设备解码溢出丢包。 |
|
|
// 各声道发送线程按 tick - lastSendTick(线程内局部) >= paceTicks 控制该声道下发频率。 |
|
|
@Volatile |
|
|
@Volatile |
|
|
private var leftPaceTicks: Int = 1 |
|
|
private var leftPaceTicks: Int = 1 |
|
|
@Volatile |
|
|
@Volatile |
|
|
private var rightPaceTicks: Int = 1 |
|
|
private var rightPaceTicks: Int = 1 |
|
|
// 各声道上次成功发送所处的拍号;-1 表示尚未发送过(立即可发)。用于按 paceTicks 控制每声道下发频率。 |
|
|
// 声道缓存安全上限(纯防 OOM 兜底):现在缓存元素是单帧 opus,超限丢最旧帧并告警。 |
|
|
private var lastLeftSendTick = -1L |
|
|
|
|
|
private var lastRightSendTick = -1L |
|
|
|
|
|
// 左右声道各自的下行缓存:主队列取出的合包先按声道分流到这里,再由发送线程按各自档位取出下发。 |
|
|
|
|
|
// 降速档位会在此积压;仅在音频发送线程内访问,无需额外加锁。 |
|
|
|
|
|
private val leftHoldBuffer = ArrayDeque<ByteArray>() |
|
|
|
|
|
private val rightHoldBuffer = ArrayDeque<ByteArray>() |
|
|
|
|
|
// 缓存安全上限(纯防 OOM 兜底):超限时丢弃最旧包并告警;单包约 165B,10000 包≈1.6MB。 |
|
|
|
|
|
private const val CHANNEL_HOLD_BUFFER_MAX = 10000 |
|
|
private const val CHANNEL_HOLD_BUFFER_MAX = 10000 |
|
|
// 左右同一拍都满足发送条件时的轮转标记,保证两声道公平、避免一方饿死。 |
|
|
// ---- 下行合包(已从 OpusAudioManager 上移到 BleService) ---- |
|
|
private var holdDrainPreferLeft = true |
|
|
// 每包合并的 opus 帧数(默认5,调试界面可调 1..20):发送线程每拍从声道队列取最多 bundleFrameCount 帧, |
|
|
|
|
|
// 不足则用静音帧补齐、队列空则不发;组包布局 [4B 序号(小端)] + [1B 声道] + [N × opus 帧]。 |
|
|
|
|
|
@Volatile |
|
|
|
|
|
private var bundleFrameCount = 5 |
|
|
|
|
|
// 合包头部字节数:bytes0-3 序号(小端 uint32) + byte4 声道 |
|
|
|
|
|
private const val BUNDLE_HEADER_SIZE = 5 |
|
|
|
|
|
// 左右声道各自的下行包序号(每成功发一包自增),供设备侧核对连续性;仅各自声道线程访问 |
|
|
|
|
|
private var leftPacketSeq = 0 |
|
|
|
|
|
private var rightPacketSeq = 0 |
|
|
|
|
|
// 补齐用静音 opus 帧模板(OpusAudioManager 运行时编码零PCM生成,经 onSilenceFrameReady 送来);未就绪时为 null |
|
|
|
|
|
@Volatile |
|
|
|
|
|
private var silenceFrame: ByteArray? = null |
|
|
// ---- F4/F5 解码空余字节数 → 下发档位:简单三档(可在通话调试界面运行时调整) ---- |
|
|
// ---- F4/F5 解码空余字节数 → 下发档位:简单三档(可在通话调试界面运行时调整) ---- |
|
|
// 每档 = (剩余字节数下限 门限, 发送间隔 sendMs)。设备上报某声道解码空余 freeBytes 后, |
|
|
// 每档 = (剩余字节数下限 门限, 发送间隔 sendMs)。设备上报某声道解码空余 freeBytes 后, |
|
|
// 从第一档往下取第一个满足 freeBytes>=门限 的档,用该档发送间隔下发(内部按 audioSendIntervalNormal 换算为拍数)。 |
|
|
// 从第一档往下取第一个满足 freeBytes>=门限 的档,用该档发送间隔下发(内部按 audioSendIntervalNormal 换算为拍数)。 |
|
|
@ -354,11 +365,16 @@ object BleService { |
|
|
notifyAudioDataReceived(data, channel) |
|
|
notifyAudioDataReceived(data, channel) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
override fun onAudioDataEncoded(data: ByteArray) { |
|
|
override fun onAudioFrameEncoded(channelPrefix: Byte, frame: ByteArray) { |
|
|
// 将编码后的音频数据加入发送队列 |
|
|
// 单帧入对应声道队列,由发送线程按拍取 bundleFrameCount 帧合包下发 |
|
|
addAudioDataToSendQueue(data) |
|
|
enqueueOpusFrame(channelPrefix, frame) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
override fun onSilenceFrameReady(frame: ByteArray) { |
|
|
|
|
|
// 缓存静音帧模板,用于合包时不足帧补齐 |
|
|
|
|
|
silenceFrame = frame |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
override fun onDecodeStreamStateChanged(isStarted: Boolean) { |
|
|
override fun onDecodeStreamStateChanged(isStarted: Boolean) { |
|
|
Log.d(TAG, "Opus解码流状态变化: $isStarted") |
|
|
Log.d(TAG, "Opus解码流状态变化: $isStarted") |
|
|
} |
|
|
} |
|
|
@ -1500,136 +1516,131 @@ object BleService { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 将一包已成型的音频合帧加入下行发送队列 |
|
|
* 将一帧 opus 按声道加入对应下行队列。合包(取 N 帧/补静音/组头)在发送线程按拍完成。 |
|
|
* 包格式:[4B 序号(大端 uint32)] + [1B 声道(0=左/1=右)] + [5 × opus(40B)] |
|
|
* 重连窗口内不丢弃:继续入队暂存,回连成功后续发;仅超 OOM 兜底上限才丢最旧帧。 |
|
|
* 双声道独立编码模式下,每次回调送来的就是一整包,直接入队,不再二次切片 |
|
|
* @param channelPrefix 声道标识(CHANNEL_LEFT=0 / CHANNEL_RIGHT=1) |
|
|
* @param data 待发送的合帧包 |
|
|
* @param frame 一帧 opus 编码数据 |
|
|
*/ |
|
|
*/ |
|
|
private fun addAudioDataToSendQueue(data: ByteArray) { |
|
|
private fun enqueueOpusFrame(channelPrefix: Byte, frame: ByteArray) { |
|
|
if (data.isEmpty()) return |
|
|
if (frame.isEmpty()) return |
|
|
// 重连窗口内不丢弃:继续入队,由发送线程在暂停期间抽干到左右声道缓存暂存,回连成功后按节拍继续下发。 |
|
|
val buffer = when (channelPrefix) { |
|
|
if (!audioSendQueue.offer(data)) { |
|
|
OpusAudioManager.CHANNEL_RIGHT -> rightSendBuffer |
|
|
Log.w(TAG, "音频发送队列已满,丢弃当前包 size=${data.size}") |
|
|
// 左声道及未知声道统一归入左队列 |
|
|
|
|
|
else -> leftSendBuffer |
|
|
} |
|
|
} |
|
|
|
|
|
while (buffer.size >= CHANNEL_HOLD_BUFFER_MAX) { |
|
|
|
|
|
buffer.pollFirst() |
|
|
|
|
|
CallLog.w(TAG, "声道积压缓存超上限($CHANNEL_HOLD_BUFFER_MAX),丢弃最旧帧 ch=$channelPrefix") |
|
|
|
|
|
} |
|
|
|
|
|
buffer.offerLast(frame) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 启动音频数据发送线程(优化版本) |
|
|
* 启动左右两条独立发送线程(各发一路声道),右声道相对左声道错开 RIGHT_SEND_THREAD_STAGGER_MS 启动, |
|
|
|
|
|
* 避免两路同一时刻抢 BLE 写。每条线程按自身声道的 paceTicks 控制下发频率,互不影响。 |
|
|
*/ |
|
|
*/ |
|
|
private fun startAudioSendThread() { |
|
|
private fun startAudioSendThread() { |
|
|
if (audioSendThread?.isAlive == true) { |
|
|
if (leftSendThread?.isAlive == true || rightSendThread?.isAlive == true) { |
|
|
Log.d(TAG, "音频发送线程已在运行") |
|
|
Log.d(TAG, "音频发送线程已在运行") |
|
|
return |
|
|
return |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
isAudioSending.set(true) |
|
|
isAudioSending.set(true) |
|
|
audioSendThread = Thread { |
|
|
leftSendThread = createChannelSendThread(isLeft = true, startOffsetMs = 0L).apply { start() } |
|
|
CallLog.i(TAG, "音频发送线程已启动") |
|
|
rightSendThread = createChannelSendThread(isLeft = false, startOffsetMs = RIGHT_SEND_THREAD_STAGGER_MS).apply { start() } |
|
|
|
|
|
CallLog.i(TAG, "音频发送线程已启动(左/右双线程,右错开 ${RIGHT_SEND_THREAD_STAGGER_MS}ms)") |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
// 固定 40ms 一拍的发送节拍。每一拍做一次"发不发/发哪路"的决策;左右各按自身档位(paceTicks) |
|
|
/** |
|
|
// 控制频率(40/80/160ms),定时器始终稳定在 audioSendIntervalNormal 一拍,下发间隔不随 poll/发送耗时抖动。 |
|
|
* 构建单声道发送线程:固定 audioSendIntervalNormal 一拍(睡到绝对时刻,不随耗时抖动), |
|
|
var nextTickAt = System.currentTimeMillis() |
|
|
* 每拍按该声道 paceTicks 判断是否到点,到点则从该声道缓存取队首一包下发。 |
|
|
|
|
|
* 重连暂停期间只跳过下发、不清缓存(译音继续积压待回连续发)。 |
|
|
|
|
|
* @param isLeft true=左声道 / false=右声道 |
|
|
|
|
|
* @param startOffsetMs 相对启动时刻的错开毫秒(右声道用来与左声道错峰) |
|
|
|
|
|
*/ |
|
|
|
|
|
private fun createChannelSendThread(isLeft: Boolean, startOffsetMs: Long): Thread { |
|
|
|
|
|
val chName = if (isLeft) "L" else "R" |
|
|
|
|
|
return Thread { |
|
|
|
|
|
CallLog.i(TAG, "音频发送线程[$chName]已启动 offset=${startOffsetMs}ms") |
|
|
|
|
|
var nextTickAt = System.currentTimeMillis() + startOffsetMs |
|
|
var tick = 0L |
|
|
var tick = 0L |
|
|
|
|
|
var lastSendTick = -1L |
|
|
|
|
|
// 首拍错开:右线程先睡 startOffsetMs 再进入循环,使两路发送时刻相互错峰 |
|
|
|
|
|
val initDelay = nextTickAt - System.currentTimeMillis() |
|
|
|
|
|
if (initDelay > 0) { |
|
|
|
|
|
try { Thread.sleep(initDelay) } catch (e: InterruptedException) { return@Thread } |
|
|
|
|
|
} |
|
|
while (isAudioSending.get() && !Thread.currentThread().isInterrupted) { |
|
|
while (isAudioSending.get() && !Thread.currentThread().isInterrupted) { |
|
|
try { |
|
|
try { |
|
|
// 0) 重连窗口:暂停下发但**不丢译音**——仍抽干主队列到左右声道缓存暂存 |
|
|
// 重连暂停期间只跳过下发,不动缓存(译音继续积压,回连后续发) |
|
|
// (缓存超大兜底 CHANNEL_HOLD_BUFFER_MAX 才丢最旧防 OOM),只是本拍不写 BLE; |
|
|
if (!isDownlinkPaused) { |
|
|
// 待回连成功恢复后按节拍把暂存的译音继续发出。 |
|
|
val buffer = if (isLeft) leftSendBuffer else rightSendBuffer |
|
|
if (isDownlinkPaused) { |
|
|
val paceTicks = if (isLeft) leftPaceTicks else rightPaceTicks |
|
|
while (true) { |
|
|
if (buffer.isNotEmpty() && (lastSendTick < 0 || tick - lastSendTick >= paceTicks)) { |
|
|
val more = audioSendQueue.poll() ?: break |
|
|
if (trySendChannelChunk(isLeft)) lastSendTick = tick |
|
|
routeToHoldBuffer(more) |
|
|
|
|
|
} |
|
|
} |
|
|
nextTickAt += audioSendIntervalNormal |
|
|
|
|
|
tick++ |
|
|
|
|
|
val sleepMs = nextTickAt - System.currentTimeMillis() |
|
|
|
|
|
if (sleepMs > 0) Thread.sleep(sleepMs) else nextTickAt = System.currentTimeMillis() |
|
|
|
|
|
continue |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 1) 非阻塞抽干主队列,按声道分流到左右缓存(不在热路径阻塞,保证节拍稳定)。 |
|
|
|
|
|
// 合包布局:[4B 序号(小端)] + [1B 声道(0=左/1=右)] + [N × opus] |
|
|
|
|
|
while (true) { |
|
|
|
|
|
val more = audioSendQueue.poll() ?: break |
|
|
|
|
|
routeToHoldBuffer(more) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 2) 本拍最多发一包:左右各按自身档位(paceTicks)判断是否到点;两路都到点时按 holdDrainPreferLeft |
|
|
|
|
|
// 轮转二选一,另一路顺延到下一拍(即"一拍一包、左右交替"的下发方式)。 |
|
|
|
|
|
val leftDue = leftHoldBuffer.isNotEmpty() && |
|
|
|
|
|
(lastLeftSendTick < 0 || tick - lastLeftSendTick >= leftPaceTicks) |
|
|
|
|
|
val rightDue = rightHoldBuffer.isNotEmpty() && |
|
|
|
|
|
(lastRightSendTick < 0 || tick - lastRightSendTick >= rightPaceTicks) |
|
|
|
|
|
val pickLeft: Boolean? = when { |
|
|
|
|
|
leftDue && rightDue -> { holdDrainPreferLeft = !holdDrainPreferLeft; holdDrainPreferLeft } |
|
|
|
|
|
leftDue -> true |
|
|
|
|
|
rightDue -> false |
|
|
|
|
|
else -> null |
|
|
|
|
|
} |
|
|
|
|
|
when (pickLeft) { |
|
|
|
|
|
true -> if (trySendChannelChunk(true)) lastLeftSendTick = tick |
|
|
|
|
|
false -> if (trySendChannelChunk(false)) lastRightSendTick = tick |
|
|
|
|
|
null -> {} |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
// 固定一拍:睡到下一拍绝对时刻,节拍稳定不随发送耗时抖动 |
|
|
// 3) 固定 40ms 节拍:无论本拍是否发送都睡到下一拍绝对时刻,保证下发节拍稳定、不随耗时抖动。 |
|
|
|
|
|
nextTickAt += audioSendIntervalNormal |
|
|
nextTickAt += audioSendIntervalNormal |
|
|
tick++ |
|
|
tick++ |
|
|
val sleepMs = nextTickAt - System.currentTimeMillis() |
|
|
val sleepMs = nextTickAt - System.currentTimeMillis() |
|
|
if (sleepMs > 0) { |
|
|
if (sleepMs > 0) { |
|
|
Thread.sleep(sleepMs) |
|
|
Thread.sleep(sleepMs) |
|
|
} else { |
|
|
} else { |
|
|
// 落后于节拍(发送/分流超时),重新对齐,避免之后疯狂追发 |
|
|
// 落后于节拍(发送超时),重新对齐,避免之后疯狂追发 |
|
|
nextTickAt = System.currentTimeMillis() |
|
|
nextTickAt = System.currentTimeMillis() |
|
|
} |
|
|
} |
|
|
} catch (e: InterruptedException) { |
|
|
} catch (e: InterruptedException) { |
|
|
Log.d(TAG, "音频发送线程被中断") |
|
|
Log.d(TAG, "音频发送线程[$chName]被中断") |
|
|
break |
|
|
break |
|
|
} catch (e: Exception) { |
|
|
} catch (e: Exception) { |
|
|
Log.e(TAG, "音频发送线程异常: ${e.message}", e) |
|
|
Log.e(TAG, "音频发送线程[$chName]异常: ${e.message}", e) |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
Log.i(TAG, "音频发送线程已停止") |
|
|
CallLog.i(TAG, "音频发送线程[$chName]已停止") |
|
|
}.apply { |
|
|
}.apply { name = "AudioSendThread-$chName" } |
|
|
name = "AudioSendThread" |
|
|
|
|
|
start() |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 从指定声道缓存取队首一包下发,返回是否成功写入。仅在音频发送线程内调用。 |
|
|
* 从指定声道队列取最多 bundleFrameCount 帧,不足用静音帧补齐,组包后下发。仅在该声道发送线程内调用。 |
|
|
* - 成功:已塞入底层发送缓冲,写调试录音并打印发送日志(Δ/包序/档位),返回 true; |
|
|
* - 队列空:不发,返回 false; |
|
|
* - 失败(拥塞):原包放回队头、记一次拥塞告警,返回 false,调用方据此不更新该声道发送拍号、下一拍重试,绝不丢包。 |
|
|
* - 成功:写调试录音、打印发送日志、推进该声道包序,返回 true; |
|
|
|
|
|
* - 失败(拥塞):取出的真实帧按原序放回队头(静音补齐帧丢弃、下拍重组),返回 false,下一拍重试,绝不丢帧。 |
|
|
*/ |
|
|
*/ |
|
|
private fun trySendChannelChunk(isLeft: Boolean): Boolean { |
|
|
private fun trySendChannelChunk(isLeft: Boolean): Boolean { |
|
|
val buffer = if (isLeft) leftHoldBuffer else rightHoldBuffer |
|
|
val buffer = if (isLeft) leftSendBuffer else rightSendBuffer |
|
|
if (buffer.isEmpty()) return false |
|
|
val n = bundleFrameCount.coerceIn(1, 20) |
|
|
val audioChunk = buffer.removeFirst() |
|
|
// 取最多 n 帧真实数据 |
|
|
// 解析包头:声道(byte[4]) 与 序号(byte[0..3] 小端),供日志与重试归位使用 |
|
|
val realFrames = ArrayList<ByteArray>(n) |
|
|
val channelByte = if (audioChunk.size >= 5) audioChunk[4] else null |
|
|
while (realFrames.size < n) { |
|
|
val seq = if (audioChunk.size >= 5) |
|
|
val f = buffer.pollFirst() ?: break |
|
|
((audioChunk[3].toInt() and 0xFF) shl 24) or |
|
|
realFrames.add(f) |
|
|
((audioChunk[2].toInt() and 0xFF) shl 16) or |
|
|
} |
|
|
((audioChunk[1].toInt() and 0xFF) shl 8) or |
|
|
if (realFrames.isEmpty()) return false // 队列空则不发 |
|
|
(audioChunk[0].toInt() and 0xFF) |
|
|
|
|
|
else -1 |
|
|
val channel = if (isLeft) OpusAudioManager.CHANNEL_LEFT else OpusAudioManager.CHANNEL_RIGHT |
|
|
val chTag = when (channelByte) { |
|
|
val chTag = if (isLeft) "L" else "R" |
|
|
OpusAudioManager.CHANNEL_LEFT -> "L" |
|
|
val seq = if (isLeft) leftPacketSeq else rightPacketSeq |
|
|
OpusAudioManager.CHANNEL_RIGHT -> "R" |
|
|
|
|
|
else -> "?($channelByte)" |
|
|
// 组包帧 = 真实帧 + 静音帧补齐到 n(静音帧未就绪时只发真实帧数,罕见的启动窗口) |
|
|
} |
|
|
val frames = ArrayList<ByteArray>(n) |
|
|
|
|
|
frames.addAll(realFrames) |
|
|
|
|
|
val silence = silenceFrame |
|
|
|
|
|
var padCount = 0 |
|
|
|
|
|
if (silence != null) { |
|
|
|
|
|
while (frames.size < n) { frames.add(silence); padCount++ } |
|
|
|
|
|
} |
|
|
|
|
|
val packet = buildBundlePacket(seq, channel, frames) |
|
|
|
|
|
|
|
|
// 同步写入,拿到真实的成功/失败:false=底层发送缓冲已满(拥塞),作为背压信号 |
|
|
// 同步写入,拿到真实的成功/失败:false=底层发送缓冲已满(拥塞),作为背压信号 |
|
|
val sent = sendAudioChunkBlocking(audioChunk) |
|
|
val sent = sendAudioChunkBlocking(packet) |
|
|
if (!sent) { |
|
|
if (!sent) { |
|
|
// 写失败:放回原声道队头,下一拍重试,绝不丢弃、声道内顺序不变 |
|
|
// 写失败:真实帧按原顺序放回队头(倒序 offerFirst),下一拍重试;静音补齐帧丢弃,下次按新帧重组 |
|
|
buffer.addFirst(audioChunk) |
|
|
for (i in realFrames.indices.reversed()) buffer.offerFirst(realFrames[i]) |
|
|
writeFailRetryCount++ |
|
|
writeFailRetryCount++ |
|
|
if (writeFailRetryCount == 1 || writeFailRetryCount % 50 == 0) { |
|
|
if (writeFailRetryCount == 1 || writeFailRetryCount % 50 == 0) { |
|
|
CallLog.w(TAG, "下行写入拥塞,重试中 ch=$chTag seq=$seq 连续失败=$writeFailRetryCount " + |
|
|
CallLog.w(TAG, "下行写入拥塞,重试中 ch=$chTag seq=$seq 连续失败=$writeFailRetryCount " + |
|
|
"hold(L=${leftHoldBuffer.size},R=${rightHoldBuffer.size})") |
|
|
"buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})") |
|
|
} |
|
|
} |
|
|
return false |
|
|
return false |
|
|
} |
|
|
} |
|
|
@ -1637,80 +1648,77 @@ object BleService { |
|
|
CallLog.i(TAG, "下行写入已恢复,之前连续失败=$writeFailRetryCount") |
|
|
CallLog.i(TAG, "下行写入已恢复,之前连续失败=$writeFailRetryCount") |
|
|
writeFailRetryCount = 0 |
|
|
writeFailRetryCount = 0 |
|
|
} |
|
|
} |
|
|
// 调试录音文件按声道分别保存(左右各自独立编码,混写无法解码听辨): |
|
|
// 成功后推进该声道包序 |
|
|
// 剥离 5 字节包头后,按声道标记写入对应声道文件(仅成功时写,避免重试重复) |
|
|
if (isLeft) leftPacketSeq++ else rightPacketSeq++ |
|
|
if (audioChunk.size > 5) { |
|
|
|
|
|
val opusPayload = audioChunk.copyOfRange(5, audioChunk.size) |
|
|
// 调试录音:剥离 5 字节包头后按声道写入(含静音补齐部分,听辨即静音) |
|
|
when (channelByte) { |
|
|
if (packet.size > BUNDLE_HEADER_SIZE) { |
|
|
|
|
|
val opusPayload = packet.copyOfRange(BUNDLE_HEADER_SIZE, packet.size) |
|
|
|
|
|
when (channel) { |
|
|
OpusAudioManager.CHANNEL_LEFT -> recordfile1Left?.saveAudioDataToWav(opusPayload) |
|
|
OpusAudioManager.CHANNEL_LEFT -> recordfile1Left?.saveAudioDataToWav(opusPayload) |
|
|
OpusAudioManager.CHANNEL_RIGHT -> recordfile1Right?.saveAudioDataToWav(opusPayload) |
|
|
OpusAudioManager.CHANNEL_RIGHT -> recordfile1Right?.saveAudioDataToWav(opusPayload) |
|
|
else -> {} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
// 发送成功后才推进发送节拍统计与声道包序 |
|
|
|
|
|
if (audioChunk.size >= 5) { |
|
|
|
|
|
val now = System.currentTimeMillis() |
|
|
|
|
|
val deltaMs = if (lastAudioSendTime < 0) 0 else now - lastAudioSendTime |
|
|
|
|
|
lastAudioSendTime = now |
|
|
|
|
|
// 按声道统计各自的包序增量:正常应为 +1,出现非 +1 即说明该声道丢包或乱序 |
|
|
|
|
|
val seqGap = when (channelByte) { |
|
|
|
|
|
OpusAudioManager.CHANNEL_LEFT -> { |
|
|
|
|
|
val gap = if (lastLeftSeq < 0) 1L else seq.toLong() - lastLeftSeq |
|
|
|
|
|
lastLeftSeq = seq.toLong() |
|
|
|
|
|
gap |
|
|
|
|
|
} |
|
|
|
|
|
OpusAudioManager.CHANNEL_RIGHT -> { |
|
|
|
|
|
val gap = if (lastRightSeq < 0) 1L else seq.toLong() - lastRightSeq |
|
|
|
|
|
lastRightSeq = seq.toLong() |
|
|
|
|
|
gap |
|
|
|
|
|
} |
|
|
|
|
|
else -> 0L |
|
|
|
|
|
} |
|
|
|
|
|
val gapTag = if (seqGap == 1L) "" else " !gap=$seqGap" |
|
|
|
|
|
val sendLogLine = "音频下行发送 ch=$chTag seq=$seq Δ=${deltaMs}ms size=${audioChunk.size}B " + |
|
|
|
|
|
"lastSeq(L=$lastLeftSeq,R=$lastRightSeq)$gapTag pace(L=${leftPaceTicks}x,R=${rightPaceTicks}x) " + |
|
|
|
|
|
"hold(L=${leftHoldBuffer.size},R=${rightHoldBuffer.size}) queue=${audioSendQueue.size}" |
|
|
|
|
|
// 每包都落文件:推送节拍(Δ)、档位(pace)、背压(hold/queue)、丢包(gap) 是控流排查的核心信号,需完整记录 |
|
|
|
|
|
CallLog.i(TAG, sendLogLine) |
|
|
|
|
|
|
|
|
|
|
|
// [LAT-TRACE] 点3:右声道(对方译音)首包写到 BLE 耳机(每句首包)。 |
|
|
|
|
|
// 与 azure_speech 的 [LAT-TRACE] 点2(app收到译音) 同时钟相减 = app 内部下行耗时。 |
|
|
|
|
|
if (channelByte == OpusAudioManager.CHANNEL_RIGHT) { |
|
|
|
|
|
if (now - latLastBleRightTs > 700) { |
|
|
|
|
|
CallLog.i(TAG, "[LAT-TRACE] 3.译音写到BLE(右/对方) ts=$now seq=$seq") |
|
|
|
|
|
} |
|
|
|
|
|
latLastBleRightTs = now |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 发送节拍统计与包序日志 |
|
|
|
|
|
val now = System.currentTimeMillis() |
|
|
|
|
|
val deltaMs = if (lastAudioSendTime < 0) 0 else now - lastAudioSendTime |
|
|
|
|
|
lastAudioSendTime = now |
|
|
|
|
|
val seqGap = when (channel) { |
|
|
|
|
|
OpusAudioManager.CHANNEL_LEFT -> { |
|
|
|
|
|
val gap = if (lastLeftSeq < 0) 1L else seq.toLong() - lastLeftSeq |
|
|
|
|
|
lastLeftSeq = seq.toLong(); gap |
|
|
|
|
|
} |
|
|
|
|
|
OpusAudioManager.CHANNEL_RIGHT -> { |
|
|
|
|
|
val gap = if (lastRightSeq < 0) 1L else seq.toLong() - lastRightSeq |
|
|
|
|
|
lastRightSeq = seq.toLong(); gap |
|
|
|
|
|
} |
|
|
|
|
|
else -> 0L |
|
|
|
|
|
} |
|
|
|
|
|
val gapTag = if (seqGap == 1L) "" else " !gap=$seqGap" |
|
|
|
|
|
CallLog.i(TAG, "音频下行发送 ch=$chTag seq=$seq Δ=${deltaMs}ms 帧=${realFrames.size}+静音$padCount size=${packet.size}B " + |
|
|
|
|
|
"lastSeq(L=$lastLeftSeq,R=$lastRightSeq)$gapTag pace(L=${leftPaceTicks}x,R=${rightPaceTicks}x) " + |
|
|
|
|
|
"buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})") |
|
|
|
|
|
|
|
|
|
|
|
// [LAT-TRACE] 点3:右声道(对方译音)首包写到 BLE(每句首包) |
|
|
|
|
|
if (channel == OpusAudioManager.CHANNEL_RIGHT) { |
|
|
|
|
|
if (now - latLastBleRightTs > 700) { |
|
|
|
|
|
CallLog.i(TAG, "[LAT-TRACE] 3.译音写到BLE(右/对方) ts=$now seq=$seq") |
|
|
|
|
|
} |
|
|
|
|
|
latLastBleRightTs = now |
|
|
|
|
|
} |
|
|
return true |
|
|
return true |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 将一包数据按其声道标记分流到对应的下行缓存,由发送线程按各自档位取出下发。 |
|
|
* 组下行包:[4B 序号(小端 uint32)] + [1B 声道(0=左/1=右)] + [N 帧 opus 顺次拼接] |
|
|
* 仅作为安全阀:当某声道缓存超过 CHANNEL_HOLD_BUFFER_MAX 时丢弃最旧包并告警(正常流控不会触发)。 |
|
|
|
|
|
*/ |
|
|
*/ |
|
|
private fun routeToHoldBuffer(chunk: ByteArray) { |
|
|
private fun buildBundlePacket(seq: Int, channel: Byte, frames: List<ByteArray>): ByteArray { |
|
|
val channelTag = if (chunk.size >= 5) chunk[4] else null |
|
|
var payloadSize = 0 |
|
|
val buffer = when (channelTag) { |
|
|
for (f in frames) payloadSize += f.size |
|
|
OpusAudioManager.CHANNEL_RIGHT -> rightHoldBuffer |
|
|
val packet = ByteArray(BUNDLE_HEADER_SIZE + payloadSize) |
|
|
// 左声道及无有效声道标记(异常包)统一归入左缓存处理 |
|
|
packet[0] = (seq and 0xFF).toByte() |
|
|
else -> leftHoldBuffer |
|
|
packet[1] = (seq ushr 8 and 0xFF).toByte() |
|
|
|
|
|
packet[2] = (seq ushr 16 and 0xFF).toByte() |
|
|
|
|
|
packet[3] = (seq ushr 24 and 0xFF).toByte() |
|
|
|
|
|
packet[4] = channel |
|
|
|
|
|
var offset = BUNDLE_HEADER_SIZE |
|
|
|
|
|
for (f in frames) { |
|
|
|
|
|
System.arraycopy(f, 0, packet, offset, f.size) |
|
|
|
|
|
offset += f.size |
|
|
} |
|
|
} |
|
|
if (buffer.size >= CHANNEL_HOLD_BUFFER_MAX) { |
|
|
return packet |
|
|
buffer.removeFirst() |
|
|
|
|
|
CallLog.w(TAG, "声道积压缓存超上限($CHANNEL_HOLD_BUFFER_MAX),丢弃最旧包 ch=${channelTag}") |
|
|
|
|
|
} |
|
|
|
|
|
buffer.addLast(chunk) |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 停止音频数据发送线程 |
|
|
* 停止左右两条音频发送线程并清空缓存 |
|
|
*/ |
|
|
*/ |
|
|
private fun stopAudioSendThread() { |
|
|
private fun stopAudioSendThread() { |
|
|
isAudioSending.set(false) |
|
|
isAudioSending.set(false) |
|
|
audioSendThread?.interrupt() |
|
|
leftSendThread?.interrupt() |
|
|
|
|
|
rightSendThread?.interrupt() |
|
|
resetDownlinkBuffers() |
|
|
resetDownlinkBuffers() |
|
|
CallLog.i(TAG, "音频发送线程已停止,队列和缓冲区已清空") |
|
|
CallLog.i(TAG, "音频发送线程(左/右)已停止,队列和缓冲区已清空") |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
@ -1718,15 +1726,13 @@ object BleService { |
|
|
* 供停止发送线程(会话结束/清理)时调用;重连暂停期间**不**调用它——那时要保留缓存的译音待回连后续发。 |
|
|
* 供停止发送线程(会话结束/清理)时调用;重连暂停期间**不**调用它——那时要保留缓存的译音待回连后续发。 |
|
|
*/ |
|
|
*/ |
|
|
private fun resetDownlinkBuffers() { |
|
|
private fun resetDownlinkBuffers() { |
|
|
audioSendQueue.clear() |
|
|
leftSendBuffer.clear() // 清空左声道下行缓存队列 |
|
|
audioBuffer.clear() // 清空音频缓冲区 |
|
|
rightSendBuffer.clear() // 清空右声道下行缓存队列 |
|
|
leftHoldBuffer.clear() // 清空左声道下行缓存 |
|
|
audioBuffer.clear() // 清空音频缓冲区 |
|
|
rightHoldBuffer.clear() // 清空右声道下行缓存 |
|
|
leftPaceTicks = 1 // 档位复位为全速 |
|
|
leftPaceTicks = 1 // 档位复位为全速 40ms |
|
|
|
|
|
rightPaceTicks = 1 |
|
|
rightPaceTicks = 1 |
|
|
lastLeftSendTick = -1L // 重置左右声道发送拍号 |
|
|
leftPacketSeq = 0 // 复位左右声道下行包序号 |
|
|
lastRightSendTick = -1L |
|
|
rightPacketSeq = 0 |
|
|
holdDrainPreferLeft = true |
|
|
|
|
|
lastAudioSendTime = -1L // 重置发送节拍计时,下次首包 Δ 从 0 开始 |
|
|
lastAudioSendTime = -1L // 重置发送节拍计时,下次首包 Δ 从 0 开始 |
|
|
lastLeftSeq = -1L // 重置左右声道包序追踪 |
|
|
lastLeftSeq = -1L // 重置左右声道包序追踪 |
|
|
lastRightSeq = -1L |
|
|
lastRightSeq = -1L |
|
|
@ -1746,7 +1752,7 @@ object BleService { |
|
|
if (isDownlinkPaused) return |
|
|
if (isDownlinkPaused) return |
|
|
isDownlinkPaused = true |
|
|
isDownlinkPaused = true |
|
|
CallLog.i(TAG, "[CALL_RECONNECT] 下行音频已暂停(重连中),译音继续缓存待回连后续发 " + |
|
|
CallLog.i(TAG, "[CALL_RECONNECT] 下行音频已暂停(重连中),译音继续缓存待回连后续发 " + |
|
|
"hold(L=${leftHoldBuffer.size},R=${rightHoldBuffer.size}) queue=${audioSendQueue.size}") |
|
|
"buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})") |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
@ -1755,16 +1761,14 @@ object BleService { |
|
|
*/ |
|
|
*/ |
|
|
private fun exitDownlinkPause() { |
|
|
private fun exitDownlinkPause() { |
|
|
if (!isDownlinkPaused) return |
|
|
if (!isDownlinkPaused) return |
|
|
// 复位节拍/档位(不动缓存):从全速开始排空积压,F4/F5 会随即接管调速 |
|
|
// 复位档位(不动缓存):从全速开始排空积压,F4/F5 会随即接管调速。 |
|
|
|
|
|
// 两声道线程内的 lastSendTick 会因 tick 已推进而立即到点,无需外部复位。 |
|
|
leftPaceTicks = 1 |
|
|
leftPaceTicks = 1 |
|
|
rightPaceTicks = 1 |
|
|
rightPaceTicks = 1 |
|
|
lastLeftSendTick = -1L |
|
|
|
|
|
lastRightSendTick = -1L |
|
|
|
|
|
holdDrainPreferLeft = true |
|
|
|
|
|
lastAudioSendTime = -1L |
|
|
lastAudioSendTime = -1L |
|
|
isDownlinkPaused = false |
|
|
isDownlinkPaused = false |
|
|
CallLog.i(TAG, "[CALL_RECONNECT] 下行音频已恢复,档位复位全速续发缓存译音 " + |
|
|
CallLog.i(TAG, "[CALL_RECONNECT] 下行音频已恢复,档位复位全速续发缓存译音 " + |
|
|
"hold(L=${leftHoldBuffer.size},R=${rightHoldBuffer.size}) queue=${audioSendQueue.size}") |
|
|
"buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})") |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
@ -1851,10 +1855,15 @@ object BleService { |
|
|
Log.e(TAG, "蓝牙连接或特征值未准备就绪") |
|
|
Log.e(TAG, "蓝牙连接或特征值未准备就绪") |
|
|
false |
|
|
false |
|
|
} else { |
|
|
} else { |
|
|
ch.value = chunk |
|
|
// 左右两条发送线程共用同一 callWriteChar(共享可变 .value)与同一 gatt, |
|
|
val ok = gatt.writeCharacteristic(ch) == true |
|
|
// 必须串行化「设值+写入」这一段,否则并发写会互相覆盖 value,发出错误/重复数据。 |
|
|
if (ok) bytesSentInCurrentSecond += chunk.size |
|
|
// 锁只在提交这一瞬持有(WRITE_TYPE_NO_RESPONSE 不等 ACK),配合右线程 20ms 错开,几乎不互相阻塞。 |
|
|
ok |
|
|
synchronized(bleWriteLock) { |
|
|
|
|
|
ch.value = chunk |
|
|
|
|
|
val ok = gatt.writeCharacteristic(ch) == true |
|
|
|
|
|
if (ok) bytesSentInCurrentSecond += chunk.size |
|
|
|
|
|
ok |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
} catch (e: Exception) { |
|
|
} catch (e: Exception) { |
|
|
Log.e(TAG, "发送音频数据块异常: ${e.message}", e) |
|
|
Log.e(TAG, "发送音频数据块异常: ${e.message}", e) |
|
|
@ -2014,8 +2023,8 @@ object BleService { |
|
|
Log.i(TAG, "[CALL_TRANS_DEBUG] 设置音频下行发送间隔: ${audioSendIntervalNormal}ms") |
|
|
Log.i(TAG, "[CALL_TRANS_DEBUG] 设置音频下行发送间隔: ${audioSendIntervalNormal}ms") |
|
|
} |
|
|
} |
|
|
if (bundleFrameCount != null && bundleFrameCount > 0) { |
|
|
if (bundleFrameCount != null && bundleFrameCount > 0) { |
|
|
opusAudioManager.setBundleFrameCount(bundleFrameCount) |
|
|
this.bundleFrameCount = bundleFrameCount.coerceIn(1, 20) |
|
|
Log.i(TAG, "[CALL_TRANS_DEBUG] 设置下行合包帧数: ${opusAudioManager.getBundleFrameCount()}") |
|
|
Log.i(TAG, "[CALL_TRANS_DEBUG] 设置下行合包帧数: ${this.bundleFrameCount}") |
|
|
} |
|
|
} |
|
|
// 三档控流:门限允许 0(第三档兜底),故用 >=0 判定;发送间隔要求 >0 |
|
|
// 三档控流:门限允许 0(第三档兜底),故用 >=0 判定;发送间隔要求 >0 |
|
|
if (t1FreeBytes != null && t1FreeBytes >= 0) tier1FreeBytes = t1FreeBytes |
|
|
if (t1FreeBytes != null && t1FreeBytes >= 0) tier1FreeBytes = t1FreeBytes |
|
|
@ -2040,7 +2049,7 @@ object BleService { |
|
|
fun getCallTranslationDebugParams(): Map<String, Any> { |
|
|
fun getCallTranslationDebugParams(): Map<String, Any> { |
|
|
return mapOf( |
|
|
return mapOf( |
|
|
"sendIntervalMs" to audioSendIntervalNormal, |
|
|
"sendIntervalMs" to audioSendIntervalNormal, |
|
|
"bundleFrameCount" to opusAudioManager.getBundleFrameCount(), |
|
|
"bundleFrameCount" to bundleFrameCount, |
|
|
"tier1FreeBytes" to tier1FreeBytes, |
|
|
"tier1FreeBytes" to tier1FreeBytes, |
|
|
"tier1SendMs" to tier1SendMs, |
|
|
"tier1SendMs" to tier1SendMs, |
|
|
"tier2FreeBytes" to tier2FreeBytes, |
|
|
"tier2FreeBytes" to tier2FreeBytes, |
|
|
|