Browse Source

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消息显示方式为扩展函数实现
徐勤民 9 hours ago
parent
commit
e389f9fda8

+ 2 - 5
app/build.gradle

@@ -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'
 
 
 }

+ 2 - 0
app/src/main/java/com/nova/brain/glass/MyApplication.java

@@ -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

+ 178 - 0
app/src/main/java/com/nova/brain/glass/helper/AsrHelper.kt

@@ -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")
+    }
+}

+ 3 - 3
app/src/main/java/com/nova/brain/glass/helper/IntentRecognizeHelper.kt

@@ -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()
             })
     }

+ 5 - 0
app/src/main/java/com/nova/brain/glass/helper/OfflineCmdServiceHelper.kt

@@ -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 - 9
app/src/main/java/com/nova/brain/glass/repository/HeaderInterceptor.kt

@@ -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
         )

+ 7 - 26
app/src/main/java/com/nova/brain/glass/ui/ChatActivity.kt

@@ -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()
     }
 }

+ 9 - 26
app/src/main/java/com/nova/brain/glass/ui/WelcomeActivity.kt

@@ -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() {

+ 2 - 2
app/src/main/java/com/nova/brain/glass/viewmodel/WelcomeVM.kt

@@ -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 ->

+ 5 - 3
build.gradle

@@ -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/' }

+ 0 - 1
core/build.gradle

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

+ 1 - 1
gradle/wrapper/gradle-wrapper.properties

@@ -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