Browse Source

上传客户端代码

newdev_shunjiawei
liwei1dao 2 months ago
parent
commit
401d97e622
  1. 379
      flutter_02.log
  2. 22
      lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart
  3. 54
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt
  4. 6
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt
  5. 94
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  6. 6
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift
  7. 114
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift
  8. 26
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift
  9. 289
      tools/ble_log.sh

379
flutter_02.log

File diff suppressed because one or more lines are too long

22
lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart

@ -12,17 +12,21 @@ class CallTranslationDebugController extends GetxController {
final GetStorage _storage = GetStorage();
// 持久化键
static const String _kSendInterval = 'call_trans_debug_send_interval';
// 码率默认值 16→8kbps、发送间隔默认值 100→40ms,键名升版丢弃旧偏好,确保新默认生效
static const String _kSendInterval = 'call_trans_debug_send_interval_v2';
static const String _kBundleFrameCount = 'call_trans_debug_bundle_frames';
static const String _kUplinkBitrate = 'call_trans_debug_uplink_bitrate';
static const String _kDownlinkBitrate = 'call_trans_debug_downlink_bitrate';
// 默认值(与原生默认保持一致)
static const int defaultSendIntervalMs = 100;
static const String _kUplinkBitrate = 'call_trans_debug_uplink_bitrate_v2';
static const String _kDownlinkBitrate = 'call_trans_debug_downlink_bitrate_v2';
// 默认值(与原生默认保持一致,见 BleService.kt DEFAULT_AUDIO_SEND_INTERVAL_NORMAL = 40L)
// 注意:发送间隔必须 ≤ 合包产生周期。左右各每 20ms 出 1 帧、每 bundleFrameCount 帧合 1 包,
// 双声道合计每 (20 × bundleFrameCount ÷ 2) ms 产生 1 包 —— 默认 4 帧即 40ms/包。
// 间隔一旦大于这个值,发送速率追不上产生速率,队列会持续积压、时延无上限增长。
static const int defaultSendIntervalMs = 40;
static const int defaultBundleFrameCount = 4;
// 上行/下行默认每声道码率(kbps):对应上行 packetSize=40、下行 packetSize=80
static const int defaultUplinkBitrateKbps = 16;
static const int defaultDownlinkBitrateKbps = 16;
// 上行/下行默认每声道码率(kbps):对应上行 packetSize=20、下行 packetSize=40
static const int defaultUplinkBitrateKbps = 8;
static const int defaultDownlinkBitrateKbps = 8;
// 取值范围
static const int minSendIntervalMs = 20;

54
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt

@ -32,7 +32,15 @@ class BleCommandSender {
/** 指令队列 */
private val commandQueue: Queue<ByteArray> = LinkedList()
/** 通话翻译心跳包(内容固定,预先成帧,避免每 50ms 重复建包与算CRC) */
private val heartbeatPacket: ByteArray by lazy {
createCommandPacket(
BleConst.CMD_CALL_TRANSLATION_HEARTBEAT.toByte(),
byteArrayOf(BleConst.CALL_TRANSLATION_HEARTBEAT_PAYLOAD.toByte())
)
}
/** 是否正在等待回复 */
var isWaitingReply = true
private set
@ -128,6 +136,7 @@ class BleCommandSender {
* @param cmdData 命令数据
* @return 是否成功发送
*/
@Synchronized
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun sendCommand(cmdType: Byte, cmdData: ByteArray = byteArrayOf()): Boolean {
val callback = this.callback ?: run {
@ -181,6 +190,39 @@ class BleCommandSender {
}
}
/**
* 立即发送一包通话翻译心跳(cmd=0xFF, data=[0xFF]),不入队列、不等待设备回复。
*
* 心跳按 50ms 一拍持续下发,设备侧不回 ACK:若走 sendCommand 的常规路径,
* isWaitingReply 会一直为 false,心跳只能靠 1s 超时逐个排空,队列会以 20 包/秒的速度积压,
* 并把正常指令挤在后面。因此心跳单独走直写路径,且不参与 isWaitingReply 状态机。
*
* 若此刻有普通指令在途(isWaitingReply == false),本拍心跳跳过:
* 同一时刻只允许一次 GATT 写在途,抢写只会失败,下一拍(50ms 后)再发即可。
*
* @return 是否成功写入本拍心跳
*/
// 与 sendCommand/processNextCommand 互斥:writeChar.value 是同一个共享缓冲,
// 心跳线程若在主线程"填包 → 写入"之间插入,会把待发的普通指令内容覆盖掉
@Synchronized
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun sendCallTranslationHeartbeat(): Boolean {
val callback = this.callback ?: return false
if (!callback.isConnected()) return false
// 普通指令在途,让位给它,本拍不发
if (!isWaitingReply) return false
return try {
val writeChar = callback.getWriteCharacteristic() ?: return false
val gatt = callback.getBluetoothGatt() ?: return false
writeChar.value = heartbeatPacket
gatt.writeCharacteristic(writeChar) == true
} catch (e: Exception) {
Log.e(TAG, "发送通话翻译心跳异常: ${e.message}", e)
false
}
}
/**
* 处理命令响应,重置等待状态并处理下一个命令
* @param responseCommandType 响应的命令类型
@ -217,7 +259,14 @@ class BleCommandSender {
val cmdType = data[1].toInt() and 0xFF
val lenByte = data[2].toInt() and 0xFF
// 心跳(0xFF)不参与命令状态机:心跳本身就是直写下发的,
// 若在此推进 isWaitingReply/队列,会把真正在途指令的等待状态提前放开
if (cmdType == BleConst.CMD_CALL_TRANSLATION_HEARTBEAT) {
Log.d(TAG, "[CALL_TRANS_HB] 收到心跳响应,忽略")
return
}
// 正常响应处理:尽量进行 CRC 校验
var crcOk = true
@ -766,6 +815,7 @@ class BleCommandSender {
/**
* 处理队列中的下一个命令
*/
@Synchronized
private fun processNextCommand() {
if (commandQueue.isEmpty()) {
return

6
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt

@ -105,6 +105,12 @@ object BleConst {
const val CMD_CALL_TRANSLATION_ON = 0x16
/** 设备上报关闭通话翻译 0x17 (帧头0xCC, cmdLen=0x01, status=0xFF) */
const val CMD_CALL_TRANSLATION_OFF = 0x17
/** 通话翻译心跳 0xFF (帧头0xAA, cmdLen=0x01, data=0xFF),无业务内容,仅用于维持链路数据频率 */
const val CMD_CALL_TRANSLATION_HEARTBEAT = 0xFF
/** 通话翻译心跳数据位(占位,无业务含义) */
const val CALL_TRANSLATION_HEARTBEAT_PAYLOAD = 0xFF
/** 通话翻译心跳发送间隔(ms) */
const val CALL_TRANSLATION_HEARTBEAT_INTERVAL_MS = 50L
// ================== 语音唤醒状态值 ==================
/** 语音唤醒关闭 */

94
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt

@ -160,15 +160,15 @@ object BleService {
@Volatile
private var audioSendIntervalNormal = DEFAULT_AUDIO_SEND_INTERVAL_NORMAL
// 上行编码 packetSize(字节):每声道独立 mono 流每帧字节数,默认 40 = 每声道 16kbps@20ms。
// 上行编码 packetSize(字节):每声道独立 mono 流每帧字节数,默认 20 = 每声道 8kbps@20ms。
// 调试界面可修改;因编码器创建时才读取,需重新启动通话翻译才生效。
@Volatile
private var uplinkPacketSize = 40
private var uplinkPacketSize = 20
// 下行解码 packetSize(字节):立体声合并流每帧字节数,默认 80 = 每声道 16kbps@20ms。
// 下行解码 packetSize(字节):立体声合并流每帧字节数,默认 40 = 每声道 8kbps@20ms。
// 需与设备固件编码 packetSize 一致,否则解码错位;同样需重新启动通话翻译才生效。
@Volatile
private var downlinkPacketSize = 80
private var downlinkPacketSize = 40
// 左/右声道休眠标记(true 表示该声道处于休眠,暂停下发)。
// 收到 F0(左)/F1(右) 时置 true,收到 F2(左)/F3(右) 恢复指令时置 false。
// 休眠期间该声道的包暂不发送(转入积压缓存),仅传输另一声道,恢复后按序补发,不丢包。
@ -210,7 +210,34 @@ object BleService {
// 初始化状态
private var isInitialized = false
// 通话翻译心跳相关:独立 HandlerThread 起拍,避免 50ms 的定时任务压在主线程上
@Volatile
private var isHeartbeatRunning = false
private var heartbeatThread: HandlerThread? = null
private var heartbeatHandler: Handler? = null
// 心跳发送失败(链路忙/普通指令在途)的连续计数,仅用于节流日志,成功即清零
private var heartbeatFailCount = 0
private val heartbeatRunnable = object : Runnable {
override fun run() {
if (!isHeartbeatRunning) return
try {
if (commandSender.sendCallTranslationHeartbeat()) {
heartbeatFailCount = 0
} else {
heartbeatFailCount++
// 每积累 20 次(约1秒)失败告警一次,避免刷屏
if (heartbeatFailCount % 20 == 0) {
Log.w(TAG, "[CALL_TRANS_HB] 心跳连续未发出 ${heartbeatFailCount} 次(链路忙或未连接)")
}
}
} catch (e: Exception) {
Log.e(TAG, "[CALL_TRANS_HB] 心跳发送异常: ${e.message}", e)
}
heartbeatHandler?.postDelayed(this, BleConst.CALL_TRANSLATION_HEARTBEAT_INTERVAL_MS)
}
}
// ======================================================================================================
// 指令发送和管理相关变量(整合自BleCommandSender)
// ======================================================================================================
@ -864,6 +891,8 @@ object BleService {
}
isReply = true;
commandSender.clearCommandQueue()
// 连接已断开,心跳无处可发,停掉定时器
stopCallTranslationHeartbeat()
bluetoothGatt = null
notifyChar = null
writeChar = null
@ -1394,7 +1423,8 @@ object BleService {
/**
* 开始Opus数据流编码(通话翻译模式:左右声道独立编码)
* 输出合包格式:[4B 序号(大端 uint32)] + [1B 声道(0=左/1=右)] + [5 × opus(40B)] = 205B
* 输出合包格式:[4B 序号(大端 uint32)] + [1B 声道(0=左/1=右)] + [N × opus(uplinkPacketSize 字节)]
* 默认 N=4、uplinkPacketSize=20(8kbps@20ms),即每包 85B
* @return 是否成功开始编码
*/
private fun startOpusEncodeStream(): Boolean {
@ -1417,7 +1447,7 @@ object BleService {
/**
* 将一包已成型的音频合帧加入下行发送队列
* 包格式:[4B 序号(大端 uint32)] + [1B 声道(0=左/1=右)] + [5 × opus(40B)]
* 包格式:[4B 序号(大端 uint32)] + [1B 声道(0=左/1=右)] + [N × opus(uplinkPacketSize 字节)]
* 双声道独立编码模式下,每次回调送来的就是一整包,直接入队,不再二次切片
* @param data 待发送的合帧包
*/
@ -1762,6 +1792,47 @@ object BleService {
Log.e(TAG, failureReason.toString())
}
// ======================================================================================================
// 通话翻译心跳 - 50ms 一拍,维持链路数据频率
// ======================================================================================================
/**
* 启动通话翻译心跳:按 CALL_TRANSLATION_HEARTBEAT_INTERVAL_MS 固定节拍下发 0xFF 指令。
* 通话翻译期间链路一旦出现空闲,连接事件会被降频,导致上下行音频抖动;
* 心跳保证链路上始终有数据在跑。
* 心跳走直写路径,不占用指令队列与等待回复状态机,详见 BleCommandSender.sendCallTranslationHeartbeat()。
* 可重复调用(幂等),A2DP 解码器重试多轮时不会起多份定时器。
*/
fun startCallTranslationHeartbeat() {
if (isHeartbeatRunning) {
Log.d(TAG, "[CALL_TRANS_HB] 心跳已在运行,跳过重复启动")
return
}
val thread = HandlerThread("ble-call-heartbeat").apply { start() }
heartbeatThread = thread
heartbeatHandler = Handler(thread.looper)
isHeartbeatRunning = true
heartbeatHandler?.post(heartbeatRunnable)
Log.i(
TAG,
"[CALL_TRANS_HB] 通话翻译心跳已启动 cmd=0x${"%02X".format(BleConst.CMD_CALL_TRANSLATION_HEARTBEAT)} " +
"间隔=${BleConst.CALL_TRANSLATION_HEARTBEAT_INTERVAL_MS}ms"
)
}
/**
* 停止通话翻译心跳并释放心跳线程。可重复调用(幂等)。
*/
fun stopCallTranslationHeartbeat() {
if (!isHeartbeatRunning && heartbeatThread == null) return
isHeartbeatRunning = false
heartbeatHandler?.removeCallbacks(heartbeatRunnable)
heartbeatThread?.quitSafely()
heartbeatHandler = null
heartbeatThread = null
Log.i(TAG, "[CALL_TRANS_HB] 通话翻译心跳已停止")
}
// ======================================================================================================
// 通话翻译调试参数 - 运行时可调
// ======================================================================================================
@ -1858,6 +1929,8 @@ object BleService {
*/
fun closeCodec(): Boolean {
Log.i(TAG, "关闭编解码...")
// 退出通话翻译,停止心跳
stopCallTranslationHeartbeat()
recordfile?.closeFile()
recordfile1?.closeFile()
val cmdData = byteArrayOf(
@ -1899,6 +1972,8 @@ object BleService {
)
val ok = commandSender.sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), cmdData)
Log.i(TAG, "[CALL_TRANS_CMD] 通话翻译指令下发 cmd=0x05 sub=0xA2 result=$ok bytes=${cmdData.joinToString(",") { "0x%02X".format(it) }}")
// 进入通话翻译,开始下发心跳维持链路数据频率
startCallTranslationHeartbeat()
return ok
}
@ -2166,7 +2241,10 @@ object BleService {
*/
fun cleanup() {
Log.i(TAG, "清理BLE服务资源...")
// 停止通话翻译心跳
stopCallTranslationHeartbeat()
// 重置指令发送器
commandSender.reset()

6
local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift

@ -100,6 +100,12 @@ class BleConst {
static let CMD_CALL_TRANSLATION_ON: UInt8 = 0x16
/** 设备上报关闭通话翻译 0x17 (帧头0xCC, cmdLen=0x01, status=0xFF) */
static let CMD_CALL_TRANSLATION_OFF: UInt8 = 0x17
/** 通话翻译心跳 0xFF (帧头0xAA, cmdLen=0x01, data=0xFF),无业务内容,仅用于维持链路数据频率 */
static let CMD_CALL_TRANSLATION_HEARTBEAT: UInt8 = 0xFF
/** 通话翻译心跳数据位(占位,无业务含义) */
static let CALL_TRANSLATION_HEARTBEAT_PAYLOAD: UInt8 = 0xFF
/** 通话翻译心跳发送间隔(秒),即 50ms */
static let CALL_TRANSLATION_HEARTBEAT_INTERVAL: TimeInterval = 0.05
// ================== 语音唤醒状态值 ==================
/** 语音唤醒关闭 */

114
local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift

@ -104,6 +104,8 @@ public class BleService: NSObject {
private var sampleRate: Int32 = 16000
private var channels: Int32 = 1
private var frameSize: Int32 = 640
// 通话翻译上行编码码率(每声道 kbps):8kbps@20ms → 20 字节/帧,与 Android 端 uplinkPacketSize=20 保持一致
private let CALL_TRANS_UPLINK_BITRATE_KBPS: Int32 = 8
// MARK: - 音频数据发送相关属性
private var audioBuffer = [UInt8]() // 音频数据缓冲区
@ -122,6 +124,22 @@ public class BleService: NSObject {
private let nextSendEarliestLock = NSLock()
private let audioQueueLock = NSLock() // 队列操作锁
// MARK: - 通话翻译心跳相关属性
// 心跳独立起拍队列,不占用音频发送队列,也不压主线程
private let heartbeatQueue = DispatchQueue(label: "com.yunqiinnovation.ble_service.call_heartbeat", qos: .userInitiated)
private var heartbeatTimer: DispatchSourceTimer?
private var isHeartbeatRunning = false
// 心跳包内容固定,预先成帧,避免每 50ms 重复建包与算CRC
private lazy var heartbeatPacket: Data = {
var packet = Data()
packet.append(BleConst.APP_REQUEST_HEADER) // 帧头 0xAA
packet.append(BleConst.CMD_CALL_TRANSLATION_HEARTBEAT) // 指令 0xFF
packet.append(UInt8(1)) // 长度 0x01
packet.append(BleConst.CALL_TRANSLATION_HEARTBEAT_PAYLOAD) // 数据 0xFF
packet.append(self.calculateCRC8(data: packet)) // CRC8/MAXIM
return packet
}()
//private var currentCommand: Command?
// 命令结构体
@ -480,7 +498,10 @@ public class BleService: NSObject {
if centralManager != nil {
isManualDisconnect = true // 标记为主动断开
// 连接即将断开,心跳无处可发,停掉定时器
stopCallTranslationHeartbeat()
// 停止音频发送定时器
stopAudioSendTimer()
stopOpusRecording()
@ -753,8 +774,13 @@ public class BleService: NSObject {
// 确保Opus解码器处于活跃状态(如果不是则启动)
startOpusDecoding(sampleRate: 16000, channels: 2, frameSize: 1280)
opusProcessor?.startEncoding(sampleRate: 16000, channels: 1, frameRate: 20)
// 通话翻译上行编码:8kbps/声道 @20ms = 20 字节/帧,与 Android 端 uplinkPacketSize=20 对齐
opusProcessor?.startEncoding(
sampleRate: 16000,
channels: 1,
frameRate: 20,
bitrateKbps: CALL_TRANS_UPLINK_BITRATE_KBPS
)
os_log("[CALL_TRANS_CMD] 打开A2DP解码并开始录制(通话翻译)...", log: logger, type: .info)
let paramData = Data([BleConst.CODEC_CONTROL_A2DP_PLAY, BleConst.AUDIO_CHANNEL_STEREO])
@ -762,6 +788,8 @@ public class BleService: NSObject {
os_log("[CALL_TRANS_CMD] 通话翻译指令下发 cmd=0x05 sub=0xA2 result=%{public}@ bytes=[0x%02X,0x%02X]",
log: logger, type: .info,
ok ? "成功" : "失败", BleConst.CODEC_CONTROL_A2DP_PLAY, BleConst.AUDIO_CHANNEL_STEREO)
// 进入通话翻译,开始下发心跳维持链路数据频率
startCallTranslationHeartbeat()
return ok
}
/// 打开通话记录解码器并开始录制
@ -776,9 +804,10 @@ public class BleService: NSObject {
/// - Returns: 操作是否成功
public func closeCodec() -> Bool {
os_log("关闭编解码并停止录制...", log: logger, type: .info)
// 退出通话翻译,停止心跳
stopCallTranslationHeartbeat()
// 停止音频发送定时器
stopAudioSendTimer()
@ -949,6 +978,67 @@ public class BleService: NSObject {
processNextCommand()
}
// MARK: - 通话翻译心跳
/// 启动通话翻译心跳:按 CALL_TRANSLATION_HEARTBEAT_INTERVAL(50ms) 固定节拍下发 0xFF 指令。
/// 通话翻译期间链路一旦出现空闲,连接事件会被降频,导致上下行音频抖动;心跳保证链路上始终有数据在跑。
/// 心跳直写特征值,不进命令队列、不等设备回复,详见 sendCallTranslationHeartbeat()。
/// 可重复调用(幂等),A2DP 解码器重试多轮时不会起多份定时器。
func startCallTranslationHeartbeat() {
heartbeatQueue.async { [weak self] in
guard let self = self else { return }
guard !self.isHeartbeatRunning else {
os_log("[CALL_TRANS_HB] 心跳已在运行,跳过重复启动", log: self.logger, type: .debug)
return
}
self.isHeartbeatRunning = true
let timer = DispatchSource.makeTimerSource(queue: self.heartbeatQueue)
timer.schedule(deadline: .now() + BleConst.CALL_TRANSLATION_HEARTBEAT_INTERVAL,
repeating: BleConst.CALL_TRANSLATION_HEARTBEAT_INTERVAL,
leeway: .milliseconds(5))
timer.setEventHandler { [weak self] in
self?.sendCallTranslationHeartbeat()
}
self.heartbeatTimer = timer
timer.resume()
os_log("[CALL_TRANS_HB] 通话翻译心跳已启动 cmd=0x%02X 间隔=%.0fms",
log: self.logger, type: .info,
BleConst.CMD_CALL_TRANSLATION_HEARTBEAT,
BleConst.CALL_TRANSLATION_HEARTBEAT_INTERVAL * 1000)
}
}
/// 停止通话翻译心跳。可重复调用(幂等)。
func stopCallTranslationHeartbeat() {
heartbeatQueue.async { [weak self] in
guard let self = self else { return }
guard self.isHeartbeatRunning || self.heartbeatTimer != nil else { return }
self.isHeartbeatRunning = false
self.heartbeatTimer?.cancel()
self.heartbeatTimer = nil
os_log("[CALL_TRANS_HB] 通话翻译心跳已停止", log: self.logger, type: .info)
}
}
/// 发送一拍心跳:直接写指令特征值,不入队列、不等待回复。
/// 心跳 50ms 一拍且设备不回 ACK,若走 sendCommand 常规路径,isReply 会一直为 false,
/// 心跳只能靠 1s 超时逐个排空,队列会以 20 包/秒积压并把正常指令挤在后面。
/// 普通指令在途(isReply == false)时本拍让位,50ms 后再发。
private func sendCallTranslationHeartbeat() {
guard let characteristic = writeCharacteristic,
let peripheral = peripheralDevice,
connectionState == BleConst.STATE_CONNECTED else {
return
}
// 普通指令在途,让位给它,本拍不发(与 Android 对齐)
guard isReply else { return }
peripheral.writeValue(heartbeatPacket, for: characteristic, type: .withoutResponse)
}
/// 计算CRC8/MAXIM校验值(与Android端保持一致)
private func calculateCRC8(data: Data) -> UInt8 {
var crc: Int = 0
@ -1069,6 +1159,13 @@ public class BleService: NSObject {
return
}
// 心跳(0xFF)不参与命令状态机:心跳本身就是直写下发的,
// 若在此推进 isReply/队列,会把真正在途指令的等待状态提前放开
guard cmd != BleConst.CMD_CALL_TRANSLATION_HEARTBEAT else {
os_log("[CALL_TRANS_HB] 收到心跳响应,忽略", log: logger, type: .debug)
return
}
// 记录响应详情
var hexString = data.map { String(format: "0x%02X", $0) }.joined(separator: ", ")
// os_log("liwei---- 收到设备响应: %@", log: logger, type: .debug, hexString)
@ -1472,6 +1569,7 @@ public class BleService: NSObject {
/// 清理所有资源
func cleanup() {
stopCallTranslationHeartbeat()
stopScan()
disconnect()
@ -1999,7 +2097,9 @@ extension BleService: CBCentralManagerDelegate {
updateConnectionState(BleConst.STATE_DISCONNECTED)
stopOpusRecording()
// 连接已断开,心跳无处可发,停掉定时器
stopCallTranslationHeartbeat()
// 只有在非主动断开的情况下才重新连接
if !isManualDisconnect {
if central.state == .poweredOn {

26
local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift

@ -372,32 +372,32 @@ class SwiftOpusAudioProcessor: NSObject {
/// - sampleRate: 采样率(默认16000Hz)
/// - channels: 声道数(默认1)
/// - frameRate: 帧长度(默认20ms)
/// - bitrateKbps: 每声道码率(kbps,默认 8),决定每帧字节数 = 码率 × 帧长 ÷ 8
/// - isHeader: 是否包含头部信息
/// - Returns: 是否启动成功
func startEncoding(sampleRate: Int32 = 16000,
channels: Int32 = 1,
func startEncoding(sampleRate: Int32 = 16000,
channels: Int32 = 1,
frameRate: Int32 = 20,
bitrateKbps: Int32 = 8,
isHeader: Bool = false) -> Bool {
if isEncodingActive {
return true
}
print("启动JLOpusEncoder编码 - 采样率: \(sampleRate)Hz, 声道: \(channels), 帧长度: \(frameRate)ms, 头部: \(isHeader)")
print("启动JLOpusEncoder编码 - 采样率: \(sampleRate)Hz, 声道: \(channels), 帧长度: \(frameRate)ms, 码率: \(bitrateKbps)kbps/声道, 头部: \(isHeader)")
// 创建JLOpusFormat配置
encoderFormat = JLOpusFormat.defaultFormats()
encoderFormat?.sampleRate = Int32(sampleRate)
encoderFormat?.channels = Int32(channels)
encoderFormat?.frameDuration = Int32(frameRate)
encoderFormat?.bitRate = bitrateKbps * 1000 * channels
encoderFormat?.hasDataHeader = isHeader
// 根据配置计算数据大小
if channels == 1 {
encoderFormat?.dataSize = frameRate == 20 ? 40 : 80 // 20ms=40bytes, 40ms=80bytes
} else {
encoderFormat?.dataSize = frameRate == 20 ? 80 : 160 // 双声道翻倍
}
// 每帧字节数 = 每声道码率(kbps) × 帧长(ms) ÷ 8,双声道翻倍
// 默认 8kbps@20ms 单声道 = 20 字节/帧
encoderFormat?.dataSize = bitrateKbps * frameRate * channels / 8
guard let format = encoderFormat else {
print("编码器格式配置失败")
return false

289
tools/ble_log.sh

@ -0,0 +1,289 @@
#!/usr/bin/env bash
#
# BLE 通话翻译日志抓取 / 过滤工具
#
# 用途:排查"通话中 BLE buff 溢出且不恢复"问题,把 Android / iOS / Dart 三处日志
# 按同一套关键词过滤出来,边看边落盘,方便和设备端同事对时间轴。
#
# 常用:
# ./tools/ble_log.sh android # 实时抓 Android BLE 日志(同时写文件)
# ./tools/ble_log.sh android -k # 只看通话翻译相关行(强过滤)
# ./tools/ble_log.sh ios # 实时抓 iOS 真机日志
# ./tools/ble_log.sh pull-android # 拉 app 内的 Dart 日志文件 app_*.log
# ./tools/ble_log.sh pull-ios # 同上(iOS,需 devicectl)
# ./tools/ble_log.sh filter <日志文件> # 从已有日志里筛通话翻译相关行
# ./tools/ble_log.sh stat <日志文件> # 快速统计:拥塞/休眠/发送节拍
#
set -uo pipefail
APP_ID="com.saitong.voitrans"
IOS_SUBSYSTEM="com.yunqiinnovation.ble_service"
# Android 需要抓的 TAG(flutter 用于捞 Dart 侧 Logger 输出)
ANDROID_TAGS=(
BleService
BleCommandSender
BleServicePlugin
BleCompanionManager
OpusAudioManager
flutter
)
# ---------------------------------------------------------------------------
# 通话翻译相关关键词。
# 前半段是代码里已有的日志标记,后半段 CT_* 是排查期建议补充的聚合日志标记
# (每秒吞吐 CT_RATE / 设备流控 CT_FLOW / 写入背压 CT_TX / 心跳 CT_HB)。
# 新增日志时统一用 [CT_xxx] 前缀,这里就不用再改。
# ---------------------------------------------------------------------------
CT_PATTERN='CT_RATE|CT_FLOW|CT_TX|CT_HB|CALL_TRANS_CMD|CALL_TRANS_HB|CALL_TRANS_DEBUG'
CT_PATTERN="$CT_PATTERN"'|音频下行发送|下行写入拥塞|下行写入已恢复|音频发送队列已满|音频发送线程|声道积压缓存'
CT_PATTERN="$CT_PATTERN"'|休眠|补发积压|恢复左声道|恢复右声道|通话翻译|编解码|心跳'
CT_PATTERN="$CT_PATTERN"'|连接参数|连接优先级|onConnectionUpdated|MTU|mtu'
CT_PATTERN="$CT_PATTERN"'|已连接|已断开|连接断开|重连|GATT'
OUT_DIR="${BLE_LOG_DIR:-./ble_logs}"
usage() {
# 打印文件头部的注释块(到第一个非注释行为止)
awk 'NR==1 {next} /^#/ {sub(/^# ?/, ""); print; next} {exit}' "$0"
exit "${1:-0}"
}
die() { echo "错误: $*" >&2; exit 1; }
ts() { date +%Y%m%d_%H%M%S; }
need() { command -v "$1" >/dev/null 2>&1 || die "未找到命令 $1,请先安装"; }
# 生成输出文件路径
out_file() {
mkdir -p "$OUT_DIR"
echo "$OUT_DIR/$1_$(ts).log"
}
# ---------------------------------------------------------------------------
# Android 实时抓取
# ---------------------------------------------------------------------------
cmd_android() {
local keyword_only=0 all_history=0 outfile=""
while [[ $# -gt 0 ]]; do
case "$1" in
-k|--keyword) keyword_only=1; shift ;;
-a|--all) all_history=1; shift ;;
-o|--out) outfile="$2"; shift 2 ;;
*) die "未知参数 $1" ;;
esac
done
need adb
adb get-state >/dev/null 2>&1 || die "adb 没有连接到设备(adb devices 检查一下)"
[[ -z "$outfile" ]] && outfile="$(out_file android)"
# 只保留关心的 TAG,其余全部静音(*:S)
local specs=()
for t in "${ANDROID_TAGS[@]}"; do specs+=("$t:V"); done
specs+=("AndroidRuntime:E" "*:S")
echo "==> 抓取 Android 日志,落盘: $outfile"
echo " TAG: ${ANDROID_TAGS[*]}"
[[ $keyword_only -eq 1 ]] && echo " 模式: 仅通话翻译关键词"
echo " Ctrl+C 结束"
echo
# -T 1 表示从当前时刻开始,避免历史日志刷屏;-a 则抓取缓冲区里的全部历史
local head_args=(-v threadtime)
[[ $all_history -eq 0 ]] && head_args+=(-T 1)
adb logcat -c 2>/dev/null || true
if [[ $keyword_only -eq 1 ]]; then
adb logcat "${head_args[@]}" "${specs[@]}" \
| grep --line-buffered -E "$CT_PATTERN" \
| tee "$outfile"
else
adb logcat "${head_args[@]}" "${specs[@]}" | tee "$outfile"
fi
}
# ---------------------------------------------------------------------------
# iOS 实时抓取
# 真机优先用 idevicesyslog(libimobiledevice,brew install libimobiledevice)
# 模拟器 / 本机用 log stream
# ---------------------------------------------------------------------------
cmd_ios() {
local keyword_only=0 use_sim=0 outfile=""
while [[ $# -gt 0 ]]; do
case "$1" in
-k|--keyword) keyword_only=1; shift ;;
--sim) use_sim=1; shift ;;
-o|--out) outfile="$2"; shift 2 ;;
*) die "未知参数 $1" ;;
esac
done
[[ -z "$outfile" ]] && outfile="$(out_file ios)"
echo "==> 抓取 iOS 日志,落盘: $outfile"
echo " Ctrl+C 结束"
echo
local filter_cmd=(cat)
[[ $keyword_only -eq 1 ]] && filter_cmd=(grep --line-buffered -E "$CT_PATTERN")
if [[ $use_sim -eq 1 ]]; then
# 模拟器 / 本机:按 subsystem + 进程名过滤
log stream --style syslog --level debug \
--predicate "subsystem == \"$IOS_SUBSYSTEM\" OR process == \"Runner\"" \
| "${filter_cmd[@]}" | tee "$outfile"
elif command -v idevicesyslog >/dev/null 2>&1; then
# 真机:idevicesyslog 最稳,能同时拿到 os_log 和 Dart print
idevicesyslog -m "" 2>/dev/null \
| grep --line-buffered -E "ble_service|BleService|Runner|flutter|$CT_PATTERN" \
| "${filter_cmd[@]}" | tee "$outfile"
else
echo "提示: 未安装 idevicesyslog,真机日志建议 brew install libimobiledevice" >&2
echo " 现在退回 log stream(新版 macOS 对真机支持受限,若无输出请用 Console.app)" >&2
echo >&2
log stream --style syslog --level debug \
--predicate "subsystem == \"$IOS_SUBSYSTEM\" OR process == \"Runner\"" \
| "${filter_cmd[@]}" | tee "$outfile"
fi
}
# ---------------------------------------------------------------------------
# 拉取 app 内的 Dart 日志文件(Logger 写的 app_YYYY-MM-DD*.log)
# ---------------------------------------------------------------------------
cmd_pull_android() {
need adb
adb get-state >/dev/null 2>&1 || die "adb 没有连接到设备"
local dest="$OUT_DIR/dart_android_$(ts)"
mkdir -p "$dest"
# getApplicationDocumentsDirectory() → /data/data/<pkg>/app_flutter
local remote_dir="app_flutter/logs"
echo "==> 从 $APP_ID 拉取 $remote_dir → $dest"
local files
files="$(adb exec-out run-as "$APP_ID" ls "$remote_dir" 2>/dev/null)" || {
die "run-as 失败。release 包不可调试,请改用 debug/profile 包,或让 app 提供日志导出入口"
}
[[ -z "$files" ]] && die "设备上没有日志文件(app 可能还没初始化 Logger)"
while IFS= read -r f; do
[[ -z "$f" ]] && continue
echo " - $f"
adb exec-out run-as "$APP_ID" cat "$remote_dir/$f" > "$dest/$f"
done <<< "$files"
echo
echo "完成: $dest"
ls -lh "$dest"
}
cmd_pull_ios() {
need xcrun
local dest="$OUT_DIR/dart_ios_$(ts)"
mkdir -p "$dest"
local udid
udid="$(xcrun devicectl list devices 2>/dev/null | awk '/connected/ {print $(NF-1); exit}')"
[[ -z "$udid" ]] && die "未找到已连接的 iOS 设备(xcrun devicectl list devices 检查一下)"
echo "==> 从设备 $udid 拉取 Documents/logs → $dest"
xcrun devicectl device copy from \
--device "$udid" \
--domain-type appDataContainer \
--domain-identifier "$APP_ID" \
--source "Documents/logs" \
--destination "$dest" \
|| die "拉取失败。备选方案:Xcode → Window → Devices and Simulators → 选中 app → Download Container"
echo "完成: $dest"
ls -lh "$dest"
}
# ---------------------------------------------------------------------------
# 离线过滤 / 统计
# ---------------------------------------------------------------------------
cmd_filter() {
local f="${1:-}"
[[ -f "$f" ]] || die "用法: $0 filter <日志文件>"
grep -E "$CT_PATTERN" "$f"
}
cmd_stat() {
local f="${1:-}"
[[ -f "$f" ]] || die "用法: $0 stat <日志文件>"
echo "=========== 通话翻译日志速览: $f ==========="
echo
echo "-- 关键事件计数 --"
local -a keys=(
'通话翻译指令下发'
'音频下行发送'
'下行写入拥塞'
'下行写入已恢复'
'音频发送队列已满'
'声道积压缓存超上限'
'收到左声道恢复指令'
'收到右声道恢复指令'
'心跳连续未发出'
'连接参数'
'连接断开'
)
for k in "${keys[@]}"; do
printf ' %-24s %s\n' "$k" "$(grep -c -- "$k" "$f" 2>/dev/null || true)"
done
echo
echo "-- 设备流控(休眠/恢复配对)--"
local sleep_n resume_n
sleep_n="$(grep -cE '休眠|SLEEP_LEFT|SLEEP_RIGHT|0xF0|0xF1' "$f" 2>/dev/null || true)"
resume_n="$(grep -cE '恢复左声道|恢复右声道|0xF2|0xF3' "$f" 2>/dev/null || true)"
echo " 休眠(F0/F1) = $sleep_n 恢复(F2/F3) = $resume_n"
if [[ "$sleep_n" -gt "$resume_n" ]]; then
echo " ⚠️ 休眠多于恢复 $((sleep_n - resume_n)) 次 —— resume 指令可能在拥塞时丢了,"
echo " 声道会一直卡在休眠态,这正是「怎么都不恢复」的典型形态,重点看最后一次休眠的时刻。"
fi
echo
echo "-- 下行发送节拍 Δ 分布(正常应贴近配置的发送间隔)--"
grep -o 'Δ=[0-9]\+ms' "$f" 2>/dev/null | grep -o '[0-9]\+' | awk '
{ n++; s+=$1; if($1>max) max=$1;
if($1<=50) b1++; else if($1<=100) b2++; else if($1<=300) b3++;
else if($1<=1000) b4++; else b5++ }
END {
if (n==0) { print " (没有匹配到「音频下行发送」行,先确认日志已打开)"; exit }
printf " 样本=%d 均值=%.1fms 最大=%dms\n", n, s/n, max
printf " <=50ms: %d 51-100ms: %d 101-300ms: %d 301-1000ms: %d >1s: %d\n",
b1+0, b2+0, b3+0, b4+0, b5+0
if (b4+b5 > 0) print " ⚠️ 存在 >300ms 的发送空档,对照同一时刻的拥塞/休眠日志看是哪一类原因"
}'
echo
echo "-- 积压水位变化(hold(L=..,R=..),持续上涨即为发不出去)--"
grep -o 'hold(L=[0-9]\+,R=[0-9]\+)' "$f" 2>/dev/null | tail -20 || true
echo
echo "-- 最后 15 条拥塞/丢包告警 --"
grep -E '下行写入拥塞|音频发送队列已满|声道积压缓存超上限|心跳连续未发出' "$f" 2>/dev/null | tail -15 || true
echo
}
# ---------------------------------------------------------------------------
main() {
local sub="${1:-}"
[[ $# -gt 0 ]] && shift
case "$sub" in
android) cmd_android "$@" ;;
ios) cmd_ios "$@" ;;
pull-android) cmd_pull_android "$@" ;;
pull-ios) cmd_pull_ios "$@" ;;
filter) cmd_filter "$@" ;;
stat) cmd_stat "$@" ;;
-h|--help|help|"") usage 0 ;;
*) echo "未知子命令: $sub" >&2; usage 1 ;;
esac
}
main "$@"
Loading…
Cancel
Save