feat(asr): 集成ASR助手并升级项目依赖

- 将Gradle版本从7.3.3升级到8.6
- 升级Kotlin版本从1.6.10到2.2.0并更新相关插件
- 升级Android Gradle Plugin到7.4.2
- 集成AsrHelper替代原有的IntentRecognizeHelper进行语音识别
- 添加Nova Nova唤醒词注册功能
- 更新SDK依赖版本并添加新的Maven仓库地址
- 移除废弃的kotlin-android-extensions插件
- 优化HeaderInterceptor中的HTTP响应处理逻辑
- 统一Toast消息显示方式为扩展函数实现
这个提交包含在:
徐勤民 2026-04-16 22:25:23 +08:00
父节点 a3e87727b7
当前提交 e389f9fda8
共有 12 个文件被更改,包括 222 次插入76 次删除

查看文件

@ -1,6 +1,5 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
@ -56,9 +55,6 @@ android {
jniLibs.srcDirs = ['libs']
}
}
androidExtensions {
experimental = true
}
namespace 'com.nova.brain.glass'
packagingOptions {
@ -80,9 +76,10 @@ dependencies {
implementation 'com.google.android.material:material:1.3.0'
implementation "io.noties.markwon:core:4.6.2"
implementation ('com.rokid.security:glass3.open.sdk:2.1.5-E') {
implementation ('com.rokid.security:glass3.open.sdk:2.1.6-E') {
exclude group: "org.slf4j"
}
implementation 'com.rokid.security.sdk:online-speech:0.1.0'
}

查看文件

@ -1,6 +1,7 @@
package com.nova.brain.glass;
import com.blankj.utilcode.util.Utils;
import com.nova.brain.glass.helper.AsrHelper;
import com.nova.brain.glass.helper.OfflineCmdServiceHelper;
import com.nova.brain.glass.repository.HeaderInterceptor;
import com.rokid.security.glass3.open.sdk.GlassSdk;
@ -42,6 +43,7 @@ public class MyApplication extends App {
@Override
public void onServiceConnected() {
OfflineCmdServiceHelper.INSTANCE.init();
AsrHelper.INSTANCE.init();
}
@Override

查看文件

@ -0,0 +1,178 @@
package com.nova.brain.glass.helper
import android.util.Log
import com.nova.brain.glass.model.RecognizeAction
import com.rokid.online.speech.AsrClient
import com.rokid.online.speech.OnlineSpeechSdk
import com.rokid.online.speech.OnlineSpeechSdkConfig
import com.rokid.online.speech.open.OpenSdkAudioSource
import com.xuqm.base.extensions.showMessage
object AsrHelper : OfflineCmdListener {
private const val TAG = "AsrHelper"
// 配置信息,从 online-speech-sdk-demo 参考项目复制
private const val DOMAIN = "api-test.rokid.com"
private const val ASR_PATH = "/ar/audio/api/ws/asr/streaming"
private const val TTS_PATH = "/ar/audio/api/ws/tts"
private const val AK = ""
private const val SK = ""
private const val UID = "demo-user"
private const val DEVICE_ID = "demo-device"
// 唤醒词Nova Nova
private const val WAKE_WORD = "Nova Nova"
private const val WAKE_WORD_PINYIN = "nou wa nou wa"
private var sdk: OnlineSpeechSdk? = null
private var asr: AsrClient? = null
private val audioSource = OpenSdkAudioSource()
private var isConnected = false
private var isMicRunning = false
// 拼接每次识别会话中的中间结果
private var currentPartial = ""
// 拼接跨多次识别的最终结果
private val sessionBuilder = StringBuilder()
/** 当前页面的场景标识,由各 Activity 在 onResume/onPause 中维护 */
var scene: String = "home"
/** goToDecisionCenter 命中时的回调,由各 Activity 在 onResume/onPause 中注册/清空 */
var onGoToDecisionCenter: ((action: RecognizeAction) -> Unit)? = null
fun init() {
val cfg = OnlineSpeechSdkConfig(
domain = DOMAIN,
ak = AK,
sk = SK,
uid = UID,
deviceId = DEVICE_ID,
asrPath = ASR_PATH,
ttsPath = TTS_PATH,
trustAllCerts = true,
staticHttpHeaders = mapOf(
"appCredential" to "userInfo",
"messageId" to "android-asr-${System.currentTimeMillis()}",
),
staticMessageHeaders = mapOf(
"appCredential" to "userInfo",
"messageId" to "android-asr-${System.currentTimeMillis()}",
),
)
sdk = OnlineSpeechSdk(cfg)
asr = sdk!!.createAsrClient().attachAudioSource(audioSource).also { setupAsrCallbacks(it) }
// 注册离线关键词 Nova Nova,GlassSdk 触发后启动 ASR
OfflineCmdServiceHelper.registerAsrWakeWord()
OfflineCmdServiceHelper.addOnLineListener(this)
// 自动建立 ASR 连接
asrConnect()
Log.d(TAG, "AsrHelper init done")
}
private fun asrConnect() {
asr?.connect()
Log.d(TAG, "ASR connect() called")
}
private fun asrStartMic() {
if (!isConnected) {
Log.w(TAG, "ASR startMic ignored: not connected")
return
}
if (isMicRunning) {
Log.w(TAG, "ASR startMic ignored: mic already running")
return
}
runCatching { asr?.startAsrWithMic() }
.onSuccess {
isMicRunning = true
Log.d(TAG, "ASR startAsrWithMic()")
}
.onFailure { Log.e(TAG, "ASR startAsrWithMic failed: ${it.message}") }
}
private fun setupAsrCallbacks(asrClient: AsrClient) {
asrClient.setListener(object : AsrClient.Listener {
override fun onOpen() {
isConnected = true
Log.d(TAG, "ASR websocket open")
}
override fun onStart(taskId: String) {
currentPartial = ""
sessionBuilder.clear()
Log.d(TAG, "ASR started: $taskId")
}
override fun onPartialResult(taskId: String, text: String) {
// 滚动更新当前识别中间结果
currentPartial = text
Log.d(TAG, "ASR partial: $text")
}
override fun onFinalResult(taskId: String, text: String) {
// 将最终结果追加拼接到会话字符串
sessionBuilder.append(text)
val fullText = sessionBuilder.toString()
isMicRunning = false
Log.d(TAG, "ASR final result: $fullText")
IntentRecognizeHelper.recognize(
text = fullText,
scence = scene,
onSuccess = { action ->
if (action.name == "goToDecisionCenter") {
onGoToDecisionCenter?.invoke(action)
} else {
"需要跳转任务列表".showMessage()
}
}
)
}
override fun onFinished(taskId: String) {
Log.d(TAG, "ASR ended: $taskId")
}
override fun onError(code: Int, message: String) {
Log.e(TAG, "ASR error code=$code msg=$message")
isMicRunning = false
}
override fun onClosed(code: Int, reason: String) {
isConnected = false
isMicRunning = false
Log.d(TAG, "ASR closed code=$code reason=$reason")
}
})
}
// 离线关键词回调:匹配唤醒词时启动麦克风
override fun onOfflineCmd(cmd: String) {
if (cmd == WAKE_WORD) {
Log.d(TAG, "Wake word triggered, starting mic")
asrStartMic()
}
}
fun close() {
OfflineCmdServiceHelper.removeOnLineListener(this)
if (isMicRunning) {
runCatching { asr?.stopAsrWithMic() }
isMicRunning = false
}
asr?.close()
sdk?.close()
asr = null
sdk = null
isConnected = false
sessionBuilder.clear()
Log.d(TAG, "AsrHelper closed")
}
}

查看文件

@ -10,6 +10,7 @@ import com.nova.brain.glass.repository.HeaderInterceptor
import com.nova.brain.glass.repository.Service
import com.rokid.utils.ContextUtil.getApplicationContext
import com.xuqm.base.di.manager.HttpManager
import com.xuqm.base.extensions.showMessage
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
@ -44,7 +45,6 @@ object IntentRecognizeHelper {
* @param onComplete 无论成功失败都会回调用于调用方重置 loading 状态
*/
fun recognize(
context: Context,
text: String? = null,
scence: String = "home",
onSuccess: (action: RecognizeAction) -> Unit,
@ -64,11 +64,11 @@ object IntentRecognizeHelper {
if (model.code == "0") {
onSuccess(model.data.action)
} else {
Toast.makeText(context, model.message, Toast.LENGTH_SHORT).show()
model.message.showMessage()
}
onComplete() // 无论成功失败都执行
}, { e ->
Toast.makeText(context, "请求失败: ${e.message}", Toast.LENGTH_SHORT).show()
"请求失败: ${e.message}".showMessage()
onComplete()
})
}

查看文件

@ -120,6 +120,11 @@ object OfflineCmdServiceHelper {
// 通用关键词在 init 时注册一次,页面切换不会移除它们
addCommonCmds()
}
// 注册 ASR 唤醒词(由 AsrHelper 调用)
fun registerAsrWakeWord() {
registerBeans(listOf(OfflineCmdBean("Nova Nova", "nou wa nou wa"),OfflineCmdBean("Nova Nova", "nao wa nao wa")))
}
fun addOnLineListener(listener: OfflineCmdListener){
this.listenerList.add(listener)
}

查看文件

@ -8,8 +8,6 @@ import com.xuqm.base.extensions.loge
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.internal.http.HttpHeaders
import okhttp3.internal.http.StatusLine
import okio.Buffer
import okio.BufferedSource
import okio.GzipSource
@ -38,9 +36,9 @@ class HeaderInterceptor(val context: Context) : Interceptor {
// context.getStringForPreferences(SHARE_UESR_TOKEN).loge()
val request = requestBuilder.build()
"${request.url()}(${request.method()})".loge()
"${request.url}(${request.method})".loge()
val headers = request.headers()
val headers = request.headers
val response = chain.proceed(request)
@ -50,7 +48,7 @@ class HeaderInterceptor(val context: Context) : Interceptor {
return response
}
response.body()?.also {
response.body?.also {
if (!bodyHasUnknownEncoding(headers) && hasBody(response)) {
val source: BufferedSource = it.source()
source.request(Long.MAX_VALUE) // Buffer the entire body.
@ -108,11 +106,11 @@ class HeaderInterceptor(val context: Context) : Interceptor {
fun hasBody(response: Response): Boolean {
// HEAD requests never yield a body regardless of the response headers.
if (response.request().method() == "HEAD") {
if (response.request.method == "HEAD") {
return false
}
val responseCode = response.code()
if ((responseCode < StatusLine.HTTP_CONTINUE || responseCode >= 200)
val responseCode = response.code
if ((responseCode < 100 || responseCode >= 200)
&& responseCode != HttpURLConnection.HTTP_NO_CONTENT && responseCode != HttpURLConnection.HTTP_NOT_MODIFIED
) {
return true
@ -120,7 +118,8 @@ class HeaderInterceptor(val context: Context) : Interceptor {
// If the Content-Length or Transfer-Encoding headers disagree with the response code, the
// response is malformed. For best compatibility, we honor the headers.
return HttpHeaders.contentLength(response) != -1L || "chunked".equals(
val contentLength = response.headers["Content-Length"]?.toLongOrNull() ?: -1L
return contentLength != -1L || "chunked".equals(
response.header("Transfer-Encoding"),
ignoreCase = true
)

查看文件

@ -4,8 +4,8 @@ import android.view.View
import android.widget.TextView
import com.nova.brain.glass.R
import com.nova.brain.glass.databinding.ActivityChatBinding
import com.nova.brain.glass.helper.AsrHelper
import com.nova.brain.glass.helper.BgChatDrawable
import com.nova.brain.glass.helper.IntentRecognizeHelper
import com.nova.brain.glass.helper.OfflineCmdListener
import com.nova.brain.glass.helper.OfflineCmdServiceHelper
import com.nova.brain.glass.model.ChatItem
@ -54,31 +54,8 @@ class ChatActivity : BaseListFormLayoutNormalActivity<ChatItem, ChatVM, Activity
}
}
/**
* 调用意图识别识别成功后发起 SSE 请求
* 立即启动 pb 旋转不等 recognize 接口响应
*/
private fun recognizeAndChat() {
binding.pb.visibility = View.VISIBLE
binding.pb1.visibility = View.INVISIBLE
IntentRecognizeHelper.recognize(
context = this,
scence = "decision",
onSuccess = { action ->
if (action.name == "goToDecisionCenter") {
viewModel.demoPostSse(action.params.question)
} else {
// 识别成功但没有匹配动作,停止旋转
binding.pb.isIndeterminate = false
binding.pb.visibility = View.INVISIBLE
}
},
onComplete = {
// recognize 失败时兜底停止旋转(成功路径由 loading LiveData 接管)
binding.pb.isIndeterminate = false
binding.pb.visibility = View.INVISIBLE
}
)
// 识别由 AsrHelper 在 onFinalResult 中统一触发,此处无需主动发起
}
private fun scrollToBottom() {
@ -116,15 +93,19 @@ class ChatActivity : BaseListFormLayoutNormalActivity<ChatItem, ChatVM, Activity
override fun onResume() {
super.onResume()
OfflineCmdServiceHelper.addOnLineListener(listener)
AsrHelper.scene = "decision"
AsrHelper.onGoToDecisionCenter = { action ->
viewModel.demoPostSse(action.params.question)
}
}
override fun onPause() {
super.onPause()
OfflineCmdServiceHelper.removeOnLineListener(listener)
AsrHelper.onGoToDecisionCenter = null
}
override fun onDestroy() {
super.onDestroy()
IntentRecognizeHelper.dispose()
}
}

查看文件

@ -7,7 +7,7 @@ import android.os.Looper
import androidx.lifecycle.ViewModelProvider
import com.nova.brain.glass.R
import com.nova.brain.glass.databinding.ActivityWelcomeBinding
import com.nova.brain.glass.helper.IntentRecognizeHelper
import com.nova.brain.glass.helper.AsrHelper
import com.nova.brain.glass.helper.OfflineCmdListener
import com.nova.brain.glass.helper.OfflineCmdServiceHelper
import com.nova.brain.glass.viewmodel.WelcomeVM
@ -47,34 +47,12 @@ class WelcomeActivity : BaseActivity<ActivityWelcomeBinding>() {
super.initView(savedInstanceState)
vm = ViewModelProvider(this)[WelcomeVM::class.java]
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
binding.tv.setOnClickListener {
triggerRecognize()
}
}
override fun initData() {
super.initData()
}
private fun triggerRecognize() {
startDotsAnim()
IntentRecognizeHelper.recognize(
context = this,
text = "当前阶段,最紧急的任务是什么?",
onSuccess = { action ->
if (action.name == "goToDecisionCenter") {
startActivity(
Intent(this, ChatActivity::class.java)
.putExtra("question", action.params.question)
)
}
},
onComplete = {
stopDotsAnim()
}
)
}
private val offlineCmdListener = object : OfflineCmdListener {
override fun onOfflineCmd(cmd: String) {
runOnUiThread {
@ -82,9 +60,6 @@ class WelcomeActivity : BaseActivity<ActivityWelcomeBinding>() {
"任务列表", "查看任务", "查看任务列表" -> {
startActivity(Intent(this@WelcomeActivity, TaskListActivity::class.java))
}
"决策中心", "紧急任务", "当前任务" -> {
triggerRecognize()
}
}
}
}
@ -95,6 +70,13 @@ class WelcomeActivity : BaseActivity<ActivityWelcomeBinding>() {
OfflineCmdServiceHelper.addOnLineListener(offlineCmdListener)
OfflineCmdServiceHelper.addListenerWelcome()
stopDotsAnim() // 从 ChatActivity 返回时恢复原文
AsrHelper.scene = "home"
AsrHelper.onGoToDecisionCenter = { action ->
startActivity(
Intent(this, ChatActivity::class.java)
.putExtra("question", action.params.question)
)
}
}
override fun onPause() {
@ -102,6 +84,7 @@ class WelcomeActivity : BaseActivity<ActivityWelcomeBinding>() {
OfflineCmdServiceHelper.removeOnLineListener(offlineCmdListener)
OfflineCmdServiceHelper.removeListenerWelcome()
dotsHandler.removeCallbacks(dotsRunnable)
AsrHelper.onGoToDecisionCenter = null
}
override fun onDestroy() {

查看文件

@ -6,7 +6,7 @@ import com.xuqm.base.App
import com.xuqm.base.di.manager.HttpManager
import com.xuqm.sdhbwfu.core.viewModel.BaseViewModel
import io.reactivex.schedulers.Schedulers
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
class WelcomeVM : BaseViewModel() {
@ -26,7 +26,7 @@ class WelcomeVM : BaseViewModel() {
fun demoPost() {
result.value = "POST 请求中..."
val json = """{"demo":"post","from":"glass"}"""
val body = RequestBody.create(MediaType.parse("application/json"), json)
val body = RequestBody.create("application/json".toMediaTypeOrNull(), json)
HttpManager.getApi(Service::class.java).demoPost(body)
.subscribeOn(Schedulers.io())
.subscribe({ resp ->

查看文件

@ -1,12 +1,13 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
apply from: "config.gradle"
buildscript {
ext.kotlin_version = "1.6.10"
ext.kotlin_version = "2.2.0"
repositories {
google()
mavenCentral()
maven { url 'https://nexus.xuqinmin.com/repository/android/' }
maven { url 'https://maven.rokid.com/repository/maven-public/' }
maven { url 'https://maven.rokid-inc.com/repository/maven-public/' }
maven { url 'https://maven.aliyun.com/nexus/content/repositories/releases/' }
maven { url 'https://maven.aliyun.com/repository/google/' }
maven { url 'https://maven.aliyun.com/repository/public/' }
@ -18,8 +19,8 @@ buildscript {
// }
}
dependencies {
classpath 'com.android.tools.build:gradle:7.2.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.0"
classpath 'com.android.tools.build:gradle:7.4.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.0"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
@ -33,6 +34,7 @@ allprojects {
// maven { url 'http://developer.huawei.com/repo/' }
maven { url 'https://nexus.xuqinmin.com/repository/android/' }
maven { url 'https://maven.rokid.com/repository/maven-public/' }
maven { url 'https://maven.rokid-inc.com/repository/maven-public/' }
maven { url 'https://maven.aliyun.com/nexus/content/repositories/releases/' }
maven { url 'https://maven.aliyun.com/repository/google/' }
maven { url 'https://maven.aliyun.com/repository/public/' }

查看文件

@ -1,6 +1,5 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion versions.compileSdk

查看文件

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip