|
|
@ -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 { |
|
|
|