diff --git a/app/build.gradle b/app/build.gradle index d11cab6..c7bbe0d 100644 --- a/app/build.gradle +++ b/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' } diff --git a/app/src/main/java/com/nova/brain/glass/MyApplication.java b/app/src/main/java/com/nova/brain/glass/MyApplication.java index 2bd8313..ab24c96 100644 --- a/app/src/main/java/com/nova/brain/glass/MyApplication.java +++ b/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 diff --git a/app/src/main/java/com/nova/brain/glass/helper/AsrHelper.kt b/app/src/main/java/com/nova/brain/glass/helper/AsrHelper.kt new file mode 100644 index 0000000..2da24cd --- /dev/null +++ b/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") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nova/brain/glass/helper/IntentRecognizeHelper.kt b/app/src/main/java/com/nova/brain/glass/helper/IntentRecognizeHelper.kt index dbfcfd8..bba74b0 100644 --- a/app/src/main/java/com/nova/brain/glass/helper/IntentRecognizeHelper.kt +++ b/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() }) } diff --git a/app/src/main/java/com/nova/brain/glass/helper/OfflineCmdServiceHelper.kt b/app/src/main/java/com/nova/brain/glass/helper/OfflineCmdServiceHelper.kt index 7baedd1..2089d33 100644 --- a/app/src/main/java/com/nova/brain/glass/helper/OfflineCmdServiceHelper.kt +++ b/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) } diff --git a/app/src/main/java/com/nova/brain/glass/repository/HeaderInterceptor.kt b/app/src/main/java/com/nova/brain/glass/repository/HeaderInterceptor.kt index 8a5faf5..d585316 100644 --- a/app/src/main/java/com/nova/brain/glass/repository/HeaderInterceptor.kt +++ b/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 ) diff --git a/app/src/main/java/com/nova/brain/glass/ui/ChatActivity.kt b/app/src/main/java/com/nova/brain/glass/ui/ChatActivity.kt index ac32eca..e12ad52 100644 --- a/app/src/main/java/com/nova/brain/glass/ui/ChatActivity.kt +++ b/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 - 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 + viewModel.demoPostSse(action.params.question) + } } override fun onPause() { super.onPause() OfflineCmdServiceHelper.removeOnLineListener(listener) + AsrHelper.onGoToDecisionCenter = null } override fun onDestroy() { super.onDestroy() - IntentRecognizeHelper.dispose() } } \ No newline at end of file diff --git a/app/src/main/java/com/nova/brain/glass/ui/WelcomeActivity.kt b/app/src/main/java/com/nova/brain/glass/ui/WelcomeActivity.kt index 286f2c7..5544e02 100644 --- a/app/src/main/java/com/nova/brain/glass/ui/WelcomeActivity.kt +++ b/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() { 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() { "任务列表", "查看任务", "查看任务列表" -> { startActivity(Intent(this@WelcomeActivity, TaskListActivity::class.java)) } - "决策中心", "紧急任务", "当前任务" -> { - triggerRecognize() - } } } } @@ -95,6 +70,13 @@ class WelcomeActivity : BaseActivity() { 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() { OfflineCmdServiceHelper.removeOnLineListener(offlineCmdListener) OfflineCmdServiceHelper.removeListenerWelcome() dotsHandler.removeCallbacks(dotsRunnable) + AsrHelper.onGoToDecisionCenter = null } override fun onDestroy() { diff --git a/app/src/main/java/com/nova/brain/glass/viewmodel/WelcomeVM.kt b/app/src/main/java/com/nova/brain/glass/viewmodel/WelcomeVM.kt index 3ca58e3..37e392c 100644 --- a/app/src/main/java/com/nova/brain/glass/viewmodel/WelcomeVM.kt +++ b/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 -> diff --git a/build.gradle b/build.gradle index 7d7271c..7dab5ba 100644 --- a/build.gradle +++ b/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/' } diff --git a/core/build.gradle b/core/build.gradle index c9eda76..29b5fcc 100644 --- a/core/build.gradle +++ b/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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index bb7f445..e056b8d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/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