ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

科大讯飞语音链路 Demo:从唤醒到播报的完整抽离

科大讯飞语音链路 Demo:从唤醒到播报的完整抽离 在一个桌面类 Android 项目里语音助手经常会越写越“粘”首页要显示状态悬浮窗要同步状态业务指令要启动系统设置通话页面又要暂停麦克风。最后真正想复用的那条链路反而被 Launcher 业务包住了。一句话结论这次抽离的关键不是重写讯飞 SDK而是把“唤醒、实时转写、星火回复、TTS 播报”放进一个独立页面和独立状态机让 Demo 只表达语音链路本身。本文基于当前工程代码说明Android 配置为minSdk 30、targetSdk 37、compileSdk 37语音能力来自本地AIKit.aar和SparkChain.aar星火对话回复走讯飞 OpenAI 兼容 HTTP 接口。具体 SDK 返回码和授权策略可能随讯飞版本变化本文只解释当前项目里的实现边界。1. 大背景这篇文章解释新增的独立 Demo 页面如何工作以及它和原有语音助手服务的关系。本文会讲MainActivity如何串起完整语音链路。VoicePipelineDemoStateMachine为什么单独存在。sdk目录下的语音能力封装如何被复用。首页入口为什么先停止常驻语音服务。星火模型返回和 TTS 播报之间的数据关系。本文不展开讯飞 SDK 内部源码也不讨论默认桌面、天气、环信通话、悬浮窗等业务模块。它们属于产品壳层不是这次 Demo 的重点。2. 全局地图一条链路四个能力独立 Demo 的主流程可以看成四段播报完成继续对话用户说“小飞小飞”1. 离线唤醒WakeWordEngine2. 实时转写SpeechRecognizer3. 模型回复SparkHttpClient4. 语音播报SpeechSynthesizer唤醒词只负责开启第一轮对话。进入连续对话后TTS 每次播完都会直接回到实时转写只有用户退出对话或页面重新进入待命状态才需要再次说唤醒词。对应到文件职责代码位置职责MainActivity.kt独立 Demo 页面和语音链路编排VoicePipelineDemoStateMachine.ktDemo 专属 UI 状态不依赖 Android SDKsdk/WakeWordEngine.ktAIKit 离线唤醒“小飞小飞”命中后回调sdk/SpeechRecognizer.ktSparkChain 在线实时转写输出中间结果和最终结果llm/SparkHttpClient.kt调用星火 OpenAI 兼容 HTTP 接口拿到模型回复sdk/SpeechSynthesizer.ktAIKit Aisound 离线 TTS生成 PCM 并用AudioTrack播放这也是这次抽离的边界SDK 封装继续复用Launcher 业务不进入 Demo。3. 为什么先抽一个状态机页面上需要展示当前阶段、识别文本和模型回复。如果直接在 Activity 里到处改TextView很快会变成“某个回调里改一个标题另一个回调里忘了清空文本”的状态。所以新增了VoicePipelineDemoStateMachine。它不关心麦克风、Activity 生命周期也不调用 SDK只负责把语音链路推进成 UI 状态enumclassVoicePipelineDemoPhase{STOPPED,INSTALLING,INITIALIZING,STANDBY,AWAKENED,LISTENING,THINKING,SPEAKING,ERROR,}例如一轮用户提问会经历LISTENING - 正在实时转写保存 transcript THINKING - 带着 transcript 请求星火模型 SPEAKING - 保存 modelReply交给 TTS 播报 LISTENING - 播报完成后继续听下一轮这层状态机的好处是测试容易写。当前新增的 JVM 单测覆盖了两个关键行为识别文本会被带到星火思考和 TTS 播报阶段回到待唤醒时会清理上一轮文本。它测的是页面规则不需要启动 Android 设备也不需要真实麦克风。4. 独立工程只保留一条语音链路这次最终交付不是在robot工程里新增页面而是在同级目录voice-pipeline-demo下新建一个独立 Android 工程。它有自己的settings.gradle.kts、app/build.gradle.kts、AndroidManifest.xml、AAR、assets 和入口 Activity。这个拆法有两个好处。第一Demo 不再依赖 Launcher 的生命周期。它启动后只处理自己的权限、资源复制、SDK 初始化、唤醒监听、实时转写、星火请求和 TTS 播报。第二AIKit 和 SparkChain 这类进程级运行时只由 Demo 自己管理。页面不可见或销毁时MainActivity会释放唤醒、ASR、SparkChain、TTS 和 AIKit 资源避免麦克风或全局 SDK 实例残留。因此robot工程里的常驻VoiceAssistantService不再参与这个 Demo。原项目可以继续保持自己的桌面语音助手新工程负责提供最小可运行、便于讲解和二次迁移的语音链路样板。5. Demo 初始化资源、AIKit、SparkChainMainActivity.startDemo()做三件事。第一步释放 APK 里的离线资源valinstallerResourceInstaller(this)installer.installIfNeeded{progress-publish(stateMachine.installing(progress))}ResourceInstaller会把assets/iflytek/ivw和assets/iflytek/aisound复制到应用私有目录。唤醒和 TTS 都依赖这些本地模型。第二步初始化 AIKitvalnewRuntimeAIKitRuntime(applicationContext,installer.workDir)newRuntime.initialize{result-...}AIKit 负责离线唤醒和离线 TTS。密钥不写在仓库里而是从local.properties注入到BuildConfig。第三步初始化 SparkChainvalnewSparkRuntimeSparkChainRuntime(applicationContext)valsparkResultnewSparkRuntime.initialize()SparkChain 在当前项目里只用于中文大模型实时转写。真正的星火“模型回复”不是这个 AAR 里的 LLM 页面而是SparkHttpClient通过 HTTP 请求拿到。初始化完成后Activity 创建三个引擎wakeEngineWakeWordEngine(...)recognizerSpeechRecognizer(...)synthesizerSpeechSynthesizer(...)然后进入STANDBY等待“小飞小飞”。6. 唤醒从待命到第一句 TTS待唤醒阶段由WakeWordEngine.start()开启。它内部使用PcmRecorder持续采集16k/16bit/monoPCM然后通过 AIKit IVW 能力写入唤醒引擎。当 SDK 回调里出现func_wake_up或func_pre_wakeup时WakeWordEngine触发onWake()。Demo 页面收到后执行wakeEngine?.stop()conversationActivetruepublish(stateMachine.awakened(WakeWordEngine.WAKE_WORD))speak(我在请讲){startListening()}这里有一个重要细节唤醒命中后先停止唤醒录音再进入 TTS。否则同一个页面内也会出现唤醒录音和识别录音互相争用的问题。“我在请讲”播完以后才进入实时转写。7. 实时转写中间结果用于页面反馈最终结果用于请求星火SpeechRecognizer来自原项目对 SparkChain ASR 的封装。它做的事情很明确start()创建一轮 ASR 会话。PcmRecorder把麦克风 PCM 持续写给 SparkChain。onPartial返回中间识别文本用来实时刷新页面。onFinal返回最终文本用来进入星火请求。Demo 里的处理逻辑是privatefunhandlePartial(text:String){if(!conversationActive||text.isBlank())returnpublish(stateMachine.listening(text))}中间结果只更新 UI不触发模型请求。最终结果才进入handleRecognized()valtextrawText.trim()if(text.isBlank()){startListening()return}if(isDemoExit(text)){conversationActivefalsespeak(好的语音 Demo 已回到待唤醒){enterStandby()}return}askSpark(text)这样页面可以实时看到用户说了什么但不会因为每个中间字词都去请求模型。8. 星火回复HTTP 模型返回适合 TTS 的短文本当前项目里的星火对话客户端是SparkHttpClient。它调用的是https://spark-api-open.xf-yun.com/v1/chat/completions鉴权使用Authorization: Bearer APIPassword配置项是local.properties里的iflytek.sparkApiPassword。请求体由SparkHttpRequestBuilder构造默认非流式返回方便后续 TTS 一次性播报完整句子。Demo 里没有再经过IntentRouter因为这个页面不演示“打开设置”这类本地业务指令。它直接请求星火valclientSparkHttpClient()valsnapshotsynchronized(history){history.toList()}client.chat(text,snapshot).onSuccess{rememberTurn(text,it)}.getOrElse{星火大模型暂时没有返回成功结果请稍后再试}history只保留最近 8 条消息目的是让连续对话有一点上下文同时避免请求体无限增长。模型回复拿到后Activity 进入SPEAKING状态。如果没有配置iflytek.sparkApiPassword页面不会崩溃而是直接给出可播报的提示语。这是 Demo 很重要的体验边界配置缺失是可诊断状态不应该变成空白页面。9. TTS 播报合成结束不等于播放结束SpeechSynthesizer封装的是 AIKit Aisound 离线 TTS。它不是简单地“合成完就回调完成”而是做了两层处理。第一层SDK 回调里的 PCM 不直接在回调线程播放而是放入独立播放线程playbackExecutor.execute{writePcmOnPlaybackThread(speechContext,bytes)}这样可以避免AudioTrack.write()阻塞 SDK 回调线程。第二层SDK 通知合成结束后还要等AudioTrack队列里的 PCM 真正播完delayMsremainingPlaybackMsLocked(track)PLAYBACK_DRAIN_MARGIN_MS main.postDelayed(runnable,delayMs)这个细节很容易被忽略。对用户来说“播报完成”是耳朵听完不是 SDK 不再产出 PCM。如果提前释放AudioTrack短句尾部可能被吞掉。Demo 页面调用 TTS 的方式很轻privatefunspeak(text:String,onComplete:()-Unit){publish(stateMachine.speaking(text))synthesizer?.speak(text){main.post(onComplete)}?.onFailure{handleEngineError(it.message.orEmpty())}}星火回复播完后onComplete继续调用startListening()形成连续对话。10. 易混点SparkChain ASR 和星火 HTTP 不是同一层这次抽离里最容易混的概念是“SparkChain”和“星火模型返回”。在当前项目中名称当前用途输入输出SparkChainRuntime初始化 SparkChain SDKAppId、ApiKey、ApiSecret可用的 ASR 运行时SpeechRecognizer实时转写麦克风 PCM中文识别文本SparkHttpClient星火对话回复用户文本和历史消息适合播报的回复文本也就是说SparkChain AAR 负责“把声音变成文字”SparkHttpClient负责“把文字交给大模型生成回复”。这两个步骤都和讯飞有关但在代码里是两条不同通道。把这层边界说清楚以后Demo 的结构就很直观ASR 返回transcriptHTTP 返回modelReplyTTS 播放modelReply。11. 实践意义这个 Demo 的价值不只是多了一个页面而是把语音能力变成了可以单独验证、单独讲解、单独演示的模块。对开发来说它减少了排查成本。语音链路出问题时可以先进入 Demo 页面确认是资源复制失败、AIKit 鉴权失败、SparkChain ASR 没结果、星火 HTTP 没配置还是 TTS 播放异常。对产品来说它把 Launcher 的业务判断拿掉了。Demo 不会打开设置不会显示天气不会处理环信通话只回答一个问题从唤醒到播报这条链路是否跑通。对后续重构来说它也给了一个清晰方向底层 SDK 封装可以被单独搬迁到新工程业务态服务和演示态页面各自维护自己的状态机。共享能力不共享页面状态。12. 总结回到最开始的问题如何把“语音唤醒 实时转写 星火模型返回 TTS 播报”从桌面助手里抽出来答案不是复制一份大服务而是分三层底层能力复用WakeWordEngine、SpeechRecognizer、SparkHttpClient、SpeechSynthesizer。Demo 编排独立MainActivity只管理语音链路。UI 状态独立VoicePipelineDemoStateMachine只描述 Demo 页面该显示什么。这次抽离的核心是让 Demo 只保留语音链路本身唤醒负责开始ASR 负责听懂星火负责回答TTS 负责说出来。项目源代码地址
返回列表