From f479f9916d4b466d0f2a51c06b747db17beae125 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Tue, 14 Jul 2026 20:55:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=96=B0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E7=9A=84=E9=80=9A=E8=AF=9D=E7=BF=BB=E8=AF=91=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...�翻译-BLE协议规范-v2-固件对接.md | 437 ++++++++++++++++++ docs/通话翻译-流程与控流协议.md | 6 +- lib/data/services/ble_manager.dart | 11 +- .../call_translation_debug_controller.dart | 105 ++++- .../views/call_translation_debug_view.dart | 143 ++++-- .../yunqiinnovation/ble_service/BleService.kt | 286 +++++++++--- .../ble_service/BleServicePlugin.kt | 7 +- .../Sources/ble_service/BleService.swift | 239 ++++++++-- .../ble_service/SwiftBleServicePlugin.swift | 4 +- .../ble_service/lib/ble_service.dart | 25 +- 10 files changed, 1088 insertions(+), 175 deletions(-) create mode 100644 docs/通话翻译-BLE协议规范-v2-固件对接.md diff --git a/docs/通话翻译-BLE协议规范-v2-固件对接.md b/docs/通话翻译-BLE协议规范-v2-固件对接.md new file mode 100644 index 000000000..1609ed4c3 --- /dev/null +++ b/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/` | diff --git a/docs/通话翻译-流程与控流协议.md b/docs/通话翻译-流程与控流协议.md index 0698aa2fc..41ff08949 100644 --- a/docs/通话翻译-流程与控流协议.md +++ b/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」方案**当前代码未采用**,不在本文范围。 --- diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index 40e6f6ebe..fe3cc8f45 100644 --- a/lib/data/services/ble_manager.dart +++ b/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 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> getCallTranslationDebugParams() async { try { return await _bleService.getCallTranslationDebugParams(); diff --git a/lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart b/lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart index 32fe6d9ac..64d1390dd 100644 --- a/lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart +++ b/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(); 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 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(_kDownlinkBitrate); + final savedUpBitrate = _storage.read(_kUplinkBitrate); + if (savedDownBitrate != null && bitrateOptions.contains(savedDownBitrate)) { + downlinkBitrateKbps.value = savedDownBitrate; + } + if (savedUpBitrate != null && bitrateOptions.contains(savedUpBitrate)) { + uplinkBitrateKbps.value = savedUpBitrate; + } + final savedInterval = _storage.read(_kSendInterval); final savedFrames = _storage.read(_kBundleFrameCount); final savedContinuous = _storage.read(_kContinuousSend); @@ -87,6 +131,19 @@ class CallTranslationDebugController extends GetxController { try { final params = await _bleManager.getCallTranslationDebugParams(); if (params.isEmpty) return; + // 同样先同步码率,再同步依赖它的帧数 + if (_storage.read(_kDownlinkBitrate) == null) { + final nb = params['downlinkBitrateKbps'] as int?; + if (nb != null && bitrateOptions.contains(nb)) { + downlinkBitrateKbps.value = nb; + } + } + if (_storage.read(_kUplinkBitrate) == null) { + final nb = params['uplinkBitrateKbps'] as int?; + if (nb != null && bitrateOptions.contains(nb)) { + uplinkBitrateKbps.value = nb; + } + } if (_storage.read(_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( '通话翻译调试', diff --git a/lib/modules/call_translation_debug/views/call_translation_debug_view.dart b/lib/modules/call_translation_debug/views/call_translation_debug_view.dart index a22f974f9..1998d0516 100644 --- a/lib/modules/call_translation_debug/views/call_translation_debug_view.dart +++ b/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, ), - ), - ), + ); + }), ], ), ); diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 999d30548..54dad8aa4 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/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) - // 同步写入,拿到真实的成功/失败: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]) + // 一拍的数据(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=底层发送缓冲已满(拥塞),作为背压信号 + 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, rightFrames: List, 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, silence: ByteArray): ByteArray { - val out = ByteArray(n * OPUS_FRAME_BYTES) + private fun padToFrames(n: Int, frameBytes: Int, frames: List, 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 { 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() diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt index 43b0affd9..3b1e2862c 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt +++ b/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("sendIntervalMs"))?.toLong() val bundleFrameCount = (call.argument("bundleFrameCount"))?.toInt() val continuousSend = call.argument("continuousSend") - BleService.setCallTranslationDebugParams(sendIntervalMs, bundleFrameCount, continuousSend) + val downlinkBitrateKbps = (call.argument("downlinkBitrateKbps"))?.toInt() + val uplinkBitrateKbps = (call.argument("uplinkBitrateKbps"))?.toInt() + BleService.setCallTranslationDebugParams( + sendIntervalMs, bundleFrameCount, continuousSend, + downlinkBitrateKbps, uplinkBitrateKbps + ) result.success(true) } "getCallTranslationDebugParams" -> { diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift index 0ef8bc467..f92d554c4 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift +++ b/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,13 +170,47 @@ 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? // 命令结构体 @@ -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) - // 同步写入,拿到真实的成功/失败:false=底层发送缓冲已满(拥塞),作为背压信号 - guard sendAudioPacket(packet) else { - // 写失败:真实帧按原顺序退回各自队头,下一拍重组重试;静音补齐帧丢弃 + // 一拍的数据(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.. 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) ] } diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift index 719bcb1a7..0eb432069 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift +++ b/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) diff --git a/local_plugins/ble_service/lib/ble_service.dart b/local_plugins/ble_service/lib/ble_service.dart index 340ffb076..8ddc17d6b 100644 --- a/local_plugins/ble_service/lib/ble_service.dart +++ b/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 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> getCallTranslationDebugParams() async { try { final result = await _methodChannel .invokeMethod('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');