docs(helper): 更新语音识别和图像解码工具类文档

- 为 AsrHelper 添加详细的类注释,说明工作流程、连接管理和场景路由机制
- 为 BitmapDecodeHelper 添加类注释,解释 AR 眼镜低内存场景下的优化策略
- 为 GlassMediaServiceHelper 添加类注释,说明双检锁懒加载缓存机制
- 为 MyApplication 添加类注释,详细说明多域名 AppComponent 初始化和 SDK 绑定流程
- 为各个关键方法添加详细的 KDoc 注释,包括参数说明和使用场景
- 优化代码注释的中文表达,使其更加清晰易懂
这个提交包含在:
徐勤民 2026-04-21 22:30:53 +08:00
父节点 dcf9d51014
当前提交 c2d8a0f40e
共有 5 个文件被更改,包括 344 次插入31 次删除

170
CLAUDE.md 普通文件
查看文件

@ -0,0 +1,170 @@
# C大脑智能眼镜应用
Rokid Glass 3 AR 眼镜的工业质检辅助应用。支持语音唤醒、在线 ASR/TTS、OCR 识别、AI 对话等功能,用于喷涂质检和单证检验两类业务场景。
---
## 项目结构
```
c-brain-glass/
├── app/ 主应用模块UI、ViewModel、业务逻辑
├── base/ 网络框架、UI 基类、工具类Java
├── core/ AR 相关扩展、自定义 ViewKotlin
└── server/ 服务端相关代码(不参与 app 构建)
```
### app 模块包结构
```
com.nova.brain.glass
├── ui/ 14 个 Activity页面
├── viewmodel/ 15 个 ViewModel
├── repository/ Retrofit 接口Service.kt、Service3.kt+ 请求拦截器
├── helper/ 工具单例ASR、离线关键词、媒体服务、位图解码等
├── model/ 数据模型(请求体、响应体)
├── common/ SharedPreferences 配置、崩溃处理器
└── MyApplication.java 全局初始化入口
```
---
## 构建与运行
```bash
# 调试包
./gradlew :app:assembleDebug
# 正式包
./gradlew :app:assembleRelease
```
- **最低 SDK**: 26Android 8.0
- **目标 SDK**: 29Android 10
- **编译 SDK**: 33
- **Kotlin**: 2.2.0 / **Java**: 11
### 语音服务配置
语音相关密钥在 `app/build.gradle``buildConfigField` 中维护:
| 字段 | 说明 |
|------|------|
| `SPEECH_DOMAIN` | ASR/TTS 服务器地址IP:端口) |
| `SPEECH_AK` / `SPEECH_SK` | 鉴权 AccessKey / SecretKey |
| `SPEECH_ASR_PATH` | ASR WebSocket 路径 |
| `SPEECH_TTS_PATH` | TTS WebSocket 路径 |
---
## 关键模块说明
### 语音交互流程
```
离线唤醒词(飞宝飞宝 / C大脑
└─ AsrHelper.onOfflineCmd()
├─ 无网络 → showNoNetworkDialog()
└─ 有网络 → TTS 播报"在呢,您请说"
└─ TtsClient.onFinished()
└─ asrStartMic() 开始麦克风采集
└─ AsrClient.onFinalResult()
├─ scene=="decision" → onDirectChatChatActivity AI对话
└─ 其他场景 → IntentRecognizeHelper.recognize() 意图识别
└─ 页面导航回调
```
**相关文件**
- `helper/AsrHelper.kt` — ASR/TTS 全局管理,包含连接状态机和指数退避重连
- `helper/OfflineCmdServiceHelper.kt` — 离线关键词注册/注销,按页面场景切换
- `helper/IntentRecognizeHelper.kt` — 调用后台接口将语音文本映射为导航动作
### 场景路由AsrHelper.scene
| scene 值 | 设置页面 | ASR 结果处理 |
|----------|---------|------------|
| `"home"` | WelcomeActivity | 意图识别,导航到任务中心或决策中心 |
| `"list"` | TaskListActivity | 意图识别,支持打开任务详情 |
| `"decision"` | ChatActivity | 跳过意图识别,直接发送给 AI 对话 |
### 多 AppComponent 网络架构
`MyApplication` 为不同服务域名各建一套 OkHttpClient + Retrofit
| 字段 | 域名 | 用途 |
|------|------|------|
| `appComponent` | `baseUrl` | 主后台(任务、审阅、决策中心) |
| `appComponent1` | vicp.fun | 意图识别接口 |
| `appComponent2` | vicp.fun | 喷涂质检接口 |
| `appComponent3` | vicp.fun | 单证检验接口 |
通过 `HttpManager.getApi(component, Service::class.java)` 获取对应实例。
### 位图解码
`helper/BitmapDecodeHelper.kt` 采用两步采样:先 `inJustDecodeBounds=true` 只读尺寸,再按 ImageView 实际大小计算 `inSampleSize`,使用 `RGB_565` 格式(比 ARGB_8888 省 50% 内存。ImageView 解码任务通过 Activity 内部的 `imageDecodeExecutor`(单线程池)执行,在 `onDestroy()``shutdown()`
---
## 业务流程
### 喷涂质检
```
SprayingActivity → 获取任务信息
└─ SprayingOCRActivity → 拍照上传,服务端 OCR 识别
└─ SprayingResultActivity → 展示 OCR 结果(两码比对)
├─ 通过/不合格 → SprayingFinishActivity → 确认提交 → 返回任务列表
└─ 人工更正 → SprayingManualResultActivity → 手动选择结果
```
### 单证检验
```
InspectionActivity → 拍照上传,服务端识别单证
└─ InspectionResultActivity → 展示检验结果
├─ 合格/不合格 → InspectionCompleteActivity
└─ 缺失单证 → InspectionMissingActivity → 补充上传
```
---
## Activity 与离线关键词管理
每个 Activity 在 `onResume` 注册专属关键词,在 `onPause` 注销,避免后台页面响应语音:
```kotlin
override fun onResume() {
OfflineCmdServiceHelper.addListenerXxx() // 注册本页关键词
OfflineCmdServiceHelper.addOnLineListener(listener)
}
override fun onPause() {
OfflineCmdServiceHelper.removeListenerXxx()
OfflineCmdServiceHelper.removeOnLineListener(listener)
}
```
通用关键词(退出、返回、继续、下一个)在 `init()` 时一次性注册,不随页面切换变化。
---
## 主要依赖
| 依赖 | 版本 | 用途 |
|------|------|------|
| `glass3.open.sdk` | 2.1.7-E | Rokid Glass 硬件 SDK |
| `online-speech` | 0.1.0 | Rokid 在线 ASR/TTS |
| `retrofit2` | 2.4.0 | HTTP 网络请求 |
| `rxjava2` / `rxandroid` | — | 异步流处理 |
| `dagger` | 2.40.5 | 依赖注入 |
| `glide` | 4.10.0 | 图片加载 |
| `markwon` | 4.6.2 | Markdown 渲染AI 对话) |
---
## 注意事项
- **Glass SDK 初始化顺序**`OfflineCmdServiceHelper.init()` 必须在 `AsrHelper.init()` 之前,因为后者依赖前者注册唤醒词。
- **内存**设备内存有限,ImageView 在 `onDestroy()` 中需调用 `setImageDrawable(null)` 释放 Bitmap 引用;解码线程池在 `onDestroy()``shutdown()`
- **SSL**:内网服务器使用自签证书,`trustAllCerts = true`,生产环境应替换为正式证书。
- **网络切换**ASR/TTS 连接在网络断开后进入指数退避重连,网络恢复时立即重置并重连,无需手动干预。

查看文件

@ -11,22 +11,41 @@ import com.xuqm.base.di.component.AppComponent;
import com.xuqm.base.di.manager.HttpManager; import com.xuqm.base.di.manager.HttpManager;
/** /**
* 应用全局入口
*
* <p>负责以下初始化工作
* <ol>
* <li>为不同服务域名各自创建独立的 {@link AppComponent}避免 OkHttp 连接池跨域名串用</li>
* <li>绑定 Rokid Glass SDK 安全服务连接成功后顺序初始化离线关键词和在线语音模块</li>
* </ol>
*
* <p> AppComponent 对应关系
* <ul>
* <li>{@code appComponent} 主后台任务中心决策中心审阅等核心接口</li>
* <li>{@code appComponent1} 意图识别服务 ASR 文本映射为页面导航动作</li>
* <li>{@code appComponent2} 喷涂质检服务拍照上传OCR 识别结果保存</li>
* <li>{@code appComponent3} 单证检验服务文件验证质检判定任务完成</li>
* </ul>
*
* @author xuqm * @author xuqm
*/ */
public class MyApplication extends App { public class MyApplication extends App {
// 主后台服务地址通过内网穿透暴露到公网
public static String baseUrl = "http://22fs132201.imwork.net"; public static String baseUrl = "http://22fs132201.imwork.net";
// public static String baseUrl = "http://192.168.6.20"; // public static String baseUrl = "http://192.168.6.20";
// 意图识别 // 意图识别服务的 Dagger 网络组件
public static AppComponent appComponent1; public static AppComponent appComponent1;
// 喷涂 // 喷涂质检服务的 Dagger 网络组件
public static AppComponent appComponent2; public static AppComponent appComponent2;
// 单证检验服务的 Dagger 网络组件
public static AppComponent appComponent3; public static AppComponent appComponent3;
@Override @Override
public void onCreate() { public void onCreate() {
super.onCreate(); super.onCreate();
// 为每个域名单独构建 OkHttpClient + Retrofit 实例互不干扰
appComponent = HttpManager.getAppComponent(baseUrl, new HeaderInterceptor(getApplicationContext())); appComponent = HttpManager.getAppComponent(baseUrl, new HeaderInterceptor(getApplicationContext()));
appComponent1 = HttpManager.getAppComponent("https://22v1322u01.vicp.fun", new HeaderInterceptor(getApplicationContext())); appComponent1 = HttpManager.getAppComponent("https://22v1322u01.vicp.fun", new HeaderInterceptor(getApplicationContext()));
appComponent2 = HttpManager.getAppComponent("https://22v1322u01.vicp.fun", new HeaderInterceptor(getApplicationContext())); appComponent2 = HttpManager.getAppComponent("https://22v1322u01.vicp.fun", new HeaderInterceptor(getApplicationContext()));
@ -37,7 +56,6 @@ public class MyApplication extends App {
// appComponent3 = HttpManager.getAppComponent("http://192.168.22.199:8820/", new HeaderInterceptor(getApplicationContext())); // appComponent3 = HttpManager.getAppComponent("http://192.168.22.199:8820/", new HeaderInterceptor(getApplicationContext()));
initSdk(); initSdk();
} }
@Override @Override
@ -45,27 +63,34 @@ public class MyApplication extends App {
return super.showLog(); return super.showLog();
} }
/**
* 绑定 Rokid Glass 安全服务
*
* <p>Glass SDK 需要通过系统 AIDL 服务完成硬件鉴权绑定成功后才能使用离线关键词和相机等能力
* 离线关键词{@link OfflineCmdServiceHelper}和在线语音{@link AsrHelper}必须在此回调中初始化
* 否则底层 SDK 尚未就绪调用会静默失败
*/
void initSdk() { void initSdk() {
// 如果SDK已经初始化了则直接返回 // SDK 已就绪时无需重复绑定进程保活场景下可能触发
if (GlassSdk.isReady()) { if (GlassSdk.isReady()) {
return; return;
} }
GlassSdk.bindSecurityService(Utils.getApp(), new IServiceConnectionCallback() { GlassSdk.bindSecurityService(Utils.getApp(), new IServiceConnectionCallback() {
@Override @Override
public void onServiceConnected() { public void onServiceConnected() {
// 先注册离线关键词再初始化在线语音ASR 唤醒词依赖离线服务
OfflineCmdServiceHelper.INSTANCE.init(); OfflineCmdServiceHelper.INSTANCE.init();
AsrHelper.INSTANCE.init(); AsrHelper.INSTANCE.init();
} }
@Override @Override
public void onServiceDisconnected() { public void onServiceDisconnected() {
// 系统服务断开硬件能力暂时不可用等待系统自动重连
} }
@Override @Override
public void onBindingDied() { public void onBindingDied() {
// Binder 死亡通常是系统进程崩溃需要重新绑定当前版本依赖系统自恢复
} }
}); });
} }

查看文件

@ -27,6 +27,25 @@ import com.rokid.online.speech.open.OpenSdkAudioSource
import com.xuqm.base.common.AppManager import com.xuqm.base.common.AppManager
import kotlin.system.exitProcess import kotlin.system.exitProcess
/**
* 在线语音识别ASR与语音合成TTS的全局管理单例
*
* ## 工作流程
* 1. 离线关键词引擎检测到唤醒词"飞宝飞宝" / "C大脑" [onOfflineCmd]
* 2. TTS 播报唤醒响应"在呢,您请说" [TtsClient.Listener.onFinished]
* 3. 启动麦克风开始 ASR 流式识别 [asrStartMic]
* 4. 识别完成后调用 [IntentRecognizeHelper.recognize] 将文本映射为页面导航动作
*
* ## 连接管理
* - ASR TTS 各自维护独立的 WebSocket 连接断线后采用指数退避策略自动重连
* 基础间隔 3s最大间隔 30s最多重试 5
* - 超过重试上限后置为 paused 状态网络恢复时[networkCallback]重置并立即重连
*
* ## 场景路由
* [scene] 由各 Activity onResume/onPause 中设置决定 ASR 结果的处理方式
* - "home" / "list" 意图识别后执行页面跳转
* - "decision" 文本直接发送给 ChatActivity 发起 AI 对话
*/
object AsrHelper : OfflineCmdListener { object AsrHelper : OfflineCmdListener {
private const val TAG = "AsrHelper" private const val TAG = "AsrHelper"
@ -40,38 +59,45 @@ object AsrHelper : OfflineCmdListener {
private val ASR_PATH get() = BuildConfig.SPEECH_ASR_PATH private val ASR_PATH get() = BuildConfig.SPEECH_ASR_PATH
private val TTS_PATH get() = BuildConfig.SPEECH_TTS_PATH private val TTS_PATH get() = BuildConfig.SPEECH_TTS_PATH
// 唤醒词Nova Nova // 支持的唤醒词(与 OfflineCmdServiceHelper.registerAsrWakeWord 中注册的词保持一致)
private const val WAKE_WORD1 = "飞宝飞宝" private const val WAKE_WORD1 = "飞宝飞宝"
private var sdk: OnlineSpeechSdk? = null private var sdk: OnlineSpeechSdk? = null
private var asr: AsrClient? = null private var asr: AsrClient? = null
private var tts: TtsClient? = null private var tts: TtsClient? = null
// audioSource 和 ttsPlayer 由 SDK 内部管理生命周期,随 asr/tts 连接一同关闭
private val audioSource = OpenSdkAudioSource() private val audioSource = OpenSdkAudioSource()
private val ttsPlayer = AndroidPcmTtsStreamPlayer() private val ttsPlayer = AndroidPcmTtsStreamPlayer()
private var isConnected = false // ---- 连接状态机 ----
private var isMicRunning = false private var isConnected = false // ASR WebSocket 已连接
private var isTtsConnected = false private var isMicRunning = false // 麦克风正在采集音频
private var isAsrConnecting = false private var isTtsConnected = false // TTS WebSocket 已连接
private var isTtsConnecting = false private var isAsrConnecting = false // ASR 正在建立连接(防止重复发起)
private var pendingStartMic = false private var isTtsConnecting = false // TTS 正在建立连接(防止重复发起)
private var isClosed = false private var pendingStartMic = false // ASR 未连接时收到启动麦克风请求,连接成功后补发
private var asrReconnectAttempt = 0 private var isClosed = false // 已调用 close(),阻止重连循环
private var ttsReconnectAttempt = 0 private var asrReconnectAttempt = 0 // ASR 当前重连次数
private var asrReconnectPaused = false private var ttsReconnectAttempt = 0 // TTS 当前重连次数
private var ttsReconnectPaused = false private var asrReconnectPaused = false // ASR 重连已达上限,暂停直到网络恢复
private var ttsReconnectPaused = false // TTS 重连已达上限,暂停直到网络恢复
private var connectivityManager: ConnectivityManager? = null private var connectivityManager: ConnectivityManager? = null
private var networkCallbackRegistered = false private var networkCallbackRegistered = false
// 唤醒响应文本TTS 播报,播报结束后自动开启麦克风)
private const val WAKE_RESPONSE = "在呢,您请说" private const val WAKE_RESPONSE = "在呢,您请说"
// 用户说话超时30 秒内无最终结果则自动停止麦克风
private const val LISTENING_TIMEOUT_MS = 30_000L private const val LISTENING_TIMEOUT_MS = 30_000L
// 重连退避参数
private const val BASE_RECONNECT_DELAY_MS = 3_000L private const val BASE_RECONNECT_DELAY_MS = 3_000L
private const val MAX_RECONNECT_DELAY_MS = 30_000L private const val MAX_RECONNECT_DELAY_MS = 30_000L
private const val MAX_RECONNECT_ATTEMPTS = 5 private const val MAX_RECONNECT_ATTEMPTS = 5
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var listeningDialog: AlertDialog? = null private var listeningDialog: AlertDialog? = null // 正在聆听的半透明提示框
private var noNetworkDialog: AlertDialog? = null private var noNetworkDialog: AlertDialog? = null // 无网络时的全屏提示框
// 30 秒内无最终结果时自动停止麦克风,防止长时间占用音频焦点
private val listeningTimeoutRunnable = Runnable { private val listeningTimeoutRunnable = Runnable {
if (!isMicRunning) return@Runnable if (!isMicRunning) return@Runnable
runCatching { asr?.stopAsrWithMic() } runCatching { asr?.stopAsrWithMic() }
@ -79,6 +105,8 @@ object AsrHelper : OfflineCmdListener {
dismissListeningDialog() dismissListeningDialog()
Log.d(TAG, "ASR listening timeout after ${LISTENING_TIMEOUT_MS}ms") Log.d(TAG, "ASR listening timeout after ${LISTENING_TIMEOUT_MS}ms")
} }
// 每秒检查一次网络;恢复后关闭无网对话框,否则持续显示
private val networkCheckRunnable = object : Runnable { private val networkCheckRunnable = object : Runnable {
override fun run() { override fun run() {
if (isNetworkAvailable()) { if (isNetworkAvailable()) {
@ -89,6 +117,8 @@ object AsrHelper : OfflineCmdListener {
mainHandler.postDelayed(this, 1000L) mainHandler.postDelayed(this, 1000L)
} }
} }
// 延迟重连任务(由 scheduleAsrReconnect 投递到 mainHandler
private val asrReconnectRunnable = Runnable { private val asrReconnectRunnable = Runnable {
if (shouldReconnectAsr()) { if (shouldReconnectAsr()) {
asrConnect() asrConnect()
@ -99,6 +129,8 @@ object AsrHelper : OfflineCmdListener {
ttsConnect() ttsConnect()
} }
} }
// 网络状态监听:恢复时立即重连,不需要等待下一轮轮询
private val networkCallback = object : ConnectivityManager.NetworkCallback() { private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
Log.d(TAG, "Network available, reconnect speech channels if needed") Log.d(TAG, "Network available, reconnect speech channels if needed")
@ -112,27 +144,34 @@ object AsrHelper : OfflineCmdListener {
} }
} }
// 拼接每次识别会话中的中间结果 // 用 StringBuilder 累积本次会话的中间识别结果,避免频繁创建短生命周期 String 对象
private val currentPartial = StringBuilder() private val currentPartial = StringBuilder()
/** 当前页面的场景标识,由各 Activity 在 onResume/onPause 中维护 */ /**
* 当前页面的场景标识由各 Activity onResume/onPause 中维护
* 可选值"home" | "list" | "decision"
*/
var scene: String = "home" var scene: String = "home"
/** goToDecisionCenter 命中时的回调,由各 Activity 在 onResume/onPause 中注册/清空 */ /** goToDecisionCenter 意图命中时的回调,由各 Activity 在 onResume/onPause 中注册/清空 */
var onGoToDecisionCenter: ((action: RecognizeAction) -> Unit)? = null var onGoToDecisionCenter: ((action: RecognizeAction) -> Unit)? = null
/** goToTaskCenter 命中时的回调,由各 Activity 在 onResume/onPause 中注册/清空 */ /** goToTaskCenter 意图命中时的回调,由各 Activity 在 onResume/onPause 中注册/清空 */
var onGoToTaskCenter: ((action: RecognizeAction) -> Unit)? = null var onGoToTaskCenter: ((action: RecognizeAction) -> Unit)? = null
/** list 场景下由 TaskListActivity 注册,返回当前列表数据作为 extra 传给服务端 */ /** list 场景下由 TaskListActivity 注册,返回当前列表数据作为 extra 传给服务端辅助意图识别 */
var extraProvider: (() -> List<Any>)? = null var extraProvider: (() -> List<Any>)? = null
/** openTaskDetail 命中时的回调,由 TaskListActivity 在 onResume/onPause 中注册/清空 */ /** openTaskDetail 意图命中时的回调,由 TaskListActivity 在 onResume/onPause 中注册/清空 */
var onOpenTaskDetail: ((action: RecognizeAction) -> Unit)? = null var onOpenTaskDetail: ((action: RecognizeAction) -> Unit)? = null
/** scene == "decision" 时直接用 ASR 文本发起对话,由 ChatActivity 注册 */ /** scene == "decision" 时跳过意图识别,直接将 ASR 文本发给 ChatActivity 发起对话 */
var onDirectChat: ((text: String) -> Unit)? = null var onDirectChat: ((text: String) -> Unit)? = null
/**
* 初始化 SDK注册唤醒词并建立 ASR/TTS 连接
* 必须在 GlassSdk.bindSecurityService onServiceConnected 回调中调用
*/
fun init() { fun init() {
isClosed = false isClosed = false
val cfg = OnlineSpeechSdkConfig( val cfg = OnlineSpeechSdkConfig(
@ -143,6 +182,7 @@ object AsrHelper : OfflineCmdListener {
deviceId = DEVICE_ID, deviceId = DEVICE_ID,
asrPath = ASR_PATH, asrPath = ASR_PATH,
ttsPath = TTS_PATH, ttsPath = TTS_PATH,
// 内网自签证书,跳过 SSL 校验
trustAllCerts = true, trustAllCerts = true,
staticHttpHeaders = mapOf( staticHttpHeaders = mapOf(
"appCredential" to "userInfo", "appCredential" to "userInfo",
@ -158,12 +198,12 @@ object AsrHelper : OfflineCmdListener {
asr = sdk!!.createAsrClient().attachAudioSource(audioSource).also { setupAsrCallbacks(it) } asr = sdk!!.createAsrClient().attachAudioSource(audioSource).also { setupAsrCallbacks(it) }
tts = sdk!!.createTtsClient().attachStreamPlayer(ttsPlayer).also { setupTtsCallbacks(it) } tts = sdk!!.createTtsClient().attachStreamPlayer(ttsPlayer).also { setupTtsCallbacks(it) }
// 注册离线关键词 Nova Nova,GlassSdk 触发后启动 ASR // 注册唤醒词到离线引擎,触发后进入在线 ASR 流程
OfflineCmdServiceHelper.registerAsrWakeWord() OfflineCmdServiceHelper.registerAsrWakeWord()
OfflineCmdServiceHelper.addOnLineListener(this) OfflineCmdServiceHelper.addOnLineListener(this)
registerNetworkCallback() registerNetworkCallback()
// 自动建立 ASR / TTS 连接 // 立即建立 ASR / TTS 连接,缩短首次唤醒的响应延迟
resetReconnectState() resetReconnectState()
asrConnect() asrConnect()
ttsConnect() ttsConnect()
@ -199,8 +239,13 @@ object AsrHelper : OfflineCmdListener {
} }
} }
/**
* 启动麦克风开始 ASR 识别
* ASR 连接尚未就绪则置 [pendingStartMic]=true onOpen 回调时补发
*/
private fun asrStartMic() { private fun asrStartMic() {
if (!isConnected) { if (!isConnected) {
// 重连已暂停时(超出最大次数),先重置再重试
if (asrReconnectPaused) { if (asrReconnectPaused) {
resetAsrReconnectState() resetAsrReconnectState()
} }
@ -231,6 +276,10 @@ object AsrHelper : OfflineCmdListener {
mainHandler.removeCallbacks(listeningTimeoutRunnable) mainHandler.removeCallbacks(listeningTimeoutRunnable)
} }
/**
* 安排 ASR 重连任务指数退避
* 超过 [MAX_RECONNECT_ATTEMPTS] 次后置 paused等待网络恢复事件重置
*/
private fun scheduleAsrReconnect(delayMs: Long? = null) { private fun scheduleAsrReconnect(delayMs: Long? = null) {
if (!shouldReconnectAsr()) return if (!shouldReconnectAsr()) return
if (asrReconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { if (asrReconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
@ -241,11 +290,13 @@ object AsrHelper : OfflineCmdListener {
} }
asrReconnectAttempt += 1 asrReconnectAttempt += 1
val actualDelayMs = delayMs ?: calculateReconnectDelay(asrReconnectAttempt) val actualDelayMs = delayMs ?: calculateReconnectDelay(asrReconnectAttempt)
// 先取消已有的延迟任务,防止重复投递
mainHandler.removeCallbacks(asrReconnectRunnable) mainHandler.removeCallbacks(asrReconnectRunnable)
mainHandler.postDelayed(asrReconnectRunnable, actualDelayMs) mainHandler.postDelayed(asrReconnectRunnable, actualDelayMs)
Log.d(TAG, "ASR reconnect scheduled in ${actualDelayMs}ms, attempt=$asrReconnectAttempt") Log.d(TAG, "ASR reconnect scheduled in ${actualDelayMs}ms, attempt=$asrReconnectAttempt")
} }
/** 安排 TTS 重连任务(指数退避),逻辑同 [scheduleAsrReconnect]。 */
private fun scheduleTtsReconnect(delayMs: Long? = null) { private fun scheduleTtsReconnect(delayMs: Long? = null) {
if (!shouldReconnectTts()) return if (!shouldReconnectTts()) return
if (ttsReconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { if (ttsReconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
@ -279,6 +330,10 @@ object AsrHelper : OfflineCmdListener {
return !isClosed && !isTtsConnected && !isTtsConnecting && !ttsReconnectPaused && isNetworkAvailable() return !isClosed && !isTtsConnected && !isTtsConnecting && !ttsReconnectPaused && isNetworkAvailable()
} }
/**
* 指数退避延迟attempt=1 3sattempt=2 6sattempt=3 12s上限 30s
* 使用位移而非 pow() 避免浮点运算
*/
private fun calculateReconnectDelay(attempt: Int): Long { private fun calculateReconnectDelay(attempt: Int): Long {
val factor = 1L shl (attempt - 1).coerceAtLeast(0) val factor = 1L shl (attempt - 1).coerceAtLeast(0)
return (BASE_RECONNECT_DELAY_MS * factor).coerceAtMost(MAX_RECONNECT_DELAY_MS) return (BASE_RECONNECT_DELAY_MS * factor).coerceAtMost(MAX_RECONNECT_DELAY_MS)
@ -533,7 +588,13 @@ object AsrHelper : OfflineCmdListener {
}) })
} }
// 离线关键词回调:唤醒词触发时先 TTS 播报,播报结束后启动麦克风 /**
* 离线关键词回调
*
* 唤醒词触发时显示聆听对话框 TTS 播报响应 TTS 结束后启动麦克风 onFinished
* TTS 不可用则直接启动麦克风保证交互不中断
* "退出/返回" 等通用关键词由 [handleNoNetworkDialogCmd] 拦截处理用于无网对话框场景
*/
override fun onOfflineCmd(cmd: String) { override fun onOfflineCmd(cmd: String) {
if (handleNoNetworkDialogCmd(cmd)) return if (handleNoNetworkDialogCmd(cmd)) return
if (cmd == WAKE_WORD1 || cmd == "C大脑") { if (cmd == WAKE_WORD1 || cmd == "C大脑") {
@ -545,6 +606,7 @@ object AsrHelper : OfflineCmdListener {
} }
showListeningDialog() showListeningDialog()
restartListeningTimeout() restartListeningTimeout()
// 提前设置 pending,确保 TTS 播报期间若 ASR 连接断开也能在重连后自动补发
pendingStartMic = true pendingStartMic = true
resetReconnectState() resetReconnectState()
if (isTtsConnected) { if (isTtsConnected) {
@ -584,9 +646,16 @@ object AsrHelper : OfflineCmdListener {
return true return true
} }
/**
* 关闭并释放所有资源
*
* 调用后 [isClosed]=true所有重连逻辑将拒绝执行防止已销毁状态下触发新连接
* 调用顺序先取消所有 pending 任务 关闭麦克风 关闭 WebSocket 释放 SDK
*/
fun close() { fun close() {
isClosed = true isClosed = true
OfflineCmdServiceHelper.removeOnLineListener(this) OfflineCmdServiceHelper.removeOnLineListener(this)
// 取消所有 Handler 延迟任务,防止 close 后仍触发重连或超时回调
mainHandler.removeCallbacks(listeningTimeoutRunnable) mainHandler.removeCallbacks(listeningTimeoutRunnable)
mainHandler.removeCallbacks(networkCheckRunnable) mainHandler.removeCallbacks(networkCheckRunnable)
mainHandler.removeCallbacks(asrReconnectRunnable) mainHandler.removeCallbacks(asrReconnectRunnable)
@ -598,6 +667,7 @@ object AsrHelper : OfflineCmdListener {
} }
dismissListeningDialog() dismissListeningDialog()
unregisterNetworkCallback() unregisterNetworkCallback()
// 按依赖顺序关闭ASR/TTS Client → SDK
asr?.close() asr?.close()
tts?.close() tts?.close()
sdk?.close() sdk?.close()

查看文件

@ -4,20 +4,45 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import kotlin.math.max import kotlin.math.max
/**
* 图片采样解码工具
*
* 针对 AR 眼镜低内存场景做了两处优化
* 1. 先用 inJustDecodeBounds=true 只读取图片尺寸不分配像素内存
* 2. RGB_56516-bit替代默认的 ARGB_888832-bit内存占用减少 50%
*/
object BitmapDecodeHelper { object BitmapDecodeHelper {
/**
* 按目标显示尺寸对图片进行采样解码避免加载原图导致 OOM
*
* @param path 图片文件的绝对路径
* @param reqWidth 目标宽度像素通常为 ImageView 的测量宽度
* @param reqHeight 目标高度像素通常为 ImageView 的测量高度
* @return 解码后的 Bitmap文件不存在或解码失败时返回 null
*/
fun decodeSampledBitmap(path: String, reqWidth: Int, reqHeight: Int): Bitmap? { fun decodeSampledBitmap(path: String, reqWidth: Int, reqHeight: Int): Bitmap? {
// 第一次解码:只读尺寸,不分配像素内存
val boundsOptions = BitmapFactory.Options().apply { val boundsOptions = BitmapFactory.Options().apply {
inJustDecodeBounds = true inJustDecodeBounds = true
} }
BitmapFactory.decodeFile(path, boundsOptions) BitmapFactory.decodeFile(path, boundsOptions)
// 第二次解码:按计算出的采样率加载
val decodeOptions = BitmapFactory.Options().apply { val decodeOptions = BitmapFactory.Options().apply {
inSampleSize = calculateInSampleSize(boundsOptions, reqWidth, reqHeight) inSampleSize = calculateInSampleSize(boundsOptions, reqWidth, reqHeight)
// RGB_565 比 ARGB_8888 节省一半内存,眼镜屏幕不需要 alpha 通道
inPreferredConfig = Bitmap.Config.RGB_565 inPreferredConfig = Bitmap.Config.RGB_565
} }
return BitmapFactory.decodeFile(path, decodeOptions) return BitmapFactory.decodeFile(path, decodeOptions)
} }
/**
* 计算 2 的幂次采样率使解码后的尺寸尽量接近但不小于目标尺寸
*
* 算法逻辑每轮将采样率翻倍直到缩放后的尺寸比目标尺寸小为止
* 取上一轮的采样率以保证解码图不小于显示区域防止模糊
*/
private fun calculateInSampleSize( private fun calculateInSampleSize(
options: BitmapFactory.Options, options: BitmapFactory.Options,
reqWidth: Int, reqWidth: Int,
@ -32,6 +57,7 @@ object BitmapDecodeHelper {
if (height > reqHeight || width > reqWidth) { if (height > reqHeight || width > reqWidth) {
val halfHeight = height / 2 val halfHeight = height / 2
val halfWidth = width / 2 val halfWidth = width / 2
// 只要缩放后的尺寸仍大于等于目标尺寸,就继续翻倍采样率
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
inSampleSize *= 2 inSampleSize *= 2
} }

查看文件

@ -6,10 +6,19 @@ import com.rokid.security.system.server.media.IMediaServer
import com.rokid.security.system.server.media.callback.ICameraSurfaceCallback import com.rokid.security.system.server.media.callback.ICameraSurfaceCallback
import com.rokid.security.system.server.media.callback.PhotoFileCallback import com.rokid.security.system.server.media.callback.PhotoFileCallback
/**
* Rokid Glass 媒体服务的应用层封装
*
* Glass SDK IMediaServer 通过系统 AIDL 提供首次获取有一定延迟
* 此处用双检锁@Volatile + 空检查做懒加载缓存避免每次调用都跨进程查询
* 所有方法均为可空安全调用?.若服务未就绪则静默忽略不会崩溃
*/
object GlassMediaServiceHelper { object GlassMediaServiceHelper {
@Volatile @Volatile
private var mediaService: IMediaServer? = null private var mediaService: IMediaServer? = null
/** 获取媒体服务实例(首次调用时从 SDK 获取并缓存)。 */
private fun service(): IMediaServer? { private fun service(): IMediaServer? {
val cached = mediaService val cached = mediaService
if (cached != null) { if (cached != null) {
@ -20,34 +29,47 @@ object GlassMediaServiceHelper {
} }
} }
/**
* 触发拍照结果通过 [addPhotoCallback] 注册的回调异步返回
*
* @param resolution 分辨率常量 PhotoResolution.RESOLUTION_720P
* @param path 照片保存的绝对路径
*/
fun takePhoto(resolution: Int, path: String) { fun takePhoto(resolution: Int, path: String) {
service()?.takePhoto(resolution, path) service()?.takePhoto(resolution, path)
} }
/** 注册拍照结果回调(建议在 onResume 中调用)。 */
fun addPhotoCallback(callback: PhotoFileCallback) { fun addPhotoCallback(callback: PhotoFileCallback) {
service()?.addPhotoCallback(callback) service()?.addPhotoCallback(callback)
} }
/** 注销拍照结果回调(建议在 onPause 中调用,防止内存泄漏)。 */
fun removePhotoCallback(callback: PhotoFileCallback) { fun removePhotoCallback(callback: PhotoFileCallback) {
service()?.removePhotoCallback(callback) service()?.removePhotoCallback(callback)
} }
/** 开始将相机画面共享到指定 Surface用于预览。 */
fun startCameraShare(surface: Surface, callback: ICameraSurfaceCallback) { fun startCameraShare(surface: Surface, callback: ICameraSurfaceCallback) {
service()?.startCameraShare(surface, callback) service()?.startCameraShare(surface, callback)
} }
/** 停止相机画面共享。 */
fun stopCameraShare(callback: ICameraSurfaceCallback) { fun stopCameraShare(callback: ICameraSurfaceCallback) {
service()?.stopCameraShare(callback) service()?.stopCameraShare(callback)
} }
/** 获取当前硬件支持的最大变焦级别。 */
fun getMaxZoomLevel(): Int { fun getMaxZoomLevel(): Int {
return service()?.maxZoomLevel ?: 0 return service()?.maxZoomLevel ?: 0
} }
/** 获取当前变焦级别。 */
fun getZoomLevel(): Int { fun getZoomLevel(): Int {
return service()?.zoomLevel ?: 0 return service()?.zoomLevel ?: 0
} }
/** 设置变焦级别0 为广角,最大值见 [getMaxZoomLevel])。 */
fun zoomCamera(level: Int) { fun zoomCamera(level: Int) {
service()?.zoomCamera(level) service()?.zoomCamera(level)
} }