47 changed files with 3772 additions and 1007 deletions
@ -0,0 +1,277 @@ |
|||||
|
// 通话录音 Dart 端 PCM 合成器 |
||||
|
// |
||||
|
// 用途:把 device_jieli 上行(in.uplink)/下行(in.downlink)两路异步 PCM 帧 |
||||
|
// 配对成 16k/16bit/双声道(左=本端、右=对端)的标准 WAV 文件,独立于 |
||||
|
// Azure 插件的 RecordFile 路径(避开其 setAudioConfig no-op 与 push 通道的 |
||||
|
// 参数 key 不一致 bug)。 |
||||
|
// |
||||
|
// 注意:device_jieli 的设备录音没有原生 pause API,本类的暂停语义通过 |
||||
|
// 在 Dart 层丢弃接收到的 PCM 帧实现。 |
||||
|
import 'dart:async'; |
||||
|
import 'dart:io'; |
||||
|
import 'dart:math'; |
||||
|
import 'dart:typed_data'; |
||||
|
|
||||
|
import 'package:device_jieli/device_jieli.dart'; |
||||
|
|
||||
|
import '/core/utils/logger.dart'; |
||||
|
|
||||
|
class JieliCallRecorder { |
||||
|
JieliCallRecorder({ |
||||
|
this.sampleRate = 16000, |
||||
|
this.bitsPerSample = 16, |
||||
|
this.onWavePcm, |
||||
|
}); |
||||
|
|
||||
|
/// 采样率(Hz)。device_jieli DeviceRecord 默认 16000。 |
||||
|
final int sampleRate; |
||||
|
|
||||
|
/// 位深,固定 16bit。 |
||||
|
final int bitsPerSample; |
||||
|
|
||||
|
/// 写入文件后的立体声 PCM 通知,用于驱动上层波形/振幅。 |
||||
|
final void Function(Uint8List stereoPcm)? onWavePcm; |
||||
|
|
||||
|
static const String _tag = 'JieliCallRecorder'; |
||||
|
static const int _channels = 2; |
||||
|
|
||||
|
StreamSubscription<JieliEvent>? _eventSub; |
||||
|
RandomAccessFile? _raf; |
||||
|
String? _filePath; |
||||
|
int _dataBytes = 0; |
||||
|
bool _paused = false; |
||||
|
bool _running = false; |
||||
|
|
||||
|
// 上下行 PCM 字节缓冲(s16le) |
||||
|
final BytesBuilder _uplink = BytesBuilder(copy: false); |
||||
|
final BytesBuilder _downlink = BytesBuilder(copy: false); |
||||
|
|
||||
|
bool get isRunning => _running; |
||||
|
String? get filePath => _filePath; |
||||
|
|
||||
|
/// 启动录音:写 WAV 头占位、订阅事件、调起设备录音上行。 |
||||
|
Future<bool> start(String filePath) async { |
||||
|
if (_running) { |
||||
|
Logger.w(_tag, '重复 start 调用,已忽略'); |
||||
|
return false; |
||||
|
} |
||||
|
try { |
||||
|
final file = File(filePath); |
||||
|
final dir = file.parent; |
||||
|
if (!await dir.exists()) { |
||||
|
await dir.create(recursive: true); |
||||
|
} |
||||
|
if (await file.exists()) { |
||||
|
await file.delete(); |
||||
|
} |
||||
|
_raf = await file.open(mode: FileMode.write); |
||||
|
await _raf!.writeFrom(_makeWavHeader(0)); |
||||
|
|
||||
|
_dataBytes = 0; |
||||
|
_filePath = filePath; |
||||
|
_paused = false; |
||||
|
_uplink.clear(); |
||||
|
_downlink.clear(); |
||||
|
|
||||
|
_eventSub = Jielihome.instance.events.listen(_onEvent); |
||||
|
|
||||
|
try { |
||||
|
await Jielihome.instance.deviceRecordStart(sampleRate: sampleRate); |
||||
|
} catch (e) { |
||||
|
Logger.error('$_tag deviceRecordStart 失败: $e'); |
||||
|
await _shutdown(saveFile: false); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
_running = true; |
||||
|
Logger.info('$_tag 启动通话录音 path=$filePath sr=$sampleRate'); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('$_tag 启动失败: $e'); |
||||
|
await _shutdown(saveFile: false); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 暂停:仅在 Dart 层丢帧;设备端继续推流。 |
||||
|
void pause() { |
||||
|
if (!_running) return; |
||||
|
_paused = true; |
||||
|
} |
||||
|
|
||||
|
/// 恢复接收设备端帧。 |
||||
|
void resume() { |
||||
|
if (!_running) return; |
||||
|
_paused = false; |
||||
|
} |
||||
|
|
||||
|
/// 停止录音;[save] 为 false 时回滚删除文件。 |
||||
|
Future<bool> stop({required bool save}) async { |
||||
|
if (!_running) { |
||||
|
// 即便外部异常重复 stop,也保证幂等 |
||||
|
await _shutdown(saveFile: save); |
||||
|
return true; |
||||
|
} |
||||
|
_running = false; |
||||
|
try { |
||||
|
try { |
||||
|
await Jielihome.instance.deviceRecordStop(); |
||||
|
} catch (e) { |
||||
|
Logger.w(_tag, 'deviceRecordStop 失败(忽略): $e'); |
||||
|
} |
||||
|
await _shutdown(saveFile: save); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('$_tag 停止失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> _shutdown({required bool saveFile}) async { |
||||
|
await _eventSub?.cancel(); |
||||
|
_eventSub = null; |
||||
|
final raf = _raf; |
||||
|
final path = _filePath; |
||||
|
_raf = null; |
||||
|
_filePath = null; |
||||
|
_uplink.clear(); |
||||
|
_downlink.clear(); |
||||
|
_paused = false; |
||||
|
|
||||
|
if (raf != null) { |
||||
|
try { |
||||
|
if (saveFile) { |
||||
|
await raf.setPosition(0); |
||||
|
await raf.writeFrom(_makeWavHeader(_dataBytes)); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('$_tag 写 WAV 头失败: $e'); |
||||
|
} finally { |
||||
|
try { |
||||
|
await raf.flush(); |
||||
|
} catch (_) {} |
||||
|
try { |
||||
|
await raf.close(); |
||||
|
} catch (_) {} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (!saveFile && path != null) { |
||||
|
try { |
||||
|
final f = File(path); |
||||
|
if (await f.exists()) { |
||||
|
await f.delete(); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.w(_tag, '删除录音文件失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
_dataBytes = 0; |
||||
|
} |
||||
|
|
||||
|
void _onEvent(JieliEvent event) { |
||||
|
if (event is DeviceRecordAudioEvent) { |
||||
|
if (_paused) return; |
||||
|
if (event.streamId == DeviceRecordStreams.inUplink) { |
||||
|
_uplink.add(event.pcm); |
||||
|
} else if (event.streamId == DeviceRecordStreams.inDownlink) { |
||||
|
_downlink.add(event.pcm); |
||||
|
} else { |
||||
|
return; |
||||
|
} |
||||
|
_drain(); |
||||
|
} else if (event is DeviceRecordErrorEvent) { |
||||
|
Logger.error( |
||||
|
'$_tag 设备录音错误: code=${event.code} msg=${event.message ?? ''}'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 按 sample 对齐配对 uplink/downlink,写入立体声 WAV 数据段。 |
||||
|
void _drain() { |
||||
|
final raf = _raf; |
||||
|
if (raf == null) return; |
||||
|
|
||||
|
final upBytes = _uplink.takeBytes(); |
||||
|
final downBytes = _downlink.takeBytes(); |
||||
|
final pairableBytes = (min(upBytes.length, downBytes.length) ~/ 2) * 2; |
||||
|
if (pairableBytes <= 0) { |
||||
|
// 没有可配对样本,原样放回缓冲 |
||||
|
_uplink.add(upBytes); |
||||
|
_downlink.add(downBytes); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
final samples = pairableBytes ~/ 2; |
||||
|
final stereo = Uint8List(samples * 4); |
||||
|
for (int i = 0; i < samples; i++) { |
||||
|
final si = i * 2; |
||||
|
final di = i * 4; |
||||
|
// L = uplink(本端) |
||||
|
stereo[di] = upBytes[si]; |
||||
|
stereo[di + 1] = upBytes[si + 1]; |
||||
|
// R = downlink(对端) |
||||
|
stereo[di + 2] = downBytes[si]; |
||||
|
stereo[di + 3] = downBytes[si + 1]; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
raf.writeFromSync(stereo); |
||||
|
_dataBytes += stereo.length; |
||||
|
} catch (e) { |
||||
|
Logger.error('$_tag 写 PCM 失败: $e'); |
||||
|
// 写失败时,把字节放回缓冲,避免丢数据无可恢复 |
||||
|
_uplink.add(upBytes); |
||||
|
_downlink.add(downBytes); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
onWavePcm?.call(stereo); |
||||
|
|
||||
|
if (pairableBytes < upBytes.length) { |
||||
|
_uplink.add(Uint8List.sublistView(upBytes, pairableBytes)); |
||||
|
} |
||||
|
if (pairableBytes < downBytes.length) { |
||||
|
_downlink.add(Uint8List.sublistView(downBytes, pairableBytes)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 标准 PCM WAV 头(44 字节)。dataLength=0 用于占位,stop 时回填实际值。 |
||||
|
Uint8List _makeWavHeader(int dataLength) { |
||||
|
final byteRate = sampleRate * _channels * (bitsPerSample ~/ 8); |
||||
|
final blockAlign = _channels * (bitsPerSample ~/ 8); |
||||
|
final totalLength = 36 + dataLength; |
||||
|
final h = ByteData(44); |
||||
|
|
||||
|
// "RIFF" |
||||
|
h.setUint8(0, 0x52); |
||||
|
h.setUint8(1, 0x49); |
||||
|
h.setUint8(2, 0x46); |
||||
|
h.setUint8(3, 0x46); |
||||
|
h.setUint32(4, totalLength, Endian.little); |
||||
|
// "WAVE" |
||||
|
h.setUint8(8, 0x57); |
||||
|
h.setUint8(9, 0x41); |
||||
|
h.setUint8(10, 0x56); |
||||
|
h.setUint8(11, 0x45); |
||||
|
// "fmt " |
||||
|
h.setUint8(12, 0x66); |
||||
|
h.setUint8(13, 0x6d); |
||||
|
h.setUint8(14, 0x74); |
||||
|
h.setUint8(15, 0x20); |
||||
|
h.setUint32(16, 16, Endian.little); // fmt chunk size |
||||
|
h.setUint16(20, 1, Endian.little); // PCM |
||||
|
h.setUint16(22, _channels, Endian.little); |
||||
|
h.setUint32(24, sampleRate, Endian.little); |
||||
|
h.setUint32(28, byteRate, Endian.little); |
||||
|
h.setUint16(32, blockAlign, Endian.little); |
||||
|
h.setUint16(34, bitsPerSample, Endian.little); |
||||
|
// "data" |
||||
|
h.setUint8(36, 0x64); |
||||
|
h.setUint8(37, 0x61); |
||||
|
h.setUint8(38, 0x74); |
||||
|
h.setUint8(39, 0x61); |
||||
|
h.setUint32(40, dataLength, Endian.little); |
||||
|
|
||||
|
return h.buffer.asUint8List(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,242 @@ |
|||||
|
# 杰理 RCSP SDK 指令 / 常量参考 |
||||
|
|
||||
|
> **来源**:`android/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar` 中 |
||||
|
> `com.jieli.bluetooth.constant.Command` 等 class 的 `javap -p -constants` 反编译结果。 |
||||
|
> **生成时间**:2026-05-07 |
||||
|
> **用途**:核对项目里硬编码的 cmd / opcode / 模式 ID 是否与 SDK 内部定义一致,避免猜测。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 一、RCSP CMD opcode(`com.jieli.bluetooth.constant.Command`) |
||||
|
|
||||
|
按 opcode 升序排列,**斜体** = 项目内已被 Kotlin 代码直接调用或回调命中。 |
||||
|
|
||||
|
| opcode (10) | opcode (16) | 常量名 | 用途速记 | |
||||
|
|---:|---:|---|---| |
||||
|
| 1 | 0x01 | CMD_DATA | 通用数据 | |
||||
|
| 2 | 0x02 | CMD_GET_TARGET_FEATURE_MAP | 拉取设备能力位图 | |
||||
|
| 3 | 0x03 | CMD_GET_TARGET_INFO | 拉取设备基础信息 | |
||||
|
| **4** | **0x04** | ***CMD_RECEIVE_SPEECH_START*** | **耳机语音助手按键唤醒:开始上推语音** | |
||||
|
| **5** | **0x05** | ***CMD_RECEIVE_SPEECH_STOP*** | **耳机语音助手按键松开:停止上推** | |
||||
|
| 6 | 0x06 | CMD_DISCONNECT_CLASSIC_BLUETOOTH | 断开经典蓝牙 | |
||||
|
| 7 | 0x07 | CMD_GET_SYS_INFO | 取系统信息 | |
||||
|
| 8 | 0x08 | CMD_SET_SYS_INFO | 设系统信息 | |
||||
|
| 9 | 0x09 | CMD_SYS_INFO_AUTO_UPDATE | 系统信息自动同步 | |
||||
|
| 10 | 0x0A | CMD_PHONE_CALL_REQUEST | 通话相关请求 | |
||||
|
| 11 | 0x0B | CMD_SWITCH_DEVICE_REQUEST | 多机切换请求 | |
||||
|
| 12 | 0x0C | CMD_START_FILE_BROWSE | 开始文件浏览 | |
||||
|
| 13 | 0x0D | CMD_STOP_FILE_BROWSE | 结束文件浏览 | |
||||
|
| 14 | 0x0E | CMD_FUNCTION | 功能控制(按键映射等) | |
||||
|
| 15 | 0x0F | CMD_LRC_GET_START | 开始拉取歌词 | |
||||
|
| 16 | 0x10 | CMD_LRC_GET_STOP | 停止拉取歌词 | |
||||
|
| 17 | 0x11 | CMD_LRC_PUSH_START_TTS | 推送 TTS 歌词 | |
||||
|
| 18 | 0x12 | CMD_START_PERIPHERALS_SCAN | 设备代扫外设开始 | |
||||
|
| 19 | 0x13 | CMD_UPDATE_PERIPHERALS_RESULT | 代扫结果上报 | |
||||
|
| 20 | 0x14 | CMD_STOP_PERIPHERALS_SCAN | 代扫结束 | |
||||
|
| 22 | 0x16 | CMD_START_FILE_TRANSFER | 文件传输开始 | |
||||
|
| 23 | 0x17 | CMD_STOP_FILE_TRANSFER | 文件传输结束 | |
||||
|
| 24 | 0x18 | CMD_NOTIFY_FILE_TRANSFER_OP | 文件传输操作通知 | |
||||
|
| 25 | 0x19 | CMD_SEARCH_DEVICE | 搜索设备 | |
||||
|
| 26 | 0x1A | CMD_EXTERNAL_FLASH_IO_CTRL | 外部 Flash IO 控制 | |
||||
|
| 27 | 0x1B | CMD_START_LARGE_FILE_TRANSFER | 大文件传输开始 | |
||||
|
| 28 | 0x1C | CMD_STOP_LARGE_FILE_TRANSFER | 大文件传输结束 | |
||||
|
| 29 | 0x1D | CMD_LARGE_FILE_TRANSFER_OP | 大文件传输操作 | |
||||
|
| 30 | 0x1E | CMD_CANCEL_LARGE_FILE_TRANSFER | 取消大文件传输 | |
||||
|
| 31 | 0x1F | CMD_FILE_BROWSE_DELETE | 文件浏览删除 | |
||||
|
| 32 | 0x20 | CMD_LARGE_FILE_TRANSFER_GET_NAME | 取大文件名 | |
||||
|
| 33 | 0x21 | CMD_NOTIFY_PREPARE_ENV | 准备环境通知 | |
||||
|
| 34 | 0x22 | CMD_FORMAT_DEVICE | 设备格式化 | |
||||
|
| 35 | 0x23 | CMD_DELETE_FILE_BY_NAME | 按名删文件 | |
||||
|
| 36 | 0x24 | CMD_READ_FILE_FROM_DEVICE | 从设备读文件 | |
||||
|
| 37 | 0x25 | CMD_RTC_EXPAND | RTC 扩展 | |
||||
|
| 38 | 0x26 | CMD_BATCH | 批量命令 | |
||||
|
| 39 | 0x27 | CMD_DEV_PARAM_EXTEND | 设备参数扩展 | |
||||
|
| 40 | 0x28 | CMD_SMALL_FILE_TRANSFER | 小文件传输 | |
||||
|
| 41 | 0x29 | CMD_READ_ERROR_MSG | 读错误信息 | |
||||
|
| 48 | 0x30 | CMD_DATA_TRANSFER | 通用数据传输 | |
||||
|
| 49 | 0x31 | CMD_QUERY_CONNECTED_PHONE_BT_INFO | 查询已连手机蓝牙信息 | |
||||
|
| 51 | 0x33 | CMD_PUBLIC_SETTINGS | 公共设置 | |
||||
|
| **52** | **0x34** | ***CMD_TRANSLATION_MODE*** | **翻译模式控制 + 上下行 PCM 数据通道** | |
||||
|
| 161 | 0xA1 | CMD_PUSH_MESSAGE_TO_DEVICE | APP → 设备消息推送 | |
||||
|
| 192 | 0xC0 | CMD_ADV_SETTINGS | 广告设置 | |
||||
|
| 193 | 0xC1 | CMD_ADV_GET_INFO | 广告信息查询 | |
||||
|
| 194 | 0xC2 | CMD_ADV_DEVICE_NOTIFY | 设备广告通知 | |
||||
|
| 195 | 0xC3 | CMD_ADV_NOTIFY_SETTINGS | 广告通知设置 | |
||||
|
| 196 | 0xC4 | CMD_ADV_DEV_REQUEST_OPERATION | 设备发起的请求 | |
||||
|
| 208 | 0xD0 | CMD_NOTIFY_DEVICE_APP_INFO | 通知设备 APP 信息 | |
||||
|
| 209 | 0xD1 | CMD_SETTINGS_COMMUNICATION_MTU | 协商通讯 MTU | |
||||
|
| **210** | **0xD2** | ***CMD_RECEIVE_SPEECH_CANCEL*** | **取消语音助手会话** | |
||||
|
| 212 | 0xD4 | CMD_GET_DEV_MD5 | 取设备 MD5 | |
||||
|
| 213 | 0xD5 | CMD_GET_LOW_LATENCY_SETTINGS | 低延时模式设置 | |
||||
|
| 214 | 0xD6 | CMD_GET_EXTERNAL_FLASH_MSG | 外部 Flash 信息 | |
||||
|
| 216 | 0xD8 | CMD_SET_DEVICE_STORAGE | 设备存储设置 | |
||||
|
| 217 | 0xD9 | CMD_GET_DEVICE_CONFIG_INFO | 设备配置信息 | |
||||
|
| 225 | 0xE1 | CMD_OTA_GET_DEVICE_UPDATE_FILE_INFO_OFFSET | OTA:取升级文件偏移 | |
||||
|
| 226 | 0xE2 | CMD_OTA_INQUIRE_DEVICE_IF_CAN_UPDATE | OTA:询问可否升级 | |
||||
|
| 227 | 0xE3 | CMD_OTA_ENTER_UPDATE_MODE | OTA:进入升级模式 | |
||||
|
| 228 | 0xE4 | CMD_OTA_EXIT_UPDATE_MODE | OTA:退出升级模式 | |
||||
|
| 229 | 0xE5 | CMD_OTA_SEND_FIRMWARE_UPDATE_BLOCK | OTA:发送固件块 | |
||||
|
| 230 | 0xE6 | CMD_OTA_GET_DEVICE_REFRESH_FIRMWARE_STATUS | OTA:取刷新状态 | |
||||
|
| 231 | 0xE7 | CMD_REBOOT_DEVICE | 重启设备 | |
||||
|
| 232 | 0xE8 | CMD_OTA_NOTIFY_UPDATE_CONTENT_SIZE | OTA:通知升级内容大小 | |
||||
|
| 240 | 0xF0 | CMD_CUSTOM | 厂商自定义 | |
||||
|
| 241 | 0xF1 | CMD_PHONE_NUMBER_PLAY_MODE | 手机号播报模式 | |
||||
|
| 255 | 0xFF | CMD_EXTRA_CUSTOM | 扩展自定义 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 二、音频通道两条独立通路(关键) |
||||
|
|
||||
|
杰理 SDK 上行麦克风音频实际上有两条**互不关联**的命令通路,调用入口和回调机制完全不同: |
||||
|
|
||||
|
### 通路 A:原生语音助手(cmd=4 / 5 / 210) |
||||
|
- **类**:`com.jieli.bluetooth.impl.rcsp.record.RecordOpImpl` |
||||
|
- **回调**:`OnRecordStateCallback`(多回调可共存) |
||||
|
- **触发模型**:固件以"耳机硬键唤醒"为前提才会持续上推 |
||||
|
- APP 主动调用 `startRecord()` 通常只能拿到 `RECORD_STATE_START` 回调, |
||||
|
`RECORD_STATE_WORKING` 帧推送依赖耳机端按键事件 / 厂商定制 |
||||
|
- **下行**:**无**。该通路只管上行 |
||||
|
- **不抢占 eSCO / A2DP** |
||||
|
|
||||
|
### 通路 B:翻译模式(cmd=52) |
||||
|
- **类**:`com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl` |
||||
|
- **接口**:`writeAudioData()` 上下行复用 |
||||
|
- **触发模型**:APP 调用 `enterMode()` 后固件**主动**持续上推 PCM |
||||
|
- `MODE_CALL_TRANSLATION (3)` + `STRATEGY_DEVICE_ALWAYS_RECORDING (1)` → 不依赖通话事件即可拿帧 |
||||
|
- `MODE_CALL_TRANSLATION_WITH_STEREO (6)` → 依赖真实通话事件(SCO_MIX),AI 助理场景**零帧** |
||||
|
- **下行**:通过 `feedTranslatedAudio()` 走 RCSP 注入回耳机 |
||||
|
- **会占用 SCO**,与 A2DP 媒体音乐互斥 |
||||
|
|
||||
|
> 实测结论(ColorOS + 当前耳机型号):通路 A 的 `startRecord` 仅有 `START` 回调, |
||||
|
> 持续帧不到货。**当前可拿上行 PCM 的唯一稳定通路是 B(cmd=52)+ MODE_CALL_TRANSLATION (3)**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 三、`TranslationMode` 模式常量 |
||||
|
|
||||
|
来自 `com.jieli.bluetooth.bean.translation.TranslationMode`: |
||||
|
|
||||
|
| 值 | 常量 | 上行 | 下行 | AI 助理可用性 | |
||||
|
|---:|---|:---:|:---:|---| |
||||
|
| 0 | MODE_IDLE | - | - | 关闭 | |
||||
|
| 1 | MODE_RECORD | ✓ | ✗ | 只录音,不能放 TTS | |
||||
|
| 2 | MODE_RECORDING_TRANSLATION | ✓ | ✗ | 只录音 + 字幕 | |
||||
|
| **3** | **MODE_CALL_TRANSLATION** | ✓ | ✓ | **当前唯一全双工可用** | |
||||
|
| 4 | MODE_AUDIO_TRANSLATION | ✓ | ✓ | 音视频文件翻译,需喂文件 PCM | |
||||
|
| 5 | MODE_FACE_TO_FACE_TRANSLATION | ✓ | ✓ | 面对面翻译,依赖双麦阵列 | |
||||
|
| **6** | **MODE_CALL_TRANSLATION_WITH_STEREO** | **✗** | ✓ | **AI 助理场景零帧 → 必须规避** | |
||||
|
|
||||
|
> ⚠️ 项目里 [TranslationModeIds](android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationModeHandler.kt) 与 SDK 编号**完全一致**, |
||||
|
> `MODE_CALL_TRANSLATION = 3`、`MODE_CALL_TRANSLATION_WITH_STEREO = 6`。 |
||||
|
|
||||
|
### RecordingStrategy(`TranslationMode` 内嵌) |
||||
|
|
||||
|
| 值 | 常量 | 含义 | |
||||
|
|---:|---|---| |
||||
|
| 0 | STRATEGY_CUSTOM_RECORDING | APP 自定录音节奏 | |
||||
|
| **1** | **STRATEGY_DEVICE_ALWAYS_RECORDING** | **设备持续录音上推(AI 助理场景需用此项)** | |
||||
|
| 2 | STRATEGY_DEVICE_AUTO_RECORDING | 设备 VAD 自动启停 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 四、`AudioData.source`(cmd=52 上下行 PCM 包的来源标识) |
||||
|
|
||||
|
| 值 | 常量 | 含义 | |
||||
|
|---:|---|---| |
||||
|
| -1 | SOURCE_UNKNOWN | 未知 | |
||||
|
| 0 | SOURCE_FILE | 文件喂入 | |
||||
|
| 1 | SOURCE_DEVICE_MIC | 耳机麦 | |
||||
|
| 2 | SOURCE_PHONE_MIC | 手机麦 | |
||||
|
| 3 | SOURCE_E_SCO_UP_LINK | SCO 上行(通话场景) | |
||||
|
| 4 | SOURCE_E_SCO_DOWN_LINK | SCO 下行(通话场景) | |
||||
|
| 5 | SOURCE_M_SBC | mSBC 编码源 | |
||||
|
| 6 | SOURCE_E_SCO_MIX | SCO 上下行混音(mode=6 stereo 用) | |
||||
|
|
||||
|
> AI 助理上行帧实际取自 `SOURCE_DEVICE_MIC`(mode=3);mode=6 等待 `SOURCE_E_SCO_MIX`, |
||||
|
> 没有真实通话时此源不出包,所以零帧。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 五、`RecordParam`(cmd=4 通路的录音参数) |
||||
|
|
||||
|
来自 `com.jieli.bluetooth.bean.record.RecordParam`: |
||||
|
|
||||
|
### voiceType |
||||
|
| 值 | 常量 | |
||||
|
|---:|---| |
||||
|
| 0 | VOICE_TYPE_PCM | |
||||
|
| 1 | VOICE_TYPE_SPEEX | |
||||
|
| **2** | **VOICE_TYPE_OPUS**(项目当前使用) | |
||||
|
|
||||
|
### sampleRate(**单位是 kHz**,注意不是 Hz) |
||||
|
| 值 | 常量 | 实际频率 | |
||||
|
|---:|---|---:| |
||||
|
| 8 | SAMPLE_RATE_8K | 8000 Hz | |
||||
|
| **16** | **SAMPLE_RATE_16K** | **16000 Hz**(项目当前使用) | |
||||
|
|
||||
|
### vadWay |
||||
|
| 值 | 常量 | |
||||
|
|---:|---| |
||||
|
| **0** | **VAD_WAY_DEVICE**(设备端 VAD,项目当前使用) | |
||||
|
| 1 | VAD_WAY_SDK(SDK 软 VAD) | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 六、`RecordState`(cmd=4 通路的回调状态机) |
||||
|
|
||||
|
| 字段 | 值 | 常量 | 说明 | |
||||
|
|---|---:|---|---| |
||||
|
| state | 0 | RECORD_STATE_IDLE | 录音结束 / 异常退出 | |
||||
|
| state | 1 | RECORD_STATE_START | 已应答 startRecord,**注意:不代表后续会有数据** | |
||||
|
| state | 2 | RECORD_STATE_WORKING | 数据持续上推中(payload 见 voiceData / voiceDataBlock) | |
||||
|
| reason | 0 | REASON_NORMAL | 正常 | |
||||
|
| reason | 1 | REASON_STOP | 主动 stop | |
||||
|
|
||||
|
字段: |
||||
|
- `voiceDataBlock`:分块裸数据 |
||||
|
- `voiceData`:单包数据 |
||||
|
- `recordParam`:本次录音参数回带 |
||||
|
- `message`:异常描述 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 七、音频编解码类型(`com.jieli.bluetooth.constant.Constants`) |
||||
|
|
||||
|
| 值 | 常量 | |
||||
|
|---:|---| |
||||
|
| 0 | AUDIO_TYPE_PCM | |
||||
|
| 1 | AUDIO_TYPE_SPEEX | |
||||
|
| **2** | **AUDIO_TYPE_OPUS**(项目当前使用) | |
||||
|
| 3 | AUDIO_TYPE_M_SBC | |
||||
|
| 4 | AUDIO_TYPE_JLA_V2 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 八、项目现状速查表 |
||||
|
|
||||
|
| 功能 | 入口类 | 走的 cmd | 模式 ID | 备注 | |
||||
|
|---|---|---:|---:|---| |
||||
|
| 翻译 / 通话翻译 | TranslationFeature | 52 | 3 / 6 | 6 在 ColorOS+AI 场景零帧 | |
||||
|
| 录音(独立通路) | RecordOpImpl | 4 / 5 | - | 当前 AI 助理曾尝试此通路,**实测 WORKING 帧不到货** | |
||||
|
| AI 助理 | JieliAssistantPort | 见下文决策 | - | 上行需复用 cmd=52 + mode=3 | |
||||
|
|
||||
|
> **AI 助理音频路由结论**: |
||||
|
> - **上行**:必须走 cmd=52 + `MODE_CALL_TRANSLATION (3)` + `STRATEGY_DEVICE_ALWAYS_RECORDING (1)`, |
||||
|
> 规避 mode=6 |
||||
|
> - **下行**:APP 端 `AudioTrack` USAGE_MEDIA 直送(系统蓝牙路由 → A2DP), |
||||
|
> **不走** RCSP `feedTranslatedAudio`,避免 SCO/A2DP 抢通道 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 附:如何从 aar 自行核对 |
||||
|
|
||||
|
```bash |
||||
|
unzip jl_bluetooth_rcsp_*.aar -d /tmp/jl |
||||
|
unzip /tmp/jl/classes.jar -d /tmp/jl/classes |
||||
|
javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/constant/Command.class |
||||
|
javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/bean/record/RecordParam.class |
||||
|
javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/bean/record/RecordState.class |
||||
|
javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/bean/translation/TranslationMode.class |
||||
|
javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/bean/translation/AudioData.class |
||||
|
javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/constant/Constants.class | grep AUDIO_TYPE |
||||
|
``` |
||||
@ -1,19 +1,298 @@ |
|||||
package com.jielihome.jielihome.feature |
package com.jielihome.jielihome.feature |
||||
|
|
||||
import com.jieli.bluetooth.constant.ErrorCode |
import android.annotation.SuppressLint |
||||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
import android.bluetooth.BluetoothManager |
||||
|
import android.bluetooth.le.BluetoothLeScanner |
||||
|
import android.bluetooth.le.ScanCallback |
||||
|
import android.bluetooth.le.ScanFilter |
||||
|
import android.bluetooth.le.ScanResult |
||||
|
import android.bluetooth.le.ScanSettings |
||||
|
import android.content.Context |
||||
|
import android.os.Handler |
||||
|
import android.os.Looper |
||||
|
import android.os.ParcelUuid |
||||
|
import android.util.Log |
||||
|
import com.jieli.bluetooth.bean.BluetoothOption |
||||
|
import com.jieli.bluetooth.utils.ParseDataUtil |
||||
|
import com.jielihome.jielihome.bridge.EventDispatcher |
||||
|
import java.util.UUID |
||||
|
|
||||
class ScanFeature(private val btManager: JL_BluetoothManager) { |
/** |
||||
|
* BLE 扫描入口。 |
||||
|
* |
||||
|
* 不再走 Jieli SDK 的 `btManager.scan()`(受其内置 ParseDataUtil 的 |
||||
|
* flagContent / strategy 限制),改为直接用 Android 原生 |
||||
|
* [BluetoothLeScanner] + [ScanFilter] 扫描,复合过滤: |
||||
|
* |
||||
|
* - [nameList]:对 `BluetoothDevice.name` 精确匹配(忽略大小写),空 = 不按名过滤 |
||||
|
* - [uuidList]:构造 `ScanFilter.setServiceUuid(...)`,由 OS 过滤;空 = 不按 UUID 过滤 |
||||
|
* |
||||
|
* 两者同时设置时按 (UUID 由 OS 过滤后) AND (名字命中) 上报。 |
||||
|
* |
||||
|
* 由于不再依赖 Jieli SDK 的 `BleScanMessage`,发出的 `deviceFound` 事件里 |
||||
|
* `edrAddr` / `deviceType` / `connectWay` 三个字段为 null —— 后续连接由 |
||||
|
* [com.jielihome.jielihome.feature.ConnectFeature] 用默认值(deviceType=-1, |
||||
|
* connectWay=0)尝试,可在调用方通过 connectWay override 指定。 |
||||
|
*/ |
||||
|
class ScanFeature( |
||||
|
private val context: Context, |
||||
|
private val dispatcher: EventDispatcher, |
||||
|
) { |
||||
|
|
||||
fun startScan(timeoutMs: Int): Result<Unit> { |
private val mainHandler = Handler(Looper.getMainLooper()) |
||||
val code = btManager.scan(timeoutMs) |
|
||||
return if (code == ErrorCode.ERR_NONE) Result.success(Unit) |
/** |
||||
else Result.failure(IllegalStateException("scan returned $code")) |
* 用于把 Android 原生 ScanRecord 字节交给 Jieli SDK 解析的可复用 option。 |
||||
|
* `bleScanStrategy=3` + 空 flagContent → 跳过 SDK 的 flagContent.equals 校验, |
||||
|
* 任何 mode-3 可解析的 Jieli 广播都返回 BleScanMessage(含 edrAddr/pid 等)。 |
||||
|
*/ |
||||
|
private val parserOption: BluetoothOption by lazy { |
||||
|
BluetoothOption.createDefaultOption().apply { |
||||
|
setBleScanStrategy(3) |
||||
|
setScanFilterData("") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Volatile private var scanCallback: ScanCallback? = null |
||||
|
@Volatile private var scanning: Boolean = false |
||||
|
private var stopRunnable: Runnable? = null |
||||
|
|
||||
|
@Volatile private var currentNameList: List<String> = emptyList() |
||||
|
@Volatile private var currentSkipUnnamed: Boolean = true |
||||
|
|
||||
|
@SuppressLint("MissingPermission") |
||||
|
fun startScan( |
||||
|
timeoutMs: Int, |
||||
|
nameList: List<String> = emptyList(), |
||||
|
uuidList: List<String> = emptyList(), |
||||
|
skipUnnamed: Boolean = true, |
||||
|
): Result<Unit> { |
||||
|
val scanner = leScanner() |
||||
|
?: return Result.failure(IllegalStateException("BLE scanner unavailable")) |
||||
|
|
||||
|
// 已在扫描:先停旧的(替换扫描参数) |
||||
|
if (scanning) doStop(scanner) |
||||
|
|
||||
|
currentNameList = nameList.filter { it.isNotEmpty() } |
||||
|
currentSkipUnnamed = skipUnnamed |
||||
|
|
||||
|
val filters = mutableListOf<ScanFilter>() |
||||
|
uuidList.forEach { raw -> |
||||
|
val uuid = parseUuid(raw) ?: return@forEach |
||||
|
filters.add( |
||||
|
ScanFilter.Builder() |
||||
|
.setServiceUuid(ParcelUuid(uuid)) |
||||
|
.build() |
||||
|
) |
||||
|
Log.d(TAG, "add ScanFilter serviceUuid=$uuid") |
||||
|
} |
||||
|
|
||||
|
val settings = ScanSettings.Builder() |
||||
|
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) |
||||
|
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) |
||||
|
.setReportDelay(0) |
||||
|
.build() |
||||
|
|
||||
|
val cb = object : ScanCallback() { |
||||
|
override fun onScanResult(callbackType: Int, result: ScanResult) { |
||||
|
handleScanResult(result) |
||||
|
} |
||||
|
|
||||
|
override fun onBatchScanResults(results: MutableList<ScanResult>) { |
||||
|
for (r in results) handleScanResult(r) |
||||
|
} |
||||
|
|
||||
|
override fun onScanFailed(errorCode: Int) { |
||||
|
Log.e(TAG, "onScanFailed errorCode=$errorCode") |
||||
|
scanning = false |
||||
|
scanCallback = null |
||||
|
stopRunnable?.let { mainHandler.removeCallbacks(it) } |
||||
|
stopRunnable = null |
||||
|
dispatcher.send( |
||||
|
mapOf("type" to "scanStatus", "ble" to true, "started" to false) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return try { |
||||
|
// filters 为空时传 null("扫描所有 BLE 广播") |
||||
|
scanner.startScan(filters.takeIf { it.isNotEmpty() }, settings, cb) |
||||
|
scanCallback = cb |
||||
|
scanning = true |
||||
|
Log.d( |
||||
|
TAG, |
||||
|
"startScan timeoutMs=$timeoutMs filters=${filters.size} " + |
||||
|
"nameList=$currentNameList" |
||||
|
) |
||||
|
dispatcher.send( |
||||
|
mapOf("type" to "scanStatus", "ble" to true, "started" to true) |
||||
|
) |
||||
|
|
||||
|
val stop = Runnable { |
||||
|
Log.d(TAG, "scan timeout reached, stopping") |
||||
|
stopScan() |
||||
|
} |
||||
|
stopRunnable = stop |
||||
|
mainHandler.postDelayed(stop, timeoutMs.toLong()) |
||||
|
|
||||
|
Result.success(Unit) |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "startScan exception", e) |
||||
|
scanning = false |
||||
|
Result.failure(e) |
||||
|
} |
||||
} |
} |
||||
|
|
||||
fun stopScan() { |
fun stopScan() { |
||||
btManager.stopScan() |
val scanner = leScanner() ?: return |
||||
|
doStop(scanner) |
||||
|
} |
||||
|
|
||||
|
@SuppressLint("MissingPermission") |
||||
|
private fun doStop(scanner: BluetoothLeScanner) { |
||||
|
val cb = scanCallback |
||||
|
if (cb != null) { |
||||
|
try { |
||||
|
scanner.stopScan(cb) |
||||
|
} catch (e: Exception) { |
||||
|
Log.w(TAG, "stopScan exception: ${e.message}") |
||||
|
} |
||||
|
} |
||||
|
scanCallback = null |
||||
|
stopRunnable?.let { mainHandler.removeCallbacks(it) } |
||||
|
stopRunnable = null |
||||
|
scanning = false |
||||
|
currentNameList = emptyList() |
||||
|
dispatcher.send( |
||||
|
mapOf("type" to "scanStatus", "ble" to true, "started" to false) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
fun isScanning(): Boolean = scanning |
||||
|
|
||||
|
@SuppressLint("MissingPermission") |
||||
|
private fun handleScanResult(result: ScanResult) { |
||||
|
val device = result.device |
||||
|
val name = try { |
||||
|
device.name ?: result.scanRecord?.deviceName |
||||
|
} catch (_: SecurityException) { null } |
||||
|
|
||||
|
// 未命名过滤(默认 ON):drop 掉 name 为空的环境噪声 |
||||
|
if (currentSkipUnnamed && name.isNullOrEmpty()) return |
||||
|
|
||||
|
val names = currentNameList |
||||
|
if (names.isNotEmpty()) { |
||||
|
if (name.isNullOrEmpty() || |
||||
|
names.none { it.equals(name, ignoreCase = true) } |
||||
|
) return |
||||
|
} |
||||
|
|
||||
|
// 用 Jieli SDK 的 ParseDataUtil 解一次原始 ScanRecord,能解出来就拿到 |
||||
|
// edrAddr / pid / deviceType / connectWay 等私有字段;解不出来就当作普通 |
||||
|
// BLE 设备(这些字段为 null,连接时走 BLE-only fallback)。 |
||||
|
val rawBytes = result.scanRecord?.bytes |
||||
|
val msg = if (rawBytes != null) { |
||||
|
try { ParseDataUtil.isFilterBleDevice(parserOption, rawBytes) } |
||||
|
catch (e: Throwable) { |
||||
|
Log.w(TAG, "parseBleScanMessage failed: ${e.message}") |
||||
|
null |
||||
|
} |
||||
|
} else null |
||||
|
|
||||
|
// 解析出给 Flutter 侧 "广播详情" 弹窗用的结构化字段。 |
||||
|
val adRecords = if (rawBytes != null) parseAdRecords(rawBytes) else emptyList() |
||||
|
val advRecordsPayload = adRecords.map { r -> |
||||
|
mapOf( |
||||
|
"len" to "%02d".format(r.len), |
||||
|
"type" to "0x%02X".format(r.type), |
||||
|
"data" to "0x" + r.data.toUpperHex(), |
||||
|
) |
||||
|
} |
||||
|
val advFlags = adRecords.firstOrNull { it.type == 0x01 } |
||||
|
?.data?.takeIf { it.isNotEmpty() } |
||||
|
?.let { it[0].toInt() and 0xFF } |
||||
|
val mfrRecord = adRecords.firstOrNull { it.type == 0xFF } |
||||
|
val manufacturerCompanyId = mfrRecord?.data?.takeIf { it.size >= 2 }?.let { |
||||
|
((it[1].toInt() and 0xFF) shl 8) or (it[0].toInt() and 0xFF) |
||||
|
} |
||||
|
val manufacturerData = mfrRecord?.data?.takeIf { it.size > 2 } |
||||
|
?.let { it.copyOfRange(2, it.size).toUpperHex() } |
||||
|
val serviceUuidStrings = result.scanRecord?.serviceUuids |
||||
|
?.map { it.uuid.toString().uppercase() } ?: emptyList() |
||||
|
|
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "deviceFound", |
||||
|
"name" to (name ?: ""), |
||||
|
"address" to device.address, |
||||
|
"edrAddr" to msg?.edrAddr, |
||||
|
"deviceType" to msg?.deviceType, |
||||
|
"connectWay" to msg?.connectWay, |
||||
|
"rssi" to result.rssi, |
||||
|
"rawAdv" to rawBytes?.toUpperHex(), |
||||
|
"advRecords" to advRecordsPayload, |
||||
|
"advFlags" to advFlags, |
||||
|
"manufacturerCompanyId" to manufacturerCompanyId, |
||||
|
"manufacturerData" to manufacturerData, |
||||
|
"serviceUuids" to serviceUuidStrings, |
||||
|
) |
||||
|
) |
||||
} |
} |
||||
|
|
||||
fun isScanning(): Boolean = btManager.bluetoothOperation.isScanning |
private fun leScanner(): BluetoothLeScanner? { |
||||
|
val mgr = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager |
||||
|
?: return null |
||||
|
return mgr.adapter?.bluetoothLeScanner |
||||
|
} |
||||
|
|
||||
|
/** 单个 AD (Advertising Data) 结构:len 为 "type + data" 的字节数,不含 len 字节自身。 */ |
||||
|
private data class AdRecord(val len: Int, val type: Int, val data: ByteArray) |
||||
|
|
||||
|
/** |
||||
|
* 按 BLE Core Spec §11(AD structure)解析 ScanRecord 字节。 |
||||
|
* 每条记录:`[len:1][type:1][data:len-1]`;遇到 len==0 视为 padding 结束。 |
||||
|
*/ |
||||
|
private fun parseAdRecords(bytes: ByteArray): List<AdRecord> { |
||||
|
val out = mutableListOf<AdRecord>() |
||||
|
var i = 0 |
||||
|
while (i < bytes.size) { |
||||
|
val len = bytes[i].toInt() and 0xFF |
||||
|
if (len == 0) break |
||||
|
if (i + len >= bytes.size) break // 畸形包:data 越界,丢弃尾部 |
||||
|
val type = bytes[i + 1].toInt() and 0xFF |
||||
|
val data = bytes.copyOfRange(i + 2, i + 1 + len) |
||||
|
out.add(AdRecord(len, type, data)) |
||||
|
i += 1 + len |
||||
|
} |
||||
|
return out |
||||
|
} |
||||
|
|
||||
|
private fun ByteArray.toUpperHex(): String = |
||||
|
joinToString("") { "%02X".format(it) } |
||||
|
|
||||
|
private fun parseUuid(raw: String): UUID? { |
||||
|
val s = raw.trim() |
||||
|
return try { |
||||
|
when { |
||||
|
s.length == 4 -> UUID.fromString("0000$s-0000-1000-8000-00805f9b34fb") |
||||
|
s.length == 8 -> UUID.fromString("$s-0000-1000-8000-00805f9b34fb") |
||||
|
s.length == 36 -> UUID.fromString(s) |
||||
|
s.length == 32 -> UUID.fromString( |
||||
|
"${s.substring(0, 8)}-${s.substring(8, 12)}-${s.substring(12, 16)}" + |
||||
|
"-${s.substring(16, 20)}-${s.substring(20, 32)}" |
||||
|
) |
||||
|
else -> { |
||||
|
Log.w(TAG, "invalid UUID format: $raw") |
||||
|
null |
||||
|
} |
||||
|
} |
||||
|
} catch (e: IllegalArgumentException) { |
||||
|
Log.e(TAG, "parseUuid failed: $raw", e) |
||||
|
null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private companion object { |
||||
|
const val TAG = "JieliScan" |
||||
|
} |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,143 @@ |
|||||
|
package com.jielihome.jielihome.feature.assistant |
||||
|
|
||||
|
import android.util.Log |
||||
|
import com.aiagent.device_plugin_interface.AssistantAudioFormat |
||||
|
import com.jielihome.jielihome.bridge.EventDispatcher |
||||
|
import kotlinx.coroutines.CoroutineScope |
||||
|
import kotlinx.coroutines.Dispatchers |
||||
|
import kotlinx.coroutines.Job |
||||
|
import kotlinx.coroutines.SupervisorJob |
||||
|
import kotlinx.coroutines.cancel |
||||
|
import kotlinx.coroutines.flow.launchIn |
||||
|
import kotlinx.coroutines.flow.onEach |
||||
|
import java.util.concurrent.atomic.AtomicLong |
||||
|
|
||||
|
/** |
||||
|
* Flutter ↔ [JieliAssistantPort] 的桥接。把 SharedFlow 形态的音频帧 / 错误流 |
||||
|
* 转成 [EventDispatcher] 事件(`assistantAudio` / `assistantError`),让 |
||||
|
* Dart 侧可以通过 EventChannel 实时收到耳机麦上行的 16 kHz PCM。 |
||||
|
* |
||||
|
* `start` 内部调用 [JieliAssistantPort.enter](`MODE_RECORD` + |
||||
|
* `STRATEGY_DEVICE_ALWAYS_RECORDING`),耳机会持续推 OPUS,由 Port 解码成 |
||||
|
* 16 kHz/16 bit/mono/20 ms PCM。`stop` 调用 [JieliAssistantPort.exit] 并 |
||||
|
* 取消订阅。两者通过 `synchronized` 互斥,重复调用安全。 |
||||
|
*/ |
||||
|
class AssistantBridge( |
||||
|
private val port: JieliAssistantPort, |
||||
|
private val dispatcher: EventDispatcher, |
||||
|
) { |
||||
|
|
||||
|
companion object { |
||||
|
private const val TAG = "AssistantBridge" |
||||
|
} |
||||
|
|
||||
|
private val scopeJob = SupervisorJob() |
||||
|
private val scope = CoroutineScope(Dispatchers.IO + scopeJob) |
||||
|
|
||||
|
@Volatile private var audioJob: Job? = null |
||||
|
@Volatile private var errorJob: Job? = null |
||||
|
@Volatile private var running = false |
||||
|
|
||||
|
/** 调试:每秒打一次 PCM 转发统计,方便确认音频是否真正流到 Dart */ |
||||
|
private val fwdCount = AtomicLong(0) |
||||
|
@Volatile private var lastReportMs = 0L |
||||
|
|
||||
|
@Synchronized |
||||
|
fun start(): Boolean { |
||||
|
if (running) { |
||||
|
Log.w(TAG, "start: already running, ignored") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
// 先订阅 Flow,再 enter(),避免首帧落在订阅者建立之前被 SharedFlow 丢掉 |
||||
|
// (JieliAssistantPort 用 replay=0 + DROP_OLDEST) |
||||
|
audioJob = port.audioFrames.onEach { frame -> |
||||
|
fwdCount.incrementAndGet() |
||||
|
val now = System.currentTimeMillis() |
||||
|
if (now - lastReportMs >= 1000L) { |
||||
|
Log.i( |
||||
|
TAG, |
||||
|
"fwd PCM stats (last 1s): frames=${fwdCount.getAndSet(0)} bytes/frame=${frame.bytes.size} sr=${frame.sampleRate}", |
||||
|
) |
||||
|
lastReportMs = now |
||||
|
} |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "assistantAudio", |
||||
|
"encoding" to "pcm16", |
||||
|
"sampleRate" to frame.sampleRate, |
||||
|
"channels" to frame.channels, |
||||
|
"bitsPerSample" to 16, |
||||
|
"sequence" to frame.sequence, |
||||
|
"tsMs" to (frame.timestampUs / 1000L), |
||||
|
"pcm" to frame.bytes, |
||||
|
) |
||||
|
) |
||||
|
}.launchIn(scope) |
||||
|
|
||||
|
errorJob = port.errors.onEach { err -> |
||||
|
Log.w(TAG, "port.error code=${err.code} msg=${err.message}") |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "assistantError", |
||||
|
"code" to err.code, |
||||
|
"message" to err.message, |
||||
|
) |
||||
|
) |
||||
|
}.launchIn(scope) |
||||
|
|
||||
|
try { |
||||
|
port.enter(AssistantAudioFormat.PCM_S16LE_16K_MONO_20MS) |
||||
|
} catch (t: Throwable) { |
||||
|
Log.e(TAG, "start: port.enter failed: ${t.message}", t) |
||||
|
runCatching { audioJob?.cancel() } |
||||
|
runCatching { errorJob?.cancel() } |
||||
|
audioJob = null |
||||
|
errorJob = null |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "assistantError", |
||||
|
"code" to "device.assistant.enter_failed", |
||||
|
"message" to (t.message ?: t.javaClass.simpleName), |
||||
|
) |
||||
|
) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
running = true |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "assistantStart", |
||||
|
"sampleRate" to 16000, |
||||
|
"tsMs" to System.currentTimeMillis(), |
||||
|
) |
||||
|
) |
||||
|
Log.i(TAG, "start: assistant port entered, audio bridge active") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
@Synchronized |
||||
|
fun stop() { |
||||
|
if (!running) return |
||||
|
running = false |
||||
|
runCatching { audioJob?.cancel() } |
||||
|
runCatching { errorJob?.cancel() } |
||||
|
audioJob = null |
||||
|
errorJob = null |
||||
|
runCatching { port.exit() } |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "assistantEnd", |
||||
|
"tsMs" to System.currentTimeMillis(), |
||||
|
) |
||||
|
) |
||||
|
Log.i(TAG, "stop: assistant port exited, audio bridge inactive") |
||||
|
} |
||||
|
|
||||
|
fun isRunning(): Boolean = running |
||||
|
|
||||
|
fun shutdown() { |
||||
|
stop() |
||||
|
runCatching { scope.cancel() } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,276 @@ |
|||||
|
package com.jielihome.jielihome.feature.assistant |
||||
|
|
||||
|
import android.bluetooth.BluetoothDevice |
||||
|
import android.util.Log |
||||
|
import com.aiagent.device_plugin_interface.AssistantAudioCodec |
||||
|
import com.aiagent.device_plugin_interface.AssistantAudioFormat |
||||
|
import com.aiagent.device_plugin_interface.AssistantAudioFrame |
||||
|
import com.aiagent.device_plugin_interface.AssistantError |
||||
|
import com.aiagent.device_plugin_interface.AssistantPlaybackFrame |
||||
|
import com.aiagent.device_plugin_interface.DeviceAssistantPort |
||||
|
import com.jieli.bluetooth.bean.translation.AudioData |
||||
|
import com.jieli.bluetooth.bean.translation.TranslationMode |
||||
|
import com.jieli.bluetooth.constant.Constants |
||||
|
import com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl |
||||
|
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback |
||||
|
import com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback |
||||
|
import com.jielihome.jielihome.audio.LocalPlayer |
||||
|
import com.jielihome.jielihome.audio.OpusStreamDecoder |
||||
|
import com.jielihome.jielihome.core.JieliHomeServer |
||||
|
import com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi |
||||
|
import kotlinx.coroutines.channels.BufferOverflow |
||||
|
import kotlinx.coroutines.flow.Flow |
||||
|
import kotlinx.coroutines.flow.MutableSharedFlow |
||||
|
import kotlinx.coroutines.flow.asSharedFlow |
||||
|
import java.util.concurrent.atomic.AtomicLong |
||||
|
|
||||
|
/** |
||||
|
* `DeviceAssistantPort` 的杰理实现 —— **录音模式(MODE_RECORD=1)通路**版。 |
||||
|
* |
||||
|
* # 设计要点 |
||||
|
* 杰理 demo 里没有「AI 助理」这种产品形态,但其 [TranslationImpl.enterMode] + |
||||
|
* `MODE_RECORD`(=1) + `STRATEGY_DEVICE_ALWAYS_RECORDING`(=1) 能让耳机持续上推 |
||||
|
* OPUS 音频帧到 [TranslationCallback.onReceiveAudioData],完全满足 AI 助理的 |
||||
|
* 上行采集需求,我们就借这条录音模式通路: |
||||
|
* - 上行采集:耳机麦 → 耳机固件 OPUS 编码 → onReceiveAudioData → OpusStreamDecoder |
||||
|
* → 16kHz PCM → SharedFlow 抛给编排器。**mode 字段 = 1,与 demo 行为一致**。 |
||||
|
* - 下行回灌:TTS PCM 写 [LocalPlayer](AudioTrack + USAGE_MEDIA),由 Android |
||||
|
* 蓝牙路由走 A2DP 通道送回耳机扬声器。不走 RCSP 下发,避免与 A2DP 媒体音频抢通道。 |
||||
|
* |
||||
|
* # 与 [com.jielihome.jielihome.feature.translation.JieliCallTranslationPort] 的关系 |
||||
|
* 两者都走 TranslationImpl,但 mode 不同、策略不同: |
||||
|
* - CallTranslation:mode=3/6(通话翻译),依赖真实 eSCO 通话 |
||||
|
* - 本类:mode=1(录音),纯上行,不依赖任何通话事件 |
||||
|
* 两者不会同时工作(enterMode 互斥)。 |
||||
|
* |
||||
|
* # 格式 |
||||
|
* 仅放开 PCM_S16LE / 16 kHz / mono / 20 ms。 |
||||
|
* 上行 SDK 推 OPUS,由 [OpusStreamDecoder] 解码成 16 kHz PCM 推到 Flow。 |
||||
|
* 下行 PCM 直接写 AudioTrack。 |
||||
|
* |
||||
|
* # 线程安全 |
||||
|
* `enter` / `exit` / `reportPlayback` 通过 `synchronized(this)` 互斥; |
||||
|
* SharedFlow.tryEmit / AudioTrack.write 自身线程安全; |
||||
|
* SDK 回调线程不固定,所有共享状态用 @Volatile 标记。 |
||||
|
*/ |
||||
|
class JieliAssistantPort( |
||||
|
private val server: JieliHomeServer, |
||||
|
) : DeviceAssistantPort { |
||||
|
|
||||
|
companion object { |
||||
|
private const val TAG = "JieliAssistantPort" |
||||
|
private val PCM_16K_MONO_20MS = AssistantAudioFormat.PCM_S16LE_16K_MONO_20MS |
||||
|
} |
||||
|
|
||||
|
private val _audioFrames = MutableSharedFlow<AssistantAudioFrame>( |
||||
|
replay = 0, |
||||
|
extraBufferCapacity = 128, |
||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
||||
|
) |
||||
|
private val _errors = MutableSharedFlow<AssistantError>( |
||||
|
replay = 0, |
||||
|
extraBufferCapacity = 16, |
||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
||||
|
) |
||||
|
|
||||
|
@Volatile private var entered = false |
||||
|
@Volatile private var device: BluetoothDevice? = null |
||||
|
@Volatile private var decoder: OpusStreamDecoder? = null |
||||
|
@Volatile private var player: LocalPlayer? = null |
||||
|
@Volatile private var translationImpl: TranslationImpl? = null |
||||
|
@Volatile private var translationCallback: TranslationCallback? = null |
||||
|
|
||||
|
/** 调试统计:每秒打一次 SDK PCM 帧产出量 */ |
||||
|
private val upCount = AtomicLong(0) |
||||
|
@Volatile private var lastReportMs = 0L |
||||
|
/** 首帧 flag:首次 onModeChange / onReceiveAudioData / 首次 PCM 帧发射都各打一条 INFO */ |
||||
|
@Volatile private var rxFirstModeChangeLogged = false |
||||
|
@Volatile private var rxFirstAudioLogged = false |
||||
|
/** 上行帧自增序号 */ |
||||
|
private val seqGen = AtomicLong(0) |
||||
|
|
||||
|
override fun supportedSourceFormats(): Set<AssistantAudioFormat> = setOf(PCM_16K_MONO_20MS) |
||||
|
override fun supportedSinkFormats(): Set<AssistantAudioFormat> = setOf(PCM_16K_MONO_20MS) |
||||
|
|
||||
|
override val audioFrames: Flow<AssistantAudioFrame> = _audioFrames.asSharedFlow() |
||||
|
override val errors: Flow<AssistantError> = _errors.asSharedFlow() |
||||
|
|
||||
|
@Synchronized |
||||
|
override fun enter(sourceFormat: AssistantAudioFormat) { |
||||
|
require(sourceFormat == PCM_16K_MONO_20MS) { |
||||
|
"JieliAssistantPort only supports PCM_S16LE/16k/mono/20ms (got $sourceFormat)" |
||||
|
} |
||||
|
check(!entered) { "device.assistant.busy" } |
||||
|
|
||||
|
val dev = server.connectFeature.connectedDevice() |
||||
|
?: throw IllegalStateException("device.assistant.no_device: no connected device") |
||||
|
device = dev |
||||
|
|
||||
|
// 1. 启动本地 A2DP 播放器(接收 TTS PCM) |
||||
|
val lp = LocalPlayer(sampleRate = 16000, channels = 1).also { it.start() } |
||||
|
player = lp |
||||
|
|
||||
|
// 2. 启动 Opus 解码器:耳机上推的录音流是 OPUS,解码后丢回 SharedFlow。 |
||||
|
// packetSize 必须是耳机单个 OPUS 帧的真实大小(16k/16bit/20ms 约 40B), |
||||
|
// 不是 SDK 里那种"块大小"。配 200 时 OpusManager 把 5 个拼接帧当 1 帧解, |
||||
|
// 1s 只出 10 帧 PCM(应有 50),丢 80% 数据 —— 与"AI 助理 10 fps"问题完全对应。 |
||||
|
val od = OpusStreamDecoder( |
||||
|
channel = 1, |
||||
|
packetSize = 40, |
||||
|
sampleRate = 16000, |
||||
|
onPcm = { pcm -> emitUplinkFrame(pcm) }, |
||||
|
onError = { c, m -> |
||||
|
_errors.tryEmit(AssistantError( |
||||
|
code = "device.decoder_failed", |
||||
|
message = "opus: code=$c msg=${m ?: ""}", |
||||
|
)) |
||||
|
}, |
||||
|
).also { it.start() } |
||||
|
decoder = od |
||||
|
|
||||
|
// 3. 构造 TranslationImpl + 前置校验 |
||||
|
val impl = TranslationImpl(server.internalBtManager, NoOpAITranslationApi(), dev) |
||||
|
if (!impl.isInit) { |
||||
|
runCatching { impl.destroy() } |
||||
|
cleanup() |
||||
|
throw IllegalStateException("device.assistant.rcsp_not_init: RCSP not init for ${dev.address}") |
||||
|
} |
||||
|
if (!impl.isSupportTranslation) { |
||||
|
runCatching { impl.destroy() } |
||||
|
cleanup() |
||||
|
throw IllegalStateException("device.assistant.not_supported: device does not support translation") |
||||
|
} |
||||
|
|
||||
|
// 4. 注册 TranslationCallback —— 关键回调:onReceiveAudioData 拿 OPUS 帧 |
||||
|
val cb = object : TranslationCallback { |
||||
|
override fun onModeChange(d: BluetoothDevice, m: TranslationMode) { |
||||
|
if (!rxFirstModeChangeLogged) { |
||||
|
rxFirstModeChangeLogged = true |
||||
|
Log.i(TAG, "[SDK<-DEV] onModeChange FIRST addr=${d.address} mode=${m.mode} type=${m.audioType} ch=${m.channel} sr=${m.sampleRate} strategy=${m.recordingStrategy}") |
||||
|
} else { |
||||
|
Log.i(TAG, "[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} strategy=${m.recordingStrategy}") |
||||
|
} |
||||
|
if (m.mode == TranslationMode.MODE_IDLE) { |
||||
|
// 耳机主动退出 RECORD 模式(按键 / 超时 / 被抢占) |
||||
|
_errors.tryEmit(AssistantError( |
||||
|
code = "device.mode_exited", |
||||
|
message = "headset exited MODE_RECORD → MODE_IDLE", |
||||
|
)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) { |
||||
|
val payload = data.audioData |
||||
|
if (payload == null || payload.isEmpty()) { |
||||
|
Log.w(TAG, "[SDK<-DEV] onReceiveAudioData null/empty payload source=${data.source} type=${data.type}") |
||||
|
return |
||||
|
} |
||||
|
if (!rxFirstAudioLogged) { |
||||
|
rxFirstAudioLogged = true |
||||
|
Log.i(TAG, "[SDK<-DEV] onReceiveAudioData FIRST FRAME addr=${d.address} source=${data.source} type=${data.type} size=${payload.size}") |
||||
|
} |
||||
|
when (data.type) { |
||||
|
Constants.AUDIO_TYPE_PCM -> emitUplinkFrame(payload) |
||||
|
Constants.AUDIO_TYPE_OPUS -> decoder?.feedEncoded(payload) |
||||
|
else -> Log.w(TAG, "[SDK<-DEV] onReceiveAudioData unknown type=${data.type} (skip)") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onError(d: BluetoothDevice, code: Int, msg: String) { |
||||
|
Log.e(TAG, "[SDK<-DEV] TranslationCallback.onError code=$code msg=$msg") |
||||
|
_errors.tryEmit(AssistantError( |
||||
|
code = "device.translation_error", |
||||
|
message = "code=$code msg=$msg", |
||||
|
)) |
||||
|
} |
||||
|
} |
||||
|
impl.addTranslationCallback(cb) |
||||
|
translationImpl = impl |
||||
|
translationCallback = cb |
||||
|
|
||||
|
// 5. 下发 enterMode(MODE_RECORD=1, OPUS, ch=1, 16k, STRATEGY_DEVICE_ALWAYS_RECORDING) |
||||
|
// - mode=1:与 demo 录音模式对齐,耳机 RCSP 状态机进入 MODE_RECORD |
||||
|
// - STRATEGY_DEVICE_ALWAYS_RECORDING=1:耳机固件自主采集并持续上推,APP 不用手机麦 |
||||
|
val sdkMode = TranslationMode( |
||||
|
TranslationMode.MODE_RECORD, |
||||
|
Constants.AUDIO_TYPE_OPUS, |
||||
|
1, |
||||
|
16000, |
||||
|
).setRecordingStrategy(TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING) |
||||
|
Log.i(TAG, "[APP->SDK] enterMode addr=${dev.address} mode=${sdkMode.mode}(MODE_RECORD) type=${sdkMode.audioType}(OPUS) ch=${sdkMode.channel} sr=${sdkMode.sampleRate} strategy=${sdkMode.recordingStrategy}(DEVICE_ALWAYS_RECORDING)") |
||||
|
impl.enterMode(sdkMode, cb) |
||||
|
|
||||
|
entered = true |
||||
|
Log.d(TAG, "entered assistant mode (MODE_RECORD=1, DEVICE_ALWAYS_RECORDING, A2DP playback)") |
||||
|
} |
||||
|
|
||||
|
private fun emitUplinkFrame(pcm: ByteArray) { |
||||
|
upCount.incrementAndGet() |
||||
|
val now = System.currentTimeMillis() |
||||
|
if (now - lastReportMs >= 1000L) { |
||||
|
Log.d(TAG, "uplink PCM stats (last 1s): frames=${upCount.getAndSet(0)} bytes/frame=${pcm.size}") |
||||
|
lastReportMs = now |
||||
|
} |
||||
|
val frame = AssistantAudioFrame( |
||||
|
codec = AssistantAudioCodec.PCM_S16LE, |
||||
|
sampleRate = 16000, |
||||
|
channels = 1, |
||||
|
bytes = pcm, |
||||
|
sequence = seqGen.incrementAndGet(), |
||||
|
timestampUs = now * 1000L, |
||||
|
) |
||||
|
if (!_audioFrames.tryEmit(frame)) { |
||||
|
Log.w(TAG, "audioFrames buffer overflow; dropped seq=${frame.sequence}") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Synchronized |
||||
|
override fun reportPlayback(frame: AssistantPlaybackFrame) { |
||||
|
check(entered) { "device.assistant.not_active" } |
||||
|
require(frame.codec == AssistantAudioCodec.PCM_S16LE) { |
||||
|
"JieliAssistantPort only accepts PCM_S16LE for sink (got ${frame.codec})" |
||||
|
} |
||||
|
// TTS PCM 写入本地 A2DP 播放器,由系统蓝牙路由送回耳机扬声器 |
||||
|
player?.feed(frame.bytes) |
||||
|
} |
||||
|
|
||||
|
@Synchronized |
||||
|
override fun exit() { |
||||
|
if (!entered) return |
||||
|
entered = false |
||||
|
cleanup() |
||||
|
Log.d(TAG, "exited assistant mode") |
||||
|
} |
||||
|
|
||||
|
/** 释放 enterMode / decoder / player / callback;可被 enter 失败路径调用,幂等 */ |
||||
|
private fun cleanup() { |
||||
|
val impl = translationImpl |
||||
|
val cb = translationCallback |
||||
|
val addr = device?.address |
||||
|
if (impl != null) { |
||||
|
runCatching { |
||||
|
Log.i(TAG, "[APP->SDK] exitMode addr=$addr mode=${TranslationMode.MODE_RECORD}") |
||||
|
impl.exitMode(object : OnRcspActionCallback<Int> { |
||||
|
override fun onSuccess(d: BluetoothDevice?, t: Int?) { |
||||
|
Log.i(TAG, "[APP->SDK] exitMode onSuccess addr=${d?.address} t=$t") |
||||
|
} |
||||
|
override fun onError(d: BluetoothDevice?, e: com.jieli.bluetooth.bean.base.BaseError?) { |
||||
|
Log.w(TAG, "[APP->SDK] exitMode onError addr=${d?.address} code=${e?.code} msg=${e?.message}") |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
if (cb != null) runCatching { impl.removeTranslationCallback(cb) } |
||||
|
runCatching { impl.destroy() } |
||||
|
} |
||||
|
runCatching { decoder?.stop() } |
||||
|
runCatching { player?.stop() } |
||||
|
translationImpl = null |
||||
|
translationCallback = null |
||||
|
decoder = null |
||||
|
player = null |
||||
|
device = null |
||||
|
// 复位首帧标志,下次 enter 重新打点 |
||||
|
rxFirstModeChangeLogged = false |
||||
|
rxFirstAudioLogged = false |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,141 @@ |
|||||
|
package com.jielihome.jielihome.feature.record |
||||
|
|
||||
|
import android.content.Context |
||||
|
import com.jieli.bluetooth.bean.translation.AudioData |
||||
|
import com.jieli.bluetooth.bean.translation.TranslationMode |
||||
|
import com.jieli.bluetooth.constant.Constants |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jielihome.jielihome.bridge.EventDispatcher |
||||
|
import com.jielihome.jielihome.feature.ConnectFeature |
||||
|
import com.jielihome.jielihome.feature.translation.runtime.RcspTranslationRuntime |
||||
|
import java.io.File |
||||
|
|
||||
|
/** |
||||
|
* 设备录音功能。 |
||||
|
* |
||||
|
* 通过 RCSP [TranslationMode.MODE_CALL_TRANSLATION] + STRATEGY_DEVICE_ALWAYS_RECORDING |
||||
|
* 让耳机持续上推双通道 PCM: |
||||
|
* - [AudioData.SOURCE_E_SCO_UP_LINK] → streamId = "in.uplink" (本端/耳机麦克风) |
||||
|
* - [AudioData.SOURCE_E_SCO_DOWN_LINK] → streamId = "in.downlink" (对端/通话对方) |
||||
|
* |
||||
|
* 事件类型: |
||||
|
* - `deviceRecordStart` — 上行启动成功 |
||||
|
* - `deviceRecordAudio` — PCM 帧(含 streamId 区分上下行) |
||||
|
* - `deviceRecordStop` — 上行已停止 |
||||
|
* - `deviceRecordError` — 错误 |
||||
|
* |
||||
|
* 与 [com.jielihome.jielihome.feature.translation.TranslationFeature] 互斥: |
||||
|
* 两者都调用 SDK enterMode,同时只能有一个活跃。 |
||||
|
* 调用方(MethodRouter)负责在启动前 stop 另一个。 |
||||
|
*/ |
||||
|
class DeviceRecordFeature( |
||||
|
private val context: Context, |
||||
|
private val btManager: JL_BluetoothManager, |
||||
|
private val connectFeature: ConnectFeature, |
||||
|
private val dispatcher: EventDispatcher, |
||||
|
) { |
||||
|
@Volatile private var runtime: RcspTranslationRuntime? = null |
||||
|
@Volatile private var working = false |
||||
|
@Volatile private var deviceAddress: String? = null |
||||
|
|
||||
|
fun isRecording(): Boolean = working |
||||
|
|
||||
|
/** |
||||
|
* 启动设备录音上行。 |
||||
|
* |
||||
|
* @param args 可选参数: |
||||
|
* - `address` String 目标设备 MAC;省略则取当前已连设备 |
||||
|
* - `sampleRate` Int 采样率(Hz),默认 16000 |
||||
|
*/ |
||||
|
fun start(args: Map<String, Any?>): Result<Unit> { |
||||
|
if (working) return Result.failure(IllegalStateException("already recording")) |
||||
|
|
||||
|
val address = args["address"] as? String |
||||
|
val device = address?.let { connectFeature.deviceByAddress(it) } |
||||
|
?: connectFeature.connectedDevice() |
||||
|
?: return Result.failure(IllegalStateException("no connected device; pass args.address or connect first")) |
||||
|
|
||||
|
val sampleRate = (args["sampleRate"] as? Int) ?: 16000 |
||||
|
|
||||
|
val sdkMode = TranslationMode( |
||||
|
TranslationMode.MODE_CALL_TRANSLATION, |
||||
|
Constants.AUDIO_TYPE_OPUS, |
||||
|
1, |
||||
|
sampleRate, |
||||
|
).setRecordingStrategy(TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING) |
||||
|
|
||||
|
val rt = RcspTranslationRuntime( |
||||
|
btManager = btManager, |
||||
|
device = device, |
||||
|
mode = sdkMode, |
||||
|
tempDir = File(context.cacheDir, "jieli_device_record_tmp"), |
||||
|
onPcm = { source, pcm -> |
||||
|
val streamId = when (source) { |
||||
|
AudioData.SOURCE_E_SCO_UP_LINK -> "in.uplink" |
||||
|
AudioData.SOURCE_E_SCO_DOWN_LINK -> "in.downlink" |
||||
|
else -> return@RcspTranslationRuntime |
||||
|
} |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "deviceRecordAudio", |
||||
|
"address" to device.address, |
||||
|
"streamId" to streamId, |
||||
|
"sampleRate" to sampleRate, |
||||
|
"channels" to 1, |
||||
|
"bitsPerSample" to 16, |
||||
|
"tsMs" to System.currentTimeMillis(), |
||||
|
"pcm" to pcm, |
||||
|
) |
||||
|
) |
||||
|
}, |
||||
|
onError = { code, msg -> |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "deviceRecordError", |
||||
|
"address" to device.address, |
||||
|
"code" to code, |
||||
|
"message" to msg, |
||||
|
) |
||||
|
) |
||||
|
}, |
||||
|
) |
||||
|
|
||||
|
return rt.start().fold( |
||||
|
onSuccess = { |
||||
|
runtime = rt |
||||
|
working = true |
||||
|
deviceAddress = device.address |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "deviceRecordStart", |
||||
|
"address" to device.address, |
||||
|
"sampleRate" to sampleRate, |
||||
|
"tsMs" to System.currentTimeMillis(), |
||||
|
) |
||||
|
) |
||||
|
Result.success(Unit) |
||||
|
}, |
||||
|
onFailure = { err -> |
||||
|
rt.stop() |
||||
|
Result.failure(err) |
||||
|
}, |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/** 停止设备录音上行。幂等:未录音时无副作用。 */ |
||||
|
fun stop() { |
||||
|
if (!working) return |
||||
|
working = false |
||||
|
val addr = deviceAddress |
||||
|
deviceAddress = null |
||||
|
runtime?.stop() |
||||
|
runtime = null |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "deviceRecordStop", |
||||
|
"address" to addr, |
||||
|
"tsMs" to System.currentTimeMillis(), |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,151 @@ |
|||||
|
package com.jielihome.jielihome.feature.record |
||||
|
|
||||
|
import android.util.Log |
||||
|
import com.jielihome.jielihome.api.JieliEventAdapter |
||||
|
import com.jielihome.jielihome.core.JieliHomeServer |
||||
|
import kotlinx.coroutines.channels.BufferOverflow |
||||
|
import kotlinx.coroutines.flow.Flow |
||||
|
import kotlinx.coroutines.flow.MutableSharedFlow |
||||
|
import kotlinx.coroutines.flow.asSharedFlow |
||||
|
|
||||
|
/** 录音音频帧(已解码为 PCM_S16LE)。*/ |
||||
|
data class DeviceRecordFrame( |
||||
|
val address: String, |
||||
|
/** [DeviceRecordFeature] 的 "in.uplink"(本端)或 "in.downlink"(对端)*/ |
||||
|
val streamId: String, |
||||
|
val pcm: ByteArray, |
||||
|
val sampleRate: Int, |
||||
|
val channels: Int = 1, |
||||
|
val bitsPerSample: Int = 16, |
||||
|
val tsMs: Long, |
||||
|
) { |
||||
|
// ByteArray 默认 equals/hashCode 按内容比,这里改为引用比避免大帧逐字节比 |
||||
|
override fun equals(other: Any?): Boolean = this === other |
||||
|
override fun hashCode(): Int = System.identityHashCode(this) |
||||
|
} |
||||
|
|
||||
|
/** 录音错误。 */ |
||||
|
data class DeviceRecordError(val address: String?, val code: Int, val message: String?) |
||||
|
|
||||
|
/** |
||||
|
* 设备录音端口 —— 原生层编排器的直连入口。 |
||||
|
* |
||||
|
* 与 [com.jielihome.jielihome.feature.translation.JieliCallTranslationPort] 对称: |
||||
|
* - [start] 启动耳机音频上行 |
||||
|
* - [audioFrames] 是 Kotlin Flow,收集即可获取上行/下行 PCM 帧 |
||||
|
* - [stop] 停止上行 |
||||
|
* |
||||
|
* Flutter 路径(EventChannel)不受影响:同一帧会同时推给 native flow 和 Dart EventChannel。 |
||||
|
* |
||||
|
* # 典型用法 |
||||
|
* ```kotlin |
||||
|
* val port = server.deviceRecordPort |
||||
|
* val job = scope.launch { |
||||
|
* port.audioFrames.collect { frame -> |
||||
|
* // frame.streamId == "in.uplink" → 本端说话 |
||||
|
* // frame.streamId == "in.downlink" → 对端说话 |
||||
|
* writeToFile(frame.streamId, frame.pcm) |
||||
|
* } |
||||
|
* } |
||||
|
* port.start(address = device.address) |
||||
|
* // ... |
||||
|
* port.stop() |
||||
|
* job.cancel() |
||||
|
* ``` |
||||
|
* |
||||
|
* # 生命周期 |
||||
|
* Port 是 [JieliHomeServer] 的懒加载单例(`server.deviceRecordPort`), |
||||
|
* 与 server 共存亡,无需手动 [release]。若在 server 生命周期外独立使用则需调 [release]。 |
||||
|
*/ |
||||
|
class JieliDeviceRecordPort( |
||||
|
private val server: JieliHomeServer, |
||||
|
) { |
||||
|
companion object { |
||||
|
private const val TAG = "JieliDeviceRecordPort" |
||||
|
|
||||
|
const val STREAM_UPLINK = "in.uplink" |
||||
|
const val STREAM_DOWNLINK = "in.downlink" |
||||
|
} |
||||
|
|
||||
|
private val _audioFrames = MutableSharedFlow<DeviceRecordFrame>( |
||||
|
replay = 0, |
||||
|
extraBufferCapacity = 256, |
||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
||||
|
) |
||||
|
private val _errors = MutableSharedFlow<DeviceRecordError>( |
||||
|
replay = 0, |
||||
|
extraBufferCapacity = 16, |
||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
||||
|
) |
||||
|
|
||||
|
/** 录音 PCM 帧流。每帧包含 streamId 区分上行/下行。 */ |
||||
|
val audioFrames: Flow<DeviceRecordFrame> = _audioFrames.asSharedFlow() |
||||
|
|
||||
|
/** 错误流。 */ |
||||
|
val errors: Flow<DeviceRecordError> = _errors.asSharedFlow() |
||||
|
|
||||
|
val isRecording: Boolean get() = server.deviceRecordFeature.isRecording() |
||||
|
|
||||
|
private val eventListener = object : JieliEventAdapter() { |
||||
|
override fun onDeviceRecordAudio(payload: Map<String, Any?>) { |
||||
|
val address = payload["address"] as? String ?: return |
||||
|
val streamId = payload["streamId"] as? String ?: return |
||||
|
val pcm = payload["pcm"] as? ByteArray ?: return |
||||
|
val sampleRate = (payload["sampleRate"] as? Number)?.toInt() ?: 16000 |
||||
|
val channels = (payload["channels"] as? Number)?.toInt() ?: 1 |
||||
|
val bitsPerSample = (payload["bitsPerSample"] as? Number)?.toInt() ?: 16 |
||||
|
val tsMs = (payload["tsMs"] as? Number)?.toLong() ?: System.currentTimeMillis() |
||||
|
val frame = DeviceRecordFrame( |
||||
|
address = address, |
||||
|
streamId = streamId, |
||||
|
pcm = pcm, |
||||
|
sampleRate = sampleRate, |
||||
|
channels = channels, |
||||
|
bitsPerSample = bitsPerSample, |
||||
|
tsMs = tsMs, |
||||
|
) |
||||
|
if (!_audioFrames.tryEmit(frame)) { |
||||
|
Log.w(TAG, "audioFrames buffer overflow; dropped streamId=$streamId") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onDeviceRecordError(payload: Map<String, Any?>) { |
||||
|
_errors.tryEmit( |
||||
|
DeviceRecordError( |
||||
|
address = payload["address"] as? String, |
||||
|
code = (payload["code"] as? Number)?.toInt() ?: 0, |
||||
|
message = payload["message"] as? String, |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
init { |
||||
|
server.addEventListener(eventListener) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 启动设备录音上行。若翻译功能正在运行,会先自动停止。 |
||||
|
* |
||||
|
* @param address 目标设备 MAC;null 取当前已连设备 |
||||
|
* @param sampleRate 采样率(Hz),默认 16000 |
||||
|
*/ |
||||
|
fun start(address: String? = null, sampleRate: Int = 16000): Result<Unit> { |
||||
|
if (server.translationFeature.isWorking()) server.translationFeature.stop() |
||||
|
val args = buildMap<String, Any?> { |
||||
|
if (address != null) put("address", address) |
||||
|
put("sampleRate", sampleRate) |
||||
|
} |
||||
|
return server.deviceRecordFeature.start(args) |
||||
|
} |
||||
|
|
||||
|
/** 停止设备录音上行。幂等。 */ |
||||
|
fun stop() { |
||||
|
server.deviceRecordFeature.stop() |
||||
|
} |
||||
|
|
||||
|
/** 释放事件监听。Port 作为 server 单例使用时无需调用;独立使用时须在不再需要时调用。 */ |
||||
|
fun release() { |
||||
|
server.removeEventListener(eventListener) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,280 @@ |
|||||
|
package com.jielihome.jielihome.integration |
||||
|
|
||||
|
import android.util.Log |
||||
|
import com.aiagent.device_plugin_interface.DeviceErrorCode |
||||
|
import com.aiagent.device_plugin_interface.DeviceException |
||||
|
import com.aiagent.device_plugin_interface.DeviceOtaPort |
||||
|
import com.aiagent.device_plugin_interface.DeviceOtaProgress |
||||
|
import com.aiagent.device_plugin_interface.DeviceOtaRequest |
||||
|
import com.aiagent.device_plugin_interface.DeviceOtaState |
||||
|
import com.jielihome.jielihome.api.JieliEventAdapter |
||||
|
import com.jielihome.jielihome.api.JieliEventListener |
||||
|
import com.jielihome.jielihome.core.JieliHomeServer |
||||
|
import kotlinx.coroutines.channels.BufferOverflow |
||||
|
import kotlinx.coroutines.flow.Flow |
||||
|
import kotlinx.coroutines.flow.MutableSharedFlow |
||||
|
import kotlinx.coroutines.flow.asSharedFlow |
||||
|
import java.io.File |
||||
|
import java.util.concurrent.atomic.AtomicBoolean |
||||
|
|
||||
|
/** |
||||
|
* 杰理 [DeviceOtaPort] 实现 —— 桥接 [com.jielihome.jielihome.feature.ota.OtaFeature]。 |
||||
|
* |
||||
|
* 不重写 OtaFeature 的状态机,只把它派的 `otaState` / `otaError` 事件翻译成 |
||||
|
* [DeviceOtaProgress] 推到 [progressStream]。 |
||||
|
* |
||||
|
* 请求子类支持矩阵: |
||||
|
* - [DeviceOtaRequest.File] ✓ 直接 path 传给 OtaFeature |
||||
|
* - [DeviceOtaRequest.Bytes] ✓ 落盘到 cacheDir/ota_<ts>.ufw 再走 path |
||||
|
* - [DeviceOtaRequest.Url] ✗ 杰理 SDK 无 HTTP 通道,抛 NOT_SUPPORTED |
||||
|
* - [DeviceOtaRequest.Vendor] ✓ payload['filePath'] + payload['fileFlag'] 透传 |
||||
|
* |
||||
|
* 端口生命周期: |
||||
|
* - session disconnect 时由 [JieliNativeDeviceSession] 调 [shutdown] —— 主动 cancel + |
||||
|
* 补一帧 FAILED(disconnected_remote) 让上层取消订阅 / 跳出锁定 UI。 |
||||
|
*/ |
||||
|
class JieliOtaPort internal constructor( |
||||
|
private val server: JieliHomeServer, |
||||
|
private val deviceId: String, |
||||
|
private val cacheDir: File, |
||||
|
) : DeviceOtaPort { |
||||
|
|
||||
|
companion object { |
||||
|
private const val TAG = "JieliOtaPort" |
||||
|
} |
||||
|
|
||||
|
private val _running = AtomicBoolean(false) |
||||
|
@Volatile private var _totalBytes: Long = 0 |
||||
|
@Volatile private var _tempFile: File? = null |
||||
|
@Volatile private var _disposed = false |
||||
|
|
||||
|
private val _progress = MutableSharedFlow<DeviceOtaProgress>( |
||||
|
replay = 1, |
||||
|
extraBufferCapacity = 32, |
||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
||||
|
) |
||||
|
|
||||
|
override val progressStream: Flow<DeviceOtaProgress> = _progress.asSharedFlow() |
||||
|
|
||||
|
override val isRunning: Boolean get() = _running.get() || server.otaFeature.isRunning() |
||||
|
|
||||
|
private val listener: JieliEventListener = object : JieliEventAdapter() { |
||||
|
override fun onOtaState(payload: Map<String, Any?>) { |
||||
|
val stateName = payload["state"] as? String ?: return |
||||
|
val state = parseState(stateName) ?: return |
||||
|
val sent = (payload["sent"] as? Number)?.toLong() ?: 0L |
||||
|
val total = (payload["total"] as? Number)?.toLong() ?: _totalBytes |
||||
|
val percent = (payload["percent"] as? Number)?.toInt() ?: -1 |
||||
|
val tsMs = (payload["tsMs"] as? Number)?.toLong() ?: System.currentTimeMillis() |
||||
|
emit(DeviceOtaProgress( |
||||
|
state = state, |
||||
|
sentBytes = sent, |
||||
|
totalBytes = total, |
||||
|
percent = percent, |
||||
|
tsMs = tsMs, |
||||
|
)) |
||||
|
if (state == DeviceOtaState.DONE || |
||||
|
state == DeviceOtaState.CANCELLED || |
||||
|
state == DeviceOtaState.FAILED) { |
||||
|
finalize() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onOtaError(payload: Map<String, Any?>) { |
||||
|
// OtaFeature 用整数 code;映射到 device.* 命名空间。 |
||||
|
val rawCode = (payload["code"] as? Number)?.toInt() ?: -1 |
||||
|
val msg = payload["message"] as? String |
||||
|
val mapped = mapErrorCode(rawCode) |
||||
|
emit(DeviceOtaProgress( |
||||
|
state = DeviceOtaState.FAILED, |
||||
|
sentBytes = -1, |
||||
|
totalBytes = _totalBytes, |
||||
|
percent = -1, |
||||
|
tsMs = System.currentTimeMillis(), |
||||
|
errorCode = mapped, |
||||
|
errorMessage = msg, |
||||
|
)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
init { |
||||
|
server.addEventListener(listener) |
||||
|
} |
||||
|
|
||||
|
// ─── DeviceOtaPort ──────────────────────────────────────────────────── |
||||
|
|
||||
|
@Synchronized |
||||
|
override fun start(request: DeviceOtaRequest) { |
||||
|
if (_disposed) throw DeviceException(DeviceErrorCode.NO_ACTIVE_SESSION) |
||||
|
if (!_running.compareAndSet(false, true) || server.otaFeature.isRunning()) { |
||||
|
_running.set(server.otaFeature.isRunning() || _running.get()) |
||||
|
throw DeviceException(DeviceErrorCode.OTA_BUSY, "ota already running") |
||||
|
} |
||||
|
val (path, fileFlag) = try { |
||||
|
resolveFirmware(request) |
||||
|
} catch (e: DeviceException) { |
||||
|
_running.set(false) |
||||
|
throw e |
||||
|
} catch (t: Throwable) { |
||||
|
_running.set(false) |
||||
|
throw DeviceException(DeviceErrorCode.OTA_FILE_INVALID, t.message ?: "firmware error", t) |
||||
|
} |
||||
|
_totalBytes = File(path).length().coerceAtLeast(0L) |
||||
|
|
||||
|
// 立即派一帧 INQUIRING,UI 不必等 OtaFeature 第一次回调。 |
||||
|
emit(DeviceOtaProgress( |
||||
|
state = DeviceOtaState.INQUIRING, |
||||
|
sentBytes = 0, |
||||
|
totalBytes = _totalBytes, |
||||
|
percent = 0, |
||||
|
tsMs = System.currentTimeMillis(), |
||||
|
)) |
||||
|
|
||||
|
try { |
||||
|
server.otaFeature.start( |
||||
|
address = deviceId, |
||||
|
firmwareFilePath = path, |
||||
|
blockSize = request.blockSize ?: 512, |
||||
|
fileFlagBytes = fileFlag, |
||||
|
) |
||||
|
} catch (t: Throwable) { |
||||
|
_running.set(false) |
||||
|
cleanupTemp() |
||||
|
throw DeviceException(DeviceErrorCode.OTA_TRANSFER_FAILED, t.message, t) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun cancel() { |
||||
|
if (!_running.get() && !server.otaFeature.isRunning()) return |
||||
|
server.otaFeature.cancel() |
||||
|
// 状态变更通过 onOtaState(CANCELLED) 回到 finalize。 |
||||
|
} |
||||
|
|
||||
|
/** 由 [JieliNativeDeviceSession] 在 disconnect 时调用:补 FAILED 帧并解订阅。 */ |
||||
|
fun shutdown(reason: String = DeviceErrorCode.DISCONNECTED_REMOTE) { |
||||
|
if (_disposed) return |
||||
|
_disposed = true |
||||
|
if (_running.get()) { |
||||
|
runCatching { server.otaFeature.cancel() } |
||||
|
emit(DeviceOtaProgress( |
||||
|
state = DeviceOtaState.FAILED, |
||||
|
sentBytes = -1, |
||||
|
totalBytes = _totalBytes, |
||||
|
percent = -1, |
||||
|
tsMs = System.currentTimeMillis(), |
||||
|
errorCode = reason, |
||||
|
errorMessage = "session terminated during ota", |
||||
|
)) |
||||
|
} |
||||
|
runCatching { server.removeEventListener(listener) } |
||||
|
cleanupTemp() |
||||
|
_running.set(false) |
||||
|
} |
||||
|
|
||||
|
// ─── 内部 ────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
private fun resolveFirmware(req: DeviceOtaRequest): Pair<String, ByteArray> { |
||||
|
val flag = ByteArray(0) // OtaFeature 默认 fileFlag |
||||
|
return when (req) { |
||||
|
is DeviceOtaRequest.File -> { |
||||
|
val f = File(req.filePath) |
||||
|
if (!f.isFile || f.length() <= 0) { |
||||
|
throw DeviceException( |
||||
|
DeviceErrorCode.OTA_FILE_INVALID, |
||||
|
"firmware file invalid: ${req.filePath}", |
||||
|
) |
||||
|
} |
||||
|
req.filePath to flag |
||||
|
} |
||||
|
|
||||
|
is DeviceOtaRequest.Bytes -> { |
||||
|
if (req.bytes.isEmpty()) { |
||||
|
throw DeviceException( |
||||
|
DeviceErrorCode.OTA_FILE_INVALID, |
||||
|
"ota bytes empty", |
||||
|
) |
||||
|
} |
||||
|
if (!cacheDir.isDirectory && !cacheDir.mkdirs()) { |
||||
|
throw DeviceException( |
||||
|
DeviceErrorCode.OTA_FILE_INVALID, |
||||
|
"cannot create ota cache dir: ${cacheDir.absolutePath}", |
||||
|
) |
||||
|
} |
||||
|
val tmp = File(cacheDir, "ota_${System.currentTimeMillis()}.ufw") |
||||
|
tmp.writeBytes(req.bytes) |
||||
|
_tempFile = tmp |
||||
|
tmp.absolutePath to flag |
||||
|
} |
||||
|
|
||||
|
is DeviceOtaRequest.Url -> { |
||||
|
throw DeviceException( |
||||
|
DeviceErrorCode.NOT_SUPPORTED, |
||||
|
"jieli ota does not support remote url; download in app and use File request", |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
is DeviceOtaRequest.Vendor -> { |
||||
|
if (req.vendorKey != "jieli") { |
||||
|
throw DeviceException( |
||||
|
DeviceErrorCode.INVALID_ARGUMENT, |
||||
|
"vendor mismatch: expected 'jieli', got '${req.vendorKey}'", |
||||
|
) |
||||
|
} |
||||
|
val path = req.payload["filePath"] as? String |
||||
|
?: throw DeviceException( |
||||
|
DeviceErrorCode.INVALID_ARGUMENT, |
||||
|
"jieli ota.vendor: payload['filePath'] required", |
||||
|
) |
||||
|
val f = File(path) |
||||
|
if (!f.isFile || f.length() <= 0) { |
||||
|
throw DeviceException( |
||||
|
DeviceErrorCode.OTA_FILE_INVALID, |
||||
|
"firmware file invalid: $path", |
||||
|
) |
||||
|
} |
||||
|
@Suppress("UNCHECKED_CAST") |
||||
|
val customFlag = (req.payload["fileFlag"] as? ByteArray) |
||||
|
?: (req.payload["fileFlag"] as? List<Number>)?.map { it.toByte() }?.toByteArray() |
||||
|
?: flag |
||||
|
path to customFlag |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun finalize() { |
||||
|
_running.set(false) |
||||
|
cleanupTemp() |
||||
|
} |
||||
|
|
||||
|
private fun cleanupTemp() { |
||||
|
_tempFile?.let { runCatching { it.delete() } } |
||||
|
_tempFile = null |
||||
|
} |
||||
|
|
||||
|
private fun emit(p: DeviceOtaProgress) { |
||||
|
if (!_progress.tryEmit(p)) Log.w(TAG, "progress buffer full; dropped ${p.state}") |
||||
|
} |
||||
|
|
||||
|
private fun parseState(name: String): DeviceOtaState? = when (name) { |
||||
|
"IDLE" -> DeviceOtaState.IDLE |
||||
|
"INQUIRING" -> DeviceOtaState.INQUIRING |
||||
|
"NOTIFYING_SIZE" -> DeviceOtaState.NOTIFYING_SIZE |
||||
|
"ENTERING" -> DeviceOtaState.ENTERING |
||||
|
"TRANSFERRING" -> DeviceOtaState.TRANSFERRING |
||||
|
"VERIFYING" -> DeviceOtaState.VERIFYING |
||||
|
"REBOOTING" -> DeviceOtaState.REBOOTING |
||||
|
"DONE" -> DeviceOtaState.DONE |
||||
|
"FAILED" -> DeviceOtaState.FAILED |
||||
|
"CANCELLED" -> DeviceOtaState.CANCELLED |
||||
|
else -> null |
||||
|
} |
||||
|
|
||||
|
/** OtaFeature 的负数错误码映射到设备域命名空间。 */ |
||||
|
private fun mapErrorCode(raw: Int): String = when (raw) { |
||||
|
-1 -> DeviceErrorCode.OTA_BUSY |
||||
|
-2 -> DeviceErrorCode.NO_ACTIVE_SESSION |
||||
|
-3 -> DeviceErrorCode.OTA_FILE_INVALID |
||||
|
-100 -> DeviceErrorCode.OTA_TRANSFER_FAILED |
||||
|
else -> DeviceErrorCode.OTA_TRANSFER_FAILED |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,149 @@ |
|||||
|
package com.aiagent.device_plugin_interface |
||||
|
|
||||
|
import kotlinx.coroutines.flow.Flow |
||||
|
|
||||
|
/** |
||||
|
* AI 助理场景的音频载荷编码。 |
||||
|
* |
||||
|
* 与 [CallAudioCodec] 并列但语义独立 —— AI 助理是单向对话(用户麦 → AI → 耳机扬声器), |
||||
|
* 没有通话翻译的 UPLINK/DOWNLINK 概念。 |
||||
|
*/ |
||||
|
enum class AssistantAudioCodec { OPUS, PCM_S16LE } |
||||
|
|
||||
|
/** AI 助理场景的音频格式描述。 */ |
||||
|
data class AssistantAudioFormat( |
||||
|
val codec: AssistantAudioCodec, |
||||
|
val sampleRate: Int, |
||||
|
val channels: Int, |
||||
|
val frameMs: Int, |
||||
|
) { |
||||
|
companion object { |
||||
|
/** 16 kHz / mono / 20 ms 一帧 OPUS(耳机原生格式) */ |
||||
|
val OPUS_16K_MONO_20MS = AssistantAudioFormat(AssistantAudioCodec.OPUS, 16000, 1, 20) |
||||
|
|
||||
|
/** 16 kHz / mono / 20 ms 一帧 PCM_S16LE = 640 字节 */ |
||||
|
val PCM_S16LE_16K_MONO_20MS = AssistantAudioFormat(AssistantAudioCodec.PCM_S16LE, 16000, 1, 20) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* device → 编排器:一帧用户麦克风音频(单路上行)。 |
||||
|
* |
||||
|
* 与 [CallAudioFrame] 的区别:**无 leg 字段**。AI 助理场景只有"用户说话"一路输入, |
||||
|
* 不存在"远端通话音"的概念。 |
||||
|
*/ |
||||
|
data class AssistantAudioFrame( |
||||
|
val codec: AssistantAudioCodec, |
||||
|
val sampleRate: Int, |
||||
|
val channels: Int, |
||||
|
val bytes: ByteArray, |
||||
|
/** 单调递增帧序号;丢帧后**不得**回填。 */ |
||||
|
val sequence: Long, |
||||
|
/** 单调递增的捕获时间戳(微秒)。 */ |
||||
|
val timestampUs: Long, |
||||
|
) { |
||||
|
override fun equals(other: Any?): Boolean { |
||||
|
if (this === other) return true |
||||
|
if (other !is AssistantAudioFrame) return false |
||||
|
return sequence == other.sequence && bytes.contentEquals(other.bytes) |
||||
|
} |
||||
|
override fun hashCode(): Int { |
||||
|
var r = sequence.hashCode() |
||||
|
r = 31 * r + bytes.contentHashCode() |
||||
|
return r |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 编排器 → device:把 AI 的 TTS 音频回灌给耳机扬声器播放。 |
||||
|
* |
||||
|
* 与 [TranslatedAudioFrame] 的区别:**无 leg 字段**。AI 助理只有一个回放方向 |
||||
|
* (AI 回复 → 戴耳机的用户),不存在"回灌给对端"的概念。 |
||||
|
* |
||||
|
* @param isFinal 本帧是否为当前 utterance 的最后一帧。device 端可能据此决定是否 |
||||
|
* 触发整段编码 / 一次性下发(例如 jieli RCSP 场景下按"一段 = 一个 AudioData"下发)。 |
||||
|
*/ |
||||
|
data class AssistantPlaybackFrame( |
||||
|
val codec: AssistantAudioCodec, |
||||
|
val sampleRate: Int, |
||||
|
val channels: Int, |
||||
|
val bytes: ByteArray, |
||||
|
val isFinal: Boolean = false, |
||||
|
) { |
||||
|
override fun equals(other: Any?): Boolean { |
||||
|
if (this === other) return true |
||||
|
if (other !is AssistantPlaybackFrame) return false |
||||
|
return codec == other.codec && bytes.contentEquals(other.bytes) |
||||
|
} |
||||
|
override fun hashCode(): Int { |
||||
|
var r = codec.hashCode() |
||||
|
r = 31 * r + bytes.contentHashCode() |
||||
|
return r |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** AI 助理场景下由 device 抛出的错误(不致命:编排器决定是否 stop)。 */ |
||||
|
data class AssistantError( |
||||
|
val code: String, |
||||
|
val message: String, |
||||
|
) |
||||
|
|
||||
|
/** |
||||
|
* AI 助理设备能力端口(device 侧)。 |
||||
|
* |
||||
|
* 由设备厂商插件按需实现(例如杰理基于 RCSP `MODE_CALL_TRANSLATION` 的 `UP_LINK` 上行 + |
||||
|
* `OUT_UPLINK` 回灌落地一份 `JieliAssistantPort`)。`assistant_server` 编排器统一通过 |
||||
|
* 本接口拿用户麦克风 PCM、回灌 AI TTS,不依赖具体 vendor。 |
||||
|
* |
||||
|
* 与 [DeviceCallTranslationPort] 并列但**语义完全独立**: |
||||
|
* - 不存在 leg 概念(通话翻译特有的上下行) |
||||
|
* - 上行流只有一路(用户麦) |
||||
|
* - 下行回灌只有一路(耳机扬声器) |
||||
|
* |
||||
|
* 生命周期: |
||||
|
* ``` |
||||
|
* idle → enter(format) → active → exit() → idle |
||||
|
* ``` |
||||
|
* |
||||
|
* 铁律: |
||||
|
* - 同一设备同一时刻**至多一个** active 端口;重复 `enter` 抛 |
||||
|
* `IllegalStateException("device.assistant.busy")`; |
||||
|
* - 协商格式([enter] 入参)必须出自 [supportedSourceFormats] 列出的范围; |
||||
|
* - [reportPlayback] 的 codec/sampleRate/channels 必须落在 [supportedSinkFormats] 内; |
||||
|
* - [exit] 后 [audioFrames]/[errors] 仍可订阅,但不再产新事件。 |
||||
|
*/ |
||||
|
interface DeviceAssistantPort { |
||||
|
/** 该端口能向上派发的用户麦克风音频格式集合。 */ |
||||
|
fun supportedSourceFormats(): Set<AssistantAudioFormat> |
||||
|
|
||||
|
/** 该端口能接受的 TTS 回灌格式集合。 */ |
||||
|
fun supportedSinkFormats(): Set<AssistantAudioFormat> |
||||
|
|
||||
|
/** |
||||
|
* 进入 AI 助理模式。 |
||||
|
* |
||||
|
* @param sourceFormat 协商好的源音频格式(必须 ∈ [supportedSourceFormats])。 |
||||
|
* device 侧据此决定是否做 OPUS↔PCM 转换。 |
||||
|
*/ |
||||
|
fun enter(sourceFormat: AssistantAudioFormat) |
||||
|
|
||||
|
/** |
||||
|
* 用户麦克风上行音频帧流。`enter` 之后开始派发,`exit` 后停止。 |
||||
|
* 必须是 broadcast / replayable 不变的实现,编排器多次订阅安全。 |
||||
|
*/ |
||||
|
val audioFrames: Flow<AssistantAudioFrame> |
||||
|
|
||||
|
/** |
||||
|
* 把 AI 的 TTS 音频回灌给耳机扬声器。 |
||||
|
* |
||||
|
* @throws IllegalArgumentException codec 不在 [supportedSinkFormats] 内 |
||||
|
* @throws IllegalStateException 当前未在 active 状态 |
||||
|
*/ |
||||
|
fun reportPlayback(frame: AssistantPlaybackFrame) |
||||
|
|
||||
|
/** 退出 AI 助理模式,归还设备 RCSP / 音频通道。 */ |
||||
|
fun exit() |
||||
|
|
||||
|
/** 错误流。**不致命**——编排器自行决定是否 [exit]。 */ |
||||
|
val errors: Flow<AssistantError> |
||||
|
} |
||||
@ -0,0 +1,170 @@ |
|||||
|
package com.aiagent.device_plugin_interface |
||||
|
|
||||
|
import kotlinx.coroutines.flow.Flow |
||||
|
|
||||
|
/** |
||||
|
* OTA 升级请求 —— 多态请求层级(与 Dart [DeviceOtaRequest] 对齐)。 |
||||
|
* |
||||
|
* 业务层只构造其中一种子类;插件实现按 `when` 分派。 |
||||
|
* 厂商不支持的子类一律抛 [DeviceException]([DeviceErrorCode.NOT_SUPPORTED]), |
||||
|
* **禁止**静默退化(业务侧才有"是否回退到本地下载"的语义)。 |
||||
|
*/ |
||||
|
sealed class DeviceOtaRequest { |
||||
|
/** 单块大小。null = 走厂商默认。 */ |
||||
|
abstract val blockSize: Int? |
||||
|
|
||||
|
/** 整段 OTA 的超时;null = 不限。 */ |
||||
|
abstract val timeoutMs: Long? |
||||
|
|
||||
|
/** 本地文件:app 自己下载好后放在沙盒里。 */ |
||||
|
data class File( |
||||
|
val filePath: String, |
||||
|
override val blockSize: Int? = null, |
||||
|
override val timeoutMs: Long? = null, |
||||
|
) : DeviceOtaRequest() |
||||
|
|
||||
|
/** 内存字节流:固件已经在内存里。 */ |
||||
|
data class Bytes( |
||||
|
val bytes: ByteArray, |
||||
|
override val blockSize: Int? = null, |
||||
|
override val timeoutMs: Long? = null, |
||||
|
) : DeviceOtaRequest() { |
||||
|
override fun equals(other: Any?): Boolean = |
||||
|
this === other || (other is Bytes && bytes.contentEquals(other.bytes)) |
||||
|
override fun hashCode(): Int = bytes.contentHashCode() |
||||
|
} |
||||
|
|
||||
|
/** 远程 URL:让 native 自己下载;不支持时抛 NOT_SUPPORTED 由调用方退回。 */ |
||||
|
data class Url( |
||||
|
val url: String, |
||||
|
val headers: Map<String, String> = emptyMap(), |
||||
|
override val blockSize: Int? = null, |
||||
|
override val timeoutMs: Long? = null, |
||||
|
) : DeviceOtaRequest() |
||||
|
|
||||
|
/** 厂商扩展:差分包 / 双备份 / fileFlag 等私有参数。 */ |
||||
|
data class Vendor( |
||||
|
val vendorKey: String, |
||||
|
val payload: Map<String, Any?>, |
||||
|
override val blockSize: Int? = null, |
||||
|
override val timeoutMs: Long? = null, |
||||
|
) : DeviceOtaRequest() |
||||
|
|
||||
|
companion object { |
||||
|
/** |
||||
|
* 从 method channel map 反序列化(kind 字段做 tag)。 |
||||
|
* 不识别的 kind 抛 [DeviceException]([DeviceErrorCode.INVALID_ARGUMENT])。 |
||||
|
*/ |
||||
|
@Suppress("UNCHECKED_CAST") |
||||
|
fun fromMap(map: Map<*, *>): DeviceOtaRequest { |
||||
|
val kind = map["kind"] as? String |
||||
|
?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota: kind required") |
||||
|
val blockSize = (map["blockSize"] as? Number)?.toInt() |
||||
|
val timeoutMs = (map["timeoutMs"] as? Number)?.toLong() |
||||
|
return when (kind) { |
||||
|
"file" -> File( |
||||
|
filePath = map["filePath"] as? String |
||||
|
?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.file: filePath required"), |
||||
|
blockSize = blockSize, |
||||
|
timeoutMs = timeoutMs, |
||||
|
) |
||||
|
"bytes" -> Bytes( |
||||
|
bytes = (map["bytes"] as? ByteArray) |
||||
|
?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.bytes: bytes required"), |
||||
|
blockSize = blockSize, |
||||
|
timeoutMs = timeoutMs, |
||||
|
) |
||||
|
"url" -> Url( |
||||
|
url = map["url"] as? String |
||||
|
?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.url: url required"), |
||||
|
headers = (map["headers"] as? Map<String, String>) ?: emptyMap(), |
||||
|
blockSize = blockSize, |
||||
|
timeoutMs = timeoutMs, |
||||
|
) |
||||
|
"vendor" -> Vendor( |
||||
|
vendorKey = map["vendorKey"] as? String |
||||
|
?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.vendor: vendorKey required"), |
||||
|
payload = (map["payload"] as? Map<String, Any?>) ?: emptyMap(), |
||||
|
blockSize = blockSize, |
||||
|
timeoutMs = timeoutMs, |
||||
|
) |
||||
|
else -> throw DeviceException( |
||||
|
DeviceErrorCode.INVALID_ARGUMENT, |
||||
|
"ota: unknown kind \"$kind\"", |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** OTA 全局状态机(与 Dart [DeviceOtaState] 一一对应)。 */ |
||||
|
enum class DeviceOtaState { |
||||
|
IDLE, |
||||
|
/** 容器层下载远程固件中(Url 请求专属,厂商插件看不到)。 */ |
||||
|
DOWNLOADING, |
||||
|
INQUIRING, |
||||
|
NOTIFYING_SIZE, |
||||
|
ENTERING, |
||||
|
TRANSFERRING, |
||||
|
VERIFYING, |
||||
|
REBOOTING, |
||||
|
DONE, |
||||
|
FAILED, |
||||
|
CANCELLED, |
||||
|
} |
||||
|
|
||||
|
/** OTA 进度事件。 */ |
||||
|
data class DeviceOtaProgress( |
||||
|
val state: DeviceOtaState, |
||||
|
val sentBytes: Long, |
||||
|
val totalBytes: Long, |
||||
|
/** 0..100;未知或非 transferring 阶段可为 -1。 */ |
||||
|
val percent: Int, |
||||
|
val tsMs: Long, |
||||
|
/** 终态时携带错误码(state ∈ {FAILED});其它阶段为 null。 */ |
||||
|
val errorCode: String? = null, |
||||
|
val errorMessage: String? = null, |
||||
|
) { |
||||
|
fun toMap(): Map<String, Any?> = mapOf( |
||||
|
"state" to state.name, |
||||
|
"sentBytes" to sentBytes, |
||||
|
"totalBytes" to totalBytes, |
||||
|
"percent" to percent, |
||||
|
"tsMs" to tsMs, |
||||
|
"errorCode" to errorCode, |
||||
|
"errorMessage" to errorMessage, |
||||
|
) |
||||
|
|
||||
|
val isTerminal: Boolean |
||||
|
get() = state == DeviceOtaState.DONE || |
||||
|
state == DeviceOtaState.FAILED || |
||||
|
state == DeviceOtaState.CANCELLED |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* OTA 端口(同 [DeviceCallTranslationPort] 的设计风格)。 |
||||
|
* |
||||
|
* 单设备 OTA 互斥:同时只能跑一个 [start],重复 start 抛 |
||||
|
* [DeviceException]([DeviceErrorCode.OTA_BUSY])。 |
||||
|
* |
||||
|
* 生命周期与铁律: |
||||
|
* 1. `start` 之后 [progressStream] 必有终态(DONE/FAILED/CANCELLED),不得悬挂; |
||||
|
* 2. 设备掉线时端口须主动派 FAILED 收尾; |
||||
|
* 3. `cancel` 后短时间内(≤ 5s)须收到 CANCELLED;超时强制收尾; |
||||
|
* 4. **不**要求支持断点续传,但若实现了须在 SDK 内部完成,对外只露统一进度。 |
||||
|
*/ |
||||
|
interface DeviceOtaPort { |
||||
|
fun start(request: DeviceOtaRequest) |
||||
|
fun cancel() |
||||
|
val isRunning: Boolean |
||||
|
val progressStream: Flow<DeviceOtaProgress> |
||||
|
} |
||||
|
|
||||
|
/** OTA 错误码补充(与 Dart 镜像)。 */ |
||||
|
object OtaErrorCode { |
||||
|
const val BUSY = DeviceErrorCode.OTA_BUSY |
||||
|
const val INQUIRE_REFUSED = DeviceErrorCode.OTA_INQUIRE_REFUSED |
||||
|
const val VERIFY_FAILED = DeviceErrorCode.OTA_VERIFY_FAILED |
||||
|
const val FILE_INVALID = DeviceErrorCode.OTA_FILE_INVALID |
||||
|
const val TRANSFER_FAILED = DeviceErrorCode.OTA_TRANSFER_FAILED |
||||
|
} |
||||
@ -0,0 +1,172 @@ |
|||||
|
import 'dart:async'; |
||||
|
|
||||
|
import 'package:flutter/foundation.dart'; |
||||
|
|
||||
|
/// OTA 升级请求 —— 多态请求层级。 |
||||
|
/// |
||||
|
/// 业务层只构造其中一种子类;插件实现按 `runtimeType` 分派。 |
||||
|
/// 厂商不支持的子类一律抛 `DeviceException(DeviceErrorCode.notSupported)`, |
||||
|
/// **禁止**静默退化(业务侧才有"是否回退到本地下载"的语义)。 |
||||
|
@immutable |
||||
|
sealed class DeviceOtaRequest { |
||||
|
const DeviceOtaRequest({this.blockSize, this.timeout}); |
||||
|
|
||||
|
/// 单块大小。null = 走厂商默认(杰理 RCSP 当前 512)。 |
||||
|
final int? blockSize; |
||||
|
|
||||
|
/// 整段 OTA 的超时;null = 不限。 |
||||
|
final Duration? timeout; |
||||
|
} |
||||
|
|
||||
|
/// 本地文件:app 自己下载好后放在沙盒里。 |
||||
|
class DeviceOtaFileRequest extends DeviceOtaRequest { |
||||
|
const DeviceOtaFileRequest({ |
||||
|
required this.filePath, |
||||
|
super.blockSize, |
||||
|
super.timeout, |
||||
|
}); |
||||
|
|
||||
|
final String filePath; |
||||
|
} |
||||
|
|
||||
|
/// 内存字节流:固件已经在 dart 内存里。 |
||||
|
/// |
||||
|
/// 实现方可以选择直接喂给 SDK,也可以落盘后走文件路径——通过 method |
||||
|
/// channel 传字节较大固件不经济,建议 < 4MB 用本子类,更大的用文件请求。 |
||||
|
class DeviceOtaBytesRequest extends DeviceOtaRequest { |
||||
|
const DeviceOtaBytesRequest({ |
||||
|
required this.bytes, |
||||
|
super.blockSize, |
||||
|
super.timeout, |
||||
|
}); |
||||
|
|
||||
|
final Uint8List bytes; |
||||
|
} |
||||
|
|
||||
|
/// 远程 URL:让 native 自己下载。 |
||||
|
/// |
||||
|
/// 厂商插件**不支持**时抛 `device.not_supported`,由调用方退回到 |
||||
|
/// "app 端下载 → 用 [DeviceOtaFileRequest]" 路径。 |
||||
|
class DeviceOtaUrlRequest extends DeviceOtaRequest { |
||||
|
const DeviceOtaUrlRequest({ |
||||
|
required this.url, |
||||
|
this.headers = const {}, |
||||
|
super.blockSize, |
||||
|
super.timeout, |
||||
|
}); |
||||
|
|
||||
|
final String url; |
||||
|
final Map<String, String> headers; |
||||
|
} |
||||
|
|
||||
|
/// 厂商扩展逃生口(差分包 / 双备份 / fileFlag 等私有参数)。 |
||||
|
/// |
||||
|
/// 使用时业务层就明确放弃了"厂商无关"的承诺。`vendorKey` 必须等于当前 active |
||||
|
/// vendor,否则插件抛 `device.invalid_argument`。 |
||||
|
class DeviceOtaVendorRequest extends DeviceOtaRequest { |
||||
|
const DeviceOtaVendorRequest({ |
||||
|
required this.vendorKey, |
||||
|
required this.payload, |
||||
|
super.blockSize, |
||||
|
super.timeout, |
||||
|
}); |
||||
|
|
||||
|
final String vendorKey; |
||||
|
final Map<String, Object?> payload; |
||||
|
} |
||||
|
|
||||
|
/// OTA 全局状态机。 |
||||
|
/// |
||||
|
/// ``` |
||||
|
/// idle |
||||
|
/// → downloading // [仅 Url 请求] 由容器层下载固件到本地,转 file 请求 |
||||
|
/// → inquiring // 询问设备能否升级 |
||||
|
/// → notifyingSize // 告知固件大小 |
||||
|
/// → entering // 设备进入升级模式 |
||||
|
/// → transferring(*progress*) |
||||
|
/// → verifying |
||||
|
/// → rebooting |
||||
|
/// → done | failed | cancelled |
||||
|
/// ``` |
||||
|
/// |
||||
|
/// `failed` / `cancelled` 是终态,回到 idle 之前不应再派进度事件。 |
||||
|
/// |
||||
|
/// 注:`downloading` 由 `device_manager` 容器层产生(不在厂商插件内)—— |
||||
|
/// Url 请求统一由容器下载到沙盒后转换为 file 请求再分派给 vendor port, |
||||
|
/// 厂商插件只看到 `inquiring` 起始的标准流程。 |
||||
|
enum DeviceOtaState { |
||||
|
idle, |
||||
|
downloading, |
||||
|
inquiring, |
||||
|
notifyingSize, |
||||
|
entering, |
||||
|
transferring, |
||||
|
verifying, |
||||
|
rebooting, |
||||
|
done, |
||||
|
failed, |
||||
|
cancelled, |
||||
|
} |
||||
|
|
||||
|
/// OTA 进度事件。 |
||||
|
/// |
||||
|
/// - `transferring` 阶段进度上报频率应控制在 ≥ 5Hz / ≤ 20Hz; |
||||
|
/// - 非 transferring 阶段允许 sentBytes / totalBytes 不准确(可置 0), |
||||
|
/// UI 一律以 [state] 为主; |
||||
|
/// - `done` 时 [percent] = 100,`failed` / `cancelled` 时 [percent] 可能为 -1。 |
||||
|
@immutable |
||||
|
class DeviceOtaProgress { |
||||
|
const DeviceOtaProgress({ |
||||
|
required this.state, |
||||
|
required this.sentBytes, |
||||
|
required this.totalBytes, |
||||
|
required this.percent, |
||||
|
required this.tsMs, |
||||
|
this.errorCode, |
||||
|
this.errorMessage, |
||||
|
}); |
||||
|
|
||||
|
final DeviceOtaState state; |
||||
|
final int sentBytes; |
||||
|
final int totalBytes; |
||||
|
|
||||
|
/// 0..100;未知或非 transferring 阶段可为 -1。 |
||||
|
final int percent; |
||||
|
|
||||
|
/// 事件时间戳(毫秒,挂墙时间),调试用。 |
||||
|
final int tsMs; |
||||
|
|
||||
|
/// 终态时携带错误码(state ∈ {failed});其它阶段为 null。 |
||||
|
/// 命名空间 `device.<reason>`,参见 [DeviceErrorCode]。 |
||||
|
final String? errorCode; |
||||
|
final String? errorMessage; |
||||
|
|
||||
|
bool get isTerminal => |
||||
|
state == DeviceOtaState.done || |
||||
|
state == DeviceOtaState.failed || |
||||
|
state == DeviceOtaState.cancelled; |
||||
|
} |
||||
|
|
||||
|
/// OTA 端口(同 [DeviceCallTranslationPort] 的设计风格)。 |
||||
|
/// |
||||
|
/// 单设备 OTA 互斥:同时只能跑一个 [start],重复 start 抛 |
||||
|
/// `DeviceException('device.ota_busy')`。 |
||||
|
/// |
||||
|
/// 生命周期与铁律: |
||||
|
/// 1. `start` 之后 [progressStream] 必有终态(done/failed/cancelled),不得悬挂; |
||||
|
/// 2. 设备掉线(`disconnected*`)时端口须主动派 `failed` 收尾; |
||||
|
/// 3. `cancel` 后短时间内(≤ 5s)须收到 `cancelled`,超时强制收尾; |
||||
|
/// 4. **不**要求支持断点续传,但若实现了须在 `progress.metadata` 里给出。 |
||||
|
abstract class DeviceOtaPort { |
||||
|
/// 启动 OTA。已在跑时抛 `device.ota_busy`。 |
||||
|
Future<void> start(DeviceOtaRequest request); |
||||
|
|
||||
|
/// 取消当前 OTA;空跑时 no-op。 |
||||
|
Future<void> cancel(); |
||||
|
|
||||
|
/// 当前是否在跑。 |
||||
|
bool get isRunning; |
||||
|
|
||||
|
/// 进度事件流(broadcast / multi-subscribe)。 |
||||
|
Stream<DeviceOtaProgress> get progressStream; |
||||
|
} |
||||
Loading…
Reference in new issue