diff --git a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AliyunBailianE2EHelper.kt b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AliyunBailianE2EHelper.kt index 323f5aa0..05e73b0e 100644 --- a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AliyunBailianE2EHelper.kt +++ b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AliyunBailianE2EHelper.kt @@ -475,6 +475,10 @@ class AliyunBailianE2EHelper( /** * 将 24kHz PCM 音频重采样为 16kHz */ + /** 跨块连续 + 抗混叠的 3:2 重采样,替代下面逐块线性插值的老实现(保留作对照) */ + private val resampler = Resampler24kTo16k() + + @Suppress("unused") private fun resample24kTo16k(input: ByteArray): ByteArray { // 16 bit PCM val inputShorts = ShortArray(input.size / 2) @@ -643,7 +647,7 @@ class AliyunBailianE2EHelper( val audioBytes = android.util.Base64.decode(base64Audio, android.util.Base64.DEFAULT) if (audioBytes != null && audioBytes.isNotEmpty()) { // 默认输出 24k,需重采样到 16k - val resampled = resample24kTo16k(audioBytes) + val resampled = resampler.process(audioBytes) try { fullAudioBuffer.write(resampled) diff --git a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BesCallPcmBridge.kt b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BesCallPcmBridge.kt index 0e111fed..1a8136b7 100644 --- a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BesCallPcmBridge.kt +++ b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BesCallPcmBridge.kt @@ -46,6 +46,9 @@ class BesCallPcmBridge( // 以下只在 ingress 线程上读写 private var ready = false private val gate = PeerSpeechGate() + + /** ⚠️ 对照实验开关(2026-09-23):true 时 B 路不过门限、逐帧直推,即 deepvoice 口径。测完改回 false。 */ + private val deepvoiceParity = false private val holdA = ArrayDeque() private val holdB = ArrayDeque() private var framesA = 0L @@ -156,6 +159,7 @@ class BesCallPcmBridge( return } framesB++ + if (deepvoiceParity) { deliverB(pcm); return } val wasOpen = gate.isOpen val frames = gate.accept(pcm) if (frames.isEmpty()) gatedB++ diff --git a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/Resampler24kTo16k.kt b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/Resampler24kTo16k.kt new file mode 100644 index 00000000..efc68bbd --- /dev/null +++ b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/Resampler24kTo16k.kt @@ -0,0 +1,73 @@ +package com.yunqiinnovation.azure_speech + +/** + * 24 kHz → 16 kHz 重采样(比 3:2),**跨块连续、带抗混叠低通**。 + * + * 2026-09-23 之前用的是逐块独立的线性插值:每块相位从 0 重算、没有滤波, + * 8 kHz 以上的内容折回带内,块与块之间还有微小跳变——通话翻译耳机里"偶尔杂音"的嫌疑之一。 + * + * 实现:零插值到 48 kHz → 64 阶窗函数 sinc 低通(截止 7.4 kHz,Hamming)→ 每 3 个取 1 个。 + * 只在 (k-j) 为偶数的抽头上做乘加(零插值的奇数位恒为 0),每个输出样本 32 次乘加。 + * 系数已含 ×2 的零插值增益。Python 复刻校验:1k/3k/6k 正弦幅度 0.98/0.98/0.93,频率不变。 + * + * 一个实例只服务一路(有状态:保留最后 L/2 个输入样本与 48k 网格上的相位)。 + */ +class Resampler24kTo16k { + private val hist = ShortArray(HALF) // 上一块的尾巴(最近 HALF 个输入样本,旧→新) + private var histLen = 0 + private var k = 0L // 下一个输出对应的 48k 网格位置(全局) + private var consumed = 0L // 已进入 hist 之前的输入总样本数(全局索引基准) + + fun reset() { histLen = 0; k = 0L; consumed = 0L } + + /** 输入 16bit 小端 PCM @24k,返回 16bit 小端 PCM @16k。 */ + fun process(input: ByteArray): ByteArray { + val n = input.size / 2 + if (n == 0) return ByteArray(0) + // 拼成 [hist | new],下标 i 对应全局输入索引 base + i + val buf = ShortArray(histLen + n) + System.arraycopy(hist, 0, buf, 0, histLen) + var i = 0 + while (i < n) { + buf[histLen + i] = ((input[i * 2].toInt() and 0xFF) or (input[i * 2 + 1].toInt() shl 8)).toShort() + i++ + } + val base = consumed - histLen + val lastGlobal = consumed + n - 1 // 当前可用的最大全局输入索引 + val out = java.io.ByteArrayOutputStream(n * 2 * 2 / 3 + 4) + // 输出 m 对应 48k 网格 k=3m;需要输入索引 (k-j)/2,j∈[0,L),最大为 k/2(要求 ≤ lastGlobal) + while (k / 2 <= lastGlobal) { + var acc = 0.0 + var j = if (k % 2 == 0L) 0 else 1 // 让 (k-j) 为偶数 + while (j < TAPS) { + val idx = (k - j) / 2 - base + if (idx >= 0 && idx < buf.size) acc += H[j] * buf[idx.toInt()] + j += 2 + } + val v = acc.toInt().coerceIn(-32768, 32767) + out.write(v and 0xFF); out.write((v shr 8) and 0xFF) + k += 3 + } + // 留下最后 HALF 个输入样本供下一块的抽头引用 + val keep = minOf(HALF, buf.size) + System.arraycopy(buf, buf.size - keep, hist, 0, keep) + histLen = keep + consumed += n + return out.toByteArray() + } + + companion object { + private const val TAPS = 64 + private const val HALF = TAPS / 2 + 2 + private val H = doubleArrayOf( + -0.001272, -0.001643, -0.000571, 0.001391, 0.002686, 0.001673, -0.001674, -0.004812, + -0.004142, 0.001400, 0.007913, 0.008684, 0.000477, -0.011471, -0.015874, -0.005303, + 0.014505, 0.026149, 0.014823, -0.015487, -0.040008, -0.031737, 0.011975, 0.058826, + 0.062003, 0.001164, -0.088277, -0.126869, -0.043596, 0.165759, 0.419929, 0.593380, + 0.593380, 0.419929, 0.165759, -0.043596, -0.126869, -0.088277, 0.001164, 0.062003, + 0.058826, 0.011975, -0.031737, -0.040008, -0.015487, 0.014823, 0.026149, 0.014505, + -0.005303, -0.015874, -0.011471, 0.000477, 0.008684, 0.007913, 0.001400, -0.004142, + -0.004812, -0.001674, 0.001673, 0.002686, 0.001391, -0.000571, -0.001643, -0.001272, + ) + } +} diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AliyunBailianE2EHelper.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AliyunBailianE2EHelper.swift index ba77383c..695b78e4 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AliyunBailianE2EHelper.swift +++ b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AliyunBailianE2EHelper.swift @@ -389,6 +389,9 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate { /** * 将 24kHz PCM 重采样为 16kHz */ + /// 跨块连续 + 抗混叠的 3:2 重采样,替代下面逐块线性插值的老实现(保留作对照) + private let resampler = Resampler24kTo16k() + private func resample24kTo16k(_ input: Data) -> Data { if input.count < 4 { return input } @@ -545,7 +548,7 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate { case "response.audio.delta": if let base64Audio = obj["delta"] as? String, !base64Audio.isEmpty { if let audioBytes = Data(base64Encoded: base64Audio) { - let resampled = resample24kTo16k(audioBytes) + let resampled = resampler.process(audioBytes) if !resampled.isEmpty { fullAudioBuffer.append(resampled) // 极高频(音频帧),取消 info 日志 diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/Resampler24kTo16k.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/Resampler24kTo16k.swift new file mode 100644 index 00000000..e00a88c8 --- /dev/null +++ b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/Resampler24kTo16k.swift @@ -0,0 +1,67 @@ +import Foundation + +/// 24 kHz → 16 kHz 重采样(比 3:2),**跨块连续、带抗混叠低通**。Android `Resampler24kTo16k.kt` 的对应实现。 +/// +/// 2026-09-23 之前用的是逐块独立的线性插值:每块相位从 0 重算、没有滤波, +/// 8 kHz 以上的内容折回带内,块与块之间还有微小跳变——通话翻译耳机里"偶尔杂音"的嫌疑之一。 +/// +/// 实现:零插值到 48 kHz → 64 阶窗函数 sinc 低通(截止 7.4 kHz,Hamming)→ 每 3 个取 1 个。 +/// 只在 (k-j) 为偶数的抽头上做乘加,每个输出样本 32 次乘加。系数已含 ×2 的零插值增益。 +/// 一个实例只服务一路(有状态)。 +final class Resampler24kTo16k { + private static let taps = 64 + private static let half = taps / 2 + 2 + private static let h: [Double] = [ + -0.001272, -0.001643, -0.000571, 0.001391, 0.002686, 0.001673, -0.001674, -0.004812, + -0.004142, 0.001400, 0.007913, 0.008684, 0.000477, -0.011471, -0.015874, -0.005303, + 0.014505, 0.026149, 0.014823, -0.015487, -0.040008, -0.031737, 0.011975, 0.058826, + 0.062003, 0.001164, -0.088277, -0.126869, -0.043596, 0.165759, 0.419929, 0.593380, + 0.593380, 0.419929, 0.165759, -0.043596, -0.126869, -0.088277, 0.001164, 0.062003, + 0.058826, 0.011975, -0.031737, -0.040008, -0.015487, 0.014823, 0.026149, 0.014505, + -0.005303, -0.015874, -0.011471, 0.000477, 0.008684, 0.007913, 0.001400, -0.004142, + -0.004812, -0.001674, 0.001673, 0.002686, 0.001391, -0.000571, -0.001643, -0.001272, + ] + + private var hist = [Int16](repeating: 0, count: Resampler24kTo16k.half) + private var histLen = 0 + private var k: Int64 = 0 // 下一个输出对应的 48k 网格位置(全局) + private var consumed: Int64 = 0 // 已进入 hist 之前的输入总样本数(全局索引基准) + + func reset() { histLen = 0; k = 0; consumed = 0 } + + /// 输入 16bit 小端 PCM @24k,返回 16bit 小端 PCM @16k。 + func process(_ input: Data) -> Data { + let n = input.count / 2 + if n == 0 { return Data() } + var buf = [Int16](repeating: 0, count: histLen + n) + for i in 0..= 0 && idx < Int64(buf.count) { acc += h[j] * Double(buf[Int(idx)]) } + j += 2 + } + out.append(Int16(clamping: Int(acc))) + k += 3 + } + let keep = min(Resampler24kTo16k.half, buf.count) + for i in 0.. 6) { - val interval = when (raw[6]) { - 0x01.toByte() -> 16L - 0x02.toByte() -> 20L - 0x03.toByte() -> 25L - else -> 20L - } - CallTranslationDownlink.setIntervalMs(interval) + // 帧形状 `bb d6 07 00 02 01 `,只有 data[6] 一个字节有信息(2026-09-23 抓帧确认) + // 原值交给发送器:01 立刻补一包 + 1.25x 配额,00 补两包,03 扣一包(见 onHeadsetPace) + CallTranslationDownlink.onHeadsetPace(raw[6].toInt() and 0xFF) } } // 没人订阅时不必往主线程 post——通话中每 20ms 就有两帧,空转没意义 diff --git a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/CallTranslationDownlink.kt b/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/CallTranslationDownlink.kt index 7216e4a5..522c50c0 100644 --- a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/CallTranslationDownlink.kt +++ b/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/CallTranslationDownlink.kt @@ -56,7 +56,15 @@ object CallTranslationDownlink { /** 一帧编码后码流长度 */ private const val FRAME_ENCODED_BYTES = 40 - private const val PACKET_SIZE = 84 + /** + * 每包装几帧(每帧 20 ms,两路各 40 B)。2026-09-23 从 1 改 3: + * 恒玄 SPP 走 RFCOMM 信用流控,耳机一次只放 1~3 个信用、每帧吃一个;信用一停顿, + * 协议栈把攒下的 6~11 包合成一帧突发,中间 100 多 ms 空档,而耳机播放缓冲只有一两包深, + * 扛不住就是句中杂音。3 帧/包让同样的信用窗口里耳机手上的音频翻三倍(deepvoice iOS 同做法)。 + * 布局与 deepvoice 一致:每帧 `[B 40B][A 40B]` 交替,头 `AA 56 03`。 + */ + private const val FRAMES_PER_PACKET = 1 // ⚠️ 2026-09-23 真机试过 3:耳机完全不认(全是杂音),SPP 上只能 1 帧/84B + private const val PACKET_SIZE = 4 + FRAMES_PER_PACKET * 2 * FRAME_ENCODED_BYTES // 3 帧 = 244 // ---- 追赶策略参数 ---- private const val CATCHUP_START_SEC = 1.0 @@ -65,7 +73,39 @@ object CallTranslationDownlink { private const val SILENCE_TRIM_START_SEC = 0.5 private const val SILENCE_KEEP_MS = 150 private const val SILENCE_RMS = 300.0 - private const val HARD_CAP_SEC = 0.0 + private const val HARD_CAP_SEC = 30.0 + + // ---- 2026-09-23 杂音修正(真机:定时器修成固定节拍后仍有零星杂音)---- + /** 一条腿从空到开始播,至少先攒这么多毫秒:TTS 是按块流来的,起播太早会在句中断粮 */ + private const val PRIME_MS = 120 + /** 这么久没新数据才认为这句结束,允许 WSOLA 补零把尾巴冲出来;之前是一断粮就补零, + * 新块到了又把零"夹"进内容里,等于在语音里嵌一个 20 多 ms 的空洞 */ + private const val FLUSH_IDLE_MS = 150 + /** 速率每帧最多变这么多,避免 1.0→1.3 一步跳过去 */ + private const val RATE_STEP = 0.03 + /** 裁静音回滞:低于 SILENCE_RMS 进入裁剪,高于它才退出;否则门限附近一块留一块丢,接缝密集 */ + private const val SILENCE_RMS_EXIT = 600.0 + /** 裁剪接缝处的淡入长度(样本)≈3ms */ + private const val RAMP_SAMPLES = 48 + + // ---- 2026-09-23 耳机缓冲见底修正 ---- + /** 定时器永远这个节拍,16/25 ms 的要求改成配额(每拍 20/interval 包),不再取消重排定时器 */ + private const val TICK_MS = 20L * FRAMES_PER_PACKET // 一拍一包 + /** 空闲后恢复发包:先一次性多塞这么多包给耳机垫底(耳机每次从空缓冲起播都回 data6=00)*/ + private const val PREROLL_PACKETS = 4 // 静音垫底 4 包 = 80ms + /** 两路都空后继续用编码静音续多少拍再真正停发:短句间隔不把耳机缓冲榨干; + * 不无限续是怕耳机把持续下行当成"译文在播"而压低通话音(未验证,从严)*/ + private const val TAIL_TICKS = 25 // ≈500ms + /** 一拍里最多发几包(配额 + 垫底叠加时的上限)*/ + private const val MAX_PACKETS_PER_TICK = 6 + + /** + * ⚠️ 对照实验开关(2026-09-23,只为打一版"deepvoice 口径"的测试包):true 时 + * 不裁静音、恒 1.0x、队列 4096 帧丢最旧、不打积压日志——即 8-29 最初版 / deepvoice 的行为。 + * 测完改回 false。别带着 true 提交。 + */ + private const val DEEPVOICE_PARITY = false + private const val PARITY_QUEUE_CAP_FRAMES = 4096 /** 一条腿:源 PCM 队列 + 变速器 + 编码器 + 统计。所有字段只在 synchronized(this) 里动。 */ private class Leg(val name: String) { @@ -74,6 +114,13 @@ object CallTranslationDownlink { var partial = ShortArray(FRAME_SAMPLES) // 不满 20ms 的尾巴 var partialLen = 0 var silentRun = 0 // 队尾连续静音样本数(裁静音用) + var trimming = false // 裁静音回滞状态 + var rampPending = false // 刚裁掉一段,下一块要淡入 + var lastPushNs = 0L // 最近一次收到源 PCM 的时刻 + var rateCur = 1.0 // 平滑后的当前速率 + var starved = 0L // 句中断粮次数(正在播却不够一帧、又没到收尾) + var playing = false // 这一句已经起播(起播缓冲只在句首用一次) + var lastHoleLogNs = 0L val stretcher = Wsola() var encHandle = 0L @@ -91,6 +138,7 @@ object CallTranslationDownlink { fun reset() { src.clear(); srcSamples = 0; partialLen = 0; silentRun = 0 + trimming = false; rampPending = false; lastPushNs = 0L; rateCur = 1.0; starved = 0; playing = false stretcher.reset() pushedFrames = 0; sentFrames = 0; trimmedSamples = 0; droppedSamples = 0 maxBacklogSamples = 0; lastRate = 1.0 @@ -113,6 +161,11 @@ object CallTranslationDownlink { private var sentPackets = 0L + /** 发包配额(包数,可带小数),每拍加 TICK/interval */ + private var budget = 0.0 + private var wasIdle = true + private var tailLeft = 0 + /** 静音帧(deepvoice 原样照搬,耳机侧认这个码流) */ private val silenceFrame = byteArrayOf( 0x0A.toByte(), 0x8E.toByte(), 0x30.toByte(), 0xBD.toByte(), 0x3F.toByte(), @@ -134,8 +187,11 @@ object CallTranslationDownlink { if (leg.encHandle == 0L) leg.encHandle = G722Codec.init(SAMPLE_RATE) } sentPackets = 0L + // 每场从 20 ms 起步:上一场结束时耳机可能停在 16 ms,带着它起步会先超发一段 + intervalMs = 20L + budget = 0.0; wasIdle = true; tailLeft = 0 scheduleSendTask() - Log.d(TAG, "下行发送启动, interval=${intervalMs}ms 追赶策略: >${CATCHUP_START_SEC}s 起变速, ${CATCHUP_FULL_SEC}s 达 ${MAX_RATE}x, 裁静音 >${SILENCE_TRIM_START_SEC}s") + if (!DEEPVOICE_PARITY) Log.d(TAG, "下行发送启动, interval=${intervalMs}ms 追赶策略: >${CATCHUP_START_SEC}s 起变速, ${CATCHUP_FULL_SEC}s 达 ${MAX_RATE}x, 裁静音 >${SILENCE_TRIM_START_SEC}s") } @Synchronized @@ -154,14 +210,16 @@ object CallTranslationDownlink { leg.encHandle = 0L } } - Log.d(TAG, "下行发送停止, 共发出 $sentPackets 包 (pushA=${legA.pushedFrames} pushB=${legB.pushedFrames})") - Log.w(TAG, "下行积压总账 A{$a} B{$b}") + if (!DEEPVOICE_PARITY) { + Log.d(TAG, "下行发送停止, 共发出 $sentPackets 包 (pushA=${legA.pushedFrames} pushB=${legB.pushedFrames})") + 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( + "源=%.1fs 播出=%.1fs 最大积压=%.1fs 裁静音=%.1fs 变速追回=%.1fs 兜底丢=%.1fs 断粮=${leg.starved}次".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()), @@ -188,20 +246,42 @@ object CallTranslationDownlink { * 耳机要求调整下行节奏(`0xD6`/`0xE9` 的 data[6])。 * 只在运行中才重排任务,停止状态下仅记住新值。 */ + /** + * 耳机调速帧的 data[6] 原值:0 = 缓冲空/起播,1 = 要快,2 = 正常,3 = 要慢。 + * + * 2026-09-23 真机定性:`01` 一次只持续 20 ms 就回 `02`,一场 150 次——它不是在要一个新速率, + * 而是"缓冲比目标低一包了,现在补一包"。所以除了按 16/20/25 ms 调配额(应对持续的 01), + * 收到 01 立刻加一包、00 加两包、03 扣一包,让它一吱就有反应。 + */ + @Synchronized + fun onHeadsetPace(code: Int) { + when (code) { + 0 -> { budget += 2.0; setIntervalMs(20L) } + 1 -> { budget += 1.0; setIntervalMs(16L) } + 3 -> { budget -= 1.0; setIntervalMs(25L) } + else -> setIntervalMs(20L) + } + } + @Synchronized fun setIntervalMs(ms: Long) { if (intervalMs == ms) return intervalMs = ms Log.d(TAG, "下行节奏调整为 ${ms}ms") - if (running) scheduleSendTask() + // 不再取消/重排定时器:重排会让下一包提前或推后一拍,一场 150 次就是 150 次抖动。 + // 节奏差异由 onTick 里的配额体现。 } private fun scheduleSendTask() { sendTask?.cancel(false) - sendTask = scheduler.scheduleWithFixedDelay( - { runCatching { sendOnePacket() }.onFailure { Log.w(TAG, "发包异常: ${it.message}") } }, + // ⚠️ 必须是固定节拍(AtFixedRate),不能是固定间隔(WithFixedDelay):后者从上一次任务 + // **结束**起算,每次 WSOLA + G.722 + 写 socket 的 1~3 ms 全叠进周期,"20 ms"实测 21~23 ms、 + // 只有 0.9 倍速。耳机缓冲一直往下掉,只能 60% 的时间要 16 ms 来补,两边每 80 ms 拉锯一次, + // 缓冲贴着下水位走、偶尔见底就是杂音(2026-09-23 真机,协议栈写出间隔分布定性)。 + sendTask = scheduler.scheduleAtFixedRate( + { runCatching { onTick() }.onFailure { Log.w(TAG, "发包异常: ${it.message}") } }, 0, - intervalMs, + TICK_MS, TimeUnit.MILLISECONDS ) } @@ -233,8 +313,15 @@ object CallTranslationDownlink { l.partialLen = 0 l.pushedFrames++ if (!trimSilence(l, block)) { + if (l.rampPending) { rampIn(block); l.rampPending = false } l.src.addLast(block) l.srcSamples += FRAME_SAMPLES + l.lastPushNs = System.nanoTime() + if (DEEPVOICE_PARITY && l.src.size > PARITY_QUEUE_CAP_FRAMES) { + // deepvoice / 最初版口径:满 4096 帧丢最旧,无声无息 + l.srcSamples -= l.src.removeFirst().size + l.droppedSamples += FRAME_SAMPLES + } } } } @@ -256,24 +343,38 @@ object CallTranslationDownlink { /** 积压时把长静音裁到只剩 SILENCE_KEEP_MS;返回 true 表示这一块被裁掉了。 */ private fun trimSilence(l: Leg, block: ShortArray): Boolean { + if (DEEPVOICE_PARITY) return false var acc = 0.0 for (s in block) acc += s.toDouble() * s.toDouble() val rms = sqrt(acc / block.size) - if (rms >= SILENCE_RMS) { + // 回滞:进入裁剪要低于 SILENCE_RMS,退出要高于 SILENCE_RMS_EXIT + val silent = if (l.trimming) rms < SILENCE_RMS_EXIT else rms < SILENCE_RMS + if (!silent) { l.silentRun = 0 + l.trimming = false 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 + l.trimming = true + l.rampPending = true return true } return false } + /** 裁剪接缝处的短淡入,压掉接缝的阶跃 */ + private fun rampIn(block: ShortArray) { + val n = min(RAMP_SAMPLES, block.size) + var i = 0 + while (i < n) { block[i] = (block[i] * i / n).toShort(); i++ } + } + /** 按积压深度算这一帧的播放速率。 */ private fun rateFor(backlogSec: Double): Double = when { + DEEPVOICE_PARITY -> 1.0 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) @@ -283,9 +384,37 @@ object CallTranslationDownlink { private fun nextEncodedFrame(l: Leg): ByteArray? = synchronized(l) { if (l.encHandle == 0L) return null val backlog = l.backlogSec() - val rate = rateFor(backlog) + val target = rateFor(backlog) + val rate = l.rateCur + (target - l.rateCur).coerceIn(-RATE_STEP, RATE_STEP) + l.rateCur = rate l.lastRate = rate - val out = l.stretcher.nextFrame(rate, l) ?: return null + // 这句是否已经结束:够久没新数据。结束了才允许补零收尾;没结束就宁可空一拍也不把零夹进内容 + val idleMs = if (l.lastPushNs == 0L) Long.MAX_VALUE else (System.nanoTime() - l.lastPushNs) / 1_000_000 + val flushAllowed = idleMs >= FLUSH_IDLE_MS + val unreadInStretcher = l.stretcher.unreadSamples() + val available = unreadInStretcher + l.srcSamples + if (!flushAllowed) { + // 起播门槛:空腿要先攒够 PRIME_MS;播着的腿要够一整帧(含 WSOLA 前视)才动 + val needForFrame = (FRAME_SAMPLES * rate).toLong() + l.stretcher.lookahead() + // 起播缓冲只在句首(还没 playing)用一次。句中队列空了不能再要求攒 120ms—— + // 变速追赶时消耗比云端到达快,队列中途归零很常见,再攒一次就是在句中硬插 120ms 静音。 + val priming = !l.playing && unreadInStretcher == 0L && l.srcSamples < PRIME_MS * SAMPLE_RATE / 1000 + if (priming) return null + if (available < needForFrame) { + if (l.playing) { + l.starved++ + val now = System.nanoTime() + if (now - l.lastHoleLogNs > 1_000_000_000L) { + l.lastHoleLogNs = now + Log.d(TAG, "${l.name} 路句中空洞: 可用 ${available * 1000 / SAMPLE_RATE}ms < 需 ${needForFrame * 1000 / SAMPLE_RATE}ms,距上次到数据 ${idleMs}ms") + } + } + return null + } + } + val out = l.stretcher.nextFrame(rate, l, allowPad = flushAllowed) + if (out == null) { l.playing = false; return null } // 这句播完(收尾补零也冲完了) + l.playing = true var i = 0 while (i < FRAME_SAMPLES) { val v = out[i].toInt() @@ -302,23 +431,91 @@ object CallTranslationDownlink { if (enc == null || enc.size < FRAME_ENCODED_BYTES) silenceFrame else enc } - private fun sendOnePacket() { + private val zeroPcm = ByteArray(FRAME_PCM_BYTES) + + /** 这一路此刻没内容:用它的编码器编零,码流与前后帧连续;没有编码器才退回静态静音帧 */ + private fun idleFrame(l: Leg): ByteArray = synchronized(l) { + if (l.encHandle == 0L) return silenceFrame + val enc = try { G722Codec.encodeSync(l.encHandle, zeroPcm) } catch (_: Exception) { null } + if (enc == null || enc.size < FRAME_ENCODED_BYTES) silenceFrame else enc + } + + /** 每 TICK_MS 一拍:按耳机要求的节奏累计配额,配额够一包发一包 */ + private fun onTick() { if (!running) return - val b = nextEncodedFrame(legB) - val a = nextEncodedFrame(legA) - // 两路都没内容就不发,避免通话里灌满无谓的静音包 - if (b == null && a == null) return + budget += TICK_MS.toDouble() / intervalMs + var n = 0 + while (budget >= 1.0 && n < MAX_PACKETS_PER_TICK) { + if (!emitPacket()) { budget = 0.0; break } + budget -= 1.0 + n++ + } + } + + /** 两路都是编码静音的一包(垫底用) */ + private fun writeIdlePacket() { + packetBuf[0] = 0xAA.toByte() + packetBuf[1] = 0x56.toByte() + packetBuf[2] = PACKET_SIZE.toByte() + packetBuf[3] = 0x03.toByte() + for (i in 0 until FRAMES_PER_PACKET) { + val base = 4 + i * 2 * FRAME_ENCODED_BYTES + System.arraycopy(idleFrame(legB), 0, packetBuf, base, FRAME_ENCODED_BYTES) + System.arraycopy(idleFrame(legA), 0, packetBuf, base + FRAME_ENCODED_BYTES, FRAME_ENCODED_BYTES) + } + BluetoothManager.writeRealtime(packetBuf) + sentPackets++ + } + + /** 发一包;两路都空且尾巴续完时返回 false(真正空闲,不发) */ + private val frameB = arrayOfNulls(FRAMES_PER_PACKET) + private val frameA = arrayOfNulls(FRAMES_PER_PACKET) + + private fun emitPacket(): Boolean { + var any = false + for (i in 0 until FRAMES_PER_PACKET) { + // 编码结果是共享缓冲,必须立刻拷走 + frameB[i] = nextEncodedFrame(legB)?.copyOf() + frameA[i] = nextEncodedFrame(legA)?.copyOf() + if (frameB[i] != null || frameA[i] != null) any = true + } + if (!any) { + // 两路都空:先用编码静音续 TAIL_TICKS 拍,让短句间隔不把耳机缓冲榨干;续完才停 + if (tailLeft <= 0) { wasIdle = true; return false } + tailLeft-- + } else { + tailLeft = TAIL_TICKS + if (wasIdle) { + // 从空闲恢复:耳机是从空缓冲起播的(data6=00),起播那一下最容易破。 + // 先一口气塞 PREROLL_PACKETS 包**编码静音**垫底,让它在静音上起播、攒出 80ms 缓冲, + // 真音频排在后面。之前是把前几包真音频当垫底,第一包仍从空缓冲起播——杂音就落在句首。 + wasIdle = false + repeat(PREROLL_PACKETS) { writeIdlePacket() } + } + } packetBuf[0] = 0xAA.toByte() packetBuf[1] = 0x56.toByte() packetBuf[2] = PACKET_SIZE.toByte() packetBuf[3] = 0x03.toByte() - System.arraycopy(b ?: silenceFrame, 0, packetBuf, 4, FRAME_ENCODED_BYTES) - System.arraycopy(a ?: silenceFrame, 0, packetBuf, 44, FRAME_ENCODED_BYTES) + // 空闲那一路用它自己的编码器编一帧零:G.722 是自适应差分编码,塞一段固定码流会让 + // 耳机解码器的预测状态跳一下,每次切换都是一个小瞬态 + for (i in 0 until FRAMES_PER_PACKET) { + val base = 4 + i * 2 * FRAME_ENCODED_BYTES + System.arraycopy(frameB[i] ?: idleFrame(legB), 0, packetBuf, base, FRAME_ENCODED_BYTES) + System.arraycopy(frameA[i] ?: idleFrame(legA), 0, packetBuf, base + FRAME_ENCODED_BYTES, FRAME_ENCODED_BYTES) + } BluetoothManager.writeRealtime(packetBuf) sentPackets++ - if (sentPackets == 1L || sentPackets % 100 == 0L) { - Log.d(TAG, "已下发 $sentPackets 包 (积压 A=%.1fs B=%.1fs 速率 A=%.2f B=%.2f)".format( + if (!DEEPVOICE_PARITY && (sentPackets == 1L || sentPackets % 100 == 0L)) { + logProgress() + } + return true + } + + private fun logProgress() { + run { + Log.d(TAG, "已下发 $sentPackets 包 (积压 A=%.1fs B=%.1fs 速率 A=%.2f B=%.2f 断粮 A=${legA.starved} B=${legB.starved} 节奏=${intervalMs}ms)".format( synchronized(legA) { legA.backlogSec() }, synchronized(legB) { legB.backlogSec() }, legA.lastRate, legB.lastRate)) } @@ -337,8 +534,10 @@ object CallTranslationDownlink { private class Wsola( private val N: Int = 320, private val S: Int = 160, - private val T: Int = 96, + private val T: Int = 160, // 搜索窗 ±10ms(原 ±6ms):高速段对齐更准、失真更小 ) { + /** 产出一帧输出至少要在输入里看到这么多前视样本 */ + fun lookahead(): Long = (T + N).toLong() private var inBuf = ShortArray(N * 8) private var inLen = 0 private var anaPos = 0.0 @@ -357,7 +556,7 @@ object CallTranslationDownlink { fun unreadSamples(): Long = max(0L, (inLen - padded - anaPos.roundToInt()).toLong()) /** 取 20ms 输出;返回 null 表示这条腿当前没有东西可播。 */ - fun nextFrame(rate: Double, l: Leg): ShortArray? { + fun nextFrame(rate: Double, l: Leg, allowPad: Boolean = true): ShortArray? { var produced = 0 while (produced < FRAME_SAMPLES) { val nominal = anaPos.roundToInt() @@ -370,6 +569,8 @@ object CallTranslationDownlink { // 源队列空了:还有实际音频没播完就补零冲出来,否则这条腿此刻没东西 val realLeft = inLen - padded - nominal if (produced == 0 && realLeft <= 0) return null + // 句子没结束就不补零(调用方已按可用量把关,走到这里只会是帧中途的极少数情况) + if (!allowPad && produced == 0) return null val pad = need - inLen ensure(need); java.util.Arrays.fill(inBuf, inLen, need, 0.toShort()); inLen = need; padded += pad } diff --git a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift b/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift index 7ab3b39f..c3aa0e2e 100644 --- a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift +++ b/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift @@ -156,14 +156,8 @@ public class BluetoothManagerPlugin: NSObject, FlutterPlugin { BluetoothManager.translationCallback = { [weak instance] event in if event["type"] as? String == "speedAdjustment", let raw = event["data"] as? Data, raw.count > 6 { - let interval: Int - switch raw[6] { - case 0x01: interval = 16 - case 0x02: interval = 20 - case 0x03: interval = 25 - default: interval = 20 - } - CallTranslationDownlink.shared.setIntervalMs(interval) + // 原值交给发送器:01 立刻补一包 + 1.25x 配额,00 补两包,03 扣一包(见 onHeadsetPace) + CallTranslationDownlink.shared.onHeadsetPace(Int(raw[6])) } guard let plugin = instance, plugin.audioEventSink != nil else { return } // 音频负载要转成 FlutterStandardTypedData 才能过通道 diff --git a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/CallTranslationDownlink.swift b/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/CallTranslationDownlink.swift index a54aa3fd..393382ed 100644 --- a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/CallTranslationDownlink.swift +++ b/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/CallTranslationDownlink.swift @@ -10,23 +10,27 @@ import os.log /// 包格式:`AA 56 54 03 [40B legB][40B legA]` 共 84 字节,mode=0x03 双声道 /// - 槽 0(offset 4) = legB:对端话的译文,**己方听** /// - 槽 1(offset 44)= legA:己方话的译文,**对端听** -/// 空槽填静音帧,两槽都空则整包不发。 -/// -/// 一帧固定 640 字节 PCM(320 样本 = 20ms)→ 40 字节码流, -/// 所以默认 20ms 的发送间隔正好是实时速率;耳机用 `0xD6/0xE9` -/// 要求改节奏时(16/20/25ms)调 [setIntervalMs] 跟上。 +/// ⚠️ 只能 1 帧/84B:2026-09-23 Android 真机试过 3 帧/244B(deepvoice iOS 的写法),耳机完全不认。 /// /// ## 积压追赶(2026-09-22,与 Android 同一套参数) /// -/// 译文音频是模型**突发**产出的(每 3~6 秒一段文本,音频一次性到),播放却只能 1 倍速; -/// 说话人不停顿时译文时长 ≈ 原话时长,队列只涨不缩——Android 真机压测 A 路积压到 6.2 秒, -/// 用户听到的就是「越说越慢」。处理分两层,都不丢内容: -/// 1. **裁静音**:积压超过 [silenceTrimStartSec] 时,把源音频里超过 [silenceKeepMs] 的静音段 -/// 裁到只剩 [silenceKeepMs](TTS 句间常有 200~400ms 停顿),无损。同量压测光这一步就省 10%。 -/// 2. **变速不变调追赶**(WSOLA,见 [Wsola]):积压 ≤ [catchupStartSec] 时 1.0x 原样播; -/// 到 [catchupFullSec] 线性提到 [maxRate](1.3x 是语音可懂度的常用上限)。 -/// 兜底 [hardCapSec](默认 0 = 关):积压超过它才整段丢最旧的音频——会丢内容,产品上默认不启用。 -/// Android 同量压测:最大积压 6.2s → 3.2s,变速最高只用到 1.11x。 +/// 译文音频是模型**突发**产出的,播放却只能 1 倍速;说话人不停顿时队列只涨不缩。 +/// 处理分两层,都不丢内容:积压 >[silenceTrimStartSec] 裁掉超过 [silenceKeepMs] 的静音; +/// >[catchupStartSec] 起 WSOLA 变速不变调,[catchupFullSec] 达 [maxRate]。 +/// 兜底 [hardCapSec]:积压超过它才整段丢最旧的音频,只防链路假死。 +/// +/// ## 杂音修正(2026-09-23,Android 真机六轮定性,iOS 同步) +/// +/// 1. 起播缓冲 [primeMs] **只在句首用一次**;句中队列空了有一帧就接着播——变速追赶时消耗比 +/// 云端到达快,队列中途归零很常见,再攒一次就是在句中硬插 120ms 静音(杂音落在句中)。 +/// 2. 一句话没结束([flushIdleMs] 内还有新数据)不补零收尾,宁可空一拍也不把零夹进内容。 +/// 3. 空闲那一路用它自己的编码器编零(G.722 自适应差分,塞固定码流会让解码器状态跳一下)。 +/// 4. 长停顿后恢复发包先塞 [prerollPackets] 包编码静音垫底,让耳机在静音上起播(杂音落在句首); +/// 两路都空后再续 [tailTicks] 拍静音才停,短句间隔不把耳机缓冲榨干。 +/// 5. 定时器固定 [tickMs] 节拍**永不重排**;耳机 `0xD6` 的 data[6]:0 补两包 / 1 补一包并 1.25x 配额 / +/// 3 扣一包,见 [onHeadsetPace]。它一次只吱 20ms 就回 02,意思是"缓冲低一包,现在补一包"。 +/// 6. 裁静音带回滞([silenceRms] 进 / [silenceRmsExit] 出)并在接缝淡入 [rampSamples]; +/// 速率每帧最多变 [rateStep];WSOLA 搜索窗 ±10ms。 public final class CallTranslationDownlink { public static let shared = CallTranslationDownlink() @@ -44,7 +48,18 @@ public final class CallTranslationDownlink { private static let silenceTrimStartSec = 0.5 private static let silenceKeepMs = 150 private static let silenceRms = 300.0 - private static let hardCapSec = 0.0 + private static let hardCapSec = 30.0 + + // ---- 2026-09-23 杂音修正参数(与 Android 一致)---- + private static let primeMs = 120 + private static let flushIdleMs = 150 + private static let rateStep = 0.03 + private static let silenceRmsExit = 600.0 + private static let rampSamples = 48 + private static let tickMs = 20 + private static let prerollPackets = 4 + private static let tailTicks = 25 + private static let maxPacketsPerTick = 6 /// 一条腿:源 PCM 队列 + 变速器 + 编码器 + 统计。只在 [queue] 上访问。 private final class Leg { @@ -55,6 +70,13 @@ public final class CallTranslationDownlink { var partial = [Int16](repeating: 0, count: CallTranslationDownlink.frameSamples) var partialLen = 0 var silentRun = 0 + var trimming = false // 裁静音回滞状态 + var rampPending = false // 刚裁掉一段,下一块要淡入 + var lastPushAt: CFAbsoluteTime = 0 // 最近一次收到源 PCM 的时刻 + var rateCur = 1.0 // 平滑后的当前速率 + var starved = 0 // 句中断粮次数 + var playing = false // 这一句已经起播(起播缓冲只在句首用一次) + var lastHoleLogAt: CFAbsoluteTime = 0 let stretcher = Wsola() var encoder: G722Codec? @@ -84,6 +106,8 @@ public final class CallTranslationDownlink { func reset() { srcClear(); partialLen = 0; silentRun = 0 + trimming = false; rampPending = false; lastPushAt = 0; rateCur = 1.0 + starved = 0; playing = false; lastHoleLogAt = 0 stretcher.reset() pushedFrames = 0; sentFrames = 0; trimmedSamples = 0; droppedSamples = 0 maxBacklogSamples = 0; lastRate = 1.0 @@ -101,28 +125,28 @@ public final class CallTranslationDownlink { private var intervalMs: Int = 20 private var sentPackets = 0 - // MARK: - 流畅度观测 - // - // ⚠️ 这里原来用的是 NSLog,而 NSLog 的内容在 release / profile 包里**抓不到** - // (idevicesyslog 只收得到 os_log),于是下行是整条链路上唯一的盲区。排查 - // 「杂音、不流畅」时看不到队列长度和发送节奏,只能猜。一律改走 os_log。 + /// 发包配额(包数,可带小数),每拍加 tickMs/intervalMs + private var budget = 0.0 + private var wasIdle = true + private var tailLeft = 0 + + // MARK: - 流畅度观测(release 包里只有 os_log 抓得到) private static let dlLog = OSLog(subsystem: "com.eaimar.bluetooth", category: "downlink") - /// 定时器实际触发的时刻,用来算真实间隔 —— 期望 20ms,偏大就是发送跟不上 private var lastTickAt: CFAbsoluteTime = 0 private var tickGapSumMs: Double = 0 private var tickGapCount: Int = 0 private var tickGapMaxMs: Double = 0 - /// 取样窗口内的积压峰值(帧),resetPeaks 会清 private var peakQueueA = 0 private var peakQueueB = 0 - /// 静音帧(与 deepvoice 一致,耳机侧认这个码流) + /// 静音帧(与 deepvoice 一致)。只在没有编码器时兜底用,正常空闲用 [idleFrame] 编零。 private let silenceFrame = Data([ 0x0A, 0x8E, 0x30, 0xBD, 0x3F, 0x83, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF ]) + private let zeroPcm = Data(count: CallTranslationDownlink.framePcmBytes) private init() {} @@ -135,6 +159,9 @@ public final class CallTranslationDownlink { if leg.encoder == nil { leg.encoder = G722Codec(sampleRate: 16000) } } self.sentPackets = 0 + // 每场从 20ms 起步:上一场结束时耳机可能停在 16ms + self.intervalMs = 20 + self.budget = 0; self.wasIdle = true; self.tailLeft = 0 self.lastTickAt = 0 self.tickGapSumMs = 0; self.tickGapCount = 0; self.tickGapMaxMs = 0 self.peakQueueA = 0; self.peakQueueB = 0 @@ -159,8 +186,6 @@ public final class CallTranslationDownlink { leg.stretcher.reset() leg.encoder = nil } - // 清自己的队列还不够:已经递给 GATT 层的那些包也得丢掉, - // 否则它们会一直堵在控制命令(查电量/查版本)前面 BluetoothManager.sharedInstance?.flushRealtimeQueue() let avgGap = self.tickGapCount > 0 ? self.tickGapSumMs / Double(self.tickGapCount) : 0 let bleStats = BluetoothManager.sharedInstance?.bleWriteStats() ?? [:] @@ -170,7 +195,7 @@ public final class CallTranslationDownlink { """, log: Self.dlLog, type: .info, self.sentPackets, pushedA, pushedB, - avgGap, self.tickGapMaxMs, self.intervalMs, + avgGap, self.tickGapMaxMs, Self.tickMs, String(describing: bleStats)) os_log("[BesCallDownlink] 下行积压总账 A{%{public}@} B{%{public}@}", log: Self.dlLog, type: .default, a, b) } @@ -184,20 +209,11 @@ public final class CallTranslationDownlink { let trimmed = Double(leg.trimmedSamples) / sr let dropped = Double(leg.droppedSamples) / sr let catchup = max(0, pushedSec - sentSec - trimmed - dropped - leg.backlogSec()) - return String(format: "源=%.1fs 播出=%.1fs 最大积压=%.1fs 裁静音=%.1fs 变速追回=%.1fs 兜底丢=%.1fs", - pushedSec, sentSec, Double(leg.maxBacklogSamples) / sr, trimmed, catchup, dropped) + return String(format: "源=%.1fs 播出=%.1fs 最大积压=%.1fs 裁静音=%.1fs 变速追回=%.1fs 兜底丢=%.1fs 断粮=%d次", + pushedSec, sentSec, Double(leg.maxBacklogSamples) / sr, trimmed, catchup, dropped, leg.starved) } - /// 下行发送器的实时统计,供 Dart 在切前后台等时刻取样。 - /// - /// 这些数字原来只在 os_log 里,而排查后台卡顿时 `idevicesyslog` 极不稳定 - /// (2026-09-21 断过两次),于是下行成了唯一看不见的一段。暴露出来之后, - /// Dart 侧可以把它写进文件日志(`devicectl device copy from` 直接拉), - /// 不再依赖 usbmuxd。 - /// - /// **gapAvgMs 是判断 iOS 后台节流的关键**:期望等于 intervalMs(16/20/25), - /// 明显变大就说明 DispatchSourceTimer 被系统合并触发了,下行必然断续。 - /// queueA/B 现在是「未播的源音频」折算的帧数(积压),不再是编码队列长度。 + /// 下行发送器的实时统计,供 Dart 在切前后台等时刻取样(形状不变)。 public func stats() -> [String: Any] { var out: [String: Any] = [:] queue.sync { @@ -225,8 +241,6 @@ public final class CallTranslationDownlink { return out } - /// 取样之间重置峰值,否则 gapMaxMs 会被会话早期的一次尖峰永久占住, - /// 看不出「切到后台之后」到底有没有变差。 public func resetPeaks() { queue.async { self.tickGapSumMs = 0; self.tickGapCount = 0; self.tickGapMaxMs = 0 @@ -234,28 +248,43 @@ public final class CallTranslationDownlink { } } - /// 耳机要求调整下行节奏(`0xD6`/`0xE9` 的 data[6]) - public func setIntervalMs(_ ms: Int) { + /// 耳机调速帧的 data[6] 原值:0 = 缓冲空/起播,1 = 要快,2 = 正常,3 = 要慢。 + /// 收到 01 立刻补一包(并按 16ms 配额跟上持续的要求),00 补两包,03 扣一包。 + public func onHeadsetPace(_ code: Int) { queue.async { - guard self.intervalMs != ms else { return } - self.intervalMs = ms - os_log("[BesCallDownlink] 下行节奏调整为 %dms", log: Self.dlLog, type: .info, ms) - if self.running { self.scheduleTimer() } + switch code { + case 0: self.budget += 2; self.applyInterval(20) + case 1: self.budget += 1; self.applyInterval(16) + case 3: self.budget -= 1; self.applyInterval(25) + default: self.applyInterval(20) + } } } + /// 兼容旧调用:只改配额节奏,不重排定时器 + public func setIntervalMs(_ ms: Int) { + queue.async { self.applyInterval(ms) } + } + + /// 只在 [queue] 上调用 + private func applyInterval(_ ms: Int) { + guard intervalMs != ms else { return } + intervalMs = ms + os_log("[BesCallDownlink] 下行节奏调整为 %dms", log: Self.dlLog, type: .info, ms) + } + private func scheduleTimer() { timer?.cancel() let t = DispatchSource.makeTimerSource(queue: queue) - t.schedule(deadline: .now(), repeating: .milliseconds(intervalMs)) - t.setEventHandler { [weak self] in self?.sendOnePacket() } + // 固定节拍、永不重排;节奏差异全在 onTick 的配额里体现 + t.schedule(deadline: .now(), repeating: .milliseconds(Self.tickMs)) + t.setEventHandler { [weak self] in self?.onTick() } timer = t t.resume() } /// 推一段待下发的 TTS PCM。 /// [leg] "A" = 己方译文给对端听;"B" = 对端译文给己方听。 - /// [pcm] 16kHz / 16bit / mono / little-endian,长度任意。 public func pushPcm(leg: String, pcm: Data) { guard !pcm.isEmpty else { return } let isA = leg.uppercased() == "A" || leg.lowercased() == "uplink" @@ -277,10 +306,14 @@ public final class CallTranslationDownlink { l.partialLen += take i += take if l.partialLen == Self.frameSamples { - let block = l.partial + var block = l.partial l.partialLen = 0 l.pushedFrames += 1 - if !self.trimSilence(l, block) { l.srcPush(block) } + if !self.trimSilence(l, block) { + if l.rampPending { Self.rampIn(&block); l.rampPending = false } + l.srcPush(block) + l.lastPushAt = CFAbsoluteTimeGetCurrent() + } } } } @@ -308,20 +341,32 @@ public final class CallTranslationDownlink { var acc = 0.0 for s in block { let d = Double(s); acc += d * d } let rms = (acc / Double(block.count)).squareRoot() - if rms >= Self.silenceRms { + // 回滞:进入裁剪要低于 silenceRms,退出要高于 silenceRmsExit + let silent = l.trimming ? rms < Self.silenceRmsExit : rms < Self.silenceRms + if !silent { l.silentRun = 0 + l.trimming = false return false } l.silentRun += block.count let keep = Self.silenceKeepMs * Self.sampleRate / 1000 if l.silentRun > keep && l.backlogSec() > Self.silenceTrimStartSec { l.trimmedSamples += block.count + l.trimming = true + l.rampPending = true return true } return false } - /// 按积压深度算这一帧的播放速率。 + /// 裁剪接缝处的短淡入 + private static func rampIn(_ block: inout [Int16]) { + let n = min(rampSamples, block.count) + if n <= 0 { return } + for i in 0.. Double { if backlogSec <= Self.catchupStartSec { return 1.0 } if backlogSec >= Self.catchupFullSec { return Self.maxRate } @@ -331,9 +376,37 @@ public final class CallTranslationDownlink { /// 取这条腿的下一帧编码码流;没东西可播返回 nil。只在 [queue] 上调用。 private func nextEncodedFrame(_ l: Leg) -> Data? { guard let encoder = l.encoder else { return nil } - let rate = rateFor(l.backlogSec()) + let target = rateFor(l.backlogSec()) + let rate = l.rateCur + min(max(target - l.rateCur, -Self.rateStep), Self.rateStep) + l.rateCur = rate l.lastRate = rate - guard let out = l.stretcher.nextFrame(rate: rate, leg: l) else { return nil } + let now = CFAbsoluteTimeGetCurrent() + let idleMs = l.lastPushAt == 0 ? Int.max : Int((now - l.lastPushAt) * 1000) + let flushAllowed = idleMs >= Self.flushIdleMs + let unreadInStretcher = l.stretcher.unreadSamples() + let available = unreadInStretcher + l.srcSamples + if !flushAllowed { + let needForFrame = Int(Double(Self.frameSamples) * rate) + l.stretcher.lookahead() + let priming = !l.playing && unreadInStretcher == 0 && l.srcSamples < Self.primeMs * Self.sampleRate / 1000 + if priming { return nil } + if available < needForFrame { + if l.playing { + l.starved += 1 + if now - l.lastHoleLogAt > 1.0 { + l.lastHoleLogAt = now + os_log("[BesCallDownlink] %{public}@ 路句中空洞: 可用 %dms < 需 %dms,距上次到数据 %dms", + log: Self.dlLog, type: .info, l.name, + available * 1000 / Self.sampleRate, needForFrame * 1000 / Self.sampleRate, idleMs) + } + } + return nil + } + } + guard let out = l.stretcher.nextFrame(rate: rate, leg: l, allowPad: flushAllowed) else { + l.playing = false + return nil + } + l.playing = true let pcm = out.withUnsafeBufferPointer { Data(buffer: $0) } // Int16 小端 = 本机字节序 l.sentFrames += 1 if let enc = encoder.encode(pcm), enc.count >= Self.frameEncodedBytes { @@ -342,58 +415,94 @@ public final class CallTranslationDownlink { return silenceFrame } - /// 只在 [queue] 上调用 - private func sendOnePacket() { + /// 这一路此刻没内容:用它的编码器编零,码流与前后帧连续。只在 [queue] 上调用。 + private func idleFrame(_ l: Leg) -> Data { + guard let encoder = l.encoder else { return silenceFrame } + if let enc = encoder.encode(zeroPcm), enc.count >= Self.frameEncodedBytes { + return Data(enc.prefix(Self.frameEncodedBytes)) + } + return silenceFrame + } + + /// 每 tickMs 一拍:按耳机要求的节奏累计配额,配额够一包发一包。只在 [queue] 上调用。 + private func onTick() { guard running else { return } - // 定时器真实节奏:DispatchSourceTimer 会为省电合并触发,而 handler 里还压着 - // G.722 编码与一次 GATT 写。实际间隔一旦稳定大于 intervalMs,下行就是在欠发, - // 耳机侧必然断续 —— 这是「不流畅」和「队列只涨不落」的分水岭。 let now = CFAbsoluteTimeGetCurrent() - if lastTickAt > 0 { - let gap = (now - lastTickAt) * 1000 + let prevTick = lastTickAt + if prevTick > 0 { + let gap = (now - prevTick) * 1000 tickGapSumMs += gap tickGapCount += 1 if gap > tickGapMaxMs { tickGapMaxMs = gap } } lastTickAt = now + // 配额按**真实流逝时间**算,不按"一拍一份":iOS 切后台会把定时器合并成 60~120 ms 一次 + // (2026-09-23 真机 gapAvgMs=79),DispatchSourceTimer 错过的节拍不补发,按拍计数就只剩 1/4 的包。 + // 晚触发多少就一次补齐多少(上限 maxPacketsPerTick),耳机侧看到的平均速率不变。 + let elapsedMs = prevTick > 0 ? (now - prevTick) * 1000 : Double(Self.tickMs) + budget += min(elapsedMs, Double(Self.tickMs * Self.maxPacketsPerTick)) / Double(intervalMs) + var n = 0 + while budget >= 1.0 && n < Self.maxPacketsPerTick { + if !emitPacket() { budget = 0; break } + budget -= 1.0 + n += 1 + } + } + + /// 两路都是编码静音的一包(垫底用) + private func writeIdlePacket() { + var packet = Data(capacity: Self.packetSize) + packet.append(contentsOf: [0xAA, 0x56, UInt8(Self.packetSize), 0x03]) + packet.append(idleFrame(legB)) + packet.append(idleFrame(legA)) + BluetoothManager.writeRealtimeAudio(packet) + sentPackets += 1 + } + + /// 发一包;两路都空且尾巴续完时返回 false(真正空闲,不发) + private func emitPacket() -> Bool { let b = nextEncodedFrame(legB) let a = nextEncodedFrame(legA) - // 两路都没内容就不发,避免通话里灌满无谓的静音包 - if b == nil && a == nil { return } + if b == nil && a == nil { + if tailLeft <= 0 { wasIdle = true; return false } + tailLeft -= 1 + } else { + tailLeft = Self.tailTicks + if wasIdle { + wasIdle = false + for _ in 0..2s 说明产出快于播放、追赶已介入。 if sentPackets == 1 || sentPackets % 100 == 0 { let avgGap = tickGapCount > 0 ? tickGapSumMs / Double(tickGapCount) : 0 let ble = BluetoothManager.sharedInstance?.bleWriteStats() ?? [:] - os_log("[BesCallDownlink] 已下发 %d 包 (积压 A=%.1fs B=%.1fs 速率 A=%.2f B=%.2f) 间隔 平均=%.1fms 最大=%.1fms | GATT 丢=%{public}@ 阻塞=%{public}@ 待发=%{public}@", + os_log("[BesCallDownlink] 已下发 %d 包 (积压 A=%.1fs B=%.1fs 速率 A=%.2f B=%.2f 断粮 A=%d B=%d 节奏=%dms) 间隔 平均=%.1fms 最大=%.1fms | GATT 丢=%{public}@ 阻塞=%{public}@ 待发=%{public}@", log: Self.dlLog, type: .info, sentPackets, legA.backlogSec(), legB.backlogSec(), legA.lastRate, legB.lastRate, + legA.starved, legB.starved, intervalMs, avgGap, tickGapMaxMs, String(describing: ble["dropped"] ?? 0), String(describing: ble["blocked"] ?? 0), String(describing: ble["queued"] ?? 0)) } + return true } /// WSOLA 变速不变调(Verhelst & Roelands),与 Android `Wsola` 同一套参数与步骤。 - /// /// 合成侧固定步长 S(10ms)、帧长 N(20ms)、Hann 50% 重叠相加;分析侧步长 = S × rate, - /// 每帧在名义位置 ±T 内搜索与「上一帧自然延续」最相似的起点(互相关),保证拼接处波形连续。 - /// rate=1.0 且偏移=0 时 Hann 50% 重叠相加恒等于原信号(Python 复刻验证:逐样本误差 0), - /// 所以不用在直通/变速间切换。附加时延 ≈ N + T 样本(≈26ms)。 - /// - /// 输入不够时:源队列已空 → 补零把尾巴冲出来(TTS 末尾本来就是静音);源队列还有 → 先取。 + /// 每帧在名义位置 ±T 内搜索与「上一帧自然延续」最相似的起点。rate=1.0 时恒等于原信号。 private final class Wsola { private let N = 320 private let S = 160 - private let T = 96 + private let T = 160 // 搜索窗 ±10ms(原 ±6ms):高速段对齐更准、失真更小 private var inBuf = [Int16](repeating: 0, count: 320 * 8) private var inLen = 0 private var anaPos = 0.0 @@ -415,11 +524,15 @@ public final class CallTranslationDownlink { for i in 0.. Int { T + N } + /// 变速器里还没播掉的样本(不含补的零) func unreadSamples() -> Int { max(0, inLen - padded - Int(anaPos.rounded())) } /// 取 20ms 输出;返回 nil 表示这条腿当前没有东西可播。 - func nextFrame(rate: Double, leg: Leg) -> [Int16]? { + /// [allowPad] 为 false 时(句子没结束)不补零,宁可返回 nil。 + func nextFrame(rate: Double, leg: Leg, allowPad: Bool) -> [Int16]? { var produced = 0 let frame = CallTranslationDownlink.frameSamples while produced < frame { @@ -431,6 +544,7 @@ public final class CallTranslationDownlink { if inLen < need { let realLeft = inLen - padded - nominal if produced == 0 && realLeft <= 0 { return nil } + if !allowPad && produced == 0 { return nil } let pad = need - inLen ensure(need) for i in inLen.. N * 4 {