|
|
@ -1,15 +1,18 @@ |
|
|
package com.example.bluetooth_manager |
|
|
package com.example.bluetooth_manager |
|
|
|
|
|
|
|
|
import android.util.Log |
|
|
import android.util.Log |
|
|
import java.util.concurrent.LinkedBlockingQueue |
|
|
|
|
|
import java.util.concurrent.ScheduledFuture |
|
|
import java.util.concurrent.ScheduledFuture |
|
|
import java.util.concurrent.ScheduledThreadPoolExecutor |
|
|
import java.util.concurrent.ScheduledThreadPoolExecutor |
|
|
import java.util.concurrent.TimeUnit |
|
|
import java.util.concurrent.TimeUnit |
|
|
|
|
|
import kotlin.math.max |
|
|
|
|
|
import kotlin.math.min |
|
|
|
|
|
import kotlin.math.roundToInt |
|
|
|
|
|
import kotlin.math.sqrt |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 通话翻译的**下行 TTS 音频**发送器(恒玄/BES 专用)。 |
|
|
* 通话翻译的**下行 TTS 音频**发送器(恒玄/BES 专用)。 |
|
|
* |
|
|
* |
|
|
* 上层(Dart 的 AST 流水线)只负责丢 16k/16bit/mono 的 PCM, |
|
|
* 上层(AST 流水线 / 原生直连桥)只负责丢 16k/16bit/mono 的 PCM, |
|
|
* 编码、节流、组包全在这里做——这样 Dart 不需要碰 G.722,也不需要自己掐 20ms 时钟。 |
|
|
* 编码、节流、组包全在这里做——这样 Dart 不需要碰 G.722,也不需要自己掐 20ms 时钟。 |
|
|
* |
|
|
* |
|
|
* 包格式(与 deepvoice CallTranslationHelper.sendAudioData 一致): |
|
|
* 包格式(与 deepvoice CallTranslationHelper.sendAudioData 一致): |
|
|
@ -22,37 +25,85 @@ import java.util.concurrent.TimeUnit |
|
|
* 一帧固定 640 字节 PCM(320 样本 = 20ms)→ 40 字节码流, |
|
|
* 一帧固定 640 字节 PCM(320 样本 = 20ms)→ 40 字节码流, |
|
|
* 所以默认 20ms 的发送间隔正好是实时速率。耳机通过 `0xD6/0xE9` |
|
|
* 所以默认 20ms 的发送间隔正好是实时速率。耳机通过 `0xD6/0xE9` |
|
|
* 要求改节奏时(16/20/25ms),调 [setIntervalMs] 跟上,否则耳机侧会缓冲溢出或断续。 |
|
|
* 要求改节奏时(16/20/25ms),调 [setIntervalMs] 跟上,否则耳机侧会缓冲溢出或断续。 |
|
|
|
|
|
* |
|
|
|
|
|
* ## 积压追赶(2026-09-22) |
|
|
|
|
|
* |
|
|
|
|
|
* 译文音频是模型**突发**产出的(每 3~6 秒一段文本,音频一次性到),播放却只能 1 倍速; |
|
|
|
|
|
* 说话人不停顿时,译文时长 ≈ 原话时长(这场实测 A 路 133 秒会话产出 129 秒音频), |
|
|
|
|
|
* 队列只涨不缩——真机压测 A 路积压到 **6.2 秒**,用户听到的就是「越说越慢」。 |
|
|
|
|
|
* 上行那边没有积压(桥峰值 8 帧),瓶颈就在这里。 |
|
|
|
|
|
* |
|
|
|
|
|
* 处理分两层,都不丢内容: |
|
|
|
|
|
* 1. **裁静音**:积压超过 [SILENCE_TRIM_START_SEC] 时,把源音频里超过 [SILENCE_KEEP_MS] |
|
|
|
|
|
* 的静音段裁到只剩 [SILENCE_KEEP_MS](TTS 句间常有 200~400ms 停顿),无损。 |
|
|
|
|
|
* 2. **变速不变调追赶**(WSOLA,见 [Wsola]):积压 ≤ [CATCHUP_START_SEC] 时 1.0x 原样播; |
|
|
|
|
|
* 到 [CATCHUP_FULL_SEC] 线性提到 [MAX_RATE](1.3x 是语音可懂度的常用上限)。 |
|
|
|
|
|
* 1.3x 时每秒追回 0.3 秒,配合说话人的自然停顿收敛到 2~3 秒以内。 |
|
|
|
|
|
* 兜底 [HARD_CAP_SEC](默认 0 = 关):积压超过它才整段丢最旧的音频——这会丢内容, |
|
|
|
|
|
* 产品上默认不启用。 |
|
|
|
|
|
* |
|
|
|
|
|
* ⚠️ 这里改的只是 Android;iOS 的 CallTranslationDownlink.swift 仍是原样 1 倍速播放。 |
|
|
*/ |
|
|
*/ |
|
|
object CallTranslationDownlink { |
|
|
object CallTranslationDownlink { |
|
|
private const val TAG = "BesCallDownlink" |
|
|
private const val TAG = "BesCallDownlink" |
|
|
|
|
|
|
|
|
|
|
|
private const val SAMPLE_RATE = 16000 |
|
|
|
|
|
|
|
|
/** 一帧 PCM:320 样本 × 2 字节 = 20ms @16kHz */ |
|
|
/** 一帧 PCM:320 样本 × 2 字节 = 20ms @16kHz */ |
|
|
private const val FRAME_PCM_BYTES = 640 |
|
|
private const val FRAME_SAMPLES = 320 |
|
|
|
|
|
private const val FRAME_PCM_BYTES = FRAME_SAMPLES * 2 |
|
|
|
|
|
|
|
|
/** 一帧编码后码流长度 */ |
|
|
/** 一帧编码后码流长度 */ |
|
|
private const val FRAME_ENCODED_BYTES = 40 |
|
|
private const val FRAME_ENCODED_BYTES = 40 |
|
|
|
|
|
|
|
|
private const val PACKET_SIZE = 84 |
|
|
private const val PACKET_SIZE = 84 |
|
|
|
|
|
|
|
|
/** 队列上限:约 4096 × 20ms,远超实际需要,仅作失控保护 */ |
|
|
// ---- 追赶策略参数 ---- |
|
|
private const val QUEUE_CAP = 4096 |
|
|
private const val CATCHUP_START_SEC = 2.0 |
|
|
|
|
|
private const val CATCHUP_FULL_SEC = 4.0 |
|
|
|
|
|
private const val MAX_RATE = 1.3 |
|
|
|
|
|
private const val SILENCE_TRIM_START_SEC = 1.0 |
|
|
|
|
|
private const val SILENCE_KEEP_MS = 150 |
|
|
|
|
|
private const val SILENCE_RMS = 300.0 |
|
|
|
|
|
private const val HARD_CAP_SEC = 0.0 |
|
|
|
|
|
|
|
|
|
|
|
/** 一条腿:源 PCM 队列 + 变速器 + 编码器 + 统计。所有字段只在 synchronized(this) 里动。 */ |
|
|
|
|
|
private class Leg(val name: String) { |
|
|
|
|
|
val src = ArrayDeque<ShortArray>() // 未播的源 PCM,20ms 一块 |
|
|
|
|
|
var srcSamples = 0L // src 里的样本数 |
|
|
|
|
|
var partial = ShortArray(FRAME_SAMPLES) // 不满 20ms 的尾巴 |
|
|
|
|
|
var partialLen = 0 |
|
|
|
|
|
var silentRun = 0 // 队尾连续静音样本数(裁静音用) |
|
|
|
|
|
val stretcher = Wsola() |
|
|
|
|
|
var encHandle = 0L |
|
|
|
|
|
|
|
|
// legB = 己方听(对端译文),legA = 对端听(己方译文) |
|
|
// 统计 |
|
|
private val legBQueue = LinkedBlockingQueue<ByteArray>(QUEUE_CAP) |
|
|
var pushedFrames = 0L // 收到的源帧数(20ms) |
|
|
private val legAQueue = LinkedBlockingQueue<ByteArray>(QUEUE_CAP) |
|
|
var sentFrames = 0L // 实际发出的帧数 |
|
|
|
|
|
var trimmedSamples = 0L // 裁掉的静音样本 |
|
|
|
|
|
var droppedSamples = 0L // 兜底丢弃的样本 |
|
|
|
|
|
var maxBacklogSamples = 0L |
|
|
|
|
|
var lastRate = 1.0 |
|
|
|
|
|
|
|
|
// 两条腿各自独立的编码器状态,不能共用(与解码侧 spk/mic 双 handle 同理) |
|
|
/** 还没播出去的源音频(队列里的 + 变速器里没读完的),单位样本 */ |
|
|
private var encHandleA: Long = 0L |
|
|
fun backlogSamples(): Long = srcSamples + stretcher.unreadSamples() |
|
|
private var encHandleB: Long = 0L |
|
|
fun backlogSec(): Double = backlogSamples() / SAMPLE_RATE.toDouble() |
|
|
|
|
|
|
|
|
// 攒够 640 字节才能编一帧,余量留到下次 |
|
|
fun reset() { |
|
|
private var pendingA = ByteArray(0) |
|
|
src.clear(); srcSamples = 0; partialLen = 0; silentRun = 0 |
|
|
private var pendingB = ByteArray(0) |
|
|
stretcher.reset() |
|
|
private val pendingLock = Any() |
|
|
pushedFrames = 0; sentFrames = 0; trimmedSamples = 0; droppedSamples = 0 |
|
|
|
|
|
maxBacklogSamples = 0; lastRate = 1.0 |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private val legA = Leg("A") // 对端听 |
|
|
|
|
|
private val legB = Leg("B") // 己方听 |
|
|
|
|
|
|
|
|
private val scheduler = ScheduledThreadPoolExecutor(1) |
|
|
private val scheduler = ScheduledThreadPoolExecutor(1) |
|
|
private var sendTask: ScheduledFuture<*>? = null |
|
|
private var sendTask: ScheduledFuture<*>? = null |
|
|
private val packetBuf = ByteArray(PACKET_SIZE) |
|
|
private val packetBuf = ByteArray(PACKET_SIZE) |
|
|
|
|
|
private val pcmBytes = ByteArray(FRAME_PCM_BYTES) |
|
|
|
|
|
|
|
|
@Volatile |
|
|
@Volatile |
|
|
private var running = false |
|
|
private var running = false |
|
|
@ -61,8 +112,6 @@ object CallTranslationDownlink { |
|
|
private var intervalMs: Long = 20L |
|
|
private var intervalMs: Long = 20L |
|
|
|
|
|
|
|
|
private var sentPackets = 0L |
|
|
private var sentPackets = 0L |
|
|
private var pushedA = 0L |
|
|
|
|
|
private var pushedB = 0L |
|
|
|
|
|
|
|
|
|
|
|
/** 静音帧(deepvoice 原样照搬,耳机侧认这个码流) */ |
|
|
/** 静音帧(deepvoice 原样照搬,耳机侧认这个码流) */ |
|
|
private val silenceFrame = byteArrayOf( |
|
|
private val silenceFrame = byteArrayOf( |
|
|
@ -80,19 +129,13 @@ object CallTranslationDownlink { |
|
|
fun start() { |
|
|
fun start() { |
|
|
if (running) return |
|
|
if (running) return |
|
|
running = true |
|
|
running = true |
|
|
legAQueue.clear() |
|
|
for (leg in arrayOf(legA, legB)) synchronized(leg) { |
|
|
legBQueue.clear() |
|
|
leg.reset() |
|
|
synchronized(pendingLock) { |
|
|
if (leg.encHandle == 0L) leg.encHandle = G722Codec.init(SAMPLE_RATE) |
|
|
pendingA = ByteArray(0) |
|
|
|
|
|
pendingB = ByteArray(0) |
|
|
|
|
|
} |
|
|
} |
|
|
sentPackets = 0L |
|
|
sentPackets = 0L |
|
|
pushedA = 0L |
|
|
|
|
|
pushedB = 0L |
|
|
|
|
|
if (encHandleA == 0L) encHandleA = G722Codec.init(16000) |
|
|
|
|
|
if (encHandleB == 0L) encHandleB = G722Codec.init(16000) |
|
|
|
|
|
scheduleSendTask() |
|
|
scheduleSendTask() |
|
|
Log.d(TAG, "下行发送启动, interval=${intervalMs}ms") |
|
|
Log.d(TAG, "下行发送启动, interval=${intervalMs}ms 追赶策略: >${CATCHUP_START_SEC}s 起变速, ${CATCHUP_FULL_SEC}s 达 ${MAX_RATE}x, 裁静音 >${SILENCE_TRIM_START_SEC}s") |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@Synchronized |
|
|
@Synchronized |
|
|
@ -101,16 +144,46 @@ object CallTranslationDownlink { |
|
|
running = false |
|
|
running = false |
|
|
sendTask?.cancel(false) |
|
|
sendTask?.cancel(false) |
|
|
sendTask = null |
|
|
sendTask = null |
|
|
legAQueue.clear() |
|
|
val a = summary(legA) |
|
|
legBQueue.clear() |
|
|
val b = summary(legB) |
|
|
synchronized(pendingLock) { |
|
|
for (leg in arrayOf(legA, legB)) synchronized(leg) { |
|
|
pendingA = ByteArray(0) |
|
|
leg.src.clear(); leg.srcSamples = 0; leg.partialLen = 0 |
|
|
pendingB = ByteArray(0) |
|
|
leg.stretcher.reset() |
|
|
|
|
|
if (leg.encHandle != 0L) { |
|
|
|
|
|
runCatching { G722Codec.release(leg.encHandle) } |
|
|
|
|
|
leg.encHandle = 0L |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
releaseEncoders() |
|
|
Log.d(TAG, "下行发送停止, 共发出 $sentPackets 包 (pushA=${legA.pushedFrames} pushB=${legB.pushedFrames})") |
|
|
Log.d(TAG, "下行发送停止, 共发出 $sentPackets 包 (pushA=$pushedA pushB=$pushedB)") |
|
|
Log.w(TAG, "下行积压总账 A{$a} B{$b}") |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private fun summary(leg: Leg): String = synchronized(leg) { |
|
|
|
|
|
val pushedSec = leg.pushedFrames * 0.02 |
|
|
|
|
|
val sentSec = leg.sentFrames * 0.02 |
|
|
|
|
|
"源=%.1fs 播出=%.1fs 最大积压=%.1fs 裁静音=%.1fs 变速追回=%.1fs 兜底丢=%.1fs".format( |
|
|
|
|
|
pushedSec, sentSec, leg.maxBacklogSamples / SAMPLE_RATE.toDouble(), |
|
|
|
|
|
leg.trimmedSamples / SAMPLE_RATE.toDouble(), |
|
|
|
|
|
max(0.0, pushedSec - sentSec - leg.trimmedSamples / SAMPLE_RATE.toDouble() - leg.droppedSamples / SAMPLE_RATE.toDouble() - leg.backlogSec()), |
|
|
|
|
|
leg.droppedSamples / SAMPLE_RATE.toDouble() |
|
|
|
|
|
) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** 给 Dart 的 diagnostics() 用(与 iOS 的 getCallDownlinkStats 同名同用途,键不要求一致)。 */ |
|
|
|
|
|
fun stats(): Map<String, Any> = mapOf( |
|
|
|
|
|
"running" to running, |
|
|
|
|
|
"sentPackets" to sentPackets, |
|
|
|
|
|
"intervalMs" to intervalMs, |
|
|
|
|
|
"backlogA" to synchronized(legA) { legA.backlogSec() }, |
|
|
|
|
|
"backlogB" to synchronized(legB) { legB.backlogSec() }, |
|
|
|
|
|
"maxBacklogA" to synchronized(legA) { legA.maxBacklogSamples / SAMPLE_RATE.toDouble() }, |
|
|
|
|
|
"maxBacklogB" to synchronized(legB) { legB.maxBacklogSamples / SAMPLE_RATE.toDouble() }, |
|
|
|
|
|
"rateA" to synchronized(legA) { legA.lastRate }, |
|
|
|
|
|
"rateB" to synchronized(legB) { legB.lastRate }, |
|
|
|
|
|
"trimmedA" to synchronized(legA) { legA.trimmedSamples / SAMPLE_RATE.toDouble() }, |
|
|
|
|
|
"trimmedB" to synchronized(legB) { legB.trimmedSamples / SAMPLE_RATE.toDouble() }, |
|
|
|
|
|
) |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 耳机要求调整下行节奏(`0xD6`/`0xE9` 的 data[6])。 |
|
|
* 耳机要求调整下行节奏(`0xD6`/`0xE9` 的 data[6])。 |
|
|
* 只在运行中才重排任务,停止状态下仅记住新值。 |
|
|
* 只在运行中才重排任务,停止状态下仅记住新值。 |
|
|
@ -141,74 +214,227 @@ object CallTranslationDownlink { |
|
|
fun pushPcm(leg: String, pcm: ByteArray) { |
|
|
fun pushPcm(leg: String, pcm: ByteArray) { |
|
|
if (!running || pcm.isEmpty()) return |
|
|
if (!running || pcm.isEmpty()) return |
|
|
val isA = leg.equals("A", ignoreCase = true) || leg.equals("uplink", ignoreCase = true) |
|
|
val isA = leg.equals("A", ignoreCase = true) || leg.equals("uplink", ignoreCase = true) |
|
|
val frames: List<ByteArray> |
|
|
val l = if (isA) legA else legB |
|
|
synchronized(pendingLock) { |
|
|
synchronized(l) { |
|
|
val merged = if (isA) pendingA + pcm else pendingB + pcm |
|
|
val n = pcm.size / 2 |
|
|
val frameCount = merged.size / FRAME_PCM_BYTES |
|
|
var i = 0 |
|
|
if (frameCount == 0) { |
|
|
while (i < n) { |
|
|
if (isA) pendingA = merged else pendingB = merged |
|
|
val take = min(FRAME_SAMPLES - l.partialLen, n - i) |
|
|
return |
|
|
var k = 0 |
|
|
} |
|
|
while (k < take) { |
|
|
// JNI 的 encode 本身支持多帧,一次调用编完整段,省掉逐帧过 JNI 的开销 |
|
|
val p = (i + k) * 2 |
|
|
val bulkLen = frameCount * FRAME_PCM_BYTES |
|
|
l.partial[l.partialLen + k] = ((pcm[p].toInt() and 0xFF) or (pcm[p + 1].toInt() shl 8)).toShort() |
|
|
val handle = if (isA) encHandleA else encHandleB |
|
|
k++ |
|
|
val encoded = try { |
|
|
} |
|
|
G722Codec.encodeSync(handle, merged.copyOfRange(0, bulkLen)) |
|
|
l.partialLen += take |
|
|
} catch (e: Exception) { |
|
|
i += take |
|
|
Log.w(TAG, "G.722 编码失败: ${e.message}") |
|
|
if (l.partialLen == FRAME_SAMPLES) { |
|
|
null |
|
|
val block = l.partial.copyOf() |
|
|
|
|
|
l.partialLen = 0 |
|
|
|
|
|
l.pushedFrames++ |
|
|
|
|
|
if (!trimSilence(l, block)) { |
|
|
|
|
|
l.src.addLast(block) |
|
|
|
|
|
l.srcSamples += FRAME_SAMPLES |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
val out = ArrayList<ByteArray>(frameCount) |
|
|
if (HARD_CAP_SEC > 0.0) { |
|
|
if (encoded != null) { |
|
|
val cap = (HARD_CAP_SEC * SAMPLE_RATE).toLong() |
|
|
var o = 0 |
|
|
if (l.srcSamples > cap) { |
|
|
while (o + FRAME_ENCODED_BYTES <= encoded.size) { |
|
|
val target = ((HARD_CAP_SEC - 2.0).coerceAtLeast(1.0) * SAMPLE_RATE).toLong() |
|
|
out.add(encoded.copyOfRange(o, o + FRAME_ENCODED_BYTES)) |
|
|
while (l.srcSamples > target && l.src.isNotEmpty()) { |
|
|
o += FRAME_ENCODED_BYTES |
|
|
l.srcSamples -= l.src.removeFirst().size |
|
|
|
|
|
l.droppedSamples += FRAME_SAMPLES |
|
|
|
|
|
} |
|
|
|
|
|
Log.w(TAG, "${l.name} 路积压超过 ${HARD_CAP_SEC}s,丢弃最旧音频至 ${target / SAMPLE_RATE.toDouble()}s") |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
val rest = merged.copyOfRange(bulkLen, merged.size) |
|
|
val bl = l.backlogSamples() |
|
|
if (isA) pendingA = rest else pendingB = rest |
|
|
if (bl > l.maxBacklogSamples) l.maxBacklogSamples = bl |
|
|
frames = out |
|
|
|
|
|
} |
|
|
} |
|
|
val queue = if (isA) legAQueue else legBQueue |
|
|
} |
|
|
for (f in frames) { |
|
|
|
|
|
// 队列满说明下行速度跟不上产出(通常是耳机没在收),丢最旧的保实时 |
|
|
/** 积压时把长静音裁到只剩 SILENCE_KEEP_MS;返回 true 表示这一块被裁掉了。 */ |
|
|
if (!queue.offer(f)) { |
|
|
private fun trimSilence(l: Leg, block: ShortArray): Boolean { |
|
|
queue.poll() |
|
|
var acc = 0.0 |
|
|
queue.offer(f) |
|
|
for (s in block) acc += s.toDouble() * s.toDouble() |
|
|
} |
|
|
val rms = sqrt(acc / block.size) |
|
|
|
|
|
if (rms >= SILENCE_RMS) { |
|
|
|
|
|
l.silentRun = 0 |
|
|
|
|
|
return false |
|
|
|
|
|
} |
|
|
|
|
|
l.silentRun += block.size |
|
|
|
|
|
val keep = SILENCE_KEEP_MS * SAMPLE_RATE / 1000 |
|
|
|
|
|
if (l.silentRun > keep && l.backlogSec() > SILENCE_TRIM_START_SEC) { |
|
|
|
|
|
l.trimmedSamples += block.size |
|
|
|
|
|
return true |
|
|
|
|
|
} |
|
|
|
|
|
return false |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** 按积压深度算这一帧的播放速率。 */ |
|
|
|
|
|
private fun rateFor(backlogSec: Double): Double = when { |
|
|
|
|
|
backlogSec <= CATCHUP_START_SEC -> 1.0 |
|
|
|
|
|
backlogSec >= CATCHUP_FULL_SEC -> MAX_RATE |
|
|
|
|
|
else -> 1.0 + (MAX_RATE - 1.0) * (backlogSec - CATCHUP_START_SEC) / (CATCHUP_FULL_SEC - CATCHUP_START_SEC) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** 取这条腿的下一帧编码码流;没东西可播返回 null。 */ |
|
|
|
|
|
private fun nextEncodedFrame(l: Leg): ByteArray? = synchronized(l) { |
|
|
|
|
|
if (l.encHandle == 0L) return null |
|
|
|
|
|
val backlog = l.backlogSec() |
|
|
|
|
|
val rate = rateFor(backlog) |
|
|
|
|
|
l.lastRate = rate |
|
|
|
|
|
val out = l.stretcher.nextFrame(rate, l) ?: return null |
|
|
|
|
|
var i = 0 |
|
|
|
|
|
while (i < FRAME_SAMPLES) { |
|
|
|
|
|
val v = out[i].toInt() |
|
|
|
|
|
pcmBytes[i * 2] = (v and 0xFF).toByte() |
|
|
|
|
|
pcmBytes[i * 2 + 1] = ((v shr 8) and 0xFF).toByte() |
|
|
|
|
|
i++ |
|
|
} |
|
|
} |
|
|
if (isA) pushedA += frames.size else pushedB += frames.size |
|
|
val enc = try { |
|
|
|
|
|
G722Codec.encodeSync(l.encHandle, pcmBytes) |
|
|
|
|
|
} catch (e: Exception) { |
|
|
|
|
|
Log.w(TAG, "G.722 编码失败: ${e.message}"); null |
|
|
|
|
|
} |
|
|
|
|
|
l.sentFrames++ |
|
|
|
|
|
if (enc == null || enc.size < FRAME_ENCODED_BYTES) silenceFrame else enc |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
private fun sendOnePacket() { |
|
|
private fun sendOnePacket() { |
|
|
if (!running) return |
|
|
if (!running) return |
|
|
val legB = legBQueue.poll() |
|
|
val b = nextEncodedFrame(legB) |
|
|
val legA = legAQueue.poll() |
|
|
val a = nextEncodedFrame(legA) |
|
|
// 两路都没内容就不发,避免通话里灌满无谓的静音包 |
|
|
// 两路都没内容就不发,避免通话里灌满无谓的静音包 |
|
|
if (legB == null && legA == null) return |
|
|
if (b == null && a == null) return |
|
|
|
|
|
|
|
|
packetBuf[0] = 0xAA.toByte() |
|
|
packetBuf[0] = 0xAA.toByte() |
|
|
packetBuf[1] = 0x56.toByte() |
|
|
packetBuf[1] = 0x56.toByte() |
|
|
packetBuf[2] = PACKET_SIZE.toByte() |
|
|
packetBuf[2] = PACKET_SIZE.toByte() |
|
|
packetBuf[3] = 0x03.toByte() |
|
|
packetBuf[3] = 0x03.toByte() |
|
|
System.arraycopy(legB ?: silenceFrame, 0, packetBuf, 4, FRAME_ENCODED_BYTES) |
|
|
System.arraycopy(b ?: silenceFrame, 0, packetBuf, 4, FRAME_ENCODED_BYTES) |
|
|
System.arraycopy(legA ?: silenceFrame, 0, packetBuf, 44, FRAME_ENCODED_BYTES) |
|
|
System.arraycopy(a ?: silenceFrame, 0, packetBuf, 44, FRAME_ENCODED_BYTES) |
|
|
BluetoothManager.writeBytes(packetBuf) |
|
|
BluetoothManager.writeRealtime(packetBuf) |
|
|
sentPackets++ |
|
|
sentPackets++ |
|
|
if (sentPackets == 1L || sentPackets % 100 == 0L) { |
|
|
if (sentPackets == 1L || sentPackets % 100 == 0L) { |
|
|
Log.d(TAG, "已下发 $sentPackets 包 (队列 A=${legAQueue.size} B=${legBQueue.size})") |
|
|
Log.d(TAG, "已下发 $sentPackets 包 (积压 A=%.1fs B=%.1fs 速率 A=%.2f B=%.2f)".format( |
|
|
|
|
|
synchronized(legA) { legA.backlogSec() }, synchronized(legB) { legB.backlogSec() }, |
|
|
|
|
|
legA.lastRate, legB.lastRate)) |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
private fun releaseEncoders() { |
|
|
/** |
|
|
if (encHandleA != 0L) { |
|
|
* WSOLA 变速不变调(Verhelst & Roelands)。16k 单声道,输出恒为 20ms 一帧。 |
|
|
runCatching { G722Codec.release(encHandleA) } |
|
|
* |
|
|
encHandleA = 0L |
|
|
* 合成侧固定步长 [S](10ms)、帧长 [N](20ms)、Hann 50% 重叠相加;分析侧步长 = S × rate, |
|
|
|
|
|
* 每帧在名义位置 ±[T] 内搜索与「上一帧自然延续」最相似的起点(互相关),保证拼接处波形连续。 |
|
|
|
|
|
* rate=1.0 且偏移=0 时 Hann 50% 重叠相加恒等于原信号,所以不用在直通/变速间切换。 |
|
|
|
|
|
* 附加时延 ≈ N + T 样本(≈26ms)。 |
|
|
|
|
|
* |
|
|
|
|
|
* 输入不够时:源队列已空 → 补零把尾巴冲出来(TTS 末尾本来就是静音);源队列还有 → 先取。 |
|
|
|
|
|
*/ |
|
|
|
|
|
private class Wsola( |
|
|
|
|
|
private val N: Int = 320, |
|
|
|
|
|
private val S: Int = 160, |
|
|
|
|
|
private val T: Int = 96, |
|
|
|
|
|
) { |
|
|
|
|
|
private var inBuf = ShortArray(N * 8) |
|
|
|
|
|
private var inLen = 0 |
|
|
|
|
|
private var anaPos = 0.0 |
|
|
|
|
|
private var prevEnd = -1 |
|
|
|
|
|
private val win = FloatArray(N) { i -> (0.5 - 0.5 * kotlin.math.cos(2.0 * Math.PI * i / N)).toFloat() } |
|
|
|
|
|
private val acc = FloatArray(N) |
|
|
|
|
|
private val out = ShortArray(FRAME_SAMPLES) |
|
|
|
|
|
private var padded = 0 |
|
|
|
|
|
|
|
|
|
|
|
fun reset() { |
|
|
|
|
|
inLen = 0; anaPos = 0.0; prevEnd = -1; padded = 0 |
|
|
|
|
|
java.util.Arrays.fill(acc, 0f) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** 变速器里还没播掉的样本(不含补的零) */ |
|
|
|
|
|
fun unreadSamples(): Long = max(0L, (inLen - padded - anaPos.roundToInt()).toLong()) |
|
|
|
|
|
|
|
|
|
|
|
/** 取 20ms 输出;返回 null 表示这条腿当前没有东西可播。 */ |
|
|
|
|
|
fun nextFrame(rate: Double, l: Leg): ShortArray? { |
|
|
|
|
|
var produced = 0 |
|
|
|
|
|
while (produced < FRAME_SAMPLES) { |
|
|
|
|
|
val nominal = anaPos.roundToInt() |
|
|
|
|
|
val need = nominal + T + N |
|
|
|
|
|
// 先从源队列取 |
|
|
|
|
|
while (inLen < need && l.src.isNotEmpty()) { |
|
|
|
|
|
append(l.src.removeFirst()); l.srcSamples -= N |
|
|
|
|
|
} |
|
|
|
|
|
if (inLen < need) { |
|
|
|
|
|
// 源队列空了:还有实际音频没播完就补零冲出来,否则这条腿此刻没东西 |
|
|
|
|
|
val realLeft = inLen - padded - nominal |
|
|
|
|
|
if (produced == 0 && realLeft <= 0) return null |
|
|
|
|
|
val pad = need - inLen |
|
|
|
|
|
ensure(need); java.util.Arrays.fill(inBuf, inLen, need, 0.toShort()); inLen = need; padded += pad |
|
|
|
|
|
} |
|
|
|
|
|
val start = pickStart(nominal) |
|
|
|
|
|
var i = 0 |
|
|
|
|
|
while (i < N) { acc[i] += win[i] * inBuf[start + i]; i++ } |
|
|
|
|
|
// 输出前 S 个 |
|
|
|
|
|
var j = 0 |
|
|
|
|
|
while (j < S) { |
|
|
|
|
|
val v = acc[j].roundToInt().coerceIn(-32768, 32767) |
|
|
|
|
|
out[produced + j] = v.toShort(); j++ |
|
|
|
|
|
} |
|
|
|
|
|
System.arraycopy(acc, S, acc, 0, N - S) |
|
|
|
|
|
java.util.Arrays.fill(acc, N - S, N, 0f) |
|
|
|
|
|
produced += S |
|
|
|
|
|
prevEnd = start + S |
|
|
|
|
|
anaPos += S * rate |
|
|
|
|
|
compact() |
|
|
|
|
|
} |
|
|
|
|
|
return out |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private fun pickStart(nominal: Int): Int { |
|
|
|
|
|
if (prevEnd < 0 || prevEnd + S > inLen) return nominal.coerceIn(0, inLen - N) |
|
|
|
|
|
var best = 0 |
|
|
|
|
|
var bestScore = Double.NEGATIVE_INFINITY |
|
|
|
|
|
val lo = max(-T, -nominal) |
|
|
|
|
|
val hi = min(T, inLen - N - nominal) |
|
|
|
|
|
var d = lo |
|
|
|
|
|
while (d <= hi) { |
|
|
|
|
|
val c = nominal + d |
|
|
|
|
|
var xy = 0.0; var yy = 1e-6 |
|
|
|
|
|
var k = 0 |
|
|
|
|
|
while (k < S) { |
|
|
|
|
|
val x = inBuf[prevEnd + k].toDouble(); val y = inBuf[c + k].toDouble() |
|
|
|
|
|
xy += x * y; yy += y * y; k++ |
|
|
|
|
|
} |
|
|
|
|
|
val score = xy / sqrt(yy) |
|
|
|
|
|
if (score > bestScore) { bestScore = score; best = d } |
|
|
|
|
|
d++ |
|
|
|
|
|
} |
|
|
|
|
|
return nominal + best |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private fun append(block: ShortArray) { |
|
|
|
|
|
// 之前补的零已经被新到的音频"夹"在中间,当成内容算(最多多算 26ms) |
|
|
|
|
|
padded = 0 |
|
|
|
|
|
ensure(inLen + block.size) |
|
|
|
|
|
System.arraycopy(block, 0, inBuf, inLen, block.size) |
|
|
|
|
|
inLen += block.size |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private fun ensure(cap: Int) { |
|
|
|
|
|
if (inBuf.size < cap) inBuf = inBuf.copyOf(max(cap, inBuf.size * 2)) |
|
|
} |
|
|
} |
|
|
if (encHandleB != 0L) { |
|
|
|
|
|
runCatching { G722Codec.release(encHandleB) } |
|
|
/** 丢掉已经用不到的输入前缀,避免无限增长。 */ |
|
|
encHandleB = 0L |
|
|
private fun compact() { |
|
|
|
|
|
val keepFrom = min(anaPos.roundToInt(), prevEnd) - T - N |
|
|
|
|
|
if (keepFrom > N * 4) { |
|
|
|
|
|
System.arraycopy(inBuf, keepFrom, inBuf, 0, inLen - keepFrom) |
|
|
|
|
|
inLen -= keepFrom |
|
|
|
|
|
anaPos -= keepFrom |
|
|
|
|
|
prevEnd -= keepFrom |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|