docs(helper): 更新语音识别和图像解码工具类文档
- 为 AsrHelper 添加详细的类注释,说明工作流程、连接管理和场景路由机制 - 为 BitmapDecodeHelper 添加类注释,解释 AR 眼镜低内存场景下的优化策略 - 为 GlassMediaServiceHelper 添加类注释,说明双检锁懒加载缓存机制 - 为 MyApplication 添加类注释,详细说明多域名 AppComponent 初始化和 SDK 绑定流程 - 为各个关键方法添加详细的 KDoc 注释,包括参数说明和使用场景 - 优化代码注释的中文表达,使其更加清晰易懂
这个提交包含在:
父节点
dcf9d51014
当前提交
c2d8a0f40e
170
CLAUDE.md
普通文件
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 相关扩展、自定义 View(Kotlin)
|
||||
└── 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**: 26(Android 8.0)
|
||||
- **目标 SDK**: 29(Android 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" → onDirectChat(ChatActivity 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;
|
||||
|
||||
/**
|
||||
* 应用全局入口。
|
||||
*
|
||||
* <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
|
||||
*/
|
||||
public class MyApplication extends App {
|
||||
|
||||
// 主后台服务地址(通过内网穿透暴露到公网)
|
||||
public static String baseUrl = "http://22fs132201.imwork.net";
|
||||
// public static String baseUrl = "http://192.168.6.20";
|
||||
|
||||
// 意图识别
|
||||
// 意图识别服务的 Dagger 网络组件
|
||||
public static AppComponent appComponent1;
|
||||
// 喷涂
|
||||
// 喷涂质检服务的 Dagger 网络组件
|
||||
public static AppComponent appComponent2;
|
||||
// 单证检验服务的 Dagger 网络组件
|
||||
public static AppComponent appComponent3;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
// 为每个域名单独构建 OkHttpClient + Retrofit 实例,互不干扰
|
||||
appComponent = HttpManager.getAppComponent(baseUrl, new HeaderInterceptor(getApplicationContext()));
|
||||
appComponent1 = 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()));
|
||||
|
||||
initSdk();
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -45,27 +63,34 @@ public class MyApplication extends App {
|
||||
return super.showLog();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 绑定 Rokid Glass 安全服务。
|
||||
*
|
||||
* <p>Glass SDK 需要通过系统 AIDL 服务完成硬件鉴权,绑定成功后才能使用离线关键词和相机等能力。
|
||||
* 离线关键词({@link OfflineCmdServiceHelper})和在线语音({@link AsrHelper})必须在此回调中初始化,
|
||||
* 否则底层 SDK 尚未就绪,调用会静默失败。
|
||||
*/
|
||||
void initSdk() {
|
||||
// 如果SDK已经初始化了,则直接返回
|
||||
// SDK 已就绪时无需重复绑定(进程保活场景下可能触发)
|
||||
if (GlassSdk.isReady()) {
|
||||
return;
|
||||
}
|
||||
GlassSdk.bindSecurityService(Utils.getApp(), new IServiceConnectionCallback() {
|
||||
@Override
|
||||
public void onServiceConnected() {
|
||||
// 先注册离线关键词,再初始化在线语音(ASR 唤醒词依赖离线服务)
|
||||
OfflineCmdServiceHelper.INSTANCE.init();
|
||||
AsrHelper.INSTANCE.init();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected() {
|
||||
|
||||
// 系统服务断开,硬件能力暂时不可用,等待系统自动重连
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindingDied() {
|
||||
|
||||
// Binder 死亡(通常是系统进程崩溃),需要重新绑定;当前版本依赖系统自恢复
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -27,6 +27,25 @@ import com.rokid.online.speech.open.OpenSdkAudioSource
|
||||
import com.xuqm.base.common.AppManager
|
||||
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 {
|
||||
|
||||
private const val TAG = "AsrHelper"
|
||||
@ -40,38 +59,45 @@ object AsrHelper : OfflineCmdListener {
|
||||
private val ASR_PATH get() = BuildConfig.SPEECH_ASR_PATH
|
||||
private val TTS_PATH get() = BuildConfig.SPEECH_TTS_PATH
|
||||
|
||||
// 唤醒词:Nova Nova
|
||||
// 支持的唤醒词(与 OfflineCmdServiceHelper.registerAsrWakeWord 中注册的词保持一致)
|
||||
private const val WAKE_WORD1 = "飞宝飞宝"
|
||||
|
||||
private var sdk: OnlineSpeechSdk? = null
|
||||
private var asr: AsrClient? = null
|
||||
private var tts: TtsClient? = null
|
||||
// audioSource 和 ttsPlayer 由 SDK 内部管理生命周期,随 asr/tts 连接一同关闭
|
||||
private val audioSource = OpenSdkAudioSource()
|
||||
private val ttsPlayer = AndroidPcmTtsStreamPlayer()
|
||||
|
||||
private var isConnected = false
|
||||
private var isMicRunning = false
|
||||
private var isTtsConnected = false
|
||||
private var isAsrConnecting = false
|
||||
private var isTtsConnecting = false
|
||||
private var pendingStartMic = false
|
||||
private var isClosed = false
|
||||
private var asrReconnectAttempt = 0
|
||||
private var ttsReconnectAttempt = 0
|
||||
private var asrReconnectPaused = false
|
||||
private var ttsReconnectPaused = false
|
||||
// ---- 连接状态机 ----
|
||||
private var isConnected = false // ASR WebSocket 已连接
|
||||
private var isMicRunning = false // 麦克风正在采集音频
|
||||
private var isTtsConnected = false // TTS WebSocket 已连接
|
||||
private var isAsrConnecting = false // ASR 正在建立连接(防止重复发起)
|
||||
private var isTtsConnecting = false // TTS 正在建立连接(防止重复发起)
|
||||
private var pendingStartMic = false // ASR 未连接时收到启动麦克风请求,连接成功后补发
|
||||
private var isClosed = false // 已调用 close(),阻止重连循环
|
||||
private var asrReconnectAttempt = 0 // ASR 当前重连次数
|
||||
private var ttsReconnectAttempt = 0 // TTS 当前重连次数
|
||||
private var asrReconnectPaused = false // ASR 重连已达上限,暂停直到网络恢复
|
||||
private var ttsReconnectPaused = false // TTS 重连已达上限,暂停直到网络恢复
|
||||
private var connectivityManager: ConnectivityManager? = null
|
||||
private var networkCallbackRegistered = false
|
||||
|
||||
// 唤醒响应文本(TTS 播报,播报结束后自动开启麦克风)
|
||||
private const val WAKE_RESPONSE = "在呢,您请说"
|
||||
// 用户说话超时:30 秒内无最终结果则自动停止麦克风
|
||||
private const val LISTENING_TIMEOUT_MS = 30_000L
|
||||
// 重连退避参数
|
||||
private const val BASE_RECONNECT_DELAY_MS = 3_000L
|
||||
private const val MAX_RECONNECT_DELAY_MS = 30_000L
|
||||
private const val MAX_RECONNECT_ATTEMPTS = 5
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var listeningDialog: AlertDialog? = null
|
||||
private var noNetworkDialog: AlertDialog? = null
|
||||
private var listeningDialog: AlertDialog? = null // 正在聆听的半透明提示框
|
||||
private var noNetworkDialog: AlertDialog? = null // 无网络时的全屏提示框
|
||||
|
||||
// 30 秒内无最终结果时自动停止麦克风,防止长时间占用音频焦点
|
||||
private val listeningTimeoutRunnable = Runnable {
|
||||
if (!isMicRunning) return@Runnable
|
||||
runCatching { asr?.stopAsrWithMic() }
|
||||
@ -79,6 +105,8 @@ object AsrHelper : OfflineCmdListener {
|
||||
dismissListeningDialog()
|
||||
Log.d(TAG, "ASR listening timeout after ${LISTENING_TIMEOUT_MS}ms")
|
||||
}
|
||||
|
||||
// 每秒检查一次网络;恢复后关闭无网对话框,否则持续显示
|
||||
private val networkCheckRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (isNetworkAvailable()) {
|
||||
@ -89,6 +117,8 @@ object AsrHelper : OfflineCmdListener {
|
||||
mainHandler.postDelayed(this, 1000L)
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟重连任务(由 scheduleAsrReconnect 投递到 mainHandler)
|
||||
private val asrReconnectRunnable = Runnable {
|
||||
if (shouldReconnectAsr()) {
|
||||
asrConnect()
|
||||
@ -99,6 +129,8 @@ object AsrHelper : OfflineCmdListener {
|
||||
ttsConnect()
|
||||
}
|
||||
}
|
||||
|
||||
// 网络状态监听:恢复时立即重连,不需要等待下一轮轮询
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
Log.d(TAG, "Network available, reconnect speech channels if needed")
|
||||
@ -112,27 +144,34 @@ object AsrHelper : OfflineCmdListener {
|
||||
}
|
||||
}
|
||||
|
||||
// 拼接每次识别会话中的中间结果
|
||||
// 用 StringBuilder 累积本次会话的中间识别结果,避免频繁创建短生命周期 String 对象
|
||||
private val currentPartial = StringBuilder()
|
||||
|
||||
/** 当前页面的场景标识,由各 Activity 在 onResume/onPause 中维护 */
|
||||
/**
|
||||
* 当前页面的场景标识,由各 Activity 在 onResume/onPause 中维护。
|
||||
* 可选值:"home" | "list" | "decision"
|
||||
*/
|
||||
var scene: String = "home"
|
||||
|
||||
/** goToDecisionCenter 命中时的回调,由各 Activity 在 onResume/onPause 中注册/清空 */
|
||||
/** goToDecisionCenter 意图命中时的回调,由各 Activity 在 onResume/onPause 中注册/清空 */
|
||||
var onGoToDecisionCenter: ((action: RecognizeAction) -> Unit)? = null
|
||||
|
||||
/** goToTaskCenter 命中时的回调,由各 Activity 在 onResume/onPause 中注册/清空 */
|
||||
/** goToTaskCenter 意图命中时的回调,由各 Activity 在 onResume/onPause 中注册/清空 */
|
||||
var onGoToTaskCenter: ((action: RecognizeAction) -> Unit)? = null
|
||||
|
||||
/** list 场景下由 TaskListActivity 注册,返回当前列表数据作为 extra 传给服务端 */
|
||||
/** list 场景下由 TaskListActivity 注册,返回当前列表数据作为 extra 传给服务端辅助意图识别 */
|
||||
var extraProvider: (() -> List<Any>)? = null
|
||||
|
||||
/** openTaskDetail 命中时的回调,由 TaskListActivity 在 onResume/onPause 中注册/清空 */
|
||||
/** openTaskDetail 意图命中时的回调,由 TaskListActivity 在 onResume/onPause 中注册/清空 */
|
||||
var onOpenTaskDetail: ((action: RecognizeAction) -> Unit)? = null
|
||||
|
||||
/** scene == "decision" 时直接用 ASR 文本发起对话,由 ChatActivity 注册 */
|
||||
/** scene == "decision" 时跳过意图识别,直接将 ASR 文本发给 ChatActivity 发起对话 */
|
||||
var onDirectChat: ((text: String) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* 初始化 SDK、注册唤醒词并建立 ASR/TTS 连接。
|
||||
* 必须在 GlassSdk.bindSecurityService 的 onServiceConnected 回调中调用。
|
||||
*/
|
||||
fun init() {
|
||||
isClosed = false
|
||||
val cfg = OnlineSpeechSdkConfig(
|
||||
@ -143,6 +182,7 @@ object AsrHelper : OfflineCmdListener {
|
||||
deviceId = DEVICE_ID,
|
||||
asrPath = ASR_PATH,
|
||||
ttsPath = TTS_PATH,
|
||||
// 内网自签证书,跳过 SSL 校验
|
||||
trustAllCerts = true,
|
||||
staticHttpHeaders = mapOf(
|
||||
"appCredential" to "userInfo",
|
||||
@ -158,12 +198,12 @@ object AsrHelper : OfflineCmdListener {
|
||||
asr = sdk!!.createAsrClient().attachAudioSource(audioSource).also { setupAsrCallbacks(it) }
|
||||
tts = sdk!!.createTtsClient().attachStreamPlayer(ttsPlayer).also { setupTtsCallbacks(it) }
|
||||
|
||||
// 注册离线关键词 Nova Nova,GlassSdk 触发后启动 ASR
|
||||
// 注册唤醒词到离线引擎,触发后进入在线 ASR 流程
|
||||
OfflineCmdServiceHelper.registerAsrWakeWord()
|
||||
OfflineCmdServiceHelper.addOnLineListener(this)
|
||||
registerNetworkCallback()
|
||||
|
||||
// 自动建立 ASR / TTS 连接
|
||||
// 立即建立 ASR / TTS 连接,缩短首次唤醒的响应延迟
|
||||
resetReconnectState()
|
||||
asrConnect()
|
||||
ttsConnect()
|
||||
@ -199,8 +239,13 @@ object AsrHelper : OfflineCmdListener {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动麦克风开始 ASR 识别。
|
||||
* 若 ASR 连接尚未就绪,则置 [pendingStartMic]=true,等 onOpen 回调时补发。
|
||||
*/
|
||||
private fun asrStartMic() {
|
||||
if (!isConnected) {
|
||||
// 重连已暂停时(超出最大次数),先重置再重试
|
||||
if (asrReconnectPaused) {
|
||||
resetAsrReconnectState()
|
||||
}
|
||||
@ -231,6 +276,10 @@ object AsrHelper : OfflineCmdListener {
|
||||
mainHandler.removeCallbacks(listeningTimeoutRunnable)
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排 ASR 重连任务(指数退避)。
|
||||
* 超过 [MAX_RECONNECT_ATTEMPTS] 次后置 paused,等待网络恢复事件重置。
|
||||
*/
|
||||
private fun scheduleAsrReconnect(delayMs: Long? = null) {
|
||||
if (!shouldReconnectAsr()) return
|
||||
if (asrReconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
|
||||
@ -241,11 +290,13 @@ object AsrHelper : OfflineCmdListener {
|
||||
}
|
||||
asrReconnectAttempt += 1
|
||||
val actualDelayMs = delayMs ?: calculateReconnectDelay(asrReconnectAttempt)
|
||||
// 先取消已有的延迟任务,防止重复投递
|
||||
mainHandler.removeCallbacks(asrReconnectRunnable)
|
||||
mainHandler.postDelayed(asrReconnectRunnable, actualDelayMs)
|
||||
Log.d(TAG, "ASR reconnect scheduled in ${actualDelayMs}ms, attempt=$asrReconnectAttempt")
|
||||
}
|
||||
|
||||
/** 安排 TTS 重连任务(指数退避),逻辑同 [scheduleAsrReconnect]。 */
|
||||
private fun scheduleTtsReconnect(delayMs: Long? = null) {
|
||||
if (!shouldReconnectTts()) return
|
||||
if (ttsReconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
|
||||
@ -279,6 +330,10 @@ object AsrHelper : OfflineCmdListener {
|
||||
return !isClosed && !isTtsConnected && !isTtsConnecting && !ttsReconnectPaused && isNetworkAvailable()
|
||||
}
|
||||
|
||||
/**
|
||||
* 指数退避延迟:attempt=1 → 3s,attempt=2 → 6s,attempt=3 → 12s,上限 30s。
|
||||
* 使用位移而非 pow() 避免浮点运算。
|
||||
*/
|
||||
private fun calculateReconnectDelay(attempt: Int): Long {
|
||||
val factor = 1L shl (attempt - 1).coerceAtLeast(0)
|
||||
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) {
|
||||
if (handleNoNetworkDialogCmd(cmd)) return
|
||||
if (cmd == WAKE_WORD1 || cmd == "C大脑") {
|
||||
@ -545,6 +606,7 @@ object AsrHelper : OfflineCmdListener {
|
||||
}
|
||||
showListeningDialog()
|
||||
restartListeningTimeout()
|
||||
// 提前设置 pending,确保 TTS 播报期间若 ASR 连接断开也能在重连后自动补发
|
||||
pendingStartMic = true
|
||||
resetReconnectState()
|
||||
if (isTtsConnected) {
|
||||
@ -584,9 +646,16 @@ object AsrHelper : OfflineCmdListener {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭并释放所有资源。
|
||||
*
|
||||
* 调用后 [isClosed]=true,所有重连逻辑将拒绝执行,防止已销毁状态下触发新连接。
|
||||
* 调用顺序:先取消所有 pending 任务 → 关闭麦克风 → 关闭 WebSocket → 释放 SDK。
|
||||
*/
|
||||
fun close() {
|
||||
isClosed = true
|
||||
OfflineCmdServiceHelper.removeOnLineListener(this)
|
||||
// 取消所有 Handler 延迟任务,防止 close 后仍触发重连或超时回调
|
||||
mainHandler.removeCallbacks(listeningTimeoutRunnable)
|
||||
mainHandler.removeCallbacks(networkCheckRunnable)
|
||||
mainHandler.removeCallbacks(asrReconnectRunnable)
|
||||
@ -598,6 +667,7 @@ object AsrHelper : OfflineCmdListener {
|
||||
}
|
||||
dismissListeningDialog()
|
||||
unregisterNetworkCallback()
|
||||
// 按依赖顺序关闭:ASR/TTS Client → SDK
|
||||
asr?.close()
|
||||
tts?.close()
|
||||
sdk?.close()
|
||||
|
||||
@ -4,20 +4,45 @@ import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* 图片采样解码工具。
|
||||
*
|
||||
* 针对 AR 眼镜低内存场景做了两处优化:
|
||||
* 1. 先用 inJustDecodeBounds=true 只读取图片尺寸,不分配像素内存;
|
||||
* 2. 用 RGB_565(16-bit)替代默认的 ARGB_8888(32-bit),内存占用减少 50%。
|
||||
*/
|
||||
object BitmapDecodeHelper {
|
||||
|
||||
/**
|
||||
* 按目标显示尺寸对图片进行采样解码,避免加载原图导致 OOM。
|
||||
*
|
||||
* @param path 图片文件的绝对路径
|
||||
* @param reqWidth 目标宽度(像素),通常为 ImageView 的测量宽度
|
||||
* @param reqHeight 目标高度(像素),通常为 ImageView 的测量高度
|
||||
* @return 解码后的 Bitmap;文件不存在或解码失败时返回 null
|
||||
*/
|
||||
fun decodeSampledBitmap(path: String, reqWidth: Int, reqHeight: Int): Bitmap? {
|
||||
// 第一次解码:只读尺寸,不分配像素内存
|
||||
val boundsOptions = BitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeFile(path, boundsOptions)
|
||||
|
||||
// 第二次解码:按计算出的采样率加载
|
||||
val decodeOptions = BitmapFactory.Options().apply {
|
||||
inSampleSize = calculateInSampleSize(boundsOptions, reqWidth, reqHeight)
|
||||
// RGB_565 比 ARGB_8888 节省一半内存,眼镜屏幕不需要 alpha 通道
|
||||
inPreferredConfig = Bitmap.Config.RGB_565
|
||||
}
|
||||
return BitmapFactory.decodeFile(path, decodeOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 2 的幂次采样率,使解码后的尺寸尽量接近但不小于目标尺寸。
|
||||
*
|
||||
* 算法逻辑:每轮将采样率翻倍,直到缩放后的尺寸比目标尺寸小为止,
|
||||
* 取上一轮的采样率以保证解码图不小于显示区域(防止模糊)。
|
||||
*/
|
||||
private fun calculateInSampleSize(
|
||||
options: BitmapFactory.Options,
|
||||
reqWidth: Int,
|
||||
@ -32,6 +57,7 @@ object BitmapDecodeHelper {
|
||||
if (height > reqHeight || width > reqWidth) {
|
||||
val halfHeight = height / 2
|
||||
val halfWidth = width / 2
|
||||
// 只要缩放后的尺寸仍大于等于目标尺寸,就继续翻倍采样率
|
||||
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
|
||||
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.PhotoFileCallback
|
||||
|
||||
/**
|
||||
* Rokid Glass 媒体服务的应用层封装。
|
||||
*
|
||||
* Glass SDK 的 IMediaServer 通过系统 AIDL 提供,首次获取有一定延迟。
|
||||
* 此处用双检锁(@Volatile + 空检查)做懒加载缓存,避免每次调用都跨进程查询。
|
||||
* 所有方法均为可空安全调用(?.),若服务未就绪则静默忽略,不会崩溃。
|
||||
*/
|
||||
object GlassMediaServiceHelper {
|
||||
|
||||
@Volatile
|
||||
private var mediaService: IMediaServer? = null
|
||||
|
||||
/** 获取媒体服务实例(首次调用时从 SDK 获取并缓存)。 */
|
||||
private fun service(): IMediaServer? {
|
||||
val cached = mediaService
|
||||
if (cached != null) {
|
||||
@ -20,34 +29,47 @@ object GlassMediaServiceHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发拍照,结果通过 [addPhotoCallback] 注册的回调异步返回。
|
||||
*
|
||||
* @param resolution 分辨率常量,如 PhotoResolution.RESOLUTION_720P
|
||||
* @param path 照片保存的绝对路径
|
||||
*/
|
||||
fun takePhoto(resolution: Int, path: String) {
|
||||
service()?.takePhoto(resolution, path)
|
||||
}
|
||||
|
||||
/** 注册拍照结果回调(建议在 onResume 中调用)。 */
|
||||
fun addPhotoCallback(callback: PhotoFileCallback) {
|
||||
service()?.addPhotoCallback(callback)
|
||||
}
|
||||
|
||||
/** 注销拍照结果回调(建议在 onPause 中调用,防止内存泄漏)。 */
|
||||
fun removePhotoCallback(callback: PhotoFileCallback) {
|
||||
service()?.removePhotoCallback(callback)
|
||||
}
|
||||
|
||||
/** 开始将相机画面共享到指定 Surface(用于预览)。 */
|
||||
fun startCameraShare(surface: Surface, callback: ICameraSurfaceCallback) {
|
||||
service()?.startCameraShare(surface, callback)
|
||||
}
|
||||
|
||||
/** 停止相机画面共享。 */
|
||||
fun stopCameraShare(callback: ICameraSurfaceCallback) {
|
||||
service()?.stopCameraShare(callback)
|
||||
}
|
||||
|
||||
/** 获取当前硬件支持的最大变焦级别。 */
|
||||
fun getMaxZoomLevel(): Int {
|
||||
return service()?.maxZoomLevel ?: 0
|
||||
}
|
||||
|
||||
/** 获取当前变焦级别。 */
|
||||
fun getZoomLevel(): Int {
|
||||
return service()?.zoomLevel ?: 0
|
||||
}
|
||||
|
||||
/** 设置变焦级别(0 为广角,最大值见 [getMaxZoomLevel])。 */
|
||||
fun zoomCamera(level: Int) {
|
||||
service()?.zoomCamera(level)
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户