Browse Source

client: 通话翻译耳机杂音——四个独立原因逐个修掉(Android 定性,iOS 同步)

六轮真机定位出四个叠加的原因,每修掉一个概率降一档:

1. 发包定时器系统性漂移:scheduleWithFixedDelay 从任务**结束**起算,WSOLA+G.722+
   写 socket 的 1~3ms 全叠进周期,"20ms"实测 21~23ms、只有 0.9 倍速。改成
   scheduleAtFixedRate 固定节拍且永不重排。
2. 起播缓冲在句中重新生效(杂音落在句中的真凶):队列空了就要求再攒 120ms,而
   变速追赶时队列中途归零很常见,等于句中硬插 120ms 静音。加 playing 标志让
   起播缓冲只在句首用一次;真正的断粮单独计数并打「句中空洞」日志。
3. 恢复发包时从空缓冲起播(杂音落在句首):两路都空就停发、耳机缓冲被榨干。
   现在先塞 4 包编码静音垫底再发真音频,两路空后再续 500ms 静音才停。
4. 24k→16k 重采样是逐块独立的线性插值、无抗混叠、每块相位从零重算。换成
   64 阶低通 FIR + 跨块连续(Resampler24kTo16k.kt/.swift,Python 复刻校验
   1k/3k/6k 正弦)。

顺带:耳机 0xD6 帧 data[6] 的语义是「补一包」不是「改速率」,按此重写
onHeadsetPace(01 补 1 包、00 补 2 包、03 扣 1 包);空闲那一路的静音帧改用该路
自己的编码器编零(G.722 是自适应差分,固定码流会让解码器状态跳);裁静音加
滞回(300 进 / 600 出)+ 3ms 淡入;速率每帧最多变 0.03;150ms 内有新数据不补零
收尾;HARD_CAP_SEC 补回 30s 纯兜底。DEEPVOICE_PARITY 对照开关保持 false。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 3 weeks ago
parent
commit
ace92c9ea1
  1. 6
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AliyunBailianE2EHelper.kt
  2. 4
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BesCallPcmBridge.kt
  3. 73
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/Resampler24kTo16k.kt
  4. 5
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AliyunBailianE2EHelper.swift
  5. 67
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/Resampler24kTo16k.swift
  6. 10
      apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManagerPlugin.kt
  7. 245
      apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/CallTranslationDownlink.kt
  8. 10
      apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift
  9. 268
      apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/CallTranslationDownlink.swift

6
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 * 将 24kHz PCM 音频重采样为 16kHz
*/ */
/** 跨块连续 + 抗混叠的 3:2 重采样,替代下面逐块线性插值的老实现(保留作对照) */
private val resampler = Resampler24kTo16k()
@Suppress("unused")
private fun resample24kTo16k(input: ByteArray): ByteArray { private fun resample24kTo16k(input: ByteArray): ByteArray {
// 16 bit PCM // 16 bit PCM
val inputShorts = ShortArray(input.size / 2) val inputShorts = ShortArray(input.size / 2)
@ -643,7 +647,7 @@ class AliyunBailianE2EHelper(
val audioBytes = android.util.Base64.decode(base64Audio, android.util.Base64.DEFAULT) val audioBytes = android.util.Base64.decode(base64Audio, android.util.Base64.DEFAULT)
if (audioBytes != null && audioBytes.isNotEmpty()) { if (audioBytes != null && audioBytes.isNotEmpty()) {
// 默认输出 24k,需重采样到 16k // 默认输出 24k,需重采样到 16k
val resampled = resample24kTo16k(audioBytes) val resampled = resampler.process(audioBytes)
try { try {
fullAudioBuffer.write(resampled) fullAudioBuffer.write(resampled)

4
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BesCallPcmBridge.kt

@ -46,6 +46,9 @@ class BesCallPcmBridge(
// 以下只在 ingress 线程上读写 // 以下只在 ingress 线程上读写
private var ready = false private var ready = false
private val gate = PeerSpeechGate() private val gate = PeerSpeechGate()
/** ⚠️ 对照实验开关(2026-09-23):true 时 B 路不过门限、逐帧直推,即 deepvoice 口径。测完改回 false。 */
private val deepvoiceParity = false
private val holdA = ArrayDeque<ByteArray>() private val holdA = ArrayDeque<ByteArray>()
private val holdB = ArrayDeque<ByteArray>() private val holdB = ArrayDeque<ByteArray>()
private var framesA = 0L private var framesA = 0L
@ -156,6 +159,7 @@ class BesCallPcmBridge(
return return
} }
framesB++ framesB++
if (deepvoiceParity) { deliverB(pcm); return }
val wasOpen = gate.isOpen val wasOpen = gate.isOpen
val frames = gate.accept(pcm) val frames = gate.accept(pcm)
if (frames.isEmpty()) gatedB++ if (frames.isEmpty()) gatedB++

73
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,
)
}
}

5
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 * 将 24kHz PCM 重采样为 16kHz
*/ */
/// 跨块连续 + 抗混叠的 3:2 重采样,替代下面逐块线性插值的老实现(保留作对照)
private let resampler = Resampler24kTo16k()
private func resample24kTo16k(_ input: Data) -> Data { private func resample24kTo16k(_ input: Data) -> Data {
if input.count < 4 { return input } if input.count < 4 { return input }
@ -545,7 +548,7 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate {
case "response.audio.delta": case "response.audio.delta":
if let base64Audio = obj["delta"] as? String, !base64Audio.isEmpty { if let base64Audio = obj["delta"] as? String, !base64Audio.isEmpty {
if let audioBytes = Data(base64Encoded: base64Audio) { if let audioBytes = Data(base64Encoded: base64Audio) {
let resampled = resample24kTo16k(audioBytes) let resampled = resampler.process(audioBytes)
if !resampled.isEmpty { if !resampled.isEmpty {
fullAudioBuffer.append(resampled) fullAudioBuffer.append(resampled)
// 极高频(音频帧),取消 info 日志 // 极高频(音频帧),取消 info 日志

67
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..<histLen { buf[i] = hist[i] }
input.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
for i in 0..<n {
let v = UInt16(raw[i * 2]) | (UInt16(raw[i * 2 + 1]) << 8)
buf[histLen + i] = Int16(bitPattern: v)
}
}
let base = consumed - Int64(histLen)
let lastGlobal = consumed + Int64(n) - 1
var out = [Int16]()
out.reserveCapacity(n * 2 / 3 + 2)
let h = Resampler24kTo16k.h
let taps = Resampler24kTo16k.taps
while k / 2 <= lastGlobal {
var acc = 0.0
var j = (k % 2 == 0) ? 0 : 1 // 让 (k-j) 为偶数
while j < taps {
let idx = (k - Int64(j)) / 2 - base
if idx >= 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..<keep { hist[i] = buf[buf.count - keep + i] }
histLen = keep
consumed += Int64(n)
return out.withUnsafeBufferPointer { Data(buffer: $0) }
}
}

10
apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManagerPlugin.kt

@ -256,13 +256,9 @@ class BluetoothManagerPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
if (data["type"] == "speedAdjustment") { if (data["type"] == "speedAdjustment") {
val raw = data["data"] as? ByteArray val raw = data["data"] as? ByteArray
if (raw != null && raw.size > 6) { if (raw != null && raw.size > 6) {
val interval = when (raw[6]) { // 帧形状 `bb d6 07 00 02 01 <XX>`,只有 data[6] 一个字节有信息(2026-09-23 抓帧确认)
0x01.toByte() -> 16L // 原值交给发送器:01 立刻补一包 + 1.25x 配额,00 补两包,03 扣一包(见 onHeadsetPace)
0x02.toByte() -> 20L CallTranslationDownlink.onHeadsetPace(raw[6].toInt() and 0xFF)
0x03.toByte() -> 25L
else -> 20L
}
CallTranslationDownlink.setIntervalMs(interval)
} }
} }
// 没人订阅时不必往主线程 post——通话中每 20ms 就有两帧,空转没意义 // 没人订阅时不必往主线程 post——通话中每 20ms 就有两帧,空转没意义

245
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 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 <len> 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 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_TRIM_START_SEC = 0.5
private const val SILENCE_KEEP_MS = 150 private const val SILENCE_KEEP_MS = 150
private const val SILENCE_RMS = 300.0 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) 里动。 */ /** 一条腿:源 PCM 队列 + 变速器 + 编码器 + 统计。所有字段只在 synchronized(this) 里动。 */
private class Leg(val name: String) { private class Leg(val name: String) {
@ -74,6 +114,13 @@ object CallTranslationDownlink {
var partial = ShortArray(FRAME_SAMPLES) // 不满 20ms 的尾巴 var partial = ShortArray(FRAME_SAMPLES) // 不满 20ms 的尾巴
var partialLen = 0 var partialLen = 0
var silentRun = 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() val stretcher = Wsola()
var encHandle = 0L var encHandle = 0L
@ -91,6 +138,7 @@ object CallTranslationDownlink {
fun reset() { fun reset() {
src.clear(); srcSamples = 0; partialLen = 0; silentRun = 0 src.clear(); srcSamples = 0; partialLen = 0; silentRun = 0
trimming = false; rampPending = false; lastPushNs = 0L; rateCur = 1.0; starved = 0; playing = false
stretcher.reset() stretcher.reset()
pushedFrames = 0; sentFrames = 0; trimmedSamples = 0; droppedSamples = 0 pushedFrames = 0; sentFrames = 0; trimmedSamples = 0; droppedSamples = 0
maxBacklogSamples = 0; lastRate = 1.0 maxBacklogSamples = 0; lastRate = 1.0
@ -113,6 +161,11 @@ object CallTranslationDownlink {
private var sentPackets = 0L private var sentPackets = 0L
/** 发包配额(包数,可带小数),每拍加 TICK/interval */
private var budget = 0.0
private var wasIdle = true
private var tailLeft = 0
/** 静音帧(deepvoice 原样照搬,耳机侧认这个码流) */ /** 静音帧(deepvoice 原样照搬,耳机侧认这个码流) */
private val silenceFrame = byteArrayOf( private val silenceFrame = byteArrayOf(
0x0A.toByte(), 0x8E.toByte(), 0x30.toByte(), 0xBD.toByte(), 0x3F.toByte(), 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) if (leg.encHandle == 0L) leg.encHandle = G722Codec.init(SAMPLE_RATE)
} }
sentPackets = 0L sentPackets = 0L
// 每场从 20 ms 起步:上一场结束时耳机可能停在 16 ms,带着它起步会先超发一段
intervalMs = 20L
budget = 0.0; wasIdle = true; tailLeft = 0
scheduleSendTask() 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 @Synchronized
@ -154,14 +210,16 @@ object CallTranslationDownlink {
leg.encHandle = 0L leg.encHandle = 0L
} }
} }
if (!DEEPVOICE_PARITY) {
Log.d(TAG, "下行发送停止, 共发出 $sentPackets 包 (pushA=${legA.pushedFrames} pushB=${legB.pushedFrames})") Log.d(TAG, "下行发送停止, 共发出 $sentPackets 包 (pushA=${legA.pushedFrames} pushB=${legB.pushedFrames})")
Log.w(TAG, "下行积压总账 A{$a} B{$b}") Log.w(TAG, "下行积压总账 A{$a} B{$b}")
} }
}
private fun summary(leg: Leg): String = synchronized(leg) { private fun summary(leg: Leg): String = synchronized(leg) {
val pushedSec = leg.pushedFrames * 0.02 val pushedSec = leg.pushedFrames * 0.02
val sentSec = leg.sentFrames * 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(), pushedSec, sentSec, leg.maxBacklogSamples / SAMPLE_RATE.toDouble(),
leg.trimmedSamples / 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()), 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])。 * 耳机要求调整下行节奏(`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 @Synchronized
fun setIntervalMs(ms: Long) { fun setIntervalMs(ms: Long) {
if (intervalMs == ms) return if (intervalMs == ms) return
intervalMs = ms intervalMs = ms
Log.d(TAG, "下行节奏调整为 ${ms}ms") Log.d(TAG, "下行节奏调整为 ${ms}ms")
if (running) scheduleSendTask() // 不再取消/重排定时器:重排会让下一包提前或推后一拍,一场 150 次就是 150 次抖动。
// 节奏差异由 onTick 里的配额体现。
} }
private fun scheduleSendTask() { private fun scheduleSendTask() {
sendTask?.cancel(false) sendTask?.cancel(false)
sendTask = scheduler.scheduleWithFixedDelay( // ⚠️ 必须是固定节拍(AtFixedRate),不能是固定间隔(WithFixedDelay):后者从上一次任务
{ runCatching { sendOnePacket() }.onFailure { Log.w(TAG, "发包异常: ${it.message}") } }, // **结束**起算,每次 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, 0,
intervalMs, TICK_MS,
TimeUnit.MILLISECONDS TimeUnit.MILLISECONDS
) )
} }
@ -233,8 +313,15 @@ object CallTranslationDownlink {
l.partialLen = 0 l.partialLen = 0
l.pushedFrames++ l.pushedFrames++
if (!trimSilence(l, block)) { if (!trimSilence(l, block)) {
if (l.rampPending) { rampIn(block); l.rampPending = false }
l.src.addLast(block) l.src.addLast(block)
l.srcSamples += FRAME_SAMPLES 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 表示这一块被裁掉了。 */ /** 积压时把长静音裁到只剩 SILENCE_KEEP_MS;返回 true 表示这一块被裁掉了。 */
private fun trimSilence(l: Leg, block: ShortArray): Boolean { private fun trimSilence(l: Leg, block: ShortArray): Boolean {
if (DEEPVOICE_PARITY) return false
var acc = 0.0 var acc = 0.0
for (s in block) acc += s.toDouble() * s.toDouble() for (s in block) acc += s.toDouble() * s.toDouble()
val rms = sqrt(acc / block.size) 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.silentRun = 0
l.trimming = false
return false return false
} }
l.silentRun += block.size l.silentRun += block.size
val keep = SILENCE_KEEP_MS * SAMPLE_RATE / 1000 val keep = SILENCE_KEEP_MS * SAMPLE_RATE / 1000
if (l.silentRun > keep && l.backlogSec() > SILENCE_TRIM_START_SEC) { if (l.silentRun > keep && l.backlogSec() > SILENCE_TRIM_START_SEC) {
l.trimmedSamples += block.size l.trimmedSamples += block.size
l.trimming = true
l.rampPending = true
return true return true
} }
return false 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 { private fun rateFor(backlogSec: Double): Double = when {
DEEPVOICE_PARITY -> 1.0
backlogSec <= CATCHUP_START_SEC -> 1.0 backlogSec <= CATCHUP_START_SEC -> 1.0
backlogSec >= CATCHUP_FULL_SEC -> MAX_RATE backlogSec >= CATCHUP_FULL_SEC -> MAX_RATE
else -> 1.0 + (MAX_RATE - 1.0) * (backlogSec - CATCHUP_START_SEC) / (CATCHUP_FULL_SEC - CATCHUP_START_SEC) 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) { private fun nextEncodedFrame(l: Leg): ByteArray? = synchronized(l) {
if (l.encHandle == 0L) return null if (l.encHandle == 0L) return null
val backlog = l.backlogSec() 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 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 var i = 0
while (i < FRAME_SAMPLES) { while (i < FRAME_SAMPLES) {
val v = out[i].toInt() val v = out[i].toInt()
@ -302,23 +431,91 @@ object CallTranslationDownlink {
if (enc == null || enc.size < FRAME_ENCODED_BYTES) silenceFrame else enc 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 if (!running) return
val b = nextEncodedFrame(legB) budget += TICK_MS.toDouble() / intervalMs
val a = nextEncodedFrame(legA) var n = 0
// 两路都没内容就不发,避免通话里灌满无谓的静音包 while (budget >= 1.0 && n < MAX_PACKETS_PER_TICK) {
if (b == null && a == null) return 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<ByteArray>(FRAMES_PER_PACKET)
private val frameA = arrayOfNulls<ByteArray>(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[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(b ?: silenceFrame, 0, packetBuf, 4, FRAME_ENCODED_BYTES) // 空闲那一路用它自己的编码器编一帧零:G.722 是自适应差分编码,塞一段固定码流会让
System.arraycopy(a ?: silenceFrame, 0, packetBuf, 44, FRAME_ENCODED_BYTES) // 耳机解码器的预测状态跳一下,每次切换都是一个小瞬态
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) BluetoothManager.writeRealtime(packetBuf)
sentPackets++ sentPackets++
if (sentPackets == 1L || sentPackets % 100 == 0L) { if (!DEEPVOICE_PARITY && (sentPackets == 1L || sentPackets % 100 == 0L)) {
Log.d(TAG, "已下发 $sentPackets 包 (积压 A=%.1fs B=%.1fs 速率 A=%.2f B=%.2f)".format( 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() }, synchronized(legA) { legA.backlogSec() }, synchronized(legB) { legB.backlogSec() },
legA.lastRate, legB.lastRate)) legA.lastRate, legB.lastRate))
} }
@ -337,8 +534,10 @@ object CallTranslationDownlink {
private class Wsola( private class Wsola(
private val N: Int = 320, private val N: Int = 320,
private val S: Int = 160, 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 inBuf = ShortArray(N * 8)
private var inLen = 0 private var inLen = 0
private var anaPos = 0.0 private var anaPos = 0.0
@ -357,7 +556,7 @@ object CallTranslationDownlink {
fun unreadSamples(): Long = max(0L, (inLen - padded - anaPos.roundToInt()).toLong()) fun unreadSamples(): Long = max(0L, (inLen - padded - anaPos.roundToInt()).toLong())
/** 取 20ms 输出;返回 null 表示这条腿当前没有东西可播。 */ /** 取 20ms 输出;返回 null 表示这条腿当前没有东西可播。 */
fun nextFrame(rate: Double, l: Leg): ShortArray? { fun nextFrame(rate: Double, l: Leg, allowPad: Boolean = true): ShortArray? {
var produced = 0 var produced = 0
while (produced < FRAME_SAMPLES) { while (produced < FRAME_SAMPLES) {
val nominal = anaPos.roundToInt() val nominal = anaPos.roundToInt()
@ -370,6 +569,8 @@ object CallTranslationDownlink {
// 源队列空了:还有实际音频没播完就补零冲出来,否则这条腿此刻没东西 // 源队列空了:还有实际音频没播完就补零冲出来,否则这条腿此刻没东西
val realLeft = inLen - padded - nominal val realLeft = inLen - padded - nominal
if (produced == 0 && realLeft <= 0) return null if (produced == 0 && realLeft <= 0) return null
// 句子没结束就不补零(调用方已按可用量把关,走到这里只会是帧中途的极少数情况)
if (!allowPad && produced == 0) return null
val pad = need - inLen val pad = need - inLen
ensure(need); java.util.Arrays.fill(inBuf, inLen, need, 0.toShort()); inLen = need; padded += pad ensure(need); java.util.Arrays.fill(inBuf, inLen, need, 0.toShort()); inLen = need; padded += pad
} }

10
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 BluetoothManager.translationCallback = { [weak instance] event in
if event["type"] as? String == "speedAdjustment", if event["type"] as? String == "speedAdjustment",
let raw = event["data"] as? Data, raw.count > 6 { let raw = event["data"] as? Data, raw.count > 6 {
let interval: Int // 原值交给发送器:01 立刻补一包 + 1.25x 配额,00 补两包,03 扣一包(见 onHeadsetPace)
switch raw[6] { CallTranslationDownlink.shared.onHeadsetPace(Int(raw[6]))
case 0x01: interval = 16
case 0x02: interval = 20
case 0x03: interval = 25
default: interval = 20
}
CallTranslationDownlink.shared.setIntervalMs(interval)
} }
guard let plugin = instance, plugin.audioEventSink != nil else { return } guard let plugin = instance, plugin.audioEventSink != nil else { return }
// 音频负载要转成 FlutterStandardTypedData 才能过通道 // 音频负载要转成 FlutterStandardTypedData 才能过通道

268
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 双声道 /// 包格式:`AA 56 54 03 [40B legB][40B legA]` 共 84 字节,mode=0x03 双声道
/// - 槽 0(offset 4) = legB:对端话的译文,**己方听** /// - 槽 0(offset 4) = legB:对端话的译文,**己方听**
/// - 槽 1(offset 44)= legA:己方话的译文,**对端听** /// - 槽 1(offset 44)= legA:己方话的译文,**对端听**
/// 空槽填静音帧,两槽都空则整包不发。 /// ⚠️ 只能 1 帧/84B:2026-09-23 Android 真机试过 3 帧/244B(deepvoice iOS 的写法),耳机完全不认。
///
/// 一帧固定 640 字节 PCM(320 样本 = 20ms)→ 40 字节码流,
/// 所以默认 20ms 的发送间隔正好是实时速率;耳机用 `0xD6/0xE9`
/// 要求改节奏时(16/20/25ms)调 [setIntervalMs] 跟上。
/// ///
/// ## 积压追赶(2026-09-22,与 Android 同一套参数) /// ## 积压追赶(2026-09-22,与 Android 同一套参数)
/// ///
/// 译文音频是模型**突发**产出的(每 3~6 秒一段文本,音频一次性到),播放却只能 1 倍速; /// 译文音频是模型**突发**产出的,播放却只能 1 倍速;说话人不停顿时队列只涨不缩。
/// 说话人不停顿时译文时长 ≈ 原话时长,队列只涨不缩——Android 真机压测 A 路积压到 6.2 秒, /// 处理分两层,都不丢内容:积压 >[silenceTrimStartSec] 裁掉超过 [silenceKeepMs] 的静音;
/// 用户听到的就是「越说越慢」。处理分两层,都不丢内容: /// >[catchupStartSec] 起 WSOLA 变速不变调,[catchupFullSec] 达 [maxRate]。
/// 1. **裁静音**:积压超过 [silenceTrimStartSec] 时,把源音频里超过 [silenceKeepMs] 的静音段 /// 兜底 [hardCapSec]:积压超过它才整段丢最旧的音频,只防链路假死。
/// 裁到只剩 [silenceKeepMs](TTS 句间常有 200~400ms 停顿),无损。同量压测光这一步就省 10%。 ///
/// 2. **变速不变调追赶**(WSOLA,见 [Wsola]):积压 ≤ [catchupStartSec] 时 1.0x 原样播; /// ## 杂音修正(2026-09-23,Android 真机六轮定性,iOS 同步)
/// 到 [catchupFullSec] 线性提到 [maxRate](1.3x 是语音可懂度的常用上限)。 ///
/// 兜底 [hardCapSec](默认 0 = 关):积压超过它才整段丢最旧的音频——会丢内容,产品上默认不启用。 /// 1. 起播缓冲 [primeMs] **只在句首用一次**;句中队列空了有一帧就接着播——变速追赶时消耗比
/// Android 同量压测:最大积压 6.2s → 3.2s,变速最高只用到 1.11x。 /// 云端到达快,队列中途归零很常见,再攒一次就是在句中硬插 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 final class CallTranslationDownlink {
public static let shared = CallTranslationDownlink() public static let shared = CallTranslationDownlink()
@ -44,7 +48,18 @@ public final class CallTranslationDownlink {
private static let silenceTrimStartSec = 0.5 private static let silenceTrimStartSec = 0.5
private static let silenceKeepMs = 150 private static let silenceKeepMs = 150
private static let silenceRms = 300.0 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] 上访问。 /// 一条腿:源 PCM 队列 + 变速器 + 编码器 + 统计。只在 [queue] 上访问。
private final class Leg { private final class Leg {
@ -55,6 +70,13 @@ public final class CallTranslationDownlink {
var partial = [Int16](repeating: 0, count: CallTranslationDownlink.frameSamples) var partial = [Int16](repeating: 0, count: CallTranslationDownlink.frameSamples)
var partialLen = 0 var partialLen = 0
var silentRun = 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() let stretcher = Wsola()
var encoder: G722Codec? var encoder: G722Codec?
@ -84,6 +106,8 @@ public final class CallTranslationDownlink {
func reset() { func reset() {
srcClear(); partialLen = 0; silentRun = 0 srcClear(); partialLen = 0; silentRun = 0
trimming = false; rampPending = false; lastPushAt = 0; rateCur = 1.0
starved = 0; playing = false; lastHoleLogAt = 0
stretcher.reset() stretcher.reset()
pushedFrames = 0; sentFrames = 0; trimmedSamples = 0; droppedSamples = 0 pushedFrames = 0; sentFrames = 0; trimmedSamples = 0; droppedSamples = 0
maxBacklogSamples = 0; lastRate = 1.0 maxBacklogSamples = 0; lastRate = 1.0
@ -101,28 +125,28 @@ public final class CallTranslationDownlink {
private var intervalMs: Int = 20 private var intervalMs: Int = 20
private var sentPackets = 0 private var sentPackets = 0
// MARK: - 流畅度观测 /// 发包配额(包数,可带小数),每拍加 tickMs/intervalMs
// private var budget = 0.0
// ⚠️ 这里原来用的是 NSLog,而 NSLog 的内容在 release / profile 包里**抓不到** private var wasIdle = true
// (idevicesyslog 只收得到 os_log),于是下行是整条链路上唯一的盲区。排查 private var tailLeft = 0
// 「杂音、不流畅」时看不到队列长度和发送节奏,只能猜。一律改走 os_log。
// MARK: - 流畅度观测(release 包里只有 os_log 抓得到)
private static let dlLog = OSLog(subsystem: "com.eaimar.bluetooth", category: "downlink") private static let dlLog = OSLog(subsystem: "com.eaimar.bluetooth", category: "downlink")
/// 定时器实际触发的时刻,用来算真实间隔 —— 期望 20ms,偏大就是发送跟不上
private var lastTickAt: CFAbsoluteTime = 0 private var lastTickAt: CFAbsoluteTime = 0
private var tickGapSumMs: Double = 0 private var tickGapSumMs: Double = 0
private var tickGapCount: Int = 0 private var tickGapCount: Int = 0
private var tickGapMaxMs: Double = 0 private var tickGapMaxMs: Double = 0
/// 取样窗口内的积压峰值(帧),resetPeaks 会清
private var peakQueueA = 0 private var peakQueueA = 0
private var peakQueueB = 0 private var peakQueueB = 0
/// 静音帧(与 deepvoice 一致,耳机侧认这个码流) /// 静音帧(与 deepvoice 一致)。只在没有编码器时兜底用,正常空闲用 [idleFrame] 编零。
private let silenceFrame = Data([ private let silenceFrame = Data([
0x0A, 0x8E, 0x30, 0xBD, 0x3F, 0x83, 0xFF, 0x00, 0x00, 0x00, 0x0A, 0x8E, 0x30, 0xBD, 0x3F, 0x83, 0xFF, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 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,
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() {} private init() {}
@ -135,6 +159,9 @@ public final class CallTranslationDownlink {
if leg.encoder == nil { leg.encoder = G722Codec(sampleRate: 16000) } if leg.encoder == nil { leg.encoder = G722Codec(sampleRate: 16000) }
} }
self.sentPackets = 0 self.sentPackets = 0
// 每场从 20ms 起步:上一场结束时耳机可能停在 16ms
self.intervalMs = 20
self.budget = 0; self.wasIdle = true; self.tailLeft = 0
self.lastTickAt = 0 self.lastTickAt = 0
self.tickGapSumMs = 0; self.tickGapCount = 0; self.tickGapMaxMs = 0 self.tickGapSumMs = 0; self.tickGapCount = 0; self.tickGapMaxMs = 0
self.peakQueueA = 0; self.peakQueueB = 0 self.peakQueueA = 0; self.peakQueueB = 0
@ -159,8 +186,6 @@ public final class CallTranslationDownlink {
leg.stretcher.reset() leg.stretcher.reset()
leg.encoder = nil leg.encoder = nil
} }
// 清自己的队列还不够:已经递给 GATT 层的那些包也得丢掉,
// 否则它们会一直堵在控制命令(查电量/查版本)前面
BluetoothManager.sharedInstance?.flushRealtimeQueue() BluetoothManager.sharedInstance?.flushRealtimeQueue()
let avgGap = self.tickGapCount > 0 ? self.tickGapSumMs / Double(self.tickGapCount) : 0 let avgGap = self.tickGapCount > 0 ? self.tickGapSumMs / Double(self.tickGapCount) : 0
let bleStats = BluetoothManager.sharedInstance?.bleWriteStats() ?? [:] let bleStats = BluetoothManager.sharedInstance?.bleWriteStats() ?? [:]
@ -170,7 +195,7 @@ public final class CallTranslationDownlink {
""", """,
log: Self.dlLog, type: .info, log: Self.dlLog, type: .info,
self.sentPackets, pushedA, pushedB, self.sentPackets, pushedA, pushedB,
avgGap, self.tickGapMaxMs, self.intervalMs, avgGap, self.tickGapMaxMs, Self.tickMs,
String(describing: bleStats)) String(describing: bleStats))
os_log("[BesCallDownlink] 下行积压总账 A{%{public}@} B{%{public}@}", log: Self.dlLog, type: .default, a, b) 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 trimmed = Double(leg.trimmedSamples) / sr
let dropped = Double(leg.droppedSamples) / sr let dropped = Double(leg.droppedSamples) / sr
let catchup = max(0, pushedSec - sentSec - trimmed - dropped - leg.backlogSec()) let catchup = max(0, pushedSec - sentSec - trimmed - dropped - leg.backlogSec())
return String(format: "源=%.1fs 播出=%.1fs 最大积压=%.1fs 裁静音=%.1fs 变速追回=%.1fs 兜底丢=%.1fs", return String(format: "源=%.1fs 播出=%.1fs 最大积压=%.1fs 裁静音=%.1fs 变速追回=%.1fs 兜底丢=%.1fs 断粮=%d次",
pushedSec, sentSec, Double(leg.maxBacklogSamples) / sr, trimmed, catchup, dropped) pushedSec, sentSec, Double(leg.maxBacklogSamples) / sr, trimmed, catchup, dropped, leg.starved)
} }
/// 下行发送器的实时统计,供 Dart 在切前后台等时刻取样。 /// 下行发送器的实时统计,供 Dart 在切前后台等时刻取样(形状不变)。
///
/// 这些数字原来只在 os_log 里,而排查后台卡顿时 `idevicesyslog` 极不稳定
/// (2026-09-21 断过两次),于是下行成了唯一看不见的一段。暴露出来之后,
/// Dart 侧可以把它写进文件日志(`devicectl device copy from` 直接拉),
/// 不再依赖 usbmuxd。
///
/// **gapAvgMs 是判断 iOS 后台节流的关键**:期望等于 intervalMs(16/20/25),
/// 明显变大就说明 DispatchSourceTimer 被系统合并触发了,下行必然断续。
/// queueA/B 现在是「未播的源音频」折算的帧数(积压),不再是编码队列长度。
public func stats() -> [String: Any] { public func stats() -> [String: Any] {
var out: [String: Any] = [:] var out: [String: Any] = [:]
queue.sync { queue.sync {
@ -225,8 +241,6 @@ public final class CallTranslationDownlink {
return out return out
} }
/// 取样之间重置峰值,否则 gapMaxMs 会被会话早期的一次尖峰永久占住,
/// 看不出「切到后台之后」到底有没有变差。
public func resetPeaks() { public func resetPeaks() {
queue.async { queue.async {
self.tickGapSumMs = 0; self.tickGapCount = 0; self.tickGapMaxMs = 0 self.tickGapSumMs = 0; self.tickGapCount = 0; self.tickGapMaxMs = 0
@ -234,28 +248,43 @@ public final class CallTranslationDownlink {
} }
} }
/// 耳机要求调整下行节奏(`0xD6`/`0xE9` 的 data[6]) /// 耳机调速帧的 data[6] 原值:0 = 缓冲空/起播,1 = 要快,2 = 正常,3 = 要慢。
public func setIntervalMs(_ ms: Int) { /// 收到 01 立刻补一包(并按 16ms 配额跟上持续的要求),00 补两包,03 扣一包。
public func onHeadsetPace(_ code: Int) {
queue.async { queue.async {
guard self.intervalMs != ms else { return } switch code {
self.intervalMs = ms case 0: self.budget += 2; self.applyInterval(20)
os_log("[BesCallDownlink] 下行节奏调整为 %dms", log: Self.dlLog, type: .info, ms) case 1: self.budget += 1; self.applyInterval(16)
if self.running { self.scheduleTimer() } 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() { private func scheduleTimer() {
timer?.cancel() timer?.cancel()
let t = DispatchSource.makeTimerSource(queue: queue) let t = DispatchSource.makeTimerSource(queue: queue)
t.schedule(deadline: .now(), repeating: .milliseconds(intervalMs)) // 固定节拍、永不重排;节奏差异全在 onTick 的配额里体现
t.setEventHandler { [weak self] in self?.sendOnePacket() } t.schedule(deadline: .now(), repeating: .milliseconds(Self.tickMs))
t.setEventHandler { [weak self] in self?.onTick() }
timer = t timer = t
t.resume() t.resume()
} }
/// 推一段待下发的 TTS PCM。 /// 推一段待下发的 TTS PCM。
/// [leg] "A" = 己方译文给对端听;"B" = 对端译文给己方听。 /// [leg] "A" = 己方译文给对端听;"B" = 对端译文给己方听。
/// [pcm] 16kHz / 16bit / mono / little-endian,长度任意。
public func pushPcm(leg: String, pcm: Data) { public func pushPcm(leg: String, pcm: Data) {
guard !pcm.isEmpty else { return } guard !pcm.isEmpty else { return }
let isA = leg.uppercased() == "A" || leg.lowercased() == "uplink" let isA = leg.uppercased() == "A" || leg.lowercased() == "uplink"
@ -277,10 +306,14 @@ public final class CallTranslationDownlink {
l.partialLen += take l.partialLen += take
i += take i += take
if l.partialLen == Self.frameSamples { if l.partialLen == Self.frameSamples {
let block = l.partial var block = l.partial
l.partialLen = 0 l.partialLen = 0
l.pushedFrames += 1 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 var acc = 0.0
for s in block { let d = Double(s); acc += d * d } for s in block { let d = Double(s); acc += d * d }
let rms = (acc / Double(block.count)).squareRoot() 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.silentRun = 0
l.trimming = false
return false return false
} }
l.silentRun += block.count l.silentRun += block.count
let keep = Self.silenceKeepMs * Self.sampleRate / 1000 let keep = Self.silenceKeepMs * Self.sampleRate / 1000
if l.silentRun > keep && l.backlogSec() > Self.silenceTrimStartSec { if l.silentRun > keep && l.backlogSec() > Self.silenceTrimStartSec {
l.trimmedSamples += block.count l.trimmedSamples += block.count
l.trimming = true
l.rampPending = true
return true return true
} }
return false return false
} }
/// 按积压深度算这一帧的播放速率。 /// 裁剪接缝处的短淡入
private static func rampIn(_ block: inout [Int16]) {
let n = min(rampSamples, block.count)
if n <= 0 { return }
for i in 0..<n { block[i] = Int16(Int(block[i]) * i / n) }
}
/// 按积压深度算目标速率。
private func rateFor(_ backlogSec: Double) -> Double { private func rateFor(_ backlogSec: Double) -> Double {
if backlogSec <= Self.catchupStartSec { return 1.0 } if backlogSec <= Self.catchupStartSec { return 1.0 }
if backlogSec >= Self.catchupFullSec { return Self.maxRate } if backlogSec >= Self.catchupFullSec { return Self.maxRate }
@ -331,9 +376,37 @@ public final class CallTranslationDownlink {
/// 取这条腿的下一帧编码码流;没东西可播返回 nil。只在 [queue] 上调用。 /// 取这条腿的下一帧编码码流;没东西可播返回 nil。只在 [queue] 上调用。
private func nextEncodedFrame(_ l: Leg) -> Data? { private func nextEncodedFrame(_ l: Leg) -> Data? {
guard let encoder = l.encoder else { return nil } 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 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 小端 = 本机字节序 let pcm = out.withUnsafeBufferPointer { Data(buffer: $0) } // Int16 小端 = 本机字节序
l.sentFrames += 1 l.sentFrames += 1
if let enc = encoder.encode(pcm), enc.count >= Self.frameEncodedBytes { if let enc = encoder.encode(pcm), enc.count >= Self.frameEncodedBytes {
@ -342,58 +415,94 @@ public final class CallTranslationDownlink {
return silenceFrame return silenceFrame
} }
/// 只在 [queue] 上调用 /// 这一路此刻没内容:用它的编码器编零,码流与前后帧连续。只在 [queue] 上调用。
private func sendOnePacket() { 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 } guard running else { return }
// 定时器真实节奏:DispatchSourceTimer 会为省电合并触发,而 handler 里还压着
// G.722 编码与一次 GATT 写。实际间隔一旦稳定大于 intervalMs,下行就是在欠发,
// 耳机侧必然断续 —— 这是「不流畅」和「队列只涨不落」的分水岭。
let now = CFAbsoluteTimeGetCurrent() let now = CFAbsoluteTimeGetCurrent()
if lastTickAt > 0 { let prevTick = lastTickAt
let gap = (now - lastTickAt) * 1000 if prevTick > 0 {
let gap = (now - prevTick) * 1000
tickGapSumMs += gap tickGapSumMs += gap
tickGapCount += 1 tickGapCount += 1
if gap > tickGapMaxMs { tickGapMaxMs = gap } if gap > tickGapMaxMs { tickGapMaxMs = gap }
} }
lastTickAt = now 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 b = nextEncodedFrame(legB)
let a = nextEncodedFrame(legA) let a = nextEncodedFrame(legA)
// 两路都没内容就不发,避免通话里灌满无谓的静音包 if b == nil && a == nil {
if b == nil && a == nil { return } if tailLeft <= 0 { wasIdle = true; return false }
tailLeft -= 1
} else {
tailLeft = Self.tailTicks
if wasIdle {
wasIdle = false
for _ in 0..<Self.prerollPackets { writeIdlePacket() }
}
}
var packet = Data(capacity: Self.packetSize) var packet = Data(capacity: Self.packetSize)
packet.append(contentsOf: [0xAA, 0x56, UInt8(Self.packetSize), 0x03]) packet.append(contentsOf: [0xAA, 0x56, UInt8(Self.packetSize), 0x03])
packet.append(b ?? silenceFrame) packet.append(b ?? idleFrame(legB))
packet.append(a ?? silenceFrame) packet.append(a ?? idleFrame(legA))
BluetoothManager.writeRealtimeAudio(packet) BluetoothManager.writeRealtimeAudio(packet)
sentPackets += 1 sentPackets += 1
// 每 100 包(约 2 秒)一条。积压持续 >2s 说明产出快于播放、追赶已介入。
if sentPackets == 1 || sentPackets % 100 == 0 { if sentPackets == 1 || sentPackets % 100 == 0 {
let avgGap = tickGapCount > 0 ? tickGapSumMs / Double(tickGapCount) : 0 let avgGap = tickGapCount > 0 ? tickGapSumMs / Double(tickGapCount) : 0
let ble = BluetoothManager.sharedInstance?.bleWriteStats() ?? [:] 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, log: Self.dlLog, type: .info,
sentPackets, legA.backlogSec(), legB.backlogSec(), legA.lastRate, legB.lastRate, sentPackets, legA.backlogSec(), legB.backlogSec(), legA.lastRate, legB.lastRate,
legA.starved, legB.starved, intervalMs,
avgGap, tickGapMaxMs, avgGap, tickGapMaxMs,
String(describing: ble["dropped"] ?? 0), String(describing: ble["dropped"] ?? 0),
String(describing: ble["blocked"] ?? 0), String(describing: ble["blocked"] ?? 0),
String(describing: ble["queued"] ?? 0)) String(describing: ble["queued"] ?? 0))
} }
return true
} }
/// WSOLA 变速不变调(Verhelst & Roelands),与 Android `Wsola` 同一套参数与步骤。 /// WSOLA 变速不变调(Verhelst & Roelands),与 Android `Wsola` 同一套参数与步骤。
///
/// 合成侧固定步长 S(10ms)、帧长 N(20ms)、Hann 50% 重叠相加;分析侧步长 = S × rate, /// 合成侧固定步长 S(10ms)、帧长 N(20ms)、Hann 50% 重叠相加;分析侧步长 = S × rate,
/// 每帧在名义位置 ±T 内搜索与「上一帧自然延续」最相似的起点(互相关),保证拼接处波形连续。 /// 每帧在名义位置 ±T 内搜索与「上一帧自然延续」最相似的起点。rate=1.0 时恒等于原信号。
/// rate=1.0 且偏移=0 时 Hann 50% 重叠相加恒等于原信号(Python 复刻验证:逐样本误差 0),
/// 所以不用在直通/变速间切换。附加时延 ≈ N + T 样本(≈26ms)。
///
/// 输入不够时:源队列已空 → 补零把尾巴冲出来(TTS 末尾本来就是静音);源队列还有 → 先取。
private final class Wsola { private final class Wsola {
private let N = 320 private let N = 320
private let S = 160 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 inBuf = [Int16](repeating: 0, count: 320 * 8)
private var inLen = 0 private var inLen = 0
private var anaPos = 0.0 private var anaPos = 0.0
@ -415,11 +524,15 @@ public final class CallTranslationDownlink {
for i in 0..<acc.count { acc[i] = 0 } for i in 0..<acc.count { acc[i] = 0 }
} }
/// 产出一帧输出至少要在输入里看到这么多前视样本
func lookahead() -> Int { T + N }
/// 变速器里还没播掉的样本(不含补的零) /// 变速器里还没播掉的样本(不含补的零)
func unreadSamples() -> Int { max(0, inLen - padded - Int(anaPos.rounded())) } func unreadSamples() -> Int { max(0, inLen - padded - Int(anaPos.rounded())) }
/// 取 20ms 输出;返回 nil 表示这条腿当前没有东西可播。 /// 取 20ms 输出;返回 nil 表示这条腿当前没有东西可播。
func nextFrame(rate: Double, leg: Leg) -> [Int16]? { /// [allowPad] 为 false 时(句子没结束)不补零,宁可返回 nil。
func nextFrame(rate: Double, leg: Leg, allowPad: Bool) -> [Int16]? {
var produced = 0 var produced = 0
let frame = CallTranslationDownlink.frameSamples let frame = CallTranslationDownlink.frameSamples
while produced < frame { while produced < frame {
@ -431,6 +544,7 @@ public final class CallTranslationDownlink {
if inLen < need { if inLen < need {
let realLeft = inLen - padded - nominal let realLeft = inLen - padded - nominal
if produced == 0 && realLeft <= 0 { return nil } if produced == 0 && realLeft <= 0 { return nil }
if !allowPad && produced == 0 { return nil }
let pad = need - inLen let pad = need - inLen
ensure(need) ensure(need)
for i in inLen..<need { inBuf[i] = 0 } for i in inLen..<need { inBuf[i] = 0 }
@ -478,7 +592,6 @@ public final class CallTranslationDownlink {
} }
private func append(_ block: [Int16]) { private func append(_ block: [Int16]) {
// 之前补的零已经被新到的音频"夹"在中间,当成内容算(最多多算 26ms)
padded = 0 padded = 0
ensure(inLen + block.count) ensure(inLen + block.count)
for i in 0..<block.count { inBuf[inLen + i] = block[i] } for i in 0..<block.count { inBuf[inLen + i] = block[i] }
@ -491,7 +604,6 @@ public final class CallTranslationDownlink {
} }
} }
/// 丢掉已经用不到的输入前缀,避免无限增长。
private func compact() { private func compact() {
let keepFrom = min(Int(anaPos.rounded()), prevEnd) - T - N let keepFrom = min(Int(anaPos.rounded()), prevEnd) - T - N
if keepFrom > N * 4 { if keepFrom > N * 4 {

Loading…
Cancel
Save