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 实际代码为准)
> 关键词: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)
/// [bundleFrameCount] 每包每声道的 opus 帧数(1..6),包长 = 2*帧数*40B
/// [bundleFrameCount] 每包每声道的 opus 帧数,包长 = 2*帧数*单帧字节
/// [continuousSend] 是否持续发送静音(无译音时也保持音频流不断)
/// [downlinkBitrateKbps] 下行 opus 码率(kbps):单帧字节 = 码率×20ms÷8(16k→40B / 8k→20B)
/// [uplinkBitrateKbps] 上行 opus 码率(kbps),必须与固件实际编码码率一致
Future<bool> setCallTranslationDebugParams({
int? sendIntervalMs,
int? bundleFrameCount,
bool? continuousSend,
int? downlinkBitrateKbps,
int? uplinkBitrateKbps,
}) async {
try {
return await _bleService.setCallTranslationDebugParams(
sendIntervalMs: sendIntervalMs,
bundleFrameCount: bundleFrameCount,
continuousSend: continuousSend,
downlinkBitrateKbps: downlinkBitrateKbps,
uplinkBitrateKbps: uplinkBitrateKbps,
);
} catch (e) {
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 {
try {
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';
/// 通话翻译调试控制器
/// 负责调试参数(音频下行发送间隔、每声道合包帧数、是否持续发送)的读取、保存、下发,
/// 负责调试参数(上/下行 opus 码率、音频下行发送间隔、每声道合包帧数、是否持续发送)的读取、保存、下发,
/// 以及通话翻译编解码的启动/停止。
///
/// 下行包为无包头、左右帧级交替格式:[L0 40B][R0 40B]...[L(n-1)][R(n-1)],包长 = 2*n*40B。
/// 每帧 opus 承载 20ms 音频,故「间隔 == 帧数 × 20ms」时下发速率与实时语速 1:1。
/// 下行包为无包头、左右帧级交替格式:[L0][R0]...[L(n-1)][R(n-1)],包长 = 2 × n × 单帧字节。
/// **单帧字节不是常量**,由码率决定:`单帧字节 = 码率kbps × 20ms ÷ 8`(16k→40B / 8k→20B / 24k→60B)。
/// 每帧 opus 恒承载 20ms 音频,故「间隔 == 帧数 × 20ms」时下发速率与实时语速 1:1(与码率无关)。
class CallTranslationDebugController extends GetxController {
final BleManager _bleManager = Get.find<BleManager>();
final GetStorage _storage = GetStorage();
@ -18,22 +19,33 @@ class CallTranslationDebugController extends GetxController {
static const String _kSendInterval = 'call_trans_debug_send_interval';
static const String _kBundleFrameCount = 'call_trans_debug_bundle_frames';
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;
// 默认值(与原生默认保持一致: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 defaultBundleFrameCount = 2;
static const bool defaultContinuousSend = false;
static const int defaultBitrateKbps = 16;
// 取值范围
static const int minSendIntervalMs = 20;
static const int maxSendIntervalMs = 1000;
static const int sendIntervalStepMs = 20; // 音频下行间隔步长 20ms
static const int minSendIntervalMs = 5;
static const int maxSendIntervalMs = 200;
static const int sendIntervalStepMs = 5; // 音频下行间隔步长 5ms
static const int minBundleFrameCount = 1;
// 包长 2*n*40B 必须塞得进一次 BLE 写(MTU 517 → 可写 514B),6 帧=480B 是安全上限
static const int maxBundleFrameCount = 6;
static const int maxBundleFrameCount = 20;
// 可选码率档位(kbps):每档对应的单帧字节 = 档位 × 2.5
static const List<int> bitrateOptions = [8, 16, 24, 32];
/// 码率(kbps) → 单帧 opus 字节数(与原生 bitrateToFrameBytes 一致)
static int frameBytesOf(int bitrateKbps) => bitrateKbps * frameDurationMs ~/ 8;
/// 音频下行发送间隔(ms)
final RxInt sendIntervalMs = defaultSendIntervalMs.obs;
@ -44,8 +56,30 @@ class CallTranslationDebugController extends GetxController {
/// 是否持续发送音频(无译音时也按拍下发全静音包,保持设备侧音频流不断)
final RxBool continuousSend = defaultContinuousSend.obs;
/// 当前配置下的下行包长(字节):左右交替、每声道 n 帧、每帧 40B
int get packetSizeBytes => 2 * bundleFrameCount.value * 40;
/// 下行(APP→设备) opus 码率(kbps)
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;供界面提示用
int get realtimeIntervalMs => bundleFrameCount.value * frameDurationMs;
@ -66,6 +100,16 @@ class CallTranslationDebugController extends GetxController {
/// 从本地存储加载参数
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 savedFrames = _storage.read<int>(_kBundleFrameCount);
final savedContinuous = _storage.read<bool>(_kContinuousSend);
@ -87,6 +131,19 @@ class CallTranslationDebugController extends GetxController {
try {
final params = await _bleManager.getCallTranslationDebugParams();
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) {
final ni = params['sendIntervalMs'] as int?;
if (ni != null && ni > 0) {
@ -123,8 +180,24 @@ class CallTranslationDebugController extends GetxController {
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() {
downlinkBitrateKbps.value = defaultBitrateKbps;
uplinkBitrateKbps.value = defaultBitrateKbps;
sendIntervalMs.value = defaultSendIntervalMs;
bundleFrameCount.value = defaultBundleFrameCount;
continuousSend.value = defaultContinuousSend;
@ -135,13 +208,19 @@ class CallTranslationDebugController extends GetxController {
_storage.write(_kSendInterval, sendIntervalMs.value);
_storage.write(_kBundleFrameCount, bundleFrameCount.value);
_storage.write(_kContinuousSend, continuousSend.value);
_storage.write(_kDownlinkBitrate, downlinkBitrateKbps.value);
_storage.write(_kUplinkBitrate, uplinkBitrateKbps.value);
final ok = await _bleManager.setCallTranslationDebugParams(
sendIntervalMs: sendIntervalMs.value,
bundleFrameCount: bundleFrameCount.value,
continuousSend: continuousSend.value,
downlinkBitrateKbps: downlinkBitrateKbps.value,
uplinkBitrateKbps: uplinkBitrateKbps.value,
);
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');
Get.snackbar(
'通话翻译调试',

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

@ -33,6 +33,8 @@ class CallTranslationDebugView
children: [
_buildStatusCard(isDark),
SizedBox(height: 16.h),
_buildBitrateCard(isDark),
SizedBox(height: 16.h),
_buildSendIntervalCard(isDark),
SizedBox(height: 16.h),
_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) {
const minFrames = CallTranslationDebugController.minBundleFrameCount;
const maxFrames = CallTranslationDebugController.maxBundleFrameCount;
return _card(
isDark,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_title(isDark, '每声道合包帧数',
'一包里左右声道各放多少帧 opus,按 左/右/左/右 交替写入、无包头(${CallTranslationDebugController.minBundleFrameCount}~${CallTranslationDebugController.maxBundleFrameCount} 帧)'),
'一拍里左右声道各放多少帧 opus,按 左/右/左/右 交替写入、无包头($minFrames~$maxFrames 帧)。'
'超过单次 BLE 写上限时会自动按「左右一对」对齐拆成多个写,设备侧解析不受影响'),
SizedBox(height: 8.h),
Obx(
() => Row(
Obx(() {
// 显式读取依赖的 Rx,确保码率变化时本块重建
final frames = controller.bundleFrameCount.value;
return Row(
children: [
Expanded(
child: Slider(
value: controller.bundleFrameCount.value
.clamp(
CallTranslationDebugController.minBundleFrameCount,
CallTranslationDebugController.maxBundleFrameCount,
)
.toDouble(),
min: CallTranslationDebugController.minBundleFrameCount
.toDouble(),
max: CallTranslationDebugController.maxBundleFrameCount
.toDouble(),
divisions:
CallTranslationDebugController.maxBundleFrameCount -
CallTranslationDebugController.minBundleFrameCount,
label: '${controller.bundleFrameCount.value} 帧',
onChanged: (v) =>
controller.setBundleFrameCount(v.round()),
value: frames.clamp(minFrames, maxFrames).toDouble(),
min: minFrames.toDouble(),
max: maxFrames.toDouble(),
divisions: maxFrames - minFrames,
label: '$frames 帧',
onChanged: (v) => controller.setBundleFrameCount(v.round()),
),
),
_valueChip(isDark, '${controller.bundleFrameCount.value} 帧'),
_valueChip(isDark, '$frames 帧'),
],
),
),
);
}),
SizedBox(height: 4.h),
// 包长与实时节拍提示:每帧 20ms,间隔 == 帧数×20ms 时下发速率与语速 1:1
Obx(
() => Text(
'包长 ${controller.packetSizeBytes} 字节(左右各 ${controller.bundleFrameCount.value} 帧 × 40B)'
',与实时语速匹配的间隔为 ${controller.realtimeIntervalMs} ms'
'${controller.sendIntervalMs.value == controller.realtimeIntervalMs ? '(当前已匹配)' : '(当前 ${controller.sendIntervalMs.value} ms,'
'${controller.sendIntervalMs.value > controller.realtimeIntervalMs ? '偏慢,音频会积压' : '偏快,会超发'})'}',
// 包长与实时节拍提示:每帧恒 20ms(与码率无关),间隔 == 帧数×20ms 时下发速率与语速 1:1
Obx(() {
// 显式读取三个 Rx:码率变 → 帧长变 → 包长变,缺一个订阅这里就不会刷新
final frames = controller.bundleFrameCount.value;
final bitrate = controller.downlinkBitrateKbps.value;
final interval = controller.sendIntervalMs.value;
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(
fontSize: 11.sp,
color: controller.sendIntervalMs.value ==
controller.realtimeIntervalMs
color: matched
? (isDark ? Colors.white38 : Colors.black38)
: 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,超限丢最旧帧并告警。
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 一律丢弃。
private const val OPUS_FRAME_BYTES = 40
// 无包头协议靠固定帧长切分,帧长必须严格恒定,否则设备侧整包错位 —— 故入队前校验帧长,
// 长度不等于当前 downlinkFrameBytes 的帧一律丢弃。
// 每包每声道的 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
private var bundleFrameCount = 2
// 每包帧数上限:包长 2*n*40 必须塞得进一次 BLE 写(MTU 517 → 可写 514B),6 帧=480B 已是安全上限。
private const val MAX_BUNDLE_FRAME_COUNT = 6
// 每声道帧数上限。不受 MTU 约束:一拍的数据若超过单次 BLE 写上限(MTU-3),会按 2×帧长对齐
// 拆成多个 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 时即使左右队列都空也按拍发全静音包,保持设备侧音频流不断流;
// 为 false 时两侧皆空则跳过该拍不发(默认,省链路占用)。
@Volatile
@ -363,9 +383,10 @@ object BleService {
override fun onSilenceFrameReady(frame: ByteArray) {
// 缓存静音帧模板,用于合包时不足帧补齐。
// 无包头协议按固定 40B 切分,模板长度不对会让整包左右对位全错,故只接受恰好 OPUS_FRAME_BYTES 的模板
if (frame.size != OPUS_FRAME_BYTES) {
CallLog.w(TAG, "静音帧模板长度异常 size=${frame.size}B (期望 ${OPUS_FRAME_BYTES}B),拒绝采用")
// 无包头协议按固定帧长切分,模板长度不对会让整包左右对位全错,
// 故只接受长度恰好等于当前 downlinkFrameBytes 的模板(改码率后编码器会重新生成新长度的模板)
if (frame.size != downlinkFrameBytes) {
CallLog.w(TAG, "静音帧模板长度异常 size=${frame.size}B (期望 ${downlinkFrameBytes}B),拒绝采用")
return
}
silenceFrame = frame
@ -1443,16 +1464,16 @@ object BleService {
}
/**
* 上行音频收包长度保护:普通流 opus 帧=40B;通话流(8kbps/60ms)立体声一帧=120B=2×60B。
* 两条流共用此校验,故按其公约数 40 判:40 与 120 均为 40 的整数倍,合法包应是 40 的整数倍且非空。
* 注意不要改成 % 60——那会把普通单声道流(40B)误判为坏包。
* 非 40 整数倍或空包视为异常(坏包/字节流错位),返回 false —— 调用方据此直接丢弃、
* 上行音频收包长度保护:合法包应是「当前上行单帧字节数」的整数倍且非空。
* 帧长随上行码率变化(16kbps→40B / 8kbps→20B),故按 uplinkFrameBytes 判,不能写死 40。
* 非整数倍或空包视为异常(坏包/字节流错位),返回 false —— 调用方据此直接丢弃、
* 且不计入接收统计、不喂解码器;避免坏包喂进定长流式解码器把后续整段字节流搞错位、全部解错。
* @return true=长度合法可继续处理;false=异常包(已打印告警),应丢弃
*/
private fun isValidUplinkAudioLen(data: ByteArray): Boolean {
if (data.isNotEmpty() && data.size % 40 == 0) return true
CallLog.w(TAG, "上行收到异常长度音频包,已丢弃: size=${data.size}B (非 opus 帧长 40B 整数倍)")
val frameBytes = uplinkFrameBytes
if (data.isNotEmpty() && data.size % frameBytes == 0) return true
CallLog.w(TAG, "上行收到异常长度音频包,已丢弃: size=${data.size}B (非上行帧长 ${frameBytes}B 整数倍)")
return false
}
@ -1503,14 +1524,35 @@ object BleService {
// 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数据流编码(通话翻译模式:左右声道独立编码)
* 输出下行包格式(无包头,左右交替):[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 是否成功开始编码
*/
private fun startOpusEncodeStream(): Boolean {
startAudioSendThread()
return opusAudioManager.startDualEncodeStream(false, 16000, OPUS_FRAME_BYTES)
return opusAudioManager.startDualEncodeStream(false, 16000, downlinkFrameBytes)
}
/**
@ -1530,14 +1572,14 @@ object BleService {
* 重连窗口内不丢弃:继续入队暂存,回连成功后续发;仅超 OOM 兜底上限才丢最旧帧。
*
* 无包头协议下设备侧按固定 40B 切分左右声道,任何一帧长度异常都会让整包及之后的声道对位全错,
* 故非 OPUS_FRAME_BYTES 的帧在入队处直接拦掉,保证队列里的帧长恒定。
* 故长度不等于当前 downlinkFrameBytes 的帧在入队处直接拦掉,保证队列里的帧长恒定。
* @param channelPrefix 声道标识(CHANNEL_LEFT=0 / CHANNEL_RIGHT=1)
* @param frame 一帧 opus 编码数据
*/
private fun enqueueOpusFrame(channelPrefix: Byte, frame: ByteArray) {
if (frame.isEmpty()) return
if (frame.size != OPUS_FRAME_BYTES) {
CallLog.w(TAG, "丢弃异常长度 opus 帧 ch=$channelPrefix size=${frame.size}B (期望 ${OPUS_FRAME_BYTES}B)")
if (frame.size != downlinkFrameBytes) {
CallLog.w(TAG, "丢弃异常长度 opus 帧 ch=$channelPrefix size=${frame.size}B (期望 ${downlinkFrameBytes}B)")
return
}
val buffer = when (channelPrefix) {
@ -1566,7 +1608,9 @@ object BleService {
audioSendThread = createAudioSendThread().apply { start() }
val n = bundleFrameCount.coerceIn(1, MAX_BUNDLE_FRAME_COUNT)
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
// 无包头协议靠固定帧长切分,缺帧必须补静音才能保证包长恒定;模板未就绪则本拍放弃,帧退回队列不丢
// 帧长在本拍内取一次快照:改码率是异步的,中途变化会让同一包里的帧长不一致(整包错位)
val frameBytes = downlinkFrameBytes
// 无包头协议靠固定帧长切分,缺帧必须补静音才能保证包长恒定;模板未就绪(或长度与当前码率不匹配)
// 则本拍放弃,帧退回队列不丢
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 rightFrames.indices.reversed()) rightSendBuffer.offerFirst(rightFrames[i])
silenceNotReadyCount++
if (silenceNotReadyCount == 1 || silenceNotReadyCount % 50 == 0) {
CallLog.w(TAG, "静音帧模板未就绪,暂缓下发(累计 $silenceNotReadyCount 拍),译音已退回队列不丢")
CallLog.w(TAG, "静音帧模板未就绪(或与当前帧长 ${frameBytes}B 不匹配),暂缓下发" +
"(累计 $silenceNotReadyCount 拍),译音已退回队列不丢")
}
return false
}
@ -1637,44 +1685,63 @@ object BleService {
val padLeft = n - leftFrames.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=底层发送缓冲已满(拥塞),作为背压信号
val sent = sendAudioChunkBlocking(packet)
if (!sent) {
// 写失败:真实帧按原顺序退回各自队头(倒序 offerFirst),下一拍重组重试;静音补齐帧丢弃
for (i in leftFrames.indices.reversed()) leftSendBuffer.offerFirst(leftFrames[i])
for (i in rightFrames.indices.reversed()) rightSendBuffer.offerFirst(rightFrames[i])
if (!sendAudioChunkBlocking(packet)) {
congested = true
break
}
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++
if (writeFailRetryCount == 1 || writeFailRetryCount % 50 == 0) {
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 (writeFailRetryCount > 0) {
if (sentFrames == 0) return false
} else if (writeFailRetryCount > 0) {
CallLog.i(TAG, "下行写入已恢复,之前连续失败=$writeFailRetryCount")
writeFailRetryCount = 0
}
val seq = packetSeq++
// 调试录音:按声道各自落地(真实帧+静音补齐,与该路实际下发内容一致,听辨即静音)
recordfile1Left?.saveAudioDataToWav(padToFrames(n, leftFrames, silence))
recordfile1Right?.saveAudioDataToWav(padToFrames(n, rightFrames, silence))
// 调试录音:按声道各自落地已实际发出的帧(真实帧+静音补齐,与下发内容一致,听辨即静音)
recordfile1Left?.saveAudioDataToWav(padToFrames(sentFrames, frameBytes, leftFrames, silence))
recordfile1Right?.saveAudioDataToWav(padToFrames(sentFrames, frameBytes, rightFrames, silence))
// 发送节拍统计与包序日志;持续发送的纯静音包按 25 拍节流,避免刷屏
val now = System.currentTimeMillis()
val deltaMs = if (lastAudioSendTime < 0) 0 else now - lastAudioSendTime
lastAudioSendTime = now
val splitTag = if (writeCount > 1) " 拆$writeCount 写" else ""
if (hasRealAudio) {
silentPacketCount = 0
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})")
} else {
silentPacketCount++
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)]
* 每帧恒为 OPUS_FRAME_BYTES 字节,包长恒为 2*n*OPUS_FRAME_BYTES;某侧该位缺帧则填静音帧,保证左右严格对位。
* 组下行分片(无包头,左右帧级交替):取第 startFrame 起的 count 帧,拼成
* [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(
n: Int,
startFrame: Int,
count: Int,
frameBytes: Int,
leftFrames: List<ByteArray>,
rightFrames: List<ByteArray>,
silence: ByteArray
): ByteArray {
val packet = ByteArray(2 * n * OPUS_FRAME_BYTES)
val packet = ByteArray(2 * count * frameBytes)
var offset = 0
for (i in 0 until n) {
System.arraycopy(leftFrames.getOrNull(i) ?: silence, 0, packet, offset, OPUS_FRAME_BYTES)
offset += OPUS_FRAME_BYTES
System.arraycopy(rightFrames.getOrNull(i) ?: silence, 0, packet, offset, OPUS_FRAME_BYTES)
offset += OPUS_FRAME_BYTES
for (k in 0 until count) {
val i = startFrame + k
System.arraycopy(leftFrames.getOrNull(i) ?: silence, 0, packet, offset, frameBytes)
offset += frameBytes
System.arraycopy(rightFrames.getOrNull(i) ?: silence, 0, packet, offset, frameBytes)
offset += frameBytes
}
return packet
}
/** 把某一路的真实帧补静音至 n 帧后顺次拼接(仅供调试录音落地,内容与该路实际下发的一致) */
private fun padToFrames(n: Int, frames: List<ByteArray>, silence: ByteArray): ByteArray {
val out = ByteArray(n * OPUS_FRAME_BYTES)
private fun padToFrames(n: Int, frameBytes: Int, frames: List<ByteArray>, silence: ByteArray): ByteArray {
val out = ByteArray(n * frameBytes)
var offset = 0
for (i in 0 until n) {
System.arraycopy(frames.getOrNull(i) ?: silence, 0, out, offset, OPUS_FRAME_BYTES)
offset += OPUS_FRAME_BYTES
System.arraycopy(frames.getOrNull(i) ?: silence, 0, out, offset, frameBytes)
offset += frameBytes
}
return out
}
@ -2009,27 +2083,38 @@ object BleService {
/**
* 设置通话翻译调试参数(运行时生效,无需重连)。任一参数为 null 或非法值则不修改。
* @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 downlinkBitrateKbps 下行(APP→设备)opus 码率(kbps),决定单帧字节数;改动会重启编码流
* @param uplinkBitrateKbps 上行(设备→APP)opus 码率(kbps),须与固件实际编码码率一致;改动会重启解码流
*/
fun setCallTranslationDebugParams(
sendIntervalMs: Long?,
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) {
audioSendIntervalNormal = sendIntervalMs.coerceIn(1L, 1000L)
Log.i(TAG, "[CALL_TRANS_DEBUG] 设置音频下行发送间隔: ${audioSendIntervalNormal}ms")
}
if (bundleFrameCount != null && bundleFrameCount > 0) {
this.bundleFrameCount = bundleFrameCount.coerceIn(1, MAX_BUNDLE_FRAME_COUNT)
val packetSize = 2 * this.bundleFrameCount * OPUS_FRAME_BYTES
Log.i(TAG, "[CALL_TRANS_DEBUG] 设置每声道合包帧数: ${this.bundleFrameCount} (包长 ${packetSize}B)")
// 一次 BLE 写的净荷上限是 MTU-3,超了底层会写失败/截断,这里提前告警。
// 仅在 MTU 已协商时才判(未连接时 currentMtu 还是默认 23,判了必然误报)
if (currentMtu > 23 && packetSize > currentMtu - 3) {
CallLog.w(TAG, "[CALL_TRANS_DEBUG] 包长 ${packetSize}B 超出当前 MTU 可写上限(${currentMtu - 3}B),下行会写失败")
}
val totalBytes = 2 * this.bundleFrameCount * downlinkFrameBytes
// 超过单次 BLE 写上限时会自动按 2×帧长 对齐拆成多个写,这里把拆分数算出来记日志
val framesPerWrite = maxFramesPerWrite(downlinkFrameBytes).coerceAtMost(this.bundleFrameCount)
val writes = (this.bundleFrameCount + framesPerWrite - 1) / framesPerWrite
val splitTag = if (writes > 1) ",超出单次 BLE 写上限 → 拆 $writes 个写(每写 ${framesPerWrite} 帧)" else ""
Log.i(TAG, "[CALL_TRANS_DEBUG] 设置每声道合包帧数: ${this.bundleFrameCount} " +
"(帧长 ${downlinkFrameBytes}B → 一拍 ${totalBytes}B$splitTag)")
}
if (continuousSend != null) {
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> {
return mapOf(
"sendIntervalMs" to audioSendIntervalNormal,
"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 {
Log.i(TAG, "打开解码器...")
startOpusStreamDecoding(false, 1, 16000, 40)
// 普通单声道流(非通话翻译)固定 16kbps/40B 帧;同步收包校验基准,避免沿用通话翻译的上行帧长把它误判成坏包
uplinkFrameBytes = bitrateToFrameBytes(DEFAULT_OPUS_BITRATE_KBPS)
startOpusStreamDecoding(false, 1, 16000, uplinkFrameBytes)
val cmdData = byteArrayOf(
BleConst.CODEC_CONTROL_DECODE_ON.toByte(),
channelMode.toByte()
@ -2144,9 +2286,9 @@ object BleService {
startOpusEncodeStream()
recordfile1Left?.creatingFiles();
recordfile1Right?.creatingFiles();
// 设备端通话上行 opus 参数:8kbps/60ms/8kHz 立体声。
// packetSize=每帧字节数=码率×帧长÷8=8000×0.06÷8=60B(单声道),立体声打包 2×60=120B。
startOpusStreamDecoding(false, 2, 16000, 80)
// 上行:设备立体声 opus。解码器 packetSize = 一个立体声单元的字节数 = 2 × 单声道帧长,
// 帧长由上行码率决定(16kbps→40B → 单元 80B;8kbps→20B → 单元 40B),不能写死。
startOpusStreamDecoding(false, 2, 16000, 2 * uplinkFrameBytes)
val cmdData = byteArrayOf(
BleConst.CODEC_CONTROL_A2DP_PLAY.toByte(),
channelMode.toByte()
@ -2163,8 +2305,8 @@ object BleService {
*/
fun openCallRecordDecoder(channelMode: Int = BleConst.AUDIO_CHANNEL_STEREO): Boolean {
Log.i(TAG, "打开通话录音解码器...")
// 通话录音与通话翻译走同一条设备上行流:8kbps/60ms/8kHz 立体声,packetSize=2×60=120B。
startOpusStreamDecoding(false, 2, 16000, 80)
// 通话录音与通话翻译走同一条设备上行流(立体声):packetSize = 2 × 上行单声道帧长,随上行码率变化
startOpusStreamDecoding(false, 2, 16000, 2 * uplinkFrameBytes)
val cmdData = byteArrayOf(
BleConst.CODEC_CONTROL_CALL_RECORD_PLAY.toByte(),
channelMode.toByte()
@ -2188,7 +2330,9 @@ object BleService {
*/
fun openEncoder(channelMode: Int = BleConst.AUDIO_CHANNEL_STEREO): Boolean {
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(
// BleConst.CODEC_CONTROL_ENCODE_ON.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 bundleFrameCount = (call.argument<Number>("bundleFrameCount"))?.toInt()
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)
}
"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 对齐)
// 左右声道各一条缓存队列,共用**一条**发送定时器:每拍从左右队列各取 N 帧,
// 按 [L0][R0][L1][R1]... 帧级交替拼成一个无包头包下发;某侧不足的位用静音帧补齐。
// 队列元素是单帧 opus(恒 OPUS_FRAME_BYTES=40B)。
// 队列元素是单帧 opus,长度恒为 downlinkFrameBytes(由下行码率决定,16kbps→40B)。
private var leftSendBuffer = [Data]()
private var rightSendBuffer = [Data]()
private let sendBufferLock = NSLock()
@ -131,12 +131,17 @@ public class BleService: NSObject {
private var _writeFailRetryCount = 0
/// 上一包音频实际发送的时间戳,仅用于发送节拍日志统计间隔;-1 表示尚未发送过
private var _lastAudioSendTime: TimeInterval = -1
/// 每包每声道的 opus 帧数(1..MAX_BUNDLE_FRAME_COUNT),运行时可调
/// 每包每声道的 opus 帧数(1..maxBundleFrameCount),运行时可调
private var _bundleFrameCount = 2
/// 音频下行发送间隔(秒),运行时可调
private var _audioSendInterval: TimeInterval = 0.04
/// 持续发送开关:true 时即使左右队列都空也按拍发全静音包,保持设备侧音频流不断
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 }
set { sendStateLock.lock(); _continuousSendEnabled = newValue; sendStateLock.unlock() }
}
// 音频发送配置常量(与 Android 默认值对齐)
/// 单帧 opus 固定长度(编码器 CBR)。无包头协议靠固定帧长切分,长度必须严格恒定,否则设备侧整包错位。
private let OPUS_FRAME_BYTES = 40
/// 每包帧数上限:包长 2*n*40 必须塞得进一次 BLE 写,6 帧=480B 已是安全上限
private let MAX_BUNDLE_FRAME_COUNT = 6
private var downlinkFrameBytes: Int {
get { sendStateLock.lock(); defer { sendStateLock.unlock() }; return _downlinkFrameBytes }
set { sendStateLock.lock(); _downlinkFrameBytes = newValue; sendStateLock.unlock() }
}
private var uplinkFrameBytes: Int {
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 兜底)
/// 码率(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 struct Command {
@ -820,9 +859,10 @@ public class BleService: NSObject {
// 确保Opus解码器处于活跃状态(如果不是则启动)
startOpusDecoding(sampleRate: 16000, channels: 2, frameSize: 1280)
// 通话翻译:左右声道独立编码,每帧 40B/20ms(与 Android setPacketSize(40) 对齐)
// 下行包格式(无包头,左右交替):[L0 40B][R0 40B]...[L(n-1)][R(n-1)],n=bundleFrameCount(默认2 → 160B)
_ = opusProcessor?.startDualEncodeStream(sampleRate: 16000, packetBytes: Int32(OPUS_FRAME_BYTES))
// 通话翻译:左右声道独立编码,每帧 20ms,帧长由下行码率决定(16kbps→40B,与 Android 对齐)
// 下行包格式(无包头,左右交替):[L0][R0]...[L(n-1)][R(n-1)],n=bundleFrameCount(默认 2 帧 → 160B)
_ = opusProcessor?.startDualEncodeStream(sampleRate: 16000,
packetBytes: Int32(downlinkFrameBytes))
startAudioSendThreads()
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) {
guard frame.count == OPUS_FRAME_BYTES else {
let expected = downlinkFrameBytes
guard frame.count == expected else {
os_log("静音帧模板长度异常 size=%dB (期望 %dB),拒绝采用",
log: logger, type: .error, frame.count, OPUS_FRAME_BYTES)
log: logger, type: .error, frame.count, expected)
return
}
silenceFrame = frame
@ -1603,12 +1645,13 @@ extension BleService {
/// 仅超 OOM 兜底上限才丢最旧帧。
///
/// 无包头协议下设备侧按固定 40B 切分左右声道,任何一帧长度异常都会让整包及之后的声道对位全错,
/// 故非 OPUS_FRAME_BYTES 的帧在入队处直接拦掉,保证队列里的帧长恒定。
/// 故长度不等于当前 downlinkFrameBytes 的帧在入队处直接拦掉,保证队列里的帧长恒定。
private func enqueueOpusFrame(channel: UInt8, frame: Data) {
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)",
log: logger, type: .error, Int(channel), frame.count, OPUS_FRAME_BYTES)
log: logger, type: .error, Int(channel), frame.count, expected)
return
}
sendBufferLock.lock()
@ -1638,9 +1681,12 @@ extension BleService {
isAudioSending = true
audioSendTimer = makeAudioSendTimer()
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,
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 {
let n = min(max(bundleFrameCount, 1), MAX_BUNDLE_FRAME_COUNT)
// 帧长在本拍内取一次快照:改码率是异步的,中途变化会让同一包里的帧长不一致(整包错位)
let frameBytes = downlinkFrameBytes
// 左右各取最多 n 帧真实数据
sendBufferLock.lock()
@ -1705,16 +1753,18 @@ extension BleService {
// 两路皆空且未开持续发送:本拍不发
if !hasRealAudio && !continuousSendEnabled { return false }
// 无包头协议靠固定帧长切分,缺帧必须补静音才能保证包长恒定;模板未就绪则本拍放弃,帧退回队列不丢
guard let silence = silenceFrame else {
// 无包头协议靠固定帧长切分,缺帧必须补静音才能保证包长恒定;
// 模板未就绪(或长度与当前码率不匹配)则本拍放弃,帧退回队列不丢
let silenceOrNil = silenceFrame
guard let silence = silenceOrNil, silence.count == frameBytes else {
sendBufferLock.lock()
leftSendBuffer.insert(contentsOf: leftFrames, at: 0)
rightSendBuffer.insert(contentsOf: rightFrames, at: 0)
sendBufferLock.unlock()
silenceNotReadyCount += 1
if silenceNotReadyCount == 1 || silenceNotReadyCount % 50 == 0 {
os_log("静音帧模板未就绪,暂缓下发(累计 %d 拍),译音已退回队列不丢",
log: logger, type: .error, silenceNotReadyCount)
os_log("静音帧模板未就绪(或与当前帧长 %dB 不匹配),暂缓下发(累计 %d 拍),译音已退回队列不丢",
log: logger, type: .error, frameBytes, silenceNotReadyCount)
}
return false
}
@ -1722,14 +1772,40 @@ extension BleService {
let padLeft = n - leftFrames.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=底层发送缓冲已满(拥塞),作为背压信号
guard sendAudioPacket(packet) else {
// 写失败:真实帧按原顺序退回各自队头,下一拍重组重试;静音补齐帧丢弃
congested = true
break
}
sentFrames += chunkFrames
sentBytes += packet.count
writeCount += 1
}
if congested {
// 拥塞:把**尚未发出**的真实帧按原顺序退回各自队头,下一拍重组重试;静音补齐帧丢弃。
// 已发出的分片无法撤回,但它们自身 2×帧长 对齐、顺序正确,设备侧字节流不会错位。
sendBufferLock.lock()
leftSendBuffer.insert(contentsOf: leftFrames, at: 0)
rightSendBuffer.insert(contentsOf: rightFrames, at: 0)
if sentFrames < leftFrames.count {
leftSendBuffer.insert(contentsOf: leftFrames[sentFrames...], at: 0)
}
if sentFrames < rightFrames.count {
rightSendBuffer.insert(contentsOf: rightFrames[sentFrames...], at: 0)
}
sendBufferLock.unlock()
sendStateLock.lock()
@ -1737,19 +1813,20 @@ extension BleService {
let fails = _writeFailRetryCount
sendStateLock.unlock()
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()
let prevFails = _writeFailRetryCount
_writeFailRetryCount = 0
if !congested { _writeFailRetryCount = 0 }
let now = Date().timeIntervalSince1970
let deltaMs = _lastAudioSendTime < 0 ? 0 : Int((now - _lastAudioSendTime) * 1000)
_lastAudioSendTime = now
sendStateLock.unlock()
if prevFails > 0 {
if !congested, prevFails > 0 {
os_log("下行写入已恢复,之前连续失败=%d", log: logger, type: .info, prevFails)
}
@ -1764,25 +1841,34 @@ extension BleService {
// 持续发送的纯静音包按 25 拍节流,避免刷屏
if hasRealAudio {
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,
leftFrames.count, padLeft, rightFrames.count, padRight, packet.count, lBuf, rBuf)
leftFrames.count, padLeft, rightFrames.count, padRight,
sentFrames, n, sentBytes, writeCount, lBuf, rBuf)
} else {
silentPacketCount += 1
if silentPacketCount % 25 == 1 {
os_log("音频下行持续发送(全静音) seq=%u Δ=%dms size=%dB 累计=%d 包",
log: logger, type: .debug, seq, deltaMs, packet.count, silentPacketCount)
os_log("音频下行持续发送(全静音) seq=%u Δ=%dms size=%dB 写=%d 累计=%d 包",
log: logger, type: .debug, seq, deltaMs, sentBytes, writeCount, silentPacketCount)
}
}
return true
}
/// 组下行包(无包头,左右帧级交替):[L0][R0][L1][R1]...[L(n-1)][R(n-1)]
/// 每帧恒为 OPUS_FRAME_BYTES 字节,包长恒为 2*n*OPUS_FRAME_BYTES;某侧该位缺帧则填静音帧,保证左右严格对位。
private func buildInterleavedPacket(n: Int, leftFrames: [Data], rightFrames: [Data], silence: Data) -> Data {
/// 组下行分片(无包头,左右帧级交替):取第 startFrame 起的 count 帧,拼成
/// [L(s)][R(s)][L(s+1)][R(s+1)]...,每帧恒为 frameBytes 字节,分片长度恒为 2*count*frameBytes。
/// 某侧该位缺帧则填静音帧,保证左右严格对位、长度恒定。
/// - 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()
packet.reserveCapacity(2 * n * OPUS_FRAME_BYTES)
for i in 0..<n {
packet.reserveCapacity(2 * count * frameBytes)
for k in 0..<count {
let i = startFrame + k
packet.append(i < leftFrames.count ? leftFrames[i] : silence)
packet.append(i < rightFrames.count ? rightFrames[i] : silence)
}
@ -1792,8 +1878,17 @@ extension BleService {
// MARK: - 通话翻译调试参数(运行时可调,与 Android 对齐)
/// 设置通话翻译调试参数(运行时生效)。任一参数为 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
if let ms = sendIntervalMs, ms > 0 {
let clamped = TimeInterval(min(max(ms, 1), 1000)) / 1000.0
@ -1805,8 +1900,13 @@ extension BleService {
}
if let frames = bundleFrameCount, frames > 0 {
self.bundleFrameCount = min(max(frames, 1), MAX_BUNDLE_FRAME_COUNT)
os_log("[CALL_TRANS_DEBUG] 设置每声道合包帧数: %d (包长 %dB)", log: logger, type: .info,
self.bundleFrameCount, 2 * self.bundleFrameCount * OPUS_FRAME_BYTES)
// 超过单次 BLE 写上限时会自动按 2×帧长 对齐拆成多个写
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 {
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] {
return [
"sendIntervalMs": Int((audioSendInterval * 1000).rounded()),
"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(
sendIntervalMs: (arguments?["sendIntervalMs"] 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)

25
local_plugins/ble_service/lib/ble_service.dart

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

Loading…
Cancel
Save