@ -133,25 +133,23 @@ object BleService {
// 音频数据缓存
// 音频数据缓存
//private val audioDataBuffer = mutableListOf<Byte>()
//private val audioDataBuffer = mutableListOf<Byte>()
// 音频数据发送相关:左右声道各一条独立缓存队列 + 独立发送线程,两线程错开 20ms 启动,各自按拍 下发。
// 音频数据发送相关:左右声道各一条缓存队列,共用**一条**发送线程,每拍从左右队列各取 N 帧交替合成一包 下发。
// 缓存兼作重连暂停期间的暂存;LinkedBlockingDeque 线程安全(生产者=编码回调线程,消费者=对应 发送线程)。
// 缓存兼作重连暂停期间的暂存;LinkedBlockingDeque 线程安全(生产者=编码回调线程,消费者=发送线程)。
private val leftSendBuffer = LinkedBlockingDeque < ByteArray > ( )
private val leftSendBuffer = LinkedBlockingDeque < ByteArray > ( )
private val rightSendBuffer = LinkedBlockingDeque < ByteArray > ( )
private val rightSendBuffer = LinkedBlockingDeque < ByteArray > ( )
private var leftSendThread : Thread ? = null
private var audioSendThread : Thread ? = null
private var rightSendThread : Thread ? = null
// 写的是同一个 callWriteChar(共享可变 .value)+同一 gatt,写必须串行化;单发送线程下仍保留,防其他路径并发写。
// 两线程写的是同一个 callWriteChar(共享可变 .value)+同一 gatt,真正的写必须串行化,否则并发写会互相覆盖发错数据。
private val bleWriteLock = Any ( )
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 表示尚未发送过
private var lastAudioSendTime = - 1L
private var lastAudioSendTime = - 1L
// 左/右声道各自上一包已发送的序号,用于在日志里核对每个声道的包序是否连续(+1);-1 表示尚未发送过
private var lastLeftSeq = - 1L
private var lastRightSeq = - 1L
// 下行写入连续失败(底层发送缓冲拥塞)的计数,用于节流告警与恢复提示;写成功即清零
// 下行写入连续失败(底层发送缓冲拥塞)的计数,用于节流告警与恢复提示;写成功即清零
private var writeFailRetryCount = 0
private var writeFailRetryCount = 0
// 因静音帧模板尚未就绪而暂缓下发的连续拍数,用于节流告警;一旦模板就绪即清零
private var silenceNotReadyCount = 0
// 持续发送模式下连续发出的纯静音包数,仅用于日志节流;一旦有真实译音即清零
private var silentPacketCount = 0
// 延迟追踪:右声道(对方译音)每句首包写到 BLE 的时刻,配合 azure_speech 侧 [LAT-TRACE] 点1/点2
// 延迟追踪:右声道(对方译音)每句首包写到 BLE 的时刻,配合 azure_speech 侧 [LAT-TRACE] 点1/点2
// 相减即得 app 内部"收到译音→写到耳机"耗时;间隔>700ms 视为新一句,避免逐包刷屏
// 相减即得 app 内部"收到译音→写到耳机"耗时;间隔>700ms 视为新一句,避免逐包刷屏
private var latLastBleRightTs = 0L
private var latLastBleRightTs = 0L
@ -199,23 +197,33 @@ object BleService {
// 音频数据分块发送的常量
// 音频数据分块发送的常量
private val AUDIO_CHUNK_SIZE = 120 // 每次发送120字节
private val AUDIO_CHUNK_SIZE = 120 // 每次发送120字节
private const val DEFAULT_AUDIO_SEND_INTERVAL_NORMAL = 80L // 默认音频下行发送间隔(ms)
// 默认音频下行发送间隔(ms):一拍发 bundleFrameCount 帧/声道,每帧 20ms 音频,
// 故 间隔 == bundleFrameCount * 20ms 时下发速率与实时语速 1:1(默认 2 帧 → 40ms)。
private const val DEFAULT_AUDIO_SEND_INTERVAL_NORMAL = 40L
// 音频下行发送间隔(ms),调试界面可在运行时调整;通过 setCallTranslationDebugParams() 修改
// 音频下行发送间隔(ms),调试界面可在运行时调整;通过 setCallTranslationDebugParams() 修改
@Volatile
@Volatile
private var audioSendIntervalNormal = DEFAULT_AUDIO_SEND_INTERVAL_NORMAL
private var audioSendIntervalNormal = DEFAULT_AUDIO_SEND_INTERVAL_NORMAL
// 声道缓存安全上限(纯防 OOM 兜底):现在 缓存元素是单帧 opus,超限丢最旧帧并告警。
// 声道缓存安全上限(纯防 OOM 兜底):缓存元素是单帧 opus,超限丢最旧帧并告警。
private const val CHANNEL_HOLD_BUFFER_MAX = 10000
private const val CHANNEL_HOLD_BUFFER_MAX = 10000
// ---- 下行合包(已从 OpusAudioManager 上移到 BleService) ----
// 每包合并的 opus 帧数(默认5,调试界面可调 1..20):发送线程每拍从声道队列取最多 bundleFrameCount 帧,
// ---- 下行合包:左右交替、无包头 ----
// 不足则用静音帧补齐、队列空则不发;组包布局 [4B 序号(小端)] + [1B 声道] + [N × opus 帧]。
// 单帧 opus 固定长度(编码器 CBR packetSize=40B)。无包头协议靠固定帧长切分,长度必须严格恒定,
// 否则设备侧整包错位 —— 故入队前校验帧长,非 40B 一律丢弃。
private const val OPUS_FRAME_BYTES = 40
// 每包每声道的 opus 帧数(默认2,调试界面可调 1..MAX_BUNDLE_FRAME_COUNT)。
// 组包布局(无包头):[L0 40B][R0 40B][L1 40B][R1 40B]...[L(n-1)][R(n-1)],包长恒为 2*n*40B。
@Volatile
private var bundleFrameCount = 2
// 每包帧数上限:包长 2*n*40 必须塞得进一次 BLE 写(MTU 517 → 可写 514B),6 帧=480B 已是安全上限。
private const val MAX_BUNDLE_FRAME_COUNT = 6
// 持续发送开关:为 true 时即使左右队列都空也按拍发全静音包,保持设备侧音频流不断流;
// 为 false 时两侧皆空则跳过该拍不发(默认,省链路占用)。
@Volatile
@Volatile
private var bundleFrameCount = 5
private var continuousSendEnabled = false
// 合包头部字节数:bytes0-3 序号(小端 uint32) + byte4 声道
// 下行包序号(每成功发一包自增),无包头协议下不写入包内,仅用于日志核对节拍连续性
private const val BUNDLE_HEADER_SIZE = 5
private var packetSeq = 0
// 左右声道各自的下行包序号(每成功发一包自增),供设备侧核对连续性;仅各自声道线程访问
// 补齐用静音 opus 帧模板(OpusAudioManager 运行时编码零PCM生成,经 onSilenceFrameReady 送来);未就绪时为 null。
private var leftPacketSeq = 0
// 无包头协议要求包长恒定,故它未就绪时无法补齐,整拍不发。
private var rightPacketSeq = 0
// 补齐用静音 opus 帧模板(OpusAudioManager 运行时编码零PCM生成,经 onSilenceFrameReady 送来);未就绪时为 null
@Volatile
@Volatile
private var silenceFrame : ByteArray ? = null
private var silenceFrame : ByteArray ? = null
// 音频数据缓冲区,用于累积数据到80字节再发送
// 音频数据缓冲区,用于累积数据到80字节再发送
@ -349,12 +357,17 @@ object BleService {
}
}
override fun onAudioFrameEncoded ( channelPrefix : Byte , frame : ByteArray ) {
override fun onAudioFrameEncoded ( channelPrefix : Byte , frame : ByteArray ) {
// 单帧入对应声道队列,由发送线程按拍取 bundleFrameCount 帧合包下发
// 单帧入对应声道队列,由发送线程按拍取左右各 bundleFrameCount 帧交替 合包下发
enqueueOpusFrame ( channelPrefix , frame )
enqueueOpusFrame ( channelPrefix , frame )
}
}
override fun onSilenceFrameReady ( frame : ByteArray ) {
override fun onSilenceFrameReady ( frame : ByteArray ) {
// 缓存静音帧模板,用于合包时不足帧补齐
// 缓存静音帧模板,用于合包时不足帧补齐。
// 无包头协议按固定 40B 切分,模板长度不对会让整包左右对位全错,故只接受恰好 OPUS_FRAME_BYTES 的模板
if ( frame . size != OPUS_FRAME_BYTES ) {
CallLog . w ( TAG , " 静音帧模板长度异常 size= ${frame.size} B (期望 ${OPUS_FRAME_BYTES} B),拒绝采用 " )
return
}
silenceFrame = frame
silenceFrame = frame
}
}
@ -1492,12 +1505,12 @@ object BleService {
/ * *
/ * *
* 开 始 Opus 数 据 流 编 码 ( 通 话 翻 译 模 式 : 左 右 声 道 独 立 编 码 )
* 开 始 Opus 数 据 流 编 码 ( 通 话 翻 译 模 式 : 左 右 声 道 独 立 编 码 )
* 输 出 合 包 格 式 : [ 4 B 序 号 ( 小 端 uint32 ) ] + [ 1 B 声 道 ( 0 = 左 / 1 = 右 ) ] + [ 5 × opus ( 40 B ) ] = 205 B
* 输 出 下 行 包 格 式 ( 无 包 头 , 左 右 交 替 ) : [ L0 40 B ] [ R0 40 B ] .. . [ L ( n - 1 ) ] [ R ( n - 1 ) ] , n = bundleFrameCount ( 默 认 2 → 160 B )
* @return 是 否 成 功 开 始 编 码
* @return 是 否 成 功 开 始 编 码
* /
* /
private fun startOpusEncodeStream ( ) : Boolean {
private fun startOpusEncodeStream ( ) : Boolean {
startAudioSendThread ( )
startAudioSendThread ( )
return opusAudioManager . startDualEncodeStream ( false , 16000 , 40 )
return opusAudioManager . startDualEncodeStream ( false , 16000 , OPUS_FRAME_BYTES )
}
}
/ * *
/ * *
@ -1513,13 +1526,20 @@ object BleService {
}
}
/ * *
/ * *
* 将 一 帧 opus 按 声 道 加 入 对 应 下 行 队 列 。 合 包 ( 取 N 帧 / 补 静 音 / 组 头 ) 在 发 送 线 程 按 拍 完 成 。
* 将 一 帧 opus 按 声 道 加 入 对 应 下 行 队 列 。 合 包 ( 左 右 各 取 N 帧 / 交 替 拼 接 / 补 静 音 ) 在 发 送 线 程 按 拍 完 成 。
* 重 连 窗 口 内 不 丢 弃 : 继 续 入 队 暂 存 , 回 连 成 功 后 续 发 ; 仅 超 OOM 兜 底 上 限 才 丢 最 旧 帧 。
* 重 连 窗 口 内 不 丢 弃 : 继 续 入 队 暂 存 , 回 连 成 功 后 续 发 ; 仅 超 OOM 兜 底 上 限 才 丢 最 旧 帧 。
*
* 无 包 头 协 议 下 设 备 侧 按 固 定 40 B 切 分 左 右 声 道 , 任 何 一 帧 长 度 异 常 都 会 让 整 包 及 之 后 的 声 道 对 位 全 错 ,
* 故 非 OPUS_FRAME_BYTES 的 帧 在 入 队 处 直 接 拦 掉 , 保 证 队 列 里 的 帧 长 恒 定 。
* @param channelPrefix 声 道 标 识 ( CHANNEL_LEFT = 0 / CHANNEL_RIGHT = 1 )
* @param channelPrefix 声 道 标 识 ( CHANNEL_LEFT = 0 / CHANNEL_RIGHT = 1 )
* @param frame 一 帧 opus 编 码 数 据
* @param frame 一 帧 opus 编 码 数 据
* /
* /
private fun enqueueOpusFrame ( channelPrefix : Byte , frame : ByteArray ) {
private fun enqueueOpusFrame ( channelPrefix : Byte , frame : ByteArray ) {
if ( frame . isEmpty ( ) ) return
if ( frame . isEmpty ( ) ) return
if ( frame . size != OPUS_FRAME_BYTES ) {
CallLog . w ( TAG , " 丢弃异常长度 opus 帧 ch= $channelPrefix size= ${frame.size} B (期望 ${OPUS_FRAME_BYTES} B) " )
return
}
val buffer = when ( channelPrefix ) {
val buffer = when ( channelPrefix ) {
OpusAudioManager . CHANNEL_RIGHT -> rightSendBuffer
OpusAudioManager . CHANNEL_RIGHT -> rightSendBuffer
// 左声道及未知声道统一归入左队列
// 左声道及未知声道统一归入左队列
@ -1534,43 +1554,33 @@ object BleService {
/ * *
/ * *
* 启 动 左 右 两 条 独 立 发 送 线 程 ( 各 发 一 路 声 道 ) , 右 声 道 相 对 左 声 道 错 开 RIGHT_SEND_THREAD_STAGGER_MS 启 动 ,
* 启 动 下 行 发 送 线 程 ( 单 线 程 , 一 包 同 时 承 载 左 右 两 路 ) 。
* 避 免 两 路 同 一 时 刻 抢 BLE 写 。 每 条 线 程 按 固 定 拍 长 下 发 , 互 不 影 响 。
* 每 拍 从 左 右 队 列 各 取 最 多 bundleFrameCount 帧 , 按 L / R 交 替 拼 成 一 个 无 包 头 包 下 发 。
* /
* /
private fun startAudioSendThread ( ) {
private fun startAudioSendThread ( ) {
if ( leftSendThre ad?. isAlive == true || right SendThread ?. isAlive == true ) {
if ( au dio SendThread ?. isAlive == true ) {
Log . d ( TAG , " 音频发送线程已在运行 " )
Log . d ( TAG , " 音频发送线程已在运行 " )
return
return
}
}
isAudioSending . set ( true )
isAudioSending . set ( true )
leftSendThread = createChannelSendThread ( isLeft = true , startOffsetMs = 0L ) . apply { start ( ) }
audioSendThread = createAudioSendThread ( ) . apply { start ( ) }
rightSendThread = createChannelSendThread ( isLeft = false , startOffsetMs = RIGHT_SEND_THREAD_STAGGER_MS ) . apply { start ( ) }
val n = bundleFrameCount . coerceIn ( 1 , MAX_BUNDLE_FRAME_COUNT )
CallLog . i ( TAG , " 音频发送线程已启动(左/右双线程,右错开 ${RIGHT_SEND_THREAD_STAGGER_MS} ms) " )
CallLog . i ( TAG , " 音频发送线程已启动(左右合包/无包头) 间隔= ${audioSendIntervalNormal} ms " +
" 帧数= $n /声道 包长= ${2 * n * OPUS_FRAME_BYTES} B 持续发送= $continuousSendEnabled " )
}
}
/ * *
/ * *
* 构 建 单 声 道 发 送 线 程 : 固 定 audioSendIntervalNormal 一 拍 ( 睡 到 绝 对 时 刻 , 不 随 耗 时 抖 动 ) ,
* 构 建 下 行 发 送 线 程 : 固 定 audioSendIntervalNormal 一 拍 ( 睡 到 绝 对 时 刻 , 不 随 耗 时 抖 动 ) , 每 拍 下 发 一 包 左 右 合 成 音 频 。
* 每 拍 从 该 声 道 缓 存 取 队 首 一 包 下 发 。
* 重 连 暂 停 期 间 只 跳 过 下 发 、 不 清 缓 存 ( 译 音 继 续 积 压 待 回 连 续 发 ) 。
* 重 连 暂 停 期 间 只 跳 过 下 发 、 不 清 缓 存 ( 译 音 继 续 积 压 待 回 连 续 发 ) 。
* @param isLeft true = 左 声 道 / false = 右 声 道
* @param startOffsetMs 相 对 启 动 时 刻 的 错 开 毫 秒 ( 右 声 道 用 来 与 左 声 道 错 峰 )
* /
* /
private fun createChannelSendThread ( isLeft : Boolean , startOffsetMs : Long ) : Thread {
private fun createAudioSendThread ( ) : Thread {
val chName = if ( isLeft ) " L " else " R "
return Thread {
return Thread {
CallLog . i ( TAG , " 音频发送线程[ $chName ]已启动 offset= ${startOffsetMs} ms " )
var nextTickAt = System . currentTimeMillis ( )
var nextTickAt = System . currentTimeMillis ( ) + startOffsetMs
// 首拍错开:右线程先睡 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 {
// 重连暂停期间只跳过下发,不动缓存(译音继续积压,回连后续发)
// 重连暂停期间只跳过下发,不动缓存(译音继续积压,回连后续发)
if ( !is DownlinkPaused ) {
if ( !is DownlinkPaused ) {
val buffer = if ( isLeft ) leftSendBuffer else rightSendBuffer
trySendMixedChunk ( )
if ( buffer . isNotEmpty ( ) ) trySendChannelChunk ( isLeft )
}
}
// 固定一拍:睡到下一拍绝对时刻,节拍稳定不随发送耗时抖动
// 固定一拍:睡到下一拍绝对时刻,节拍稳定不随发送耗时抖动
nextTickAt += audioSendIntervalNormal
nextTickAt += audioSendIntervalNormal
@ -1582,55 +1592,62 @@ object BleService {
nextTickAt = System . currentTimeMillis ( )
nextTickAt = System . currentTimeMillis ( )
}
}
} catch ( e : InterruptedException ) {
} catch ( e : InterruptedException ) {
Log . d ( TAG , " 音频发送线程[ $chName ] 被中断 " )
Log . d ( TAG , " 音频发送线程被中断 " )
break
break
} catch ( e : Exception ) {
} catch ( e : Exception ) {
Log . e ( TAG , " 音频发送线程[ $chName ] 异常: ${e.message} " , e )
Log . e ( TAG , " 音频发送线程异常: ${e.message} " , e )
}
}
}
}
CallLog . i ( TAG , " 音频发送线程[ $chName ] 已停止 " )
CallLog . i ( TAG , " 音频发送线程已停止 " )
} . apply { name = " AudioSendThread- $chName " }
} . apply { name = " AudioSendThread " }
}
}
/ * *
/ * *
* 从 指 定 声 道 队 列 取 最 多 bundleFrameCount 帧 , 不 足 用 静 音 帧 补 齐 , 组 包 后 下 发 。 仅 在 该 声 道 发 送 线 程 内 调 用 。
* 一 拍 下 发 一 包 : 左 右 队 列 各 取 最 多 bundleFrameCount 帧 , 按 L0 R0 L1 R1 .. . 交 替 拼 成 无 包 头 包 下 发 。
* - 队 列 空 : 不 发 , 返 回 false ;
* - 某 一 路 帧 不 足 ( 含 一 帧 没 有 ) : 缺 的 位 用 静 音 帧 补 齐 , 保 证 包 长 恒 定 、 左 右 严 格 对 位 ;
* - 成 功 : 写 调 试 录 音 、 打 印 发 送 日 志 、 推 进 该 声 道 包 序 , 返 回 true ;
* - 两 路 皆 空 : continuousSendEnabled = true 时 发 全 静 音 包 保 持 设 备 侧 音 频 流 不 断 , 否 则 跳 过 本 拍 不 发 ;
* - 失 败 ( 拥 塞 ) : 取 出 的 真 实 帧 按 原 序 放 回 队 头 ( 静 音 补 齐 帧 丢 弃 、 下 拍 重 组 ) , 返 回 false , 下 一 拍 重 试 , 绝 不 丢 帧 。
* - 静 音 帧 模 板 未 就 绪 : 包 长 无 法 恒 定 → 本 拍 不 发 , 已 取 出 的 帧 原 样 退 回 队 头 ( 仅 编 码 器 启 动 初 期 的 极 短 窗 口 ) ;
* - 写 失 败 ( 拥 塞 ) : 取 出 的 真 实 帧 按 原 序 退 回 各 自 队 头 , 静 音 补 齐 帧 丢 弃 , 下 一 拍 重 组 重 试 , 绝 不 丢 帧 。
* @return 是 否 成 功 发 出 一 包
* /
* /
private fun trySendChannelChunk ( isLeft : Boolean ) : Boolean {
private fun trySendMixedChunk ( ) : Boolean {
val buffer = if ( isLeft ) leftSendBuffer else rightSendBuffer
val n = bundleFrameCount . coerceIn ( 1 , MAX_BUNDLE_FRAME_COUNT )
val n = bundleFrameCount . coerceIn ( 1 , 20 )
// 左右各取最多 n 帧真实数据
// 取最多 n 帧真实数据
val leftFrames = ArrayList < ByteArray > ( n )
val realFrames = ArrayList < ByteArray > ( n )
while ( leftFrames . size < n ) leftFrames . add ( leftSendBuffer . pollFirst ( ) ?: break )
while ( realFrames . size < n ) {
val rightFrames = ArrayList < ByteArray > ( n )
val f = buffer . pollFirst ( ) ?: break
while ( rightFrames . size < n ) rightFrames . add ( rightSendBuffer . pollFirst ( ) ?: break )
realFrames . add ( f )
}
if ( realFrames . isEmpty ( ) ) return false // 队列空则不发
val channel = if ( isLeft ) OpusAudioManager . CHANNEL_LEFT else OpusAudioManager . CHANNEL_RIGHT
val hasRealAudio = leftFrames . isNotEmpty ( ) || rightFrames . isNotEmpty ( )
val chTag = if ( isLeft ) " L " else " R "
// 两路皆空且未开持续发送:本拍不发
val seq = if ( isLeft ) leftPacketSeq else rightPacketSeq
if ( ! hasRealAudio && ! continuousSendEnabled ) return false
// 组包帧 = 真实帧 + 静音帧补齐到 n(静音帧未就绪时只发真实帧数,罕见的启动窗口)
// 无包头协议靠固定帧长切分,缺帧必须补静音才能保证包长恒定;模板未就绪则本拍放弃,帧退回队列不丢
val frames = ArrayList < ByteArray > ( n )
frames . addAll ( realFrames )
val silence = silenceFrame
val silence = silenceFrame
var padCount = 0
if ( silence == null ) {
if ( silence != null ) {
for ( i in leftFrames . indices . reversed ( ) ) leftSendBuffer . offerFirst ( leftFrames [ i ] )
while ( frames . size < n ) { frames . add ( silence ) ; padCount ++ }
for ( i in rightFrames . indices . reversed ( ) ) rightSendBuffer . offerFirst ( rightFrames [ i ] )
silenceNotReadyCount ++
if ( silenceNotReadyCount == 1 || silenceNotReadyCount % 50 == 0 ) {
CallLog . w ( TAG , " 静音帧模板未就绪,暂缓下发(累计 $silenceNotReadyCount 拍),译音已退回队列不丢 " )
}
return false
}
}
val packet = buildBundlePacket ( seq , channel , frames )
silenceNotReadyCount = 0
val padLeft = n - leftFrames . size
val padRight = n - rightFrames . size
val packet = buildInterleavedPacket ( n , leftFrames , rightFrames , silence )
// 同步写入,拿到真实的成功/失败:false=底层发送缓冲已满(拥塞),作为背压信号
// 同步写入,拿到真实的成功/失败:false=底层发送缓冲已满(拥塞),作为背压信号
val sent = sendAudioChunkBlocking ( packet )
val sent = sendAudioChunkBlocking ( packet )
if ( ! sent ) {
if ( ! sent ) {
// 写失败:真实帧按原顺序放回队头(倒序 offerFirst),下一拍重试;静音补齐帧丢弃,下次按新帧重组
// 写失败:真实帧按原顺序退回各自队头(倒序 offerFirst),下一拍重组重试;静音补齐帧丢弃
for ( i in realFrames . indices . reversed ( ) ) buffer . offerFirst ( realFrames [ i ] )
for ( i in leftFrames . indices . reversed ( ) ) leftSendBuffer . offerFirst ( leftFrames [ i ] )
for ( i in rightFrames . indices . reversed ( ) ) rightSendBuffer . offerFirst ( rightFrames [ i ] )
writeFailRetryCount ++
writeFailRetryCount ++
if ( writeFailRetryCount == 1 || writeFailRetryCount % 50 == 0 ) {
if ( writeFailRetryCount == 1 || writeFailRetryCount % 50 == 0 ) {
CallLog . w ( TAG , " 下行写入拥塞,重试中 ch= $chTag seq= $s eq 连续失败= $writeFailRetryCount " +
CallLog . w ( TAG , " 下行写入拥塞,重试中 seq= $packetS eq 连续失败= $writeFailRetryCount " +
" buf(L= ${leftSendBuffer.size} ,R= ${rightSendBuffer.size} ) " )
" buf(L= ${leftSendBuffer.size} ,R= ${rightSendBuffer.size} ) " )
}
}
return false
return false
@ -1639,40 +1656,30 @@ object BleService {
CallLog . i ( TAG , " 下行写入已恢复,之前连续失败= $writeFailRetryCount " )
CallLog . i ( TAG , " 下行写入已恢复,之前连续失败= $writeFailRetryCount " )
writeFailRetryCount = 0
writeFailRetryCount = 0
}
}
// 成功后推进该声道包序
val seq = packetSeq ++
if ( isLeft ) leftPacketSeq ++ else rightPacketSeq ++
// 调试录音:剥离 5 字节包头后按声道写入(含静音补齐部分,听辨即静音)
// 调试录音:按声道各自落地(真实帧+静音补齐,与该路实际下发内容一致,听辨即静音)
if ( packet . size > BUNDLE_HEADER_SIZE ) {
recordfile1Left ?. saveAudioDataToWav ( padToFrames ( n , leftFrames , silence ) )
val opusPayload = packet . copyOfRange ( BUNDLE_HEADER_SIZE , packet . size )
recordfile1Right ?. saveAudioDataToWav ( padToFrames ( n , rightFrames , silence ) )
when ( channel ) {
OpusAudioManager . CHANNEL_LEFT -> recordfile1Left ?. saveAudioDataToWav ( opusPayload )
OpusAudioManager . CHANNEL_RIGHT -> recordfile1Right ?. saveAudioDataToWav ( opusPayload )
}
}
// 发送节拍统计与包序日志
// 发送节拍统计与包序日志;持续发送的纯静音包按 25 拍节流,避免刷屏
val now = System . currentTimeMillis ( )
val now = System . currentTimeMillis ( )
val deltaMs = if ( lastAudioSendTime < 0 ) 0 else now - lastAudioSendTime
val deltaMs = if ( lastAudioSendTime < 0 ) 0 else now - lastAudioSendTime
lastAudioSendTime = now
lastAudioSendTime = now
val seqGap = when ( channel ) {
if ( hasRealAudio ) {
OpusAudioManager . CHANNEL_LEFT -> {
silentPacketCount = 0
val gap = if ( lastLeftSeq < 0 ) 1L else seq . toLong ( ) - lastLeftSeq
CallLog . i ( TAG , " 音频下行发送 seq= $seq Δ= ${deltaMs} ms L= ${leftFrames.size} +静音 $padLeft " +
lastLeftSeq = seq . toLong ( ) ; gap
" R= ${rightFrames.size} +静音 $padRight size= ${packet.size} B " +
}
" buf(L= ${leftSendBuffer.size} ,R= ${rightSendBuffer.size} ) " )
OpusAudioManager . CHANNEL_RIGHT -> {
} else {
val gap = if ( lastRightSeq < 0 ) 1L else seq . toLong ( ) - lastRightSeq
silentPacketCount ++
lastRightSeq = seq . toLong ( ) ; gap
if ( silentPacketCount % 25 == 1 ) {
CallLog . i ( TAG , " 音频下行持续发送(全静音) seq= $seq Δ= ${deltaMs} ms size= ${packet.size} B 累计= $silentPacketCount 包 " )
}
}
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 " +
" buf(L= ${leftSendBuffer.size} ,R= ${rightSendBuffer.size} ) " )
// [LAT-TRACE] 点3:右声道(对方译音)首包写到 BLE(每句首包)
// [LAT-TRACE] 点3:右声道(对方译音)首包写到 BLE(每句首包)
if ( channel == OpusAudioManager . CHANNEL_RIGHT ) {
if ( rightFrames . isNotEmpty ( ) ) {
if ( now - latLastBleRightTs > 700 ) {
if ( now - latLastBleRightTs > 700 ) {
CallLog . i ( TAG , " [LAT-TRACE] 3.译音写到BLE(右/对方) ts= $now seq= $seq " )
CallLog . i ( TAG , " [LAT-TRACE] 3.译音写到BLE(右/对方) ts= $now seq= $seq " )
}
}
@ -1682,34 +1689,46 @@ object BleService {
}
}
/ * *
/ * *
* 组 下 行 包 : [ 4 B 序 号 ( 小 端 uint32 ) ] + [ 1 B 声 道 ( 0 = 左 / 1 = 右 ) ] + [ N 帧 opus 顺 次 拼 接 ]
* 组 下 行 包 ( 无 包 头 , 左 右 帧 级 交 替 ) : [ L0 ] [ R0 ] [ L1 ] [ R1 ] .. . [ L ( n - 1 ) ] [ R ( n - 1 ) ]
* 每 帧 恒 为 OPUS_FRAME_BYTES 字 节 , 包 长 恒 为 2 * n * OPUS_FRAME_BYTES ; 某 侧 该 位 缺 帧 则 填 静 音 帧 , 保 证 左 右 严 格 对 位 。
* /
* /
private fun buildBundlePacket ( seq : Int , channel : Byte , frames : List < ByteArray > ) : ByteArray {
private fun buildInterleavedPacket (
var payloadSize = 0
n : Int ,
for ( f in frames ) payloadSize += f . size
leftFrames : List < ByteArray > ,
val packet = ByteArray ( BUNDLE_HEADER_SIZE + payloadSize )
rightFrames : List < ByteArray > ,
packet [ 0 ] = ( seq and 0xFF ) . toByte ( )
silence : ByteArray
packet [ 1 ] = ( seq ushr 8 and 0xFF ) . toByte ( )
) : ByteArray {
packet [ 2 ] = ( seq ushr 16 and 0xFF ) . toByte ( )
val packet = ByteArray ( 2 * n * OPUS_FRAME_BYTES )
packet [ 3 ] = ( seq ushr 24 and 0xFF ) . toByte ( )
var offset = 0
packet [ 4 ] = channel
for ( i in 0 until n ) {
var offset = BUNDLE_HEADER_SIZE
System . arraycopy ( leftFrames . getOrNull ( i ) ?: silence , 0 , packet , offset , OPUS_FRAME_BYTES )
for ( f in frames ) {
offset += OPUS_FRAME_BYTES
System . arraycopy ( f , 0 , packet , offset , f . size )
System . arraycopy ( rightFrames . getOrNull ( i ) ?: silence , 0 , packet , offset , OPUS_FRAME_BYTES )
offset += f . size
offset += OPUS_FRAME_BYTES
}
}
return packet
return packet
}
}
/** 把某一路的真实帧补静音至 n 帧后顺次拼接(仅供调试录音落地,内容与该路实际下发的一致) */
private fun padToFrames ( n : Int , frames : List < ByteArray > , silence : ByteArray ) : ByteArray {
val out = ByteArray ( n * OPUS_FRAME_BYTES )
var offset = 0
for ( i in 0 until n ) {
System . arraycopy ( frames . getOrNull ( i ) ?: silence , 0 , out , offset , OPUS_FRAME_BYTES )
offset += OPUS_FRAME_BYTES
}
return out
}
/ * *
/ * *
* 停 止 左 右 两 条 音 频 发 送 线 程 并 清 空 缓 存
* 停 止 音 频 发 送 线 程 并 清 空 缓 存
* /
* /
private fun stopAudioSendThread ( ) {
private fun stopAudioSendThread ( ) {
isAudioSending . set ( false )
isAudioSending . set ( false )
leftSendThread ?. interrupt ( )
audio SendThread?. interrupt ( )
rightSendThread ?. interrupt ( )
audioSendThread = null
resetDownlinkBuffers ( )
resetDownlinkBuffers ( )
CallLog . i ( TAG , " 音频发送线程(左/右)已停止,队列和缓冲区已清空 " )
CallLog . i ( TAG , " 音频发送线程已停止,队列和缓冲区已清空 " )
}
}
/ * *
/ * *
@ -1720,12 +1739,11 @@ object BleService {
leftSendBuffer . clear ( ) // 清空左声道下行缓存队列
leftSendBuffer . clear ( ) // 清空左声道下行缓存队列
rightSendBuffer . clear ( ) // 清空右声道下行缓存队列
rightSendBuffer . clear ( ) // 清空右声道下行缓存队列
audioBuffer . clear ( ) // 清空音频缓冲区
audioBuffer . clear ( ) // 清空音频缓冲区
leftPacketSeq = 0 // 复位左右声道下行包序号
packetSeq = 0 // 复位下行包序号
rightPacketSeq = 0
lastAudioSendTime = - 1L // 重置发送节拍计时,下次首包 Δ 从 0 开始
lastAudioSendTime = - 1L // 重置发送节拍计时,下次首包 Δ 从 0 开始
lastLeftSeq = - 1L // 重置左右声道包序追踪
lastRightSeq = - 1L
writeFailRetryCount = 0 // 重置写入拥塞计数
writeFailRetryCount = 0 // 重置写入拥塞计数
silenceNotReadyCount = 0
silentPacketCount = 0
latLastBleRightTs = 0L // 重置延迟追踪计时
latLastBleRightTs = 0L // 重置延迟追踪计时
}
}
@ -1991,30 +2009,43 @@ object BleService {
/ * *
/ * *
* 设 置 通 话 翻 译 调 试 参 数 ( 运 行 时 生 效 , 无 需 重 连 ) 。 任 一 参 数 为 null 或 非 法 值 则 不 修 改 。
* 设 置 通 话 翻 译 调 试 参 数 ( 运 行 时 生 效 , 无 需 重 连 ) 。 任 一 参 数 为 null 或 非 法 值 则 不 修 改 。
* @param sendIntervalMs 音 频 下 行 发 送 间 隔 ( ms )
* @param sendIntervalMs 音 频 下 行 发 送 间 隔 ( ms )
* @param bundleFrameCount 下 行 合 包 帧 数
* @param bundleFrameCount 每 包 每 声 道 的 opus 帧 数 ( 1. . MAX_BUNDLE_FRAME_COUNT ) , 包 长 = 2 * n * 40 B
* @param continuousSend 是 否 持 续 发 送 : true 时 无 译 音 也 按 拍 下 发 全 静 音 包 , 保 持 设 备 侧 音 频 流 不 断
* /
* /
fun setCallTranslationDebugParams (
fun setCallTranslationDebugParams (
sendIntervalMs : Long ? ,
sendIntervalMs : Long ? ,
bundleFrameCount : Int ?
bundleFrameCount : Int ? ,
continuousSend : Boolean ?
) {
) {
if ( sendIntervalMs != null && sendIntervalMs > 0 ) {
if ( sendIntervalMs != null && sendIntervalMs > 0 ) {
audioSendIntervalNormal = sendIntervalMs . coerceIn ( 1L , 1000L )
audioSendIntervalNormal = sendIntervalMs . coerceIn ( 1L , 1000L )
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 ) {
this . bundleFrameCount = bundleFrameCount . coerceIn ( 1 , 20 )
this . bundleFrameCount = bundleFrameCount . coerceIn ( 1 , MAX_BUNDLE_FRAME_COUNT )
Log . i ( TAG , " [CALL_TRANS_DEBUG] 设置下行合包帧数: ${this.bundleFrameCount} " )
val packetSize = 2 * this . bundleFrameCount * OPUS_FRAME_BYTES
Log . i ( TAG , " [CALL_TRANS_DEBUG] 设置每声道合包帧数: ${this.bundleFrameCount} (包长 ${packetSize} B) " )
// 一次 BLE 写的净荷上限是 MTU-3,超了底层会写失败/截断,这里提前告警。
// 仅在 MTU 已协商时才判(未连接时 currentMtu 还是默认 23,判了必然误报)
if ( currentMtu > 23 && packetSize > currentMtu - 3 ) {
CallLog . w ( TAG , " [CALL_TRANS_DEBUG] 包长 ${packetSize} B 超出当前 MTU 可写上限( ${currentMtu - 3} B),下行会写失败 " )
}
}
if ( continuousSend != null ) {
continuousSendEnabled = continuousSend
Log . i ( TAG , " [CALL_TRANS_DEBUG] 设置持续发送音频: $continuousSendEnabled " )
}
}
}
}
/ * *
/ * *
* 获 取 当 前 通 话 翻 译 调 试 参 数 。
* 获 取 当 前 通 话 翻 译 调 试 参 数 。
* @return Map : sendIntervalMs ( Long ) / bundleFrameCount ( Int )
* @return Map : sendIntervalMs ( Long ) / bundleFrameCount ( Int ) / continuousSend ( Boolean )
* /
* /
fun getCallTranslationDebugParams ( ) : Map < String , Any > {
fun getCallTranslationDebugParams ( ) : Map < String , Any > {
return mapOf (
return mapOf (
" sendIntervalMs " to audioSendIntervalNormal ,
" sendIntervalMs " to audioSendIntervalNormal ,
" bundleFrameCount " to bundleFrameCount
" bundleFrameCount " to bundleFrameCount ,
" continuousSend " to continuousSendEnabled
)
)
}
}