Browse Source

上传新版本的通话翻译逻辑代码

newdev_chengguofeng
liwei1dao 3 months ago
parent
commit
f479f9916d
  1. 437
      docs/通话翻译-BLE协议规范-v2-固件对接.md
  2. 6
      docs/通话翻译-流程与控流协议.md
  3. 11
      lib/data/services/ble_manager.dart
  4. 105
      lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart
  5. 143
      lib/modules/call_translation_debug/views/call_translation_debug_view.dart
  6. 284
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  7. 7
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt
  8. 235
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift
  9. 4
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift
  10. 25
      local_plugins/ble_service/lib/ble_service.dart

437
docs/通话翻译-BLE协议规范-v2-固件对接.md

@ -0,0 +1,437 @@
# 通话翻译 BLE 协议规范 v2(APP ↔ 固件 对接版)
> **用途**:APP 与耳机固件重新联调通话翻译的接口契约。本文只描述**双方约定的线上格式与时序**。
> **代码基准**:分支 `newdev_chengguofeng`,`local_plugins/ble_service/`(Android / iOS 行为一致)。
> **兼容性**:**下行音频格式为 v2 新格式,与 v1 不兼容**,APP 与固件必须同时升级。
---
## 目录
1. [v2 变更摘要](#1-v2-变更摘要固件重点看这里)
2. [链路总览与声道语义](#2-链路总览与声道语义)
3. [GATT 服务与特征](#3-gatt-服务与特征)
4. [码率与帧长(贯穿全文的基础)](#4-码率与帧长贯穿全文的基础)
5. [指令协议](#5-指令协议)
6. [会话时序](#6-会话时序)
7. [上行音频(设备 → APP)](#7-上行音频设备--app)
8. [下行音频(APP → 设备)★ 核心](#8-下行音频app--设备--核心)
9. [可配置参数](#9-可配置参数app-侧运行时可调)
10. [异常与边界约定](#10-异常与边界约定)
11. [联调验收清单](#11-联调验收清单)
12. [待双方确认](#12-待双方确认)
13. [附:APP 侧实现位置](#13-附app-侧实现位置备查)
---
## 1. v2 变更摘要(固件重点看这里)
| 项 | v1(旧) | **v2(新)** |
|---|---|---|
| 下行包头 | `[4B 序号(小端)][1B 声道]` 共 5 字节 | **无包头**,整包纯 opus 帧数据 |
| 下行声道组织 | 一包只装一个声道(左包 / 右包分别发) | **一包同时装左右**,帧级交替 `L R L R …` |
| 下行包长 | `5 + N×40`,变长 | **恒定 `2 × N × F` 字节** |
| 下行帧数 | 5 帧/包(单声道) | **2 帧/声道/拍**(默认,可调 1–20) |
| 下行节拍 | 80 ms,左右两路各发一包、错峰 40 ms | **40 ms**(默认),单路统一发送 |
| 某声道无数据 | 该声道不发包 | **静音帧补齐**,保证左右严格对位、长度恒定 |
| 完全无音频时 | 不发 | 可配置**持续发送**全静音包(默认关) |
| 单帧字节数 | 固定 40 B | **由码率决定的变量 `F`**,上下行各自可配 |
| F4/F5 反馈调速 | 曾设计过 | **已移除**,固件无需上报解码空余 |
**固件唯一必须重写的是下行解包逻辑**:不要再剥 5 字节包头,改为按单帧字节数 `F` 切分、左右交替取帧。指令协议、上行音频、UUID 均**不变**。
---
## 2. 链路总览与声道语义
```
BLE
┌──────────┐ 上行原声(立体声 opus) ┌──────────┐
│ │ ───────── notify ───────▶ │ │
│ 耳机 │ │ APP │ → 端到端语音翻译(ASR+翻译+TTS)
│ 固件 │ ◀──────── write ───────── │ │
│ │ 下行译音(左右交替 opus) └──────────┘
└──────────┘
```
- **上行**:耳机把「本端麦克风原声 + 对方通话原声」合成**立体声 opus** 上报给 APP。
- **下行**:APP 把两路译音编码后写回耳机播放。
**声道语义(上下行含义不同,务必区分):**
| | 左声道 | 右声道 |
|---|---|---|
| **上行**(设备→APP) | 本端麦克风原声 | 对方通话原声 |
| **下行**(APP→设备) | **本端译音**(说给对方听) | **对端译音**(说给本端听) |
APP 只负责按上表填左右;最终左/右播到哪只耳、如何与通话原声混音,**由固件决定**。
---
## 3. GATT 服务与特征
| 服务 | UUID | 特征 | UUID | 方向 | 属性 |
|---|---|---|---|---|---|
| 主服务 | `0000ABC0-0000-1111-2222-123456789ABC` | WRITE | `0000ABC1-0000-…` | APP → 设备 | Write No Response |
| | | NOTIFY | `0000ABC2-0000-…` | 设备 → APP | Notify |
| **通话音频服务** | `0000ABC0-0001-1111-2222-123456789ABC` | **CALL_WRITE** | `0000ABC1-0001-…` | **APP → 设备(下行译音)** | **Write No Response** |
| | | **CALL_RECEIVE** | `0000ABC2-0001-…` | **设备 → APP(上行原声)** | **Notify** |
- CCCD:`00002902-0000-1000-8000-00805f9b34fb`
- **MTU**:APP 连接后请求 **517**(→ 单次写净荷上限 **514 B**)
- APP 还会尝试 `CONNECTION_PRIORITY_HIGH` 与 **PHY 2M**(失败自动回退 1M)
---
## 4. 码率与帧长(贯穿全文的基础)
opus 帧时长**恒为 20 ms**(与码率无关)。CBR 模式下:
```
单帧字节数 F = 码率(kbps) × 20 ms ÷ 8
```
| 码率 | **单帧字节 F** | 一次 BLE 写最多几帧(514 ÷ 2F) |
|---|---|---|
| 8 kbps | 20 B | 12 |
| **16 kbps(默认)** | **40 B** | **6** |
| 24 kbps | 60 B | 4 |
| 32 kbps | 80 B | 3 |
**上行、下行各有独立码率**,均可在 APP 侧配置:
- `F_down`:下行单帧字节,APP 编码器用,固件解码器用。
- `F_up`:上行单帧字节,固件编码器用,APP 解码器用 —— **必须与固件实际编码码率一致**(见 §7)。
> ⚠️ **固件不要把 40 硬编码。** 请按码率推算 `F`,或按 `F = 包长 ÷ (2 × N)` 推算。这样 APP 侧调码率时固件无需改代码。
---
## 5. 指令协议
### 5.1 帧格式
```
APP 请求 : 0xAA [CMD] [LEN] [DATA…] [CRC8]
设备响应 : 0xBB [CMD] [LEN] [DATA…] [CRC8]
设备主动上报: 0xCC [CMD] [LEN] [DATA…] [CRC8]
```
- `LEN` = DATA 字节数(不含帧头/CMD/LEN/CRC);整帧长 = `LEN + 4`。
- `CRC8` 覆盖**从帧头(含)到 DATA 末尾**,不含 CRC 自身。
### 5.2 CRC8 算法(勿套用标准库的 "CRC-8/MAXIM")
APP 代码里函数名叫 `calculateCrc8Maxim`,但**实现是非反射版**:多项式 `0x31`、初值 `0x00`、MSB-first、不反射输入输出、无最终异或。
标准 CRC-8/MAXIM 是**反射版**(0x8C),**结果不同**。请直接照抄下面的实现:
```c
uint8_t crc8(const uint8_t *data, int len) {
uint8_t crc = 0x00;
for (int i = 0; i < len; i++) {
crc ^= data[i];
for (int j = 0; j < 8; j++) {
crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0x31) : (uint8_t)(crc << 1);
}
}
return crc;
}
```
### 5.3 通话翻译相关指令
| CMD | 名称 | 方向 | 说明 |
|---|---|---|---|
| **0x05** | CONTROL_CODEC | APP → 设备 | 编解码控制(见下) |
| **0x16** | CALL_TRANSLATION_ON | 设备 → APP(`0xCC`) | 设备侧(按键等)请求**开启**通话翻译 |
| **0x17** | CALL_TRANSLATION_OFF | 设备 → APP(`0xCC`) | 设备侧请求**关闭**通话翻译 |
**0x05 的 DATA = `[子命令, 声道模式]`**
| 子命令 | 值 | 含义 |
|---|---|---|
| CLOSE | `0x00` | 关闭编解码(结束通话翻译) |
| **A2DP_PLAY** | **`0xA2`** | **开启通话翻译**:mic + dac 声音上行,并接收翻译后音频 |
| DECODE_ON | `0xA1` | 仅解码(音乐/通话远端),非通话翻译 |
| CALL_RECORD_PLAY | `0xA3` | 通话录音,非通话翻译 |
声道模式:`0x01`=左 / `0x02`=右 / **`0x03`=立体声(通话翻译固定用这个)**
**实际收发字节:**
```
开启:APP → AA 05 02 A2 03 [CRC]
设备 → BB 05 02 A2 03 [CRC] ← 期望回显子命令与声道
关闭:APP → AA 05 02 00 03 [CRC]
设备 → BB 05 02 00 03 [CRC]
```
### 5.4 ACK 约定
APP 侧指令**串行**:发出一条后等 `0xBB` 响应才发下一条,**超时 1 秒**自动放行队列。
请确保每条 `0xAA` 请求都回一条 `0xBB`,否则会拖慢后续指令。
---
## 6. 会话时序
```mermaid
sequenceDiagram
participant D as 耳机固件
participant A as APP
Note over A,D: ① 开启(APP 主动 或 设备按键触发)
D-->>A: 0xCC 16 …(可选:设备侧请求开启)
A->>A: 启动上行解码器 + 下行编码器 + 发送线程
A->>D: AA 05 02 A2 03(CONTROL_CODEC / A2DP_PLAY / 立体声)
D-->>A: BB 05 02 A2 03
Note over A,D: ② 会话中(双向音频流并行)
loop 持续
D-->>A: notify CALL_RECEIVE:上行立体声 opus(本端 mic + 对方声)
A->>A: 拆左右 → 两路端到端翻译 → 两路译音 PCM → opus 编码
A->>D: write CALL_WRITE:下行 opus(左右帧级交替,无包头)
end
Note over A,D: ③ 关闭
D-->>A: 0xCC 17 …(可选:设备侧请求关闭)
A->>D: AA 05 02 00 03(CONTROL_CODEC / CLOSE)
D-->>A: BB 05 02 00 03
A->>A: 停发送线程、停编解码、清空队列
```
> ⚠️ APP 在**发出 `0xA2` 之前**就已启动下行发送线程。若「持续发送」开关是开的,固件可能在收到 `0xA2` 前就收到下行写入 —— **固件应能安全丢弃未进入翻译态时收到的音频包**。
---
## 7. 上行音频(设备 → APP)
| 项 | 约定 |
|---|---|
| 特征 | `CALL_RECEIVE`(`0000ABC2-0001-…`)notify |
| 编码 | Opus,CBR,帧时长 20 ms |
| 采样率 | 16 kHz |
| 声道 | 立体声(左 = 本端麦克风原声,右 = 对方通话原声) |
| 包头 | **无** |
| 单帧字节 | `F_up`(由上行码率决定,默认 16 kbps → 40 B) |
| APP 解码配置 | `hasHeader=false, channel=2, sampleRate=16000, packetSize = 2 × F_up`(默认 80) |
| 包长校验 | APP **丢弃长度不是 `F_up` 整数倍**的包(防止错位污染流式解码器) |
**上行码率必须双方一致。** APP 用它推 `F_up` 来做收包校验和解码器切分。固件若改了上行码率而 APP 没同步,**所有上行包都会被判成坏包丢掉**(表现:完全收不到对方声音)。
> ⚠️ **待固件确认**:APP 按 `packetSize = 2 × F_up` 的双声道流解码,即每 `2 × F_up` 字节为一个立体声单元。请确认固件上行的实际组织方式是 **`[左 F_up][右 F_up]` 交替**(与下行对称)。若不是,APP 侧解码参数需相应调整。
---
## 8. 下行音频(APP → 设备)★ 核心
| 项 | 约定 |
|---|---|
| 特征 | `CALL_WRITE`(`0000ABC1-0001-…`)**Write Without Response** |
| 编码 | Opus,**CBR**,单声道编码器 ×2(左右各一路,互相独立) |
| 采样率 | 16 kHz |
| 帧时长 | **恒定 20 ms/帧** |
| 单帧字节 | **`F`**(由下行码率决定,默认 16 kbps → **40 B**) |
| 包头 | **无** |
| 包结构 | 左右**帧级交替** |
| 一拍字节数 | **`2 × N × F`**(N = 每声道帧数,默认 N=2、F=40 → **160 B**) |
### 8.1 包结构
通式(N 帧/声道,每帧 F 字节):
```
[L0][R0][L1][R1] … [L(N-1)][R(N-1)] 每个 [] 恒为 F 字节
```
默认配置(N=2,F=40B,16 kbps)→ 160 字节:
```
偏移: 0 40 80 120 160
┌────────┬────────┬────────┬────────┐
│ L0 │ R0 │ L1 │ R1 │
│ 40B │ 40B │ 40B │ 40B │
└────────┴────────┴────────┴────────┘
左 右 左 右
```
同样 N=2,但下行码率降到 8 kbps(F=20B)→ 只有 80 字节:
```
偏移: 0 20 40 60 80
┌────┬────┬────┬────┐
│ L0 │ R0 │ L1 │ R1 │ 每帧 20B
└────┴────┴────┴────┘
```
- 每帧都是**完整、独立可解**的 opus 帧。
- 左右两路要送进**两个独立的 opus 解码器**(不要用一个立体声解码器解)。
### 8.2 固件解包伪代码
```c
#define OPUS_FRAME_MS 20
// 由码率推单帧字节:16→40, 8→20, 24→60, 32→80
static inline int frame_bytes_of(int bitrate_kbps) {
return bitrate_kbps * OPUS_FRAME_MS / 8;
}
// g_downlink_frame_bytes = frame_bytes_of(约定的下行码率)
void on_downlink_packet(const uint8_t *packet, int len) {
const int F = g_downlink_frame_bytes;
if (F <= 0 || len % (2 * F) != 0) { /* 异常包,丢弃 */ return; }
int n = len / (2 * F); // 本包帧数/声道,动态推算
for (int i = 0; i < n; i++) {
const uint8_t *left = packet + (i * 2) * F;
const uint8_t *right = packet + (i * 2 + 1) * F;
opus_decode(dec_left, left, F, pcm_left, FRAME_SAMPLES, 0);
opus_decode(dec_right, right, F, pcm_right, FRAME_SAMPLES, 0);
// 播放 / 混音
}
}
```
> **不要假设 N 固定为 2,也不要假设 F 固定为 40。** N 按 `len / (2×F)` 推,F 按码率推。这样 APP 调参时固件无需改代码。
### 8.3 一拍可能被拆成多个 BLE 写
BLE 单次写净荷上限是 `MTU-3`(**514 B**)。当一拍的数据(`2 × N × F`)超过它时,**APP 按「左右一对」(`2 × F`) 对齐拆成多个 BLE 写连续发出**。
例:N=20、F=40B → 一拍 1600 B → 拆 4 个写(6 + 6 + 6 + 2 帧)。
**这对固件完全透明,不需要任何特殊处理:**
- 每个分片本身仍是 `2 × F` 的整数倍,左右交替顺序不变;
- 所以固件无论「每个 BLE 包独立解析」还是「当成连续字节流解析」,结果都正确;
- §8.2 的解包函数原样适用。
### 8.4 静音填充(关键约定)
APP 的左右两路译音是**异步产生**的,任何一拍都可能只有一边有数据。此时 APP **不会缩短包长**,而是用一帧**编码好的静音 opus 帧**填满缺的位置。
这意味着:
- 包长永远恒定、左右永远严格对位,**固件可无条件按固定偏移切分**;
- 静音帧是**合法的 opus 帧**(由编码器编码零 PCM 得到,**不是全 0 字节**),正常解码即得静音 PCM;
- 只有一边说话时,另一边持续收到静音帧 —— 这是**正常现象,不是丢包**。
### 8.5 持续发送开关
| 状态 | 行为 |
|---|---|
| **关**(默认) | 左右都没有译音时,该拍**不发包**,链路空闲 |
| **开** | 左右都没有译音时,仍按节拍发**全静音包**,保持设备侧音频流不断 |
> 若固件的解码/播放管线**要求音频流不能断**(断流会导致 underrun、PLL 失锁、时基丢失等),告诉我们,把默认值改成「开」。
### 8.6 带宽
| 配置 | 一拍字节 / 节拍 | 带宽 |
|---|---|---|
| 16 kbps,N=2,40 ms | 160 B / 40 ms | 4000 B/s = 32 kbps(左右各 16k) |
| 8 kbps,N=2,40 ms | 80 B / 40 ms | 2000 B/s = 16 kbps(左右各 8k) |
---
## 9. 可配置参数(APP 侧运行时可调)
APP 内置调试页(通话翻译调试)可实时下发,**无需重连**,联调时可现场改:
| 参数 | 默认 | 范围 | 说明 |
|---|---|---|---|
| `downlinkBitrateKbps` | **16** | 8 / 16 / 24 / 32 | **下行**码率 → `F = kbps × 2.5` |
| `uplinkBitrateKbps` | **16** | 8 / 16 / 24 / 32 | **上行**码率,**必须与固件实际编码一致** |
| `bundleFrameCount` | **2** | **1 – 20** | 每声道每拍帧数 N;一拍 = `2 × N × F` 字节 |
| `sendIntervalMs` | **40** | **5 – 200**(步长 5) | 发送节拍 |
| `continuousSend` | **关** | 开 / 关 | 无译音时是否持续发静音包 |
### 9.1 节拍必须与帧数匹配
每帧恒为 20 ms 音频(与码率无关),所以:
```
节拍(ms) == N × 20 → 下发速率与真实语速 1:1(推荐)
节拍 > N × 20 → 偏慢,译音在 APP 侧积压,延迟越来越大
节拍 < N × 20 → 偏快,设备侧解码缓存持续上涨,可能溢出
```
速查(16 kbps / F=40B):
| N(帧/声道) | 一拍字节 | 匹配节拍 | BLE 写次数 |
|---|---|---|---|
| 1 | 80 B | 20 ms | 1 |
| **2(默认)** | **160 B** | **40 ms** | **1** |
| 4 | 320 B | 80 ms | 1 |
| 6 | 480 B | 120 ms | 1 |
| 10 | 800 B | 200 ms | 2 |
| 20 | 1600 B | 400 ms | 4 |
> ⚠️ **N > 10 配不出 1:1 节拍**:节拍上限是 200 ms,而 N=20 需要 400 ms。此时只能超发(设备侧缓存会涨)。若确实要用大 N,需把节拍上限放开 —— 告诉我们即可。
### 9.2 改码率会重启编码器
opus 编码器的 packetSize 在启动时固化。APP 改下行码率时会:**停编码流 → 清空左右发送队列 → 作废旧静音模板 → 用新帧长重启编码流**。
避免新旧帧长混进同一包导致整包错位。表现为下行有一次**极短(数十毫秒)停顿**,属正常。停顿之后**包长按新 F 变化**,固件需按新码率同步更新 `F`。
---
## 10. 异常与边界约定
| 场景 | APP 侧行为 | 对固件的影响 / 要求 |
|---|---|---|
| BLE 写拥塞(底层缓冲满) | 该分片不发,**尚未发出的**帧退回队头,下一拍续发。**不丢译音** | 已发出的分片自身对齐、顺序正确,字节流不错位;剩余帧下一拍补上 |
| 单帧长度异常(≠ 当前 F) | 入队时**直接丢弃**该帧 | 保证固件收到的包长永远是 `2 × F` 的整数倍 |
| 静音帧模板未就绪(编码器刚启动的极短窗口) | 整拍**不发**,帧退回队列 | 会话最开始可能有几十 ms 无下行,属正常 |
| 运行中改码率 | 重启编码器、清队列、重建静音模板 | 短暂停顿;之后包长按新 `F` 变化 |
| 意外断连 | 1 s 后主动回连(最多 5 次);期间**译音继续缓存不丢**;回连成功后 **1600 ms** 重下 `0xA2` 并续发 | 固件回连后需能重新接受 `0xA2` 并恢复播放 |
| 会话结束 | 停发送线程、清空左右队列 | 下行立即停止 |
| 未进翻译态就收到下行包 | 「持续发送」开启时可能发生(见 §6) | **固件应容忍并丢弃** |
---
## 11. 联调验收清单
按顺序逐项验证,每项都能独立定位问题:
- [ ] **0. 码率对齐**:双方确认上、下行码率各是多少,据此各自算出 `F`(16 kbps → 40 B)。**这是一切的前提。**
- [ ] **1. 指令通路**:APP 发 `AA 05 02 A2 03`,固件回 `BB 05 02 A2 03`;关闭同理。CRC 双方一致。
- [ ] **2. 上行通路**:固件上报立体声 opus,APP 能解出左右两路(左=本端 mic,右=对方声),无「长度非 `F_up` 倍数」告警。
- [ ] **3. 下行包长**:固件收到的每包长度恒为 `2 × N × F`(默认 160 B),且始终是 `2 × F` 的整数倍。
- [ ] **4. 下行对位**:固件按 `F` 切分,偶数块喂左解码器、奇数块喂右解码器,**两路都解码成功**(`opus_decode` 无错误返回)。
- [ ] **5. 单边说话**:只有本端说话时,右声道收到静音帧(可解码、内容为静音),**不应报错或丢包**。
- [ ] **6. 双向同传**:本端说话 → 对方听到译音;对方说话 → 本端听到译音;左右不串台。
- [ ] **7. 节拍稳定**:固件侧收包间隔稳定在 40 ms 附近,解码缓存水位不持续上涨、也不饿死。
- [ ] **8. 长时间跑**:连续 10 分钟无缓存溢出、卡顿、左右错位。
- [ ] **9. 断连恢复**:手动断连后自动回连,`0xA2` 重下发,音频恢复。
- [ ] **10. 持续发送开关**:打开后,无人说话时固件仍持续收到全静音包,播放无杂音。
- [ ] **11. 大帧数拆写**(可选):把 N 调到 20,固件收到的是多个 480 B/160 B 的包,解码播放仍正常。
- [ ] **12. 切码率**(可选):APP 切到 8 kbps 后包长变 80 B,固件按新 `F=20B` 切分仍正常。
> 联调时 APP 侧日志会打印每拍的:包序、左右真实帧数、静音补齐数、总字节、拆写次数、队列积压。出问题可直接对时间戳定位是哪一侧。
---
## 12. 待双方确认
1. **上下行码率各定多少?**(§4/§9)APP 默认上下行均 16 kbps(F=40 B),支持 8/16/24/32。**上行码率必须与固件实际编码一致**,否则 APP 会把所有上行包判成坏包。
2. **上行结构?**(§7)是否为 `[左 F_up][右 F_up]` 交替?APP 当前按 `packetSize = 2 × F_up` / 双声道解。
3. **持续发送要不要默认打开?**(§8.5)固件是否要求下行不断流?
4. **节拍与帧数怎么定?**(§9)默认 2 帧 / 40 ms(160 B)是否符合固件解码缓存的期望?需要大包低频(如 4 帧/80 ms)或小包高频(1 帧/20 ms)都可以,告知目标值即可。
5. **静音帧能否正常解码?**(§8.4)是标准 opus 帧,不是全 0 字节。
6. **未进翻译态的下行写入能否安全丢弃?**(§6)
---
## 13. 附:APP 侧实现位置(备查)
| 内容 | 文件 · 符号 |
|---|---|
| 下行组包 / 分片写 / 静音补齐 | `ble_service/android/…/BleService.kt` → `trySendMixedChunk` / `buildInterleavedPacket` / `maxFramesPerWrite` |
| iOS 同构实现 | `ble_service/ios/…/BleService.swift` → 同名方法 |
| 码率 ↔ 帧长换算、改码率重启编码器 | `BleService.kt` → `bitrateToFrameBytes` / `applyDownlinkBitrate` / `applyUplinkBitrate` |
| 上行收包校验 | `BleService.kt` → `onCharacteristicChanged` / `isValidUplinkAudioLen` |
| 指令协议 / CRC8 | `ble_service/android/…/BleCommandSender.kt` → `createCommandPacket` / `calculateCrc8Maxim` |
| UUID / 指令常量 | `ble_service/android/…/BleConst.kt` |
| 调试页(现场调参) | `lib/modules/call_translation_debug/` |

6
docs/通话翻译-流程与控流协议.md

@ -1,8 +1,12 @@
# 通话翻译:流程与控流协议 # 通话翻译:流程与控流协议
> ⚠️ **下行部分已过时(v2 改版后)**。下行音频现已改为**无包头、左右帧级交替**的新格式,且 **F4/F5 反馈调速已从代码中移除**。
> 本文第 **8 章(下行链路与控流)**、**10.2 节的 F4/F5 部分**、**11 章的默认参数**均**不再反映当前代码**,勿据此对接。
> 下行的权威定义见 → **[`通话翻译-BLE协议规范-v2-固件对接.md`](./通话翻译-BLE协议规范-v2-固件对接.md)**
> 本文其余部分(术语与声道语义、上行链路、端到端翻译层、指令协议、连接层优化)仍然有效。
> 适用分支:`newdev_chengguofeng`(以当前 HEAD 实际代码为准) > 适用分支:`newdev_chengguofeng`(以当前 HEAD 实际代码为准)
> 关键词:Jieli 耳机 · BLE 双声道 · 端到端语音翻译(AST/STS) · 下行流控 > 关键词:Jieli 耳机 · BLE 双声道 · 端到端语音翻译(AST/STS) · 下行流控
> 维护提示:本文描述的是**当前流式下行**实现(累帧合包 + 固定节拍 + F4/F5 三档调速 + 背压重试)。历史上讨论过的「整句 gate + 编码器静默确认 flush」方案**当前代码未采用**,不在本文范围。
--- ---

11
lib/data/services/ble_manager.dart

@ -1249,18 +1249,24 @@ class BleManager extends GetxService {
/// 设置通话翻译调试参数(运行时生效) /// 设置通话翻译调试参数(运行时生效)
/// [sendIntervalMs] 音频下行发送间隔(ms) /// [sendIntervalMs] 音频下行发送间隔(ms)
/// [bundleFrameCount] 每包每声道的 opus 帧数(1..6),包长 = 2*帧数*40B /// [bundleFrameCount] 每包每声道的 opus 帧数,包长 = 2*帧数*单帧字节
/// [continuousSend] 是否持续发送静音(无译音时也保持音频流不断) /// [continuousSend] 是否持续发送静音(无译音时也保持音频流不断)
/// [downlinkBitrateKbps] 下行 opus 码率(kbps):单帧字节 = 码率×20ms÷8(16k→40B / 8k→20B)
/// [uplinkBitrateKbps] 上行 opus 码率(kbps),必须与固件实际编码码率一致
Future<bool> setCallTranslationDebugParams({ Future<bool> setCallTranslationDebugParams({
int? sendIntervalMs, int? sendIntervalMs,
int? bundleFrameCount, int? bundleFrameCount,
bool? continuousSend, bool? continuousSend,
int? downlinkBitrateKbps,
int? uplinkBitrateKbps,
}) async { }) async {
try { try {
return await _bleService.setCallTranslationDebugParams( return await _bleService.setCallTranslationDebugParams(
sendIntervalMs: sendIntervalMs, sendIntervalMs: sendIntervalMs,
bundleFrameCount: bundleFrameCount, bundleFrameCount: bundleFrameCount,
continuousSend: continuousSend, continuousSend: continuousSend,
downlinkBitrateKbps: downlinkBitrateKbps,
uplinkBitrateKbps: uplinkBitrateKbps,
); );
} catch (e) { } catch (e) {
Logger.e('CALL_TRANS_DEBUG', '设置通话翻译调试参数失败: ${e.toString()}'); Logger.e('CALL_TRANS_DEBUG', '设置通话翻译调试参数失败: ${e.toString()}');
@ -1269,7 +1275,8 @@ class BleManager extends GetxService {
} }
/// 获取当前通话翻译调试参数 /// 获取当前通话翻译调试参数
/// {'sendIntervalMs': int, 'bundleFrameCount': int, 'continuousSend': bool} /// {'sendIntervalMs','bundleFrameCount','continuousSend','downlinkBitrateKbps',
/// 'uplinkBitrateKbps','downlinkFrameBytes','uplinkFrameBytes','maxBundleFrameCount','packetSizeBytes'}
Future<Map<String, dynamic>> getCallTranslationDebugParams() async { Future<Map<String, dynamic>> getCallTranslationDebugParams() async {
try { try {
return await _bleService.getCallTranslationDebugParams(); return await _bleService.getCallTranslationDebugParams();

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

@ -5,11 +5,12 @@ import '../../../data/services/ble_manager.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
/// 通话翻译调试控制器 /// 通话翻译调试控制器
/// 负责调试参数(音频下行发送间隔、每声道合包帧数、是否持续发送)的读取、保存、下发, /// 负责调试参数(上/下行 opus 码率、音频下行发送间隔、每声道合包帧数、是否持续发送)的读取、保存、下发,
/// 以及通话翻译编解码的启动/停止。 /// 以及通话翻译编解码的启动/停止。
/// ///
/// 下行包为无包头、左右帧级交替格式:[L0 40B][R0 40B]...[L(n-1)][R(n-1)],包长 = 2*n*40B。 /// 下行包为无包头、左右帧级交替格式:[L0][R0]...[L(n-1)][R(n-1)],包长 = 2 × n × 单帧字节。
/// 每帧 opus 承载 20ms 音频,故「间隔 == 帧数 × 20ms」时下发速率与实时语速 1:1。 /// **单帧字节不是常量**,由码率决定:`单帧字节 = 码率kbps × 20ms ÷ 8`(16k→40B / 8k→20B / 24k→60B)。
/// 每帧 opus 恒承载 20ms 音频,故「间隔 == 帧数 × 20ms」时下发速率与实时语速 1:1(与码率无关)。
class CallTranslationDebugController extends GetxController { class CallTranslationDebugController extends GetxController {
final BleManager _bleManager = Get.find<BleManager>(); final BleManager _bleManager = Get.find<BleManager>();
final GetStorage _storage = GetStorage(); final GetStorage _storage = GetStorage();
@ -18,22 +19,33 @@ class CallTranslationDebugController extends GetxController {
static const String _kSendInterval = 'call_trans_debug_send_interval'; static const String _kSendInterval = 'call_trans_debug_send_interval';
static const String _kBundleFrameCount = 'call_trans_debug_bundle_frames'; static const String _kBundleFrameCount = 'call_trans_debug_bundle_frames';
static const String _kContinuousSend = 'call_trans_debug_continuous_send'; static const String _kContinuousSend = 'call_trans_debug_continuous_send';
static const String _kDownlinkBitrate = 'call_trans_debug_downlink_bitrate';
static const String _kUplinkBitrate = 'call_trans_debug_uplink_bitrate';
// 单帧 opus 承载的音频时长(ms),用于推算与实时语速匹配的发送间隔 // 单帧 opus 承载的音频时长(ms):固定 20ms,与码率无关
static const int frameDurationMs = 20; static const int frameDurationMs = 20;
// 默认值(与原生默认保持一致:2 帧/声道 + 40ms 一拍 = 实时 1:1) // 单次 BLE 写的净荷上限(MTU 517 → 514B)。一拍的数据超过它时,原生会按 2×帧长 对齐
// 拆成多个 BLE 写连续发出,故帧数上限不受 MTU 约束。这里仅用于界面提示拆分数。
static const int bleWriteLimitBytes = 514;
// 默认值(与原生默认保持一致:16kbps(40B/帧) + 2 帧/声道 + 40ms 一拍 = 实时 1:1)
static const int defaultSendIntervalMs = 40; static const int defaultSendIntervalMs = 40;
static const int defaultBundleFrameCount = 2; static const int defaultBundleFrameCount = 2;
static const bool defaultContinuousSend = false; static const bool defaultContinuousSend = false;
static const int defaultBitrateKbps = 16;
// 取值范围 // 取值范围
static const int minSendIntervalMs = 20; static const int minSendIntervalMs = 5;
static const int maxSendIntervalMs = 1000; static const int maxSendIntervalMs = 200;
static const int sendIntervalStepMs = 20; // 音频下行间隔步长 20ms static const int sendIntervalStepMs = 5; // 音频下行间隔步长 5ms
static const int minBundleFrameCount = 1; static const int minBundleFrameCount = 1;
// 包长 2*n*40B 必须塞得进一次 BLE 写(MTU 517 → 可写 514B),6 帧=480B 是安全上限 static const int maxBundleFrameCount = 20;
static const int maxBundleFrameCount = 6; // 可选码率档位(kbps):每档对应的单帧字节 = 档位 × 2.5
static const List<int> bitrateOptions = [8, 16, 24, 32];
/// 码率(kbps) → 单帧 opus 字节数(与原生 bitrateToFrameBytes 一致)
static int frameBytesOf(int bitrateKbps) => bitrateKbps * frameDurationMs ~/ 8;
/// 音频下行发送间隔(ms) /// 音频下行发送间隔(ms)
final RxInt sendIntervalMs = defaultSendIntervalMs.obs; final RxInt sendIntervalMs = defaultSendIntervalMs.obs;
@ -44,8 +56,30 @@ class CallTranslationDebugController extends GetxController {
/// 是否持续发送音频(无译音时也按拍下发全静音包,保持设备侧音频流不断) /// 是否持续发送音频(无译音时也按拍下发全静音包,保持设备侧音频流不断)
final RxBool continuousSend = defaultContinuousSend.obs; final RxBool continuousSend = defaultContinuousSend.obs;
/// 当前配置下的下行包长(字节):左右交替、每声道 n 帧、每帧 40B /// 下行(APP→设备) opus 码率(kbps)
int get packetSizeBytes => 2 * bundleFrameCount.value * 40; final RxInt downlinkBitrateKbps = defaultBitrateKbps.obs;
/// 上行(设备→APP) opus 码率(kbps)——必须与固件实际编码码率一致
final RxInt uplinkBitrateKbps = defaultBitrateKbps.obs;
/// 当前下行单帧字节数(由下行码率推导)
int get downlinkFrameBytes => frameBytesOf(downlinkBitrateKbps.value);
/// 当前上行单帧字节数(由上行码率推导)
int get uplinkFrameBytes => frameBytesOf(uplinkBitrateKbps.value);
/// 一拍要发的总字节数:左右交替、每声道 n 帧
int get packetSizeBytes => 2 * bundleFrameCount.value * downlinkFrameBytes;
/// 一次 BLE 写最多能装几帧/声道(净荷上限 ÷ 2×单帧字节)
int get framesPerWrite =>
(bleWriteLimitBytes ~/ (2 * downlinkFrameBytes)).clamp(1, maxBundleFrameCount);
/// 一拍会被拆成几个 BLE 写:超过单次写上限时原生按 2×帧长 对齐自动拆分
int get writesPerTick {
final perWrite = framesPerWrite;
return (bundleFrameCount.value + perWrite - 1) ~/ perWrite;
}
/// 与实时语速 1:1 匹配的发送间隔(ms) = 帧数 × 20ms;供界面提示用 /// 与实时语速 1:1 匹配的发送间隔(ms) = 帧数 × 20ms;供界面提示用
int get realtimeIntervalMs => bundleFrameCount.value * frameDurationMs; int get realtimeIntervalMs => bundleFrameCount.value * frameDurationMs;
@ -66,6 +100,16 @@ class CallTranslationDebugController extends GetxController {
/// 从本地存储加载参数 /// 从本地存储加载参数
void _loadFromStorage() { void _loadFromStorage() {
// 码率要先加载:帧数上限依赖它推出的单帧字节数
final savedDownBitrate = _storage.read<int>(_kDownlinkBitrate);
final savedUpBitrate = _storage.read<int>(_kUplinkBitrate);
if (savedDownBitrate != null && bitrateOptions.contains(savedDownBitrate)) {
downlinkBitrateKbps.value = savedDownBitrate;
}
if (savedUpBitrate != null && bitrateOptions.contains(savedUpBitrate)) {
uplinkBitrateKbps.value = savedUpBitrate;
}
final savedInterval = _storage.read<int>(_kSendInterval); final savedInterval = _storage.read<int>(_kSendInterval);
final savedFrames = _storage.read<int>(_kBundleFrameCount); final savedFrames = _storage.read<int>(_kBundleFrameCount);
final savedContinuous = _storage.read<bool>(_kContinuousSend); final savedContinuous = _storage.read<bool>(_kContinuousSend);
@ -87,6 +131,19 @@ class CallTranslationDebugController extends GetxController {
try { try {
final params = await _bleManager.getCallTranslationDebugParams(); final params = await _bleManager.getCallTranslationDebugParams();
if (params.isEmpty) return; if (params.isEmpty) return;
// 同样先同步码率,再同步依赖它的帧数
if (_storage.read<int>(_kDownlinkBitrate) == null) {
final nb = params['downlinkBitrateKbps'] as int?;
if (nb != null && bitrateOptions.contains(nb)) {
downlinkBitrateKbps.value = nb;
}
}
if (_storage.read<int>(_kUplinkBitrate) == null) {
final nb = params['uplinkBitrateKbps'] as int?;
if (nb != null && bitrateOptions.contains(nb)) {
uplinkBitrateKbps.value = nb;
}
}
if (_storage.read<int>(_kSendInterval) == null) { if (_storage.read<int>(_kSendInterval) == null) {
final ni = params['sendIntervalMs'] as int?; final ni = params['sendIntervalMs'] as int?;
if (ni != null && ni > 0) { if (ni != null && ni > 0) {
@ -123,8 +180,24 @@ class CallTranslationDebugController extends GetxController {
continuousSend.value = value; continuousSend.value = value;
} }
/// 设置下行码率:帧长随之变化,帧数上限也会变,需把当前帧数夹回新的合法区间
void setDownlinkBitrate(int kbps) {
if (!bitrateOptions.contains(kbps)) return;
downlinkBitrateKbps.value = kbps;
bundleFrameCount.value =
bundleFrameCount.value.clamp(minBundleFrameCount, maxBundleFrameCount);
}
/// 设置上行码率(必须与固件实际编码码率一致,否则上行收包校验与解码切分都会错)
void setUplinkBitrate(int kbps) {
if (!bitrateOptions.contains(kbps)) return;
uplinkBitrateKbps.value = kbps;
}
/// 恢复默认值 /// 恢复默认值
void resetDefaults() { void resetDefaults() {
downlinkBitrateKbps.value = defaultBitrateKbps;
uplinkBitrateKbps.value = defaultBitrateKbps;
sendIntervalMs.value = defaultSendIntervalMs; sendIntervalMs.value = defaultSendIntervalMs;
bundleFrameCount.value = defaultBundleFrameCount; bundleFrameCount.value = defaultBundleFrameCount;
continuousSend.value = defaultContinuousSend; continuousSend.value = defaultContinuousSend;
@ -135,13 +208,19 @@ class CallTranslationDebugController extends GetxController {
_storage.write(_kSendInterval, sendIntervalMs.value); _storage.write(_kSendInterval, sendIntervalMs.value);
_storage.write(_kBundleFrameCount, bundleFrameCount.value); _storage.write(_kBundleFrameCount, bundleFrameCount.value);
_storage.write(_kContinuousSend, continuousSend.value); _storage.write(_kContinuousSend, continuousSend.value);
_storage.write(_kDownlinkBitrate, downlinkBitrateKbps.value);
_storage.write(_kUplinkBitrate, uplinkBitrateKbps.value);
final ok = await _bleManager.setCallTranslationDebugParams( final ok = await _bleManager.setCallTranslationDebugParams(
sendIntervalMs: sendIntervalMs.value, sendIntervalMs: sendIntervalMs.value,
bundleFrameCount: bundleFrameCount.value, bundleFrameCount: bundleFrameCount.value,
continuousSend: continuousSend.value, continuousSend: continuousSend.value,
downlinkBitrateKbps: downlinkBitrateKbps.value,
uplinkBitrateKbps: uplinkBitrateKbps.value,
); );
Logger.i('CALL_TRANS_DEBUG', Logger.i('CALL_TRANS_DEBUG',
'下发调试参数 interval=${sendIntervalMs.value}ms frames=${bundleFrameCount.value}/声道 ' '下发调试参数 下行=${downlinkBitrateKbps.value}kbps(${downlinkFrameBytes}B/帧) '
'上行=${uplinkBitrateKbps.value}kbps(${uplinkFrameBytes}B/帧) '
'interval=${sendIntervalMs.value}ms frames=${bundleFrameCount.value}/声道 '
'包长=${packetSizeBytes}B 持续发送=${continuousSend.value} result=$ok'); '包长=${packetSizeBytes}B 持续发送=${continuousSend.value} result=$ok');
Get.snackbar( Get.snackbar(
'通话翻译调试', '通话翻译调试',

143
lib/modules/call_translation_debug/views/call_translation_debug_view.dart

@ -33,6 +33,8 @@ class CallTranslationDebugView
children: [ children: [
_buildStatusCard(isDark), _buildStatusCard(isDark),
SizedBox(height: 16.h), SizedBox(height: 16.h),
_buildBitrateCard(isDark),
SizedBox(height: 16.h),
_buildSendIntervalCard(isDark), _buildSendIntervalCard(isDark),
SizedBox(height: 16.h), SizedBox(height: 16.h),
_buildBundleFrameCard(isDark), _buildBundleFrameCard(isDark),
@ -153,61 +155,134 @@ class CallTranslationDebugView
); );
} }
// 上/下行 opus 码率(决定单帧字节数)
Widget _buildBitrateCard(bool isDark) {
return _card(
isDark,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_title(isDark, 'Opus 码率',
'决定单帧字节数:单帧字节 = 码率 × 20ms ÷ 8(16k→40B / 8k→20B / 24k→60B)。上行码率必须与固件实际编码码率一致,否则收包会被判为坏包'),
SizedBox(height: 12.h),
Obx(() => _bitrateRow(
isDark,
'下行 (APP→设备)',
controller.downlinkBitrateKbps.value,
controller.downlinkFrameBytes,
controller.setDownlinkBitrate,
)),
SizedBox(height: 10.h),
Obx(() => _bitrateRow(
isDark,
'上行 (设备→APP)',
controller.uplinkBitrateKbps.value,
controller.uplinkFrameBytes,
controller.setUplinkBitrate,
)),
],
),
);
}
Widget _bitrateRow(bool isDark, String label, int current, int frameBytes,
void Function(int) onSelect) {
return Row(
children: [
SizedBox(
width: 96.w,
child: Text(
label,
style: TextStyle(
fontSize: 12.sp,
color: isDark ? Colors.white70 : Colors.black87,
),
),
),
Expanded(
child: Wrap(
spacing: 6.w,
children: CallTranslationDebugController.bitrateOptions.map((kbps) {
final selected = kbps == current;
return ChoiceChip(
label: Text('$kbps k', style: TextStyle(fontSize: 11.sp)),
selected: selected,
onSelected: (_) => onSelect(kbps),
);
}).toList(),
),
),
_valueChip(isDark, '$frameBytes B/帧'),
],
);
}
// 每包每声道帧数 // 每包每声道帧数
Widget _buildBundleFrameCard(bool isDark) { Widget _buildBundleFrameCard(bool isDark) {
const minFrames = CallTranslationDebugController.minBundleFrameCount;
const maxFrames = CallTranslationDebugController.maxBundleFrameCount;
return _card( return _card(
isDark, isDark,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_title(isDark, '每声道合包帧数', _title(isDark, '每声道合包帧数',
'一包里左右声道各放多少帧 opus,按 左/右/左/右 交替写入、无包头(${CallTranslationDebugController.minBundleFrameCount}~${CallTranslationDebugController.maxBundleFrameCount} 帧)'), '一拍里左右声道各放多少帧 opus,按 左/右/左/右 交替写入、无包头($minFrames~$maxFrames 帧)。'
'超过单次 BLE 写上限时会自动按「左右一对」对齐拆成多个写,设备侧解析不受影响'),
SizedBox(height: 8.h), SizedBox(height: 8.h),
Obx( Obx(() {
() => Row( // 显式读取依赖的 Rx,确保码率变化时本块重建
final frames = controller.bundleFrameCount.value;
return Row(
children: [ children: [
Expanded( Expanded(
child: Slider( child: Slider(
value: controller.bundleFrameCount.value value: frames.clamp(minFrames, maxFrames).toDouble(),
.clamp( min: minFrames.toDouble(),
CallTranslationDebugController.minBundleFrameCount, max: maxFrames.toDouble(),
CallTranslationDebugController.maxBundleFrameCount, divisions: maxFrames - minFrames,
) label: '$frames 帧',
.toDouble(), onChanged: (v) => controller.setBundleFrameCount(v.round()),
min: CallTranslationDebugController.minBundleFrameCount
.toDouble(),
max: CallTranslationDebugController.maxBundleFrameCount
.toDouble(),
divisions:
CallTranslationDebugController.maxBundleFrameCount -
CallTranslationDebugController.minBundleFrameCount,
label: '${controller.bundleFrameCount.value} 帧',
onChanged: (v) =>
controller.setBundleFrameCount(v.round()),
), ),
), ),
_valueChip(isDark, '${controller.bundleFrameCount.value} 帧'), _valueChip(isDark, '$frames 帧'),
], ],
), );
), }),
SizedBox(height: 4.h), SizedBox(height: 4.h),
// 包长与实时节拍提示:每帧 20ms,间隔 == 帧数×20ms 时下发速率与语速 1:1 // 包长与实时节拍提示:每帧恒 20ms(与码率无关),间隔 == 帧数×20ms 时下发速率与语速 1:1
Obx( Obx(() {
() => Text( // 显式读取三个 Rx:码率变 → 帧长变 → 包长变,缺一个订阅这里就不会刷新
'包长 ${controller.packetSizeBytes} 字节(左右各 ${controller.bundleFrameCount.value} 帧 × 40B)' final frames = controller.bundleFrameCount.value;
',与实时语速匹配的间隔为 ${controller.realtimeIntervalMs} ms' final bitrate = controller.downlinkBitrateKbps.value;
'${controller.sendIntervalMs.value == controller.realtimeIntervalMs ? '(当前已匹配)' : '(当前 ${controller.sendIntervalMs.value} ms,' final interval = controller.sendIntervalMs.value;
'${controller.sendIntervalMs.value > controller.realtimeIntervalMs ? '偏慢,音频会积压' : '偏快,会超发'})'}', final frameBytes =
CallTranslationDebugController.frameBytesOf(bitrate);
final totalBytes = 2 * frames * frameBytes;
final writes = controller.writesPerTick;
final realtime =
frames * CallTranslationDebugController.frameDurationMs;
final matched = interval == realtime;
final splitText = writes > 1
? ',超出单次 BLE 写上限 → 拆 $writes 个写(每写 ${controller.framesPerWrite} 帧)'
: '';
final paceText = matched
? '(当前已匹配)'
: '(当前 $interval ms,${interval > realtime ? '偏慢,音频会积压' : '偏快,会超发'})';
return Text(
'一拍 $totalBytes 字节(左右各 $frames 帧 × $frameBytes B)$splitText\n'
'与实时语速匹配的间隔为 $realtime ms $paceText',
style: TextStyle( style: TextStyle(
fontSize: 11.sp, fontSize: 11.sp,
color: controller.sendIntervalMs.value == color: matched
controller.realtimeIntervalMs
? (isDark ? Colors.white38 : Colors.black38) ? (isDark ? Colors.white38 : Colors.black38)
: Colors.orange, : Colors.orange,
height: 1.3, height: 1.4,
),
),
), ),
);
}),
], ],
), ),
); );

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

@ -206,16 +206,36 @@ object BleService {
// 声道缓存安全上限(纯防 OOM 兜底):缓存元素是单帧 opus,超限丢最旧帧并告警。 // 声道缓存安全上限(纯防 OOM 兜底):缓存元素是单帧 opus,超限丢最旧帧并告警。
private const val CHANNEL_HOLD_BUFFER_MAX = 10000 private const val CHANNEL_HOLD_BUFFER_MAX = 10000
// ---- Opus 码率与帧长(上下行各自可配,切勿写死字节数) ----
// opus 帧时长固定 20ms;CBR 下 单帧字节数 = 码率(kbps) × 20ms ÷ 8:
// 16kbps → 40B(默认) / 8kbps → 20B / 24kbps → 60B / 32kbps → 80B
// 帧长是变量:后台或调试页改码率后帧长随之变化,所有组包/校验/补齐都必须引用下面两个字段。
private const val OPUS_FRAME_DURATION_MS = 20
private const val DEFAULT_OPUS_BITRATE_KBPS = 16
private const val MIN_OPUS_BITRATE_KBPS = 6
private const val MAX_OPUS_BITRATE_KBPS = 64
// 下行(APP→设备)单帧 opus 字节数。编码器 packetSize 在 startEncodeStream 时固化,
// 故改它必须重启编码流并重建静音模板(见 applyDownlinkBitrate)。
@Volatile
private var downlinkFrameBytes = bitrateToFrameBytes(DEFAULT_OPUS_BITRATE_KBPS)
// 上行(设备→APP)单帧 opus 字节数:用于收包长度校验;解码器 packetSize = 2×它(立体声一单元含左右各一帧)。
@Volatile
private var uplinkFrameBytes = bitrateToFrameBytes(DEFAULT_OPUS_BITRATE_KBPS)
// ---- 下行合包:左右交替、无包头 ---- // ---- 下行合包:左右交替、无包头 ----
// 单帧 opus 固定长度(编码器 CBR packetSize=40B)。无包头协议靠固定帧长切分,长度必须严格恒定, // 无包头协议靠固定帧长切分,帧长必须严格恒定,否则设备侧整包错位 —— 故入队前校验帧长,
// 否则设备侧整包错位 —— 故入队前校验帧长,非 40B 一律丢弃。 // 长度不等于当前 downlinkFrameBytes 的帧一律丢弃。
private const val OPUS_FRAME_BYTES = 40
// 每包每声道的 opus 帧数(默认2,调试界面可调 1..MAX_BUNDLE_FRAME_COUNT)。 // 每包每声道的 opus 帧数(默认2,调试界面可调 1..MAX_BUNDLE_FRAME_COUNT)。
// 组包布局(无包头):[L0 40B][R0 40B][L1 40B][R1 40B]...[L(n-1)][R(n-1)],包长恒为 2*n*40B。 // 组包布局(无包头):[L0][R0][L1][R1]...[L(n-1)][R(n-1)],每帧 downlinkFrameBytes 字节,
// 一拍的总字节 = 2*n*downlinkFrameBytes(默认 2×2×40 = 160B)。
@Volatile @Volatile
private var bundleFrameCount = 2 private var bundleFrameCount = 2
// 每包帧数上限:包长 2*n*40 必须塞得进一次 BLE 写(MTU 517 → 可写 514B),6 帧=480B 已是安全上限。 // 每声道帧数上限。不受 MTU 约束:一拍的数据若超过单次 BLE 写上限(MTU-3),会按 2×帧长对齐
private const val MAX_BUNDLE_FRAME_COUNT = 6 // 拆成多个 BLE 写连续发出(见 trySendMixedChunk)。每个分片仍是 2×帧长的整数倍、左右交替顺序不变,
// 故设备侧无论按包解析还是按流解析都正确。
private const val MAX_BUNDLE_FRAME_COUNT = 20
// MTU 尚未协商时(currentMtu 还是默认 23)单次 BLE 写的保守字节上限,避免用 23-3=20 算出荒谬的分片数
private const val FALLBACK_BLE_WRITE_BYTES = 480
// 持续发送开关:为 true 时即使左右队列都空也按拍发全静音包,保持设备侧音频流不断流; // 持续发送开关:为 true 时即使左右队列都空也按拍发全静音包,保持设备侧音频流不断流;
// 为 false 时两侧皆空则跳过该拍不发(默认,省链路占用)。 // 为 false 时两侧皆空则跳过该拍不发(默认,省链路占用)。
@Volatile @Volatile
@ -363,9 +383,10 @@ object BleService {
override fun onSilenceFrameReady(frame: ByteArray) { override fun onSilenceFrameReady(frame: ByteArray) {
// 缓存静音帧模板,用于合包时不足帧补齐。 // 缓存静音帧模板,用于合包时不足帧补齐。
// 无包头协议按固定 40B 切分,模板长度不对会让整包左右对位全错,故只接受恰好 OPUS_FRAME_BYTES 的模板 // 无包头协议按固定帧长切分,模板长度不对会让整包左右对位全错,
if (frame.size != OPUS_FRAME_BYTES) { // 故只接受长度恰好等于当前 downlinkFrameBytes 的模板(改码率后编码器会重新生成新长度的模板)
CallLog.w(TAG, "静音帧模板长度异常 size=${frame.size}B (期望 ${OPUS_FRAME_BYTES}B),拒绝采用") if (frame.size != downlinkFrameBytes) {
CallLog.w(TAG, "静音帧模板长度异常 size=${frame.size}B (期望 ${downlinkFrameBytes}B),拒绝采用")
return return
} }
silenceFrame = frame silenceFrame = frame
@ -1443,16 +1464,16 @@ object BleService {
} }
/** /**
* 上行音频收包长度保护:普通流 opus 帧=40B;通话流(8kbps/60ms)立体声一帧=120B=2×60B。 * 上行音频收包长度保护:合法包应是「当前上行单帧字节数」的整数倍且非空。
* 两条流共用此校验,故按其公约数 40 判:40 与 120 均为 40 的整数倍,合法包应是 40 的整数倍且非空。 * 帧长随上行码率变化(16kbps→40B / 8kbps→20B),故按 uplinkFrameBytes 判,不能写死 40。
* 注意不要改成 % 60——那会把普通单声道流(40B)误判为坏包。 * 非整数倍或空包视为异常(坏包/字节流错位),返回 false —— 调用方据此直接丢弃、
* 非 40 整数倍或空包视为异常(坏包/字节流错位),返回 false —— 调用方据此直接丢弃、
* 且不计入接收统计、不喂解码器;避免坏包喂进定长流式解码器把后续整段字节流搞错位、全部解错。 * 且不计入接收统计、不喂解码器;避免坏包喂进定长流式解码器把后续整段字节流搞错位、全部解错。
* @return true=长度合法可继续处理;false=异常包(已打印告警),应丢弃 * @return true=长度合法可继续处理;false=异常包(已打印告警),应丢弃
*/ */
private fun isValidUplinkAudioLen(data: ByteArray): Boolean { private fun isValidUplinkAudioLen(data: ByteArray): Boolean {
if (data.isNotEmpty() && data.size % 40 == 0) return true val frameBytes = uplinkFrameBytes
CallLog.w(TAG, "上行收到异常长度音频包,已丢弃: size=${data.size}B (非 opus 帧长 40B 整数倍)") if (data.isNotEmpty() && data.size % frameBytes == 0) return true
CallLog.w(TAG, "上行收到异常长度音频包,已丢弃: size=${data.size}B (非上行帧长 ${frameBytes}B 整数倍)")
return false return false
} }
@ -1503,14 +1524,35 @@ object BleService {
// Opus 编码相关 - 委托给OpusAudioManager // Opus 编码相关 - 委托给OpusAudioManager
// ====================================================================================================== // ======================================================================================================
/**
* 码率(kbps) → 单帧 opus 字节数。CBR + 固定 20ms 帧:字节 = kbps × 1000 × 0.02 ÷ 8 = kbps × 20 ÷ 8。
* 16→40B / 8→20B / 24→60B / 32→80B
*/
private fun bitrateToFrameBytes(bitrateKbps: Int): Int =
bitrateKbps.coerceIn(MIN_OPUS_BITRATE_KBPS, MAX_OPUS_BITRATE_KBPS) * OPUS_FRAME_DURATION_MS / 8
/** 单帧字节数 → 码率(kbps),仅用于日志与参数回读 */
private fun frameBytesToBitrate(frameBytes: Int): Int = frameBytes * 8 / OPUS_FRAME_DURATION_MS
/**
* 一次 BLE 写最多能装几帧/声道:净荷上限(MTU-3) ÷ (2 × 帧长)。
* 一拍要发的帧数超过它时按此拆分成多个写(每个分片仍 2×帧长 对齐,设备侧解析不受影响)。
* MTU 未协商时用 FALLBACK_BLE_WRITE_BYTES,避免用默认 MTU(23) 算出 0 帧。
*/
private fun maxFramesPerWrite(frameBytes: Int): Int {
val writeLimit = if (currentMtu > 23) currentMtu - 3 else FALLBACK_BLE_WRITE_BYTES
return (writeLimit / (2 * frameBytes)).coerceAtLeast(1)
}
/** /**
* 开始Opus数据流编码(通话翻译模式:左右声道独立编码) * 开始Opus数据流编码(通话翻译模式:左右声道独立编码)
* 输出下行包格式(无包头,左右交替):[L0 40B][R0 40B]...[L(n-1)][R(n-1)],n=bundleFrameCount(默认2 → 160B) * 输出下行包格式(无包头,左右交替):[L0][R0]...[L(n-1)][R(n-1)],每帧 downlinkFrameBytes 字节,
* n=bundleFrameCount(默认 2 帧 × 40B → 160B/包)
* @return 是否成功开始编码 * @return 是否成功开始编码
*/ */
private fun startOpusEncodeStream(): Boolean { private fun startOpusEncodeStream(): Boolean {
startAudioSendThread() startAudioSendThread()
return opusAudioManager.startDualEncodeStream(false, 16000, OPUS_FRAME_BYTES) return opusAudioManager.startDualEncodeStream(false, 16000, downlinkFrameBytes)
} }
/** /**
@ -1530,14 +1572,14 @@ object BleService {
* 重连窗口内不丢弃:继续入队暂存,回连成功后续发;仅超 OOM 兜底上限才丢最旧帧。 * 重连窗口内不丢弃:继续入队暂存,回连成功后续发;仅超 OOM 兜底上限才丢最旧帧。
* *
* 无包头协议下设备侧按固定 40B 切分左右声道,任何一帧长度异常都会让整包及之后的声道对位全错, * 无包头协议下设备侧按固定 40B 切分左右声道,任何一帧长度异常都会让整包及之后的声道对位全错,
* 故非 OPUS_FRAME_BYTES 的帧在入队处直接拦掉,保证队列里的帧长恒定。 * 故长度不等于当前 downlinkFrameBytes 的帧在入队处直接拦掉,保证队列里的帧长恒定。
* @param channelPrefix 声道标识(CHANNEL_LEFT=0 / CHANNEL_RIGHT=1) * @param channelPrefix 声道标识(CHANNEL_LEFT=0 / CHANNEL_RIGHT=1)
* @param frame 一帧 opus 编码数据 * @param frame 一帧 opus 编码数据
*/ */
private fun enqueueOpusFrame(channelPrefix: Byte, frame: ByteArray) { private fun enqueueOpusFrame(channelPrefix: Byte, frame: ByteArray) {
if (frame.isEmpty()) return if (frame.isEmpty()) return
if (frame.size != OPUS_FRAME_BYTES) { if (frame.size != downlinkFrameBytes) {
CallLog.w(TAG, "丢弃异常长度 opus 帧 ch=$channelPrefix size=${frame.size}B (期望 ${OPUS_FRAME_BYTES}B)") CallLog.w(TAG, "丢弃异常长度 opus 帧 ch=$channelPrefix size=${frame.size}B (期望 ${downlinkFrameBytes}B)")
return return
} }
val buffer = when (channelPrefix) { val buffer = when (channelPrefix) {
@ -1566,7 +1608,9 @@ object BleService {
audioSendThread = createAudioSendThread().apply { start() } audioSendThread = createAudioSendThread().apply { start() }
val n = bundleFrameCount.coerceIn(1, MAX_BUNDLE_FRAME_COUNT) val n = bundleFrameCount.coerceIn(1, MAX_BUNDLE_FRAME_COUNT)
CallLog.i(TAG, "音频发送线程已启动(左右合包/无包头) 间隔=${audioSendIntervalNormal}ms " + CallLog.i(TAG, "音频发送线程已启动(左右合包/无包头) 间隔=${audioSendIntervalNormal}ms " +
"帧数=$n/声道 包长=${2 * n * OPUS_FRAME_BYTES}B 持续发送=$continuousSendEnabled") "帧数=$n/声道 帧长=${downlinkFrameBytes}B(${frameBytesToBitrate(downlinkFrameBytes)}kbps) " +
"一拍=${2 * n * downlinkFrameBytes}B 单写上限=${maxFramesPerWrite(downlinkFrameBytes)}帧 " +
"持续发送=$continuousSendEnabled")
} }
/** /**
@ -1622,14 +1666,18 @@ object BleService {
// 两路皆空且未开持续发送:本拍不发 // 两路皆空且未开持续发送:本拍不发
if (!hasRealAudio && !continuousSendEnabled) return false if (!hasRealAudio && !continuousSendEnabled) return false
// 无包头协议靠固定帧长切分,缺帧必须补静音才能保证包长恒定;模板未就绪则本拍放弃,帧退回队列不丢 // 帧长在本拍内取一次快照:改码率是异步的,中途变化会让同一包里的帧长不一致(整包错位)
val frameBytes = downlinkFrameBytes
// 无包头协议靠固定帧长切分,缺帧必须补静音才能保证包长恒定;模板未就绪(或长度与当前码率不匹配)
// 则本拍放弃,帧退回队列不丢
val silence = silenceFrame val silence = silenceFrame
if (silence == null) { if (silence == null || silence.size != frameBytes) {
for (i in leftFrames.indices.reversed()) leftSendBuffer.offerFirst(leftFrames[i]) for (i in leftFrames.indices.reversed()) leftSendBuffer.offerFirst(leftFrames[i])
for (i in rightFrames.indices.reversed()) rightSendBuffer.offerFirst(rightFrames[i]) for (i in rightFrames.indices.reversed()) rightSendBuffer.offerFirst(rightFrames[i])
silenceNotReadyCount++ silenceNotReadyCount++
if (silenceNotReadyCount == 1 || silenceNotReadyCount % 50 == 0) { if (silenceNotReadyCount == 1 || silenceNotReadyCount % 50 == 0) {
CallLog.w(TAG, "静音帧模板未就绪,暂缓下发(累计 $silenceNotReadyCount 拍),译音已退回队列不丢") CallLog.w(TAG, "静音帧模板未就绪(或与当前帧长 ${frameBytes}B 不匹配),暂缓下发" +
"(累计 $silenceNotReadyCount 拍),译音已退回队列不丢")
} }
return false return false
} }
@ -1637,44 +1685,63 @@ object BleService {
val padLeft = n - leftFrames.size val padLeft = n - leftFrames.size
val padRight = n - rightFrames.size val padRight = n - rightFrames.size
val packet = buildInterleavedPacket(n, leftFrames, rightFrames, silence)
// 一拍的数据(2×n×帧长)可能超过单次 BLE 写的净荷上限(MTU-3),按 2×帧长 对齐拆成多个写连续发出。
// 每个分片仍是 2×帧长的整数倍、左右交替顺序不变 → 设备侧按包或按流解析都正确。
val framesPerWrite = maxFramesPerWrite(frameBytes).coerceAtMost(n)
var sentFrames = 0
var sentBytes = 0
var writeCount = 0
var congested = false
while (sentFrames < n) {
val chunkFrames = minOf(framesPerWrite, n - sentFrames)
val packet = buildInterleavedPacket(sentFrames, chunkFrames, frameBytes, leftFrames, rightFrames, silence)
// 同步写入,拿到真实的成功/失败:false=底层发送缓冲已满(拥塞),作为背压信号 // 同步写入,拿到真实的成功/失败:false=底层发送缓冲已满(拥塞),作为背压信号
val sent = sendAudioChunkBlocking(packet) if (!sendAudioChunkBlocking(packet)) {
if (!sent) { congested = true
// 写失败:真实帧按原顺序退回各自队头(倒序 offerFirst),下一拍重组重试;静音补齐帧丢弃 break
for (i in leftFrames.indices.reversed()) leftSendBuffer.offerFirst(leftFrames[i]) }
for (i in rightFrames.indices.reversed()) rightSendBuffer.offerFirst(rightFrames[i]) sentFrames += chunkFrames
sentBytes += packet.size
writeCount++
}
if (congested) {
// 拥塞:把**尚未发出**的真实帧按原顺序退回各自队头,下一拍重组重试;静音补齐帧丢弃。
// 已发出的分片无法撤回,但它们自身 2×帧长 对齐、顺序正确,设备侧字节流不会错位;
// 剩余帧下一拍继续发,音频仍连续。
for (i in (leftFrames.size - 1) downTo sentFrames) leftSendBuffer.offerFirst(leftFrames[i])
for (i in (rightFrames.size - 1) downTo sentFrames) rightSendBuffer.offerFirst(rightFrames[i])
writeFailRetryCount++ writeFailRetryCount++
if (writeFailRetryCount == 1 || writeFailRetryCount % 50 == 0) { if (writeFailRetryCount == 1 || writeFailRetryCount % 50 == 0) {
CallLog.w(TAG, "下行写入拥塞,重试中 seq=$packetSeq 连续失败=$writeFailRetryCount " + CallLog.w(TAG, "下行写入拥塞,重试中 seq=$packetSeq 连续失败=$writeFailRetryCount " +
"buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})") "已发=${sentFrames}/${n}帧 buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})")
} }
return false if (sentFrames == 0) return false
} } else if (writeFailRetryCount > 0) {
if (writeFailRetryCount > 0) {
CallLog.i(TAG, "下行写入已恢复,之前连续失败=$writeFailRetryCount") CallLog.i(TAG, "下行写入已恢复,之前连续失败=$writeFailRetryCount")
writeFailRetryCount = 0 writeFailRetryCount = 0
} }
val seq = packetSeq++ val seq = packetSeq++
// 调试录音:按声道各自落地(真实帧+静音补齐,与该路实际下发内容一致,听辨即静音) // 调试录音:按声道各自落地已实际发出的帧(真实帧+静音补齐,与下发内容一致,听辨即静音)
recordfile1Left?.saveAudioDataToWav(padToFrames(n, leftFrames, silence)) recordfile1Left?.saveAudioDataToWav(padToFrames(sentFrames, frameBytes, leftFrames, silence))
recordfile1Right?.saveAudioDataToWav(padToFrames(n, rightFrames, silence)) recordfile1Right?.saveAudioDataToWav(padToFrames(sentFrames, frameBytes, rightFrames, silence))
// 发送节拍统计与包序日志;持续发送的纯静音包按 25 拍节流,避免刷屏 // 发送节拍统计与包序日志;持续发送的纯静音包按 25 拍节流,避免刷屏
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val deltaMs = if (lastAudioSendTime < 0) 0 else now - lastAudioSendTime val deltaMs = if (lastAudioSendTime < 0) 0 else now - lastAudioSendTime
lastAudioSendTime = now lastAudioSendTime = now
val splitTag = if (writeCount > 1) " 拆$writeCount 写" else ""
if (hasRealAudio) { if (hasRealAudio) {
silentPacketCount = 0 silentPacketCount = 0
CallLog.i(TAG, "音频下行发送 seq=$seq Δ=${deltaMs}ms L=${leftFrames.size}+静音$padLeft " + CallLog.i(TAG, "音频下行发送 seq=$seq Δ=${deltaMs}ms L=${leftFrames.size}+静音$padLeft " +
"R=${rightFrames.size}+静音$padRight size=${packet.size}B " + "R=${rightFrames.size}+静音$padRight 帧=${sentFrames}/${n} size=${sentBytes}B$splitTag " +
"buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})") "buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})")
} else { } else {
silentPacketCount++ silentPacketCount++
if (silentPacketCount % 25 == 1) { if (silentPacketCount % 25 == 1) {
CallLog.i(TAG, "音频下行持续发送(全静音) seq=$seq Δ=${deltaMs}ms size=${packet.size}B 累计=$silentPacketCount 包") CallLog.i(TAG, "音频下行持续发送(全静音) seq=$seq Δ=${deltaMs}ms size=${sentBytes}B$splitTag 累计=$silentPacketCount 包")
} }
} }
@ -1689,33 +1756,40 @@ object BleService {
} }
/** /**
* 组下行包(无包头,左右帧级交替):[L0][R0][L1][R1]...[L(n-1)][R(n-1)] * 组下行分片(无包头,左右帧级交替):取第 startFrame 起的 count 帧,拼成
* 每帧恒为 OPUS_FRAME_BYTES 字节,包长恒为 2*n*OPUS_FRAME_BYTES;某侧该位缺帧则填静音帧,保证左右严格对位。 * [L(s)][R(s)][L(s+1)][R(s+1)]...,每帧恒为 frameBytes 字节,分片长度恒为 2*count*frameBytes。
* 某侧该位缺帧则填静音帧,保证左右严格对位、长度恒定。
* @param startFrame 本分片从第几帧开始(一拍拆多写时用来续接,不拆分时为 0)
* @param count 本分片装几帧/声道
* @param frameBytes 单帧字节数快照(调用方从 downlinkFrameBytes 取,一拍内统一)
*/ */
private fun buildInterleavedPacket( private fun buildInterleavedPacket(
n: Int, startFrame: Int,
count: Int,
frameBytes: Int,
leftFrames: List<ByteArray>, leftFrames: List<ByteArray>,
rightFrames: List<ByteArray>, rightFrames: List<ByteArray>,
silence: ByteArray silence: ByteArray
): ByteArray { ): ByteArray {
val packet = ByteArray(2 * n * OPUS_FRAME_BYTES) val packet = ByteArray(2 * count * frameBytes)
var offset = 0 var offset = 0
for (i in 0 until n) { for (k in 0 until count) {
System.arraycopy(leftFrames.getOrNull(i) ?: silence, 0, packet, offset, OPUS_FRAME_BYTES) val i = startFrame + k
offset += OPUS_FRAME_BYTES System.arraycopy(leftFrames.getOrNull(i) ?: silence, 0, packet, offset, frameBytes)
System.arraycopy(rightFrames.getOrNull(i) ?: silence, 0, packet, offset, OPUS_FRAME_BYTES) offset += frameBytes
offset += OPUS_FRAME_BYTES System.arraycopy(rightFrames.getOrNull(i) ?: silence, 0, packet, offset, frameBytes)
offset += frameBytes
} }
return packet return packet
} }
/** 把某一路的真实帧补静音至 n 帧后顺次拼接(仅供调试录音落地,内容与该路实际下发的一致) */ /** 把某一路的真实帧补静音至 n 帧后顺次拼接(仅供调试录音落地,内容与该路实际下发的一致) */
private fun padToFrames(n: Int, frames: List<ByteArray>, silence: ByteArray): ByteArray { private fun padToFrames(n: Int, frameBytes: Int, frames: List<ByteArray>, silence: ByteArray): ByteArray {
val out = ByteArray(n * OPUS_FRAME_BYTES) val out = ByteArray(n * frameBytes)
var offset = 0 var offset = 0
for (i in 0 until n) { for (i in 0 until n) {
System.arraycopy(frames.getOrNull(i) ?: silence, 0, out, offset, OPUS_FRAME_BYTES) System.arraycopy(frames.getOrNull(i) ?: silence, 0, out, offset, frameBytes)
offset += OPUS_FRAME_BYTES offset += frameBytes
} }
return out return out
} }
@ -2009,27 +2083,38 @@ object BleService {
/** /**
* 设置通话翻译调试参数(运行时生效,无需重连)。任一参数为 null 或非法值则不修改。 * 设置通话翻译调试参数(运行时生效,无需重连)。任一参数为 null 或非法值则不修改。
* @param sendIntervalMs 音频下行发送间隔(ms) * @param sendIntervalMs 音频下行发送间隔(ms)
* @param bundleFrameCount 每包每声道的 opus 帧数(1..MAX_BUNDLE_FRAME_COUNT),包长 = 2*n*40B * @param bundleFrameCount 每包每声道的 opus 帧数(1..maxBundleFrameCount()),包长 = 2*n*下行帧长
* @param continuousSend 是否持续发送:true 时无译音也按拍下发全静音包,保持设备侧音频流不断 * @param continuousSend 是否持续发送:true 时无译音也按拍下发全静音包,保持设备侧音频流不断
* @param downlinkBitrateKbps 下行(APP→设备)opus 码率(kbps),决定单帧字节数;改动会重启编码流
* @param uplinkBitrateKbps 上行(设备→APP)opus 码率(kbps),须与固件实际编码码率一致;改动会重启解码流
*/ */
fun setCallTranslationDebugParams( fun setCallTranslationDebugParams(
sendIntervalMs: Long?, sendIntervalMs: Long?,
bundleFrameCount: Int?, bundleFrameCount: Int?,
continuousSend: Boolean? continuousSend: Boolean?,
downlinkBitrateKbps: Int? = null,
uplinkBitrateKbps: Int? = null
) { ) {
// 先应用码率:帧长会变,后面的合包帧数上限依赖它
if (downlinkBitrateKbps != null && downlinkBitrateKbps > 0) {
applyDownlinkBitrate(downlinkBitrateKbps)
}
if (uplinkBitrateKbps != null && uplinkBitrateKbps > 0) {
applyUplinkBitrate(uplinkBitrateKbps)
}
if (sendIntervalMs != null && sendIntervalMs > 0) { if (sendIntervalMs != null && sendIntervalMs > 0) {
audioSendIntervalNormal = sendIntervalMs.coerceIn(1L, 1000L) audioSendIntervalNormal = sendIntervalMs.coerceIn(1L, 1000L)
Log.i(TAG, "[CALL_TRANS_DEBUG] 设置音频下行发送间隔: ${audioSendIntervalNormal}ms") Log.i(TAG, "[CALL_TRANS_DEBUG] 设置音频下行发送间隔: ${audioSendIntervalNormal}ms")
} }
if (bundleFrameCount != null && bundleFrameCount > 0) { if (bundleFrameCount != null && bundleFrameCount > 0) {
this.bundleFrameCount = bundleFrameCount.coerceIn(1, MAX_BUNDLE_FRAME_COUNT) this.bundleFrameCount = bundleFrameCount.coerceIn(1, MAX_BUNDLE_FRAME_COUNT)
val packetSize = 2 * this.bundleFrameCount * OPUS_FRAME_BYTES val totalBytes = 2 * this.bundleFrameCount * downlinkFrameBytes
Log.i(TAG, "[CALL_TRANS_DEBUG] 设置每声道合包帧数: ${this.bundleFrameCount} (包长 ${packetSize}B)") // 超过单次 BLE 写上限时会自动按 2×帧长 对齐拆成多个写,这里把拆分数算出来记日志
// 一次 BLE 写的净荷上限是 MTU-3,超了底层会写失败/截断,这里提前告警。 val framesPerWrite = maxFramesPerWrite(downlinkFrameBytes).coerceAtMost(this.bundleFrameCount)
// 仅在 MTU 已协商时才判(未连接时 currentMtu 还是默认 23,判了必然误报) val writes = (this.bundleFrameCount + framesPerWrite - 1) / framesPerWrite
if (currentMtu > 23 && packetSize > currentMtu - 3) { val splitTag = if (writes > 1) ",超出单次 BLE 写上限 → 拆 $writes 个写(每写 ${framesPerWrite} 帧)" else ""
CallLog.w(TAG, "[CALL_TRANS_DEBUG] 包长 ${packetSize}B 超出当前 MTU 可写上限(${currentMtu - 3}B),下行会写失败") Log.i(TAG, "[CALL_TRANS_DEBUG] 设置每声道合包帧数: ${this.bundleFrameCount} " +
} "(帧长 ${downlinkFrameBytes}B → 一拍 ${totalBytes}B$splitTag)")
} }
if (continuousSend != null) { if (continuousSend != null) {
continuousSendEnabled = continuousSend continuousSendEnabled = continuousSend
@ -2037,15 +2122,70 @@ object BleService {
} }
} }
/**
* 应用下行码率:帧长随之变化。
* 编码器的 packetSize 在 startEncodeStream 时就固化了,改码率必须**重启编码流**才能生效;
* 同时必须清空左右队列并作废旧静音模板 —— 否则旧帧长的残留帧会混进新包,导致设备侧整包错位。
*/
private fun applyDownlinkBitrate(bitrateKbps: Int) {
val newFrameBytes = bitrateToFrameBytes(bitrateKbps)
if (newFrameBytes == downlinkFrameBytes) return
val wasEncoding = opusAudioManager.isDualEncoding()
CallLog.i(TAG, "[CALL_TRANS_DEBUG] 设置下行码率: ${bitrateKbps}kbps " +
"(帧长 ${downlinkFrameBytes}B → ${newFrameBytes}B, 编码流运行中=$wasEncoding)")
if (wasEncoding) opusAudioManager.stopDualEncodeStream()
// 作废旧帧长的一切残留:队列里的旧帧 + 旧静音模板
downlinkFrameBytes = newFrameBytes
silenceFrame = null
leftSendBuffer.clear()
rightSendBuffer.clear()
// 重启编码流:新 packetSize 生效,并重新生成对应长度的静音模板
if (wasEncoding) {
opusAudioManager.startDualEncodeStream(false, 16000, downlinkFrameBytes)
}
}
/**
* 应用上行码率:必须与**固件实际编码码率**一致,否则收包长度校验和解码器切分都会错。
* 解码器 packetSize = 2 × 帧长(立体声一单元含左右各一帧),改动需重启解码流。
*/
private fun applyUplinkBitrate(bitrateKbps: Int) {
val newFrameBytes = bitrateToFrameBytes(bitrateKbps)
if (newFrameBytes == uplinkFrameBytes) return
val wasDecoding = isCallTranslating
CallLog.i(TAG, "[CALL_TRANS_DEBUG] 设置上行码率: ${bitrateKbps}kbps " +
"(帧长 ${uplinkFrameBytes}B → ${newFrameBytes}B, 会话中=$wasDecoding)")
uplinkFrameBytes = newFrameBytes
// 会话中改动才需要立刻重启解码流;否则下次 openA2DPDecoder 时自然带上新值
if (wasDecoding) {
startOpusStreamDecoding(false, 2, 16000, 2 * uplinkFrameBytes)
}
}
/** /**
* 获取当前通话翻译调试参数。 * 获取当前通话翻译调试参数。
* @return Map: sendIntervalMs(Long) / bundleFrameCount(Int) / continuousSend(Boolean) * @return Map: sendIntervalMs / bundleFrameCount / continuousSend /
* downlinkBitrateKbps / uplinkBitrateKbps / downlinkFrameBytes / uplinkFrameBytes /
* maxBundleFrameCount / packetSizeBytes
*/ */
fun getCallTranslationDebugParams(): Map<String, Any> { fun getCallTranslationDebugParams(): Map<String, Any> {
return mapOf( return mapOf(
"sendIntervalMs" to audioSendIntervalNormal, "sendIntervalMs" to audioSendIntervalNormal,
"bundleFrameCount" to bundleFrameCount, "bundleFrameCount" to bundleFrameCount,
"continuousSend" to continuousSendEnabled "continuousSend" to continuousSendEnabled,
"downlinkBitrateKbps" to frameBytesToBitrate(downlinkFrameBytes),
"uplinkBitrateKbps" to frameBytesToBitrate(uplinkFrameBytes),
"downlinkFrameBytes" to downlinkFrameBytes,
"uplinkFrameBytes" to uplinkFrameBytes,
"maxBundleFrameCount" to MAX_BUNDLE_FRAME_COUNT,
"packetSizeBytes" to (2 * bundleFrameCount * downlinkFrameBytes),
"maxFramesPerWrite" to maxFramesPerWrite(downlinkFrameBytes)
) )
} }
@ -2119,7 +2259,9 @@ object BleService {
*/ */
fun openDecoder(channelMode: Int = BleConst.AUDIO_CHANNEL_STEREO): Boolean { fun openDecoder(channelMode: Int = BleConst.AUDIO_CHANNEL_STEREO): Boolean {
Log.i(TAG, "打开解码器...") Log.i(TAG, "打开解码器...")
startOpusStreamDecoding(false, 1, 16000, 40) // 普通单声道流(非通话翻译)固定 16kbps/40B 帧;同步收包校验基准,避免沿用通话翻译的上行帧长把它误判成坏包
uplinkFrameBytes = bitrateToFrameBytes(DEFAULT_OPUS_BITRATE_KBPS)
startOpusStreamDecoding(false, 1, 16000, uplinkFrameBytes)
val cmdData = byteArrayOf( val cmdData = byteArrayOf(
BleConst.CODEC_CONTROL_DECODE_ON.toByte(), BleConst.CODEC_CONTROL_DECODE_ON.toByte(),
channelMode.toByte() channelMode.toByte()
@ -2144,9 +2286,9 @@ object BleService {
startOpusEncodeStream() startOpusEncodeStream()
recordfile1Left?.creatingFiles(); recordfile1Left?.creatingFiles();
recordfile1Right?.creatingFiles(); recordfile1Right?.creatingFiles();
// 设备端通话上行 opus 参数:8kbps/60ms/8kHz 立体声。 // 上行:设备立体声 opus。解码器 packetSize = 一个立体声单元的字节数 = 2 × 单声道帧长,
// packetSize=每帧字节数=码率×帧长÷8=8000×0.06÷8=60B(单声道),立体声打包 2×60=120B。 // 帧长由上行码率决定(16kbps→40B → 单元 80B;8kbps→20B → 单元 40B),不能写死。
startOpusStreamDecoding(false, 2, 16000, 80) startOpusStreamDecoding(false, 2, 16000, 2 * uplinkFrameBytes)
val cmdData = byteArrayOf( val cmdData = byteArrayOf(
BleConst.CODEC_CONTROL_A2DP_PLAY.toByte(), BleConst.CODEC_CONTROL_A2DP_PLAY.toByte(),
channelMode.toByte() channelMode.toByte()
@ -2163,8 +2305,8 @@ object BleService {
*/ */
fun openCallRecordDecoder(channelMode: Int = BleConst.AUDIO_CHANNEL_STEREO): Boolean { fun openCallRecordDecoder(channelMode: Int = BleConst.AUDIO_CHANNEL_STEREO): Boolean {
Log.i(TAG, "打开通话录音解码器...") Log.i(TAG, "打开通话录音解码器...")
// 通话录音与通话翻译走同一条设备上行流:8kbps/60ms/8kHz 立体声,packetSize=2×60=120B。 // 通话录音与通话翻译走同一条设备上行流(立体声):packetSize = 2 × 上行单声道帧长,随上行码率变化
startOpusStreamDecoding(false, 2, 16000, 80) startOpusStreamDecoding(false, 2, 16000, 2 * uplinkFrameBytes)
val cmdData = byteArrayOf( val cmdData = byteArrayOf(
BleConst.CODEC_CONTROL_CALL_RECORD_PLAY.toByte(), BleConst.CODEC_CONTROL_CALL_RECORD_PLAY.toByte(),
channelMode.toByte() channelMode.toByte()
@ -2188,7 +2330,9 @@ object BleService {
*/ */
fun openEncoder(channelMode: Int = BleConst.AUDIO_CHANNEL_STEREO): Boolean { fun openEncoder(channelMode: Int = BleConst.AUDIO_CHANNEL_STEREO): Boolean {
Log.i(TAG, "打开编码器...") Log.i(TAG, "打开编码器...")
startOpusStreamDecoding(false, 1, 16000, 40) // 普通单声道流(非通话翻译)固定 16kbps/40B 帧;同步收包校验基准,理由同 openDecoder
uplinkFrameBytes = bitrateToFrameBytes(DEFAULT_OPUS_BITRATE_KBPS)
startOpusStreamDecoding(false, 1, 16000, uplinkFrameBytes)
// val cmdData = byteArrayOf( // val cmdData = byteArrayOf(
// BleConst.CODEC_CONTROL_ENCODE_ON.toByte(), // BleConst.CODEC_CONTROL_ENCODE_ON.toByte(),
// channelMode.toByte() // channelMode.toByte()

7
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt

@ -250,7 +250,12 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware,
val sendIntervalMs = (call.argument<Number>("sendIntervalMs"))?.toLong() val sendIntervalMs = (call.argument<Number>("sendIntervalMs"))?.toLong()
val bundleFrameCount = (call.argument<Number>("bundleFrameCount"))?.toInt() val bundleFrameCount = (call.argument<Number>("bundleFrameCount"))?.toInt()
val continuousSend = call.argument<Boolean>("continuousSend") val continuousSend = call.argument<Boolean>("continuousSend")
BleService.setCallTranslationDebugParams(sendIntervalMs, bundleFrameCount, continuousSend) val downlinkBitrateKbps = (call.argument<Number>("downlinkBitrateKbps"))?.toInt()
val uplinkBitrateKbps = (call.argument<Number>("uplinkBitrateKbps"))?.toInt()
BleService.setCallTranslationDebugParams(
sendIntervalMs, bundleFrameCount, continuousSend,
downlinkBitrateKbps, uplinkBitrateKbps
)
result.success(true) result.success(true)
} }
"getCallTranslationDebugParams" -> { "getCallTranslationDebugParams" -> {

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

@ -112,7 +112,7 @@ public class BleService: NSObject {
// MARK: - 音频下行发送(与 Android BleService 对齐) // MARK: - 音频下行发送(与 Android BleService 对齐)
// 左右声道各一条缓存队列,共用**一条**发送定时器:每拍从左右队列各取 N 帧, // 左右声道各一条缓存队列,共用**一条**发送定时器:每拍从左右队列各取 N 帧,
// 按 [L0][R0][L1][R1]... 帧级交替拼成一个无包头包下发;某侧不足的位用静音帧补齐。 // 按 [L0][R0][L1][R1]... 帧级交替拼成一个无包头包下发;某侧不足的位用静音帧补齐。
// 队列元素是单帧 opus(恒 OPUS_FRAME_BYTES=40B)。 // 队列元素是单帧 opus,长度恒为 downlinkFrameBytes(由下行码率决定,16kbps→40B)。
private var leftSendBuffer = [Data]() private var leftSendBuffer = [Data]()
private var rightSendBuffer = [Data]() private var rightSendBuffer = [Data]()
private let sendBufferLock = NSLock() private let sendBufferLock = NSLock()
@ -131,12 +131,17 @@ public class BleService: NSObject {
private var _writeFailRetryCount = 0 private var _writeFailRetryCount = 0
/// 上一包音频实际发送的时间戳,仅用于发送节拍日志统计间隔;-1 表示尚未发送过 /// 上一包音频实际发送的时间戳,仅用于发送节拍日志统计间隔;-1 表示尚未发送过
private var _lastAudioSendTime: TimeInterval = -1 private var _lastAudioSendTime: TimeInterval = -1
/// 每包每声道的 opus 帧数(1..MAX_BUNDLE_FRAME_COUNT),运行时可调 /// 每包每声道的 opus 帧数(1..maxBundleFrameCount),运行时可调
private var _bundleFrameCount = 2 private var _bundleFrameCount = 2
/// 音频下行发送间隔(秒),运行时可调 /// 音频下行发送间隔(秒),运行时可调
private var _audioSendInterval: TimeInterval = 0.04 private var _audioSendInterval: TimeInterval = 0.04
/// 持续发送开关:true 时即使左右队列都空也按拍发全静音包,保持设备侧音频流不断 /// 持续发送开关:true 时即使左右队列都空也按拍发全静音包,保持设备侧音频流不断
private var _continuousSendEnabled = false private var _continuousSendEnabled = false
/// 下行(APP→设备)单帧 opus 字节数,由下行码率决定。编码器 packetSize 在启动时固化,
/// 改它必须重启编码流并重建静音模板(见 applyDownlinkBitrate)。
private var _downlinkFrameBytes = 40
/// 上行(设备→APP)单帧 opus 字节数,由上行码率决定(须与固件实际编码码率一致)
private var _uplinkFrameBytes = 40
/// 下行包序号(每成功发一包自增);无包头协议下不写入包内,仅用于日志核对节拍连续性。 /// 下行包序号(每成功发一包自增);无包头协议下不写入包内,仅用于日志核对节拍连续性。
/// 只被发送队列访问,无需加锁。 /// 只被发送队列访问,无需加锁。
@ -165,14 +170,48 @@ public class BleService: NSObject {
get { sendStateLock.lock(); defer { sendStateLock.unlock() }; return _continuousSendEnabled } get { sendStateLock.lock(); defer { sendStateLock.unlock() }; return _continuousSendEnabled }
set { sendStateLock.lock(); _continuousSendEnabled = newValue; sendStateLock.unlock() } set { sendStateLock.lock(); _continuousSendEnabled = newValue; sendStateLock.unlock() }
} }
private var downlinkFrameBytes: Int {
// 音频发送配置常量(与 Android 默认值对齐) get { sendStateLock.lock(); defer { sendStateLock.unlock() }; return _downlinkFrameBytes }
/// 单帧 opus 固定长度(编码器 CBR)。无包头协议靠固定帧长切分,长度必须严格恒定,否则设备侧整包错位。 set { sendStateLock.lock(); _downlinkFrameBytes = newValue; sendStateLock.unlock() }
private let OPUS_FRAME_BYTES = 40 }
/// 每包帧数上限:包长 2*n*40 必须塞得进一次 BLE 写,6 帧=480B 已是安全上限 private var uplinkFrameBytes: Int {
private let MAX_BUNDLE_FRAME_COUNT = 6 get { sendStateLock.lock(); defer { sendStateLock.unlock() }; return _uplinkFrameBytes }
set { sendStateLock.lock(); _uplinkFrameBytes = newValue; sendStateLock.unlock() }
}
// 音频编码配置(与 Android 对齐)
/// opus 帧时长固定 20ms;CBR 下 单帧字节数 = 码率(kbps) × 20ms ÷ 8:
/// 16kbps→40B(默认) / 8kbps→20B / 24kbps→60B。**帧长是变量,不要写死 40。**
private let OPUS_FRAME_DURATION_MS = 20
private let DEFAULT_OPUS_BITRATE_KBPS = 16
private let MIN_OPUS_BITRATE_KBPS = 6
private let MAX_OPUS_BITRATE_KBPS = 64
/// 每声道帧数上限。不受 MTU 约束:一拍的数据若超过单次 BLE 写上限,会按 2×帧长 对齐拆成多个写连续发出
/// (每个分片仍是 2×帧长的整数倍、左右交替顺序不变,设备侧按包或按流解析都正确)。
private let MAX_BUNDLE_FRAME_COUNT = 20
/// 单次 BLE 写的净荷字节上限(iOS 用 maximumWriteValueLength 查询,查不到时的兜底值)
private let FALLBACK_BLE_WRITE_BYTES = 480
private let CHANNEL_HOLD_BUFFER_MAX = 10000 // 声道缓存安全上限(防 OOM 兜底) private let CHANNEL_HOLD_BUFFER_MAX = 10000 // 声道缓存安全上限(防 OOM 兜底)
/// 码率(kbps) → 单帧 opus 字节数
private func bitrateToFrameBytes(_ bitrateKbps: Int) -> Int {
let clamped = min(max(bitrateKbps, MIN_OPUS_BITRATE_KBPS), MAX_OPUS_BITRATE_KBPS)
return clamped * OPUS_FRAME_DURATION_MS / 8
}
/// 单帧字节数 → 码率(kbps),仅用于日志与参数回读
private func frameBytesToBitrate(_ frameBytes: Int) -> Int {
return frameBytes * 8 / OPUS_FRAME_DURATION_MS
}
/// 一次 BLE 写最多能装几帧/声道:净荷上限 ÷ (2 × 帧长)。
/// 一拍要发的帧数超过它时按此拆成多个写(每个分片仍 2×帧长 对齐,设备侧解析不受影响)。
private func maxFramesPerWrite(_ frameBytes: Int) -> Int {
let limit = peripheralDevice?.maximumWriteValueLength(for: .withoutResponse) ?? FALLBACK_BLE_WRITE_BYTES
let writeLimit = limit > 0 ? limit : FALLBACK_BLE_WRITE_BYTES
return max(writeLimit / (2 * frameBytes), 1)
}
//private var currentCommand: Command? //private var currentCommand: Command?
// 命令结构体 // 命令结构体
private struct Command { private struct Command {
@ -820,9 +859,10 @@ public class BleService: NSObject {
// 确保Opus解码器处于活跃状态(如果不是则启动) // 确保Opus解码器处于活跃状态(如果不是则启动)
startOpusDecoding(sampleRate: 16000, channels: 2, frameSize: 1280) startOpusDecoding(sampleRate: 16000, channels: 2, frameSize: 1280)
// 通话翻译:左右声道独立编码,每帧 40B/20ms(与 Android setPacketSize(40) 对齐) // 通话翻译:左右声道独立编码,每帧 20ms,帧长由下行码率决定(16kbps→40B,与 Android 对齐)
// 下行包格式(无包头,左右交替):[L0 40B][R0 40B]...[L(n-1)][R(n-1)],n=bundleFrameCount(默认2 → 160B) // 下行包格式(无包头,左右交替):[L0][R0]...[L(n-1)][R(n-1)],n=bundleFrameCount(默认 2 帧 → 160B)
_ = opusProcessor?.startDualEncodeStream(sampleRate: 16000, packetBytes: Int32(OPUS_FRAME_BYTES)) _ = opusProcessor?.startDualEncodeStream(sampleRate: 16000,
packetBytes: Int32(downlinkFrameBytes))
startAudioSendThreads() startAudioSendThreads()
os_log("[CALL_TRANS_CMD] 打开A2DP解码并开始录制(通话翻译)...", log: logger, type: .info) os_log("[CALL_TRANS_CMD] 打开A2DP解码并开始录制(通话翻译)...", log: logger, type: .info)
@ -1584,11 +1624,13 @@ extension BleService: SwiftOpusAudioProcessor.AudioDataCallback {
} }
/// 静音帧模板就绪,缓存起来用于合包时不足帧补齐。 /// 静音帧模板就绪,缓存起来用于合包时不足帧补齐。
/// 无包头协议按固定 40B 切分,模板长度不对会让整包左右对位全错,故只接受恰好 OPUS_FRAME_BYTES 的模板。 /// 无包头协议按固定帧长切分,模板长度不对会让整包左右对位全错,
/// 故只接受长度恰好等于当前 downlinkFrameBytes 的模板(改码率后编码器会重新生成新长度的模板)。
func onSilenceFrameReady(frame: Data) { func onSilenceFrameReady(frame: Data) {
guard frame.count == OPUS_FRAME_BYTES else { let expected = downlinkFrameBytes
guard frame.count == expected else {
os_log("静音帧模板长度异常 size=%dB (期望 %dB),拒绝采用", os_log("静音帧模板长度异常 size=%dB (期望 %dB),拒绝采用",
log: logger, type: .error, frame.count, OPUS_FRAME_BYTES) log: logger, type: .error, frame.count, expected)
return return
} }
silenceFrame = frame silenceFrame = frame
@ -1603,12 +1645,13 @@ extension BleService {
/// 仅超 OOM 兜底上限才丢最旧帧。 /// 仅超 OOM 兜底上限才丢最旧帧。
/// ///
/// 无包头协议下设备侧按固定 40B 切分左右声道,任何一帧长度异常都会让整包及之后的声道对位全错, /// 无包头协议下设备侧按固定 40B 切分左右声道,任何一帧长度异常都会让整包及之后的声道对位全错,
/// 故非 OPUS_FRAME_BYTES 的帧在入队处直接拦掉,保证队列里的帧长恒定。 /// 故长度不等于当前 downlinkFrameBytes 的帧在入队处直接拦掉,保证队列里的帧长恒定。
private func enqueueOpusFrame(channel: UInt8, frame: Data) { private func enqueueOpusFrame(channel: UInt8, frame: Data) {
guard !frame.isEmpty else { return } guard !frame.isEmpty else { return }
guard frame.count == OPUS_FRAME_BYTES else { let expected = downlinkFrameBytes
guard frame.count == expected else {
os_log("丢弃异常长度 opus 帧 ch=%d size=%dB (期望 %dB)", os_log("丢弃异常长度 opus 帧 ch=%d size=%dB (期望 %dB)",
log: logger, type: .error, Int(channel), frame.count, OPUS_FRAME_BYTES) log: logger, type: .error, Int(channel), frame.count, expected)
return return
} }
sendBufferLock.lock() sendBufferLock.lock()
@ -1638,9 +1681,12 @@ extension BleService {
isAudioSending = true isAudioSending = true
audioSendTimer = makeAudioSendTimer() audioSendTimer = makeAudioSendTimer()
let n = min(max(bundleFrameCount, 1), MAX_BUNDLE_FRAME_COUNT) let n = min(max(bundleFrameCount, 1), MAX_BUNDLE_FRAME_COUNT)
os_log("音频发送定时器已启动(左右合包/无包头) 间隔=%.0fms 帧数=%d/声道 包长=%dB 持续发送=%{public}@", let frameBytes = downlinkFrameBytes
os_log("音频发送定时器已启动(左右合包/无包头) 间隔=%.0fms 帧数=%d/声道 帧长=%dB(%dkbps) 一拍=%dB 单写上限=%d帧 持续发送=%{public}@",
log: logger, type: .info, audioSendInterval * 1000, n, log: logger, type: .info, audioSendInterval * 1000, n,
2 * n * OPUS_FRAME_BYTES, continuousSendEnabled ? "开" : "关") frameBytes, frameBytesToBitrate(frameBytes),
2 * n * frameBytes, maxFramesPerWrite(frameBytes),
continuousSendEnabled ? "开" : "关")
} }
/// 停止发送定时器并清空缓存 /// 停止发送定时器并清空缓存
@ -1688,6 +1734,8 @@ extension BleService {
/// - 写失败(拥塞):取出的真实帧按原序退回各自队头,静音补齐帧丢弃,下一拍重组重试,绝不丢帧 /// - 写失败(拥塞):取出的真实帧按原序退回各自队头,静音补齐帧丢弃,下一拍重组重试,绝不丢帧
private func trySendMixedChunk() -> Bool { private func trySendMixedChunk() -> Bool {
let n = min(max(bundleFrameCount, 1), MAX_BUNDLE_FRAME_COUNT) let n = min(max(bundleFrameCount, 1), MAX_BUNDLE_FRAME_COUNT)
// 帧长在本拍内取一次快照:改码率是异步的,中途变化会让同一包里的帧长不一致(整包错位)
let frameBytes = downlinkFrameBytes
// 左右各取最多 n 帧真实数据 // 左右各取最多 n 帧真实数据
sendBufferLock.lock() sendBufferLock.lock()
@ -1705,16 +1753,18 @@ extension BleService {
// 两路皆空且未开持续发送:本拍不发 // 两路皆空且未开持续发送:本拍不发
if !hasRealAudio && !continuousSendEnabled { return false } if !hasRealAudio && !continuousSendEnabled { return false }
// 无包头协议靠固定帧长切分,缺帧必须补静音才能保证包长恒定;模板未就绪则本拍放弃,帧退回队列不丢 // 无包头协议靠固定帧长切分,缺帧必须补静音才能保证包长恒定;
guard let silence = silenceFrame else { // 模板未就绪(或长度与当前码率不匹配)则本拍放弃,帧退回队列不丢
let silenceOrNil = silenceFrame
guard let silence = silenceOrNil, silence.count == frameBytes else {
sendBufferLock.lock() sendBufferLock.lock()
leftSendBuffer.insert(contentsOf: leftFrames, at: 0) leftSendBuffer.insert(contentsOf: leftFrames, at: 0)
rightSendBuffer.insert(contentsOf: rightFrames, at: 0) rightSendBuffer.insert(contentsOf: rightFrames, at: 0)
sendBufferLock.unlock() sendBufferLock.unlock()
silenceNotReadyCount += 1 silenceNotReadyCount += 1
if silenceNotReadyCount == 1 || silenceNotReadyCount % 50 == 0 { if silenceNotReadyCount == 1 || silenceNotReadyCount % 50 == 0 {
os_log("静音帧模板未就绪,暂缓下发(累计 %d 拍),译音已退回队列不丢", os_log("静音帧模板未就绪(或与当前帧长 %dB 不匹配),暂缓下发(累计 %d 拍),译音已退回队列不丢",
log: logger, type: .error, silenceNotReadyCount) log: logger, type: .error, frameBytes, silenceNotReadyCount)
} }
return false return false
} }
@ -1722,14 +1772,40 @@ extension BleService {
let padLeft = n - leftFrames.count let padLeft = n - leftFrames.count
let padRight = n - rightFrames.count let padRight = n - rightFrames.count
let packet = buildInterleavedPacket(n: n, leftFrames: leftFrames, rightFrames: rightFrames, silence: silence)
// 一拍的数据(2×n×帧长)可能超过单次 BLE 写的净荷上限,按 2×帧长 对齐拆成多个写连续发出。
// 每个分片仍是 2×帧长的整数倍、左右交替顺序不变 → 设备侧按包或按流解析都正确。
let framesPerWrite = min(maxFramesPerWrite(frameBytes), n)
var sentFrames = 0
var sentBytes = 0
var writeCount = 0
var congested = false
while sentFrames < n {
let chunkFrames = min(framesPerWrite, n - sentFrames)
let packet = buildInterleavedPacket(startFrame: sentFrames, count: chunkFrames,
frameBytes: frameBytes,
leftFrames: leftFrames, rightFrames: rightFrames,
silence: silence)
// 同步写入,拿到真实的成功/失败:false=底层发送缓冲已满(拥塞),作为背压信号 // 同步写入,拿到真实的成功/失败:false=底层发送缓冲已满(拥塞),作为背压信号
guard sendAudioPacket(packet) else { guard sendAudioPacket(packet) else {
// 写失败:真实帧按原顺序退回各自队头,下一拍重组重试;静音补齐帧丢弃 congested = true
break
}
sentFrames += chunkFrames
sentBytes += packet.count
writeCount += 1
}
if congested {
// 拥塞:把**尚未发出**的真实帧按原顺序退回各自队头,下一拍重组重试;静音补齐帧丢弃。
// 已发出的分片无法撤回,但它们自身 2×帧长 对齐、顺序正确,设备侧字节流不会错位。
sendBufferLock.lock() sendBufferLock.lock()
leftSendBuffer.insert(contentsOf: leftFrames, at: 0) if sentFrames < leftFrames.count {
rightSendBuffer.insert(contentsOf: rightFrames, at: 0) leftSendBuffer.insert(contentsOf: leftFrames[sentFrames...], at: 0)
}
if sentFrames < rightFrames.count {
rightSendBuffer.insert(contentsOf: rightFrames[sentFrames...], at: 0)
}
sendBufferLock.unlock() sendBufferLock.unlock()
sendStateLock.lock() sendStateLock.lock()
@ -1737,19 +1813,20 @@ extension BleService {
let fails = _writeFailRetryCount let fails = _writeFailRetryCount
sendStateLock.unlock() sendStateLock.unlock()
if fails == 1 || fails % 50 == 0 { if fails == 1 || fails % 50 == 0 {
os_log("下行写入拥塞,重试中 seq=%u 连续失败=%d", log: logger, type: .error, packetSeq, fails) os_log("下行写入拥塞,重试中 seq=%u 连续失败=%d 已发=%d/%d帧",
log: logger, type: .error, packetSeq, fails, sentFrames, n)
} }
return false if sentFrames == 0 { return false }
} }
sendStateLock.lock() sendStateLock.lock()
let prevFails = _writeFailRetryCount let prevFails = _writeFailRetryCount
_writeFailRetryCount = 0 if !congested { _writeFailRetryCount = 0 }
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
let deltaMs = _lastAudioSendTime < 0 ? 0 : Int((now - _lastAudioSendTime) * 1000) let deltaMs = _lastAudioSendTime < 0 ? 0 : Int((now - _lastAudioSendTime) * 1000)
_lastAudioSendTime = now _lastAudioSendTime = now
sendStateLock.unlock() sendStateLock.unlock()
if prevFails > 0 { if !congested, prevFails > 0 {
os_log("下行写入已恢复,之前连续失败=%d", log: logger, type: .info, prevFails) os_log("下行写入已恢复,之前连续失败=%d", log: logger, type: .info, prevFails)
} }
@ -1764,25 +1841,34 @@ extension BleService {
// 持续发送的纯静音包按 25 拍节流,避免刷屏 // 持续发送的纯静音包按 25 拍节流,避免刷屏
if hasRealAudio { if hasRealAudio {
silentPacketCount = 0 silentPacketCount = 0
os_log("音频下行发送 seq=%u Δ=%dms L=%d+静音%d R=%d+静音%d size=%dB buf(L=%d,R=%d)", os_log("音频下行发送 seq=%u Δ=%dms L=%d+静音%d R=%d+静音%d 帧=%d/%d size=%dB 写=%d buf(L=%d,R=%d)",
log: logger, type: .debug, seq, deltaMs, log: logger, type: .debug, seq, deltaMs,
leftFrames.count, padLeft, rightFrames.count, padRight, packet.count, lBuf, rBuf) leftFrames.count, padLeft, rightFrames.count, padRight,
sentFrames, n, sentBytes, writeCount, lBuf, rBuf)
} else { } else {
silentPacketCount += 1 silentPacketCount += 1
if silentPacketCount % 25 == 1 { if silentPacketCount % 25 == 1 {
os_log("音频下行持续发送(全静音) seq=%u Δ=%dms size=%dB 累计=%d 包", os_log("音频下行持续发送(全静音) seq=%u Δ=%dms size=%dB 写=%d 累计=%d 包",
log: logger, type: .debug, seq, deltaMs, packet.count, silentPacketCount) log: logger, type: .debug, seq, deltaMs, sentBytes, writeCount, silentPacketCount)
} }
} }
return true return true
} }
/// 组下行包(无包头,左右帧级交替):[L0][R0][L1][R1]...[L(n-1)][R(n-1)] /// 组下行分片(无包头,左右帧级交替):取第 startFrame 起的 count 帧,拼成
/// 每帧恒为 OPUS_FRAME_BYTES 字节,包长恒为 2*n*OPUS_FRAME_BYTES;某侧该位缺帧则填静音帧,保证左右严格对位。 /// [L(s)][R(s)][L(s+1)][R(s+1)]...,每帧恒为 frameBytes 字节,分片长度恒为 2*count*frameBytes。
private func buildInterleavedPacket(n: Int, leftFrames: [Data], rightFrames: [Data], silence: Data) -> Data { /// 某侧该位缺帧则填静音帧,保证左右严格对位、长度恒定。
/// - Parameters:
/// - startFrame: 本分片从第几帧开始(一拍拆多写时用来续接,不拆分时为 0)
/// - count: 本分片装几帧/声道
/// - frameBytes: 单帧字节数快照(调用方从 downlinkFrameBytes 取,一拍内统一)
private func buildInterleavedPacket(startFrame: Int, count: Int, frameBytes: Int,
leftFrames: [Data], rightFrames: [Data],
silence: Data) -> Data {
var packet = Data() var packet = Data()
packet.reserveCapacity(2 * n * OPUS_FRAME_BYTES) packet.reserveCapacity(2 * count * frameBytes)
for i in 0..<n { for k in 0..<count {
let i = startFrame + k
packet.append(i < leftFrames.count ? leftFrames[i] : silence) packet.append(i < leftFrames.count ? leftFrames[i] : silence)
packet.append(i < rightFrames.count ? rightFrames[i] : silence) packet.append(i < rightFrames.count ? rightFrames[i] : silence)
} }
@ -1792,8 +1878,17 @@ extension BleService {
// MARK: - 通话翻译调试参数(运行时可调,与 Android 对齐) // MARK: - 通话翻译调试参数(运行时可调,与 Android 对齐)
/// 设置通话翻译调试参数(运行时生效)。任一参数为 nil 或非法值则不修改。 /// 设置通话翻译调试参数(运行时生效)。任一参数为 nil 或非法值则不修改。
/// 改动发送间隔会重建定时器(若正在发送)。 /// 改动发送间隔会重建定时器;改动下行码率会重启编码流(帧长变了)。
func setCallTranslationDebugParams(sendIntervalMs: Int?, bundleFrameCount: Int?, continuousSend: Bool?) { func setCallTranslationDebugParams(sendIntervalMs: Int?, bundleFrameCount: Int?, continuousSend: Bool?,
downlinkBitrateKbps: Int? = nil, uplinkBitrateKbps: Int? = nil) {
// 先应用码率:帧长会变,后面的合包帧数上限依赖它
if let kbps = downlinkBitrateKbps, kbps > 0 {
applyDownlinkBitrate(kbps)
}
if let kbps = uplinkBitrateKbps, kbps > 0 {
applyUplinkBitrate(kbps)
}
var intervalChanged = false var intervalChanged = false
if let ms = sendIntervalMs, ms > 0 { if let ms = sendIntervalMs, ms > 0 {
let clamped = TimeInterval(min(max(ms, 1), 1000)) / 1000.0 let clamped = TimeInterval(min(max(ms, 1), 1000)) / 1000.0
@ -1805,8 +1900,13 @@ extension BleService {
} }
if let frames = bundleFrameCount, frames > 0 { if let frames = bundleFrameCount, frames > 0 {
self.bundleFrameCount = min(max(frames, 1), MAX_BUNDLE_FRAME_COUNT) self.bundleFrameCount = min(max(frames, 1), MAX_BUNDLE_FRAME_COUNT)
os_log("[CALL_TRANS_DEBUG] 设置每声道合包帧数: %d (包长 %dB)", log: logger, type: .info, // 超过单次 BLE 写上限时会自动按 2×帧长 对齐拆成多个写
self.bundleFrameCount, 2 * self.bundleFrameCount * OPUS_FRAME_BYTES) let perWrite = min(maxFramesPerWrite(downlinkFrameBytes), self.bundleFrameCount)
let writes = (self.bundleFrameCount + perWrite - 1) / perWrite
os_log("[CALL_TRANS_DEBUG] 设置每声道合包帧数: %d (帧长 %dB → 一拍 %dB, 拆 %d 个写)",
log: logger, type: .info,
self.bundleFrameCount, downlinkFrameBytes,
2 * self.bundleFrameCount * downlinkFrameBytes, writes)
} }
if let continuous = continuousSend { if let continuous = continuousSend {
continuousSendEnabled = continuous continuousSendEnabled = continuous
@ -1820,12 +1920,57 @@ extension BleService {
} }
} }
/// 应用下行码率:帧长随之变化。
/// 编码器的 packetSize 在启动时固化,改码率必须**重启编码流**才能生效;
/// 同时必须清空左右队列并作废旧静音模板 —— 否则旧帧长的残留帧会混进新包,导致设备侧整包错位。
private func applyDownlinkBitrate(_ bitrateKbps: Int) {
let newFrameBytes = bitrateToFrameBytes(bitrateKbps)
guard newFrameBytes != downlinkFrameBytes else { return }
let wasEncoding = isAudioSending
os_log("[CALL_TRANS_DEBUG] 设置下行码率: %dkbps (帧长 %dB → %dB, 编码流运行中=%{public}@)",
log: logger, type: .info, bitrateKbps, downlinkFrameBytes, newFrameBytes,
wasEncoding ? "是" : "否")
if wasEncoding { _ = opusProcessor?.stopDualEncodeStream() }
// 作废旧帧长的一切残留:队列里的旧帧 + 旧静音模板
downlinkFrameBytes = newFrameBytes
silenceFrame = nil
sendBufferLock.lock()
leftSendBuffer.removeAll()
rightSendBuffer.removeAll()
sendBufferLock.unlock()
// 重启编码流:新 packetSize 生效,并重新生成对应长度的静音模板
if wasEncoding {
_ = opusProcessor?.startDualEncodeStream(sampleRate: 16000,
packetBytes: Int32(downlinkFrameBytes))
}
}
/// 应用上行码率:必须与**固件实际编码码率**一致,否则收包切分会错。
private func applyUplinkBitrate(_ bitrateKbps: Int) {
let newFrameBytes = bitrateToFrameBytes(bitrateKbps)
guard newFrameBytes != uplinkFrameBytes else { return }
os_log("[CALL_TRANS_DEBUG] 设置上行码率: %dkbps (帧长 %dB → %dB)",
log: logger, type: .info, bitrateKbps, uplinkFrameBytes, newFrameBytes)
uplinkFrameBytes = newFrameBytes
}
/// 获取当前通话翻译调试参数 /// 获取当前通话翻译调试参数
func getCallTranslationDebugParams() -> [String: Any] { func getCallTranslationDebugParams() -> [String: Any] {
return [ return [
"sendIntervalMs": Int((audioSendInterval * 1000).rounded()), "sendIntervalMs": Int((audioSendInterval * 1000).rounded()),
"bundleFrameCount": bundleFrameCount, "bundleFrameCount": bundleFrameCount,
"continuousSend": continuousSendEnabled "continuousSend": continuousSendEnabled,
"downlinkBitrateKbps": frameBytesToBitrate(downlinkFrameBytes),
"uplinkBitrateKbps": frameBytesToBitrate(uplinkFrameBytes),
"downlinkFrameBytes": downlinkFrameBytes,
"uplinkFrameBytes": uplinkFrameBytes,
"maxBundleFrameCount": MAX_BUNDLE_FRAME_COUNT,
"packetSizeBytes": 2 * bundleFrameCount * downlinkFrameBytes,
"maxFramesPerWrite": maxFramesPerWrite(downlinkFrameBytes)
] ]
} }

4
local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift

@ -123,7 +123,9 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin {
BleService.shared.setCallTranslationDebugParams( BleService.shared.setCallTranslationDebugParams(
sendIntervalMs: (arguments?["sendIntervalMs"] as? NSNumber)?.intValue, sendIntervalMs: (arguments?["sendIntervalMs"] as? NSNumber)?.intValue,
bundleFrameCount: (arguments?["bundleFrameCount"] as? NSNumber)?.intValue, bundleFrameCount: (arguments?["bundleFrameCount"] as? NSNumber)?.intValue,
continuousSend: arguments?["continuousSend"] as? Bool continuousSend: arguments?["continuousSend"] as? Bool,
downlinkBitrateKbps: (arguments?["downlinkBitrateKbps"] as? NSNumber)?.intValue,
uplinkBitrateKbps: (arguments?["uplinkBitrateKbps"] as? NSNumber)?.intValue
) )
result(true) result(true)

25
local_plugins/ble_service/lib/ble_service.dart

@ -174,12 +174,16 @@ class BleService {
/// 设置通话翻译调试参数(运行时生效) /// 设置通话翻译调试参数(运行时生效)
/// [sendIntervalMs] 音频下行发送间隔(ms) /// [sendIntervalMs] 音频下行发送间隔(ms)
/// [bundleFrameCount] 每包每声道的 opus 帧数(1..6);下行包无包头、左右帧级交替,包长 = 2*帧数*40B /// [bundleFrameCount] 每包每声道的 opus 帧数;下行包无包头、左右帧级交替,包长 = 2*帧数*单帧字节
/// [continuousSend] 是否持续发送:true 时无译音也按拍下发全静音包,保持设备侧音频流不断 /// [continuousSend] 是否持续发送:true 时无译音也按拍下发全静音包,保持设备侧音频流不断
/// [downlinkBitrateKbps] 下行(APP→设备)opus 码率;单帧字节 = 码率×20ms÷8(16k→40B / 8k→20B)
/// [uplinkBitrateKbps] 上行(设备→APP)opus 码率,必须与固件实际编码码率一致
Future<bool> setCallTranslationDebugParams({ Future<bool> setCallTranslationDebugParams({
int? sendIntervalMs, int? sendIntervalMs,
int? bundleFrameCount, int? bundleFrameCount,
bool? continuousSend, bool? continuousSend,
int? downlinkBitrateKbps,
int? uplinkBitrateKbps,
}) async { }) async {
try { try {
final result = await _methodChannel final result = await _methodChannel
@ -187,6 +191,9 @@ class BleService {
if (sendIntervalMs != null) 'sendIntervalMs': sendIntervalMs, if (sendIntervalMs != null) 'sendIntervalMs': sendIntervalMs,
if (bundleFrameCount != null) 'bundleFrameCount': bundleFrameCount, if (bundleFrameCount != null) 'bundleFrameCount': bundleFrameCount,
if (continuousSend != null) 'continuousSend': continuousSend, if (continuousSend != null) 'continuousSend': continuousSend,
if (downlinkBitrateKbps != null)
'downlinkBitrateKbps': downlinkBitrateKbps,
if (uplinkBitrateKbps != null) 'uplinkBitrateKbps': uplinkBitrateKbps,
}); });
return result ?? false; return result ?? false;
} catch (e) { } catch (e) {
@ -195,17 +202,25 @@ class BleService {
} }
} }
/// 获取当前通话翻译调试参数 /// 获取当前通话翻译调试参数,失败返回空 Map
/// 返回 {'sendIntervalMs': int, 'bundleFrameCount': int, 'continuousSend': bool},失败返回空 Map /// {'sendIntervalMs','bundleFrameCount','continuousSend','downlinkBitrateKbps',
/// 'uplinkBitrateKbps','downlinkFrameBytes','uplinkFrameBytes','maxBundleFrameCount','packetSizeBytes'}
Future<Map<String, dynamic>> getCallTranslationDebugParams() async { Future<Map<String, dynamic>> getCallTranslationDebugParams() async {
try { try {
final result = await _methodChannel final result = await _methodChannel
.invokeMethod<Map>('getCallTranslationDebugParams'); .invokeMethod<Map>('getCallTranslationDebugParams');
if (result == null) return {}; if (result == null) return {};
int readInt(String k) => (result[k] as num?)?.toInt() ?? 0;
return { return {
'sendIntervalMs': (result['sendIntervalMs'] as num?)?.toInt() ?? 0, 'sendIntervalMs': readInt('sendIntervalMs'),
'bundleFrameCount': (result['bundleFrameCount'] as num?)?.toInt() ?? 0, 'bundleFrameCount': readInt('bundleFrameCount'),
'continuousSend': result['continuousSend'] as bool? ?? false, 'continuousSend': result['continuousSend'] as bool? ?? false,
'downlinkBitrateKbps': readInt('downlinkBitrateKbps'),
'uplinkBitrateKbps': readInt('uplinkBitrateKbps'),
'downlinkFrameBytes': readInt('downlinkFrameBytes'),
'uplinkFrameBytes': readInt('uplinkFrameBytes'),
'maxBundleFrameCount': readInt('maxBundleFrameCount'),
'packetSizeBytes': readInt('packetSizeBytes'),
}; };
} catch (e) { } catch (e) {
print('获取通话翻译调试参数失败: $e'); print('获取通话翻译调试参数失败: $e');

Loading…
Cancel
Save