尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android语音助手实战:PocketSphinx唤醒词与SpeechRecognizer指令识别无缝衔接

Android语音助手实战:PocketSphinx唤醒词与SpeechRecognizer指令识别无缝衔接 简介面向Android开发者的智能语音助手实践项目解决持续语音识别与关键词唤醒的落地难题。项目以PocketSphinx轻量唤醒词检测为入口结合Android原生SpeechRecognizer完成指令识别兼顾离线处理与设备端功耗控制适合想掌握语音交互、唤醒词训练及权限安全处理的初中级开发者参考。资源共45个文件约129KB包含java源码、xml界面与配置、gradle构建脚本、png资源图及docx说明文档等目录结构完整可直接导入Android Studio研读。已有90人学习项目代码中可看到唤醒词检测模块、识别回调流程、后台服务搭建等关键实现并附有README与额外说明资料有助于理解本地语音处理与隐私保护的设计取舍。整体是一个小而完整的App级示例便于快速上手二次开发。1. 持续语音识别助手为什么要把 Pocketsphinx 和 SpeechRecognizer 绑在一起很多第一次做 Android 语音助手的人第一反应是拿 SpeechRecognizer 循环调用startListening模拟“持续识别”跑半小时就发现要么被系统判定为滥用麦克风要么云识别额度烧得太快要么熄屏之后根本起不来。反过来只用 PocketSphinx 做离线识别确实省电但它只适合唤醒词这类小词表接不住“导航去火车站”这种开放指令。所以工程上更常见的做法是分工PocketSphinx 常驻麦克风监听唤醒词命中后立刻把麦克风让给 Android 原生 SpeechRecognizer 做一次完整指令识别识别完再切回来。这里直接把这条链路的选型、参数和交接细节讲透适合正在被“熄屏唤醒无效”“误唤醒太多”“指令识别不灵敏”卡住的 Android 开发者代码按 Kotlin 和 Android Studio 项目组织改包名就能跑通主线。2. 用 PocketSphinx 做唤醒词检测从依赖到阈值的最小工程2.1 为什么唤醒阶段必须用离线引擎先明确一个容易被忽略的事实Android 的 SpeechRecognizer 是“一次会话型”接口。你调用一次startListening它就录音、联网、等用户说完然后在onResults里结束会话整个过程是线性的你没办法让它长期蹲在后台等一个词。除非你用 RecognitionService 自己写一个系统级识别服务那属于系统应用开发的范畴普通 App 拿不到授权。所以“常驻监听”这件事只能交给本地引擎。PocketSphinx 是 CMU 的开源语音识别库Android 版以 AAR 形式发布可以在一个 16kHz 的 AudioRecord 线程上持续跑小词表识别,CPU 占用很低完全离线没有流量消耗也没有音频数据出设备的问题。它适合做唤醒词但不适合做主识别核心是 HMM 加 n-gram 语言模型词表一大模型体积和识别率都跟不上今天的云识别。它的定位就是“守门员”用极低的功耗判断“要不要把用户接下来的话交给云端去听”。这个边界先立住后面整个状态机才不会跑偏。2.2 依赖、权限和模型文件的摆放在模块的 build.gradle 里加依赖。注意这个坐标带了aarWrite 成裸implementation依赖 Gradle 解析时行为不稳定指定 artifact 类型是稳妥做法dependencies { implementation edu.cmu.pocketsphinx:pocketsphinx-android:5prealphaaar }同步依赖之后在 AndroidManifest.xml 里声明权限。这里提前把前台服务权限放进来是因为唤醒监听必须跑在服务里否则进程很容易被系统回收uses-permission android:nameandroid.permission.RECORD_AUDIO / uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_MICROPHONE / uses-permission android:nameandroid.permission.WAKE_LOCK /说明RECORD_AUDIO 是运行时权限需要在代码里动态申请FOREGROUND_SERVICE_MICROPHONE 是 Android 14 引入的前台服务类型targetSdk 到 34 却没有声明它startForeground会直接抛 ForegroundServiceTypeException。INTERNET 是为下一章的 SpeechRecognizer 预留PocketSphinx 本身不需要联网。模型文件方面常见做法是把en-us-ptm声学模型目录和cmudict-en-us.dict词典放进app/src/main/assets这两个资源在 PocketSphinx 官方 Android 示例仓库里可以直接拿到。唤醒词本身不需要单独的模型文件——它通过addKeyphraseSearch注册引擎运行时用词典给短语做发音切分。2.3 初始化 Recognizer 并注册唤醒词下面是一段完整的初始化代码。我在项目里通常把它封装成 WakeWordEngine 类方便和业务层解耦class WakeWordEngine(private val context: Context) { private var recognizer: SpeechRecognizer? null var onWakeWordDetected: () - Unit {} fun start() { if (recognizer ! null) return recognizer SpeechRecognizerSetup.defaultSetup() .setAcousticModel(context.assets, en-us-ptm) .setDictionary(context.assets, cmudict-en-us.dict) .setString(-kws_threshold, 1e-20) .setBoolean(-remove_noise, true) .result recognizer?.apply { addKeyphraseSearch(wakeup, hey assistant) addListener(createListener()) startListening(wakeup) } } fun stop() { recognizer?.stop() recognizer?.shutdown() recognizer null } private fun createListener() object : RecognitionListener { override fun onPartialResult(hypothesis: Hypothesis?) { if (hypothesis ! null) { Log.d(WakeWord, hit score${hypothesis.bestScore}) onWakeWordDetected() } } override fun onResult(hypothesis: Hypothesis?) {} override fun onBeginningOfSpeech() {} override fun onEndOfSpeech() {} override fun onTimeout() { recognizer?.startListening(wakeup) } } }代码里有几个点值得展开。addKeyphraseSearch的第一个参数是搜索名第二个是唤醒短语你可以注册多个搜索但同一时刻只有一个能挂起切换搜索名时要先stop()再startListening(另一个名字)。onPartialResult在短语被命中时携带 Hypothesis 进来bestScore是声学得分的对数域表示来自这段语音和词条发音模板的匹配程度2.4 节的阈值校准靠的就是这个值。onTimeout不是错误。PocketSphinx 长时间没有检测到语音时会回调它这时内部的监听线程已经自己停了必须在回调里重新startListening否则你会得到一个“过一会儿就再也唤不醒”的诡异 Bug。我见过不止一个项目卡在这里日志里没有异常唤醒就是偶发失效。2.4 阈值参数-kws_threshold 到底怎么调PocketSphinx 里唤醒检测的默认阈值是1e-20它表示一个似然概率下限唤醒词命中得分换算成概率后低于这个值就被丢弃。调小比如1e-30系统更敏感更容易触发也更容易误报调大比如1e-10会过滤掉更多低置信度命中。因为是指数尺度一个数量级的差别效果就很大不要用 0.0001 这种线性步进去试。我惯用的调优流程是收集数据在客厅、车里、办公室各录 20 次真实唤醒、20 次背景噪声把每次命中的 bestScore 打出来看两类样本的分数分布有没有分离带。通常噪声误命中的分数比真实唤醒低 300 到 800 分。如果两个分布重叠得很厉害优先怀疑麦克风距离和环境信噪比而不是继续动阈值——阈值只是最后一道闸门救不了录音质量本身。调完记住一组参考值敏感档1e-30均衡档1e-20保守档1e-15按你的产品场景选。3. Android 原生 SpeechRecognizer 的指令识别接入3.1 识别任务为什么在唤醒之后要交接给系统服务唤醒之后用户会说出完整指令比如“打开音乐”“设置十分钟提醒”词表几乎是开放的PocketSphinx 并不适合。Android 的 SpeechRecognizer 把录音交给系统识别服务多数设备走云端大词表识别识别质量和中文支持都远好于本地小模型。它的缺点是强依赖网络和系统服务这是它不能承担常驻监听的原因两边的特性刚好互补。这里有个常见误解值得澄清SpeechRecognizer 不是某个厂商的私有接口它是android.speech包里的公共类任何带 RECORD_AUDIO 权限的 App 都能调用。它底层走系统里的 RecognitionService 实现不同厂商 ROM 上的行为和识别质量会有差异。所以代码里不要假设所有设备都返回一样的标点和置信度这些差异要在工程层消化掉比如结果执行前做关键词归一化而不去依赖完整句匹配。3.2 最小可用的识别回调实现创建一个 SpeechRecognizer 实例把回调对象挂上去。注意RecognitionListener这个接口名和 PocketSphinx 里的同名接口没有任何关系import 时别串class CommandRecognizer(context: Context) { private val recognizer SpeechRecognizer.createSpeechRecognizer(context) var onResult: ((String) - Unit)? null var onError: ((Int) - Unit)? null fun startListening() { val intent Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) putExtra(RecognizerIntent.EXTRA_LANGUAGE, zh-CN) putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 5) putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, packageName) } recognizer.startListening(intent) } fun cancel() recognizer.cancel() private val listener object : RecognitionListener { override fun onReadyForSpeech(params: Bundle?) { // 这里适合拉起正在聆听的 UI 动画 } override fun onRmsChanged(rmsdB: Float) { // 音量条输入数值一般在 0..10 之间抖动 } override fun onPartialResults(partialResults: Bundle?) { val list partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) list?.firstOrNull()?.let { Log.d(ASR, partial$it) } } override fun onResults(results: Bundle?) { if (results null) return val list results.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) val hypothesis list?.firstOrNull() ?: return onResult?.invoke(hypothesis) } override fun onEndOfSpeech() {} override fun onError(error: Int) { onError?.invoke(error) } override fun onEvent(eventType: Int, params: Bundle?) {} } init { recognizer.setRecognitionListener(listener) } }代码说明EXTRA_LANGUAGE_MODEL用LANGUAGE_MODEL_FREE_FORM对应短句自由输入适合下指令如果你做的是地址簿、歌曲名这类固定字段可以换LANGUAGE_MODEL_WEB_SEARCH但它在部分 ROM 上支持不稳定默认不推荐。EXTRA_PARTIAL_RESULTS开启后中间结果会通过onPartialResults流式返回适合做实时字幕但因为内容会变不要拿它执行指令。EXTRA_MAX_RESULTS控制返回候选数量5 个够用。onResults里的RESULTS_RECOGNITION是一个字符串数组按置信度从高到低排。一般取第一个但如果你是“执行类指令”比如发消息、设提醒我建议把前两三个候选都保留下来识别错误时给用户一个选择机会。这个细节对语音助手的可用性影响非常大比任何算法技巧都实在。3.3 错误码对照表与重试策略SpeechRecognizer 的排错基本全靠onError里的错误码。下面这张表是我在项目里会一直贴着的对照每个错误都对应一种处理方式错误码含义常见处理ERROR_NO_MATCH识别服务没听懂语音提示“没听清”交还麦克风给唤醒监听ERROR_SPEECH_TIMEOUT用户没说话或说得太短指令窗口内重试一次连续失败就放弃ERROR_INSUFFICIENT_PERMISSIONS麦克风权限被收回检查运行时权限并重新申请ERROR_NETWORK网络不可用或超时降级到本地命令词匹配见 3.4ERROR_SERVER_DISCONNECTED识别服务连接断开释放实例延迟重建后重试ERROR_RECOGNIZER_BUSY系统识别服务被占用等 1 到 2 秒重试不要立刻再开ERROR_CLIENT客户端调用姿势不对通常是 startListening 前有会话没 cancel处理策略上有一条铁律任何错误最终都要把麦克风交还给唤醒监听。常见的烂写法是onError里打条日志就 return导致整个状态机停在一个半死的状态之后唤醒命中也没人切换。我的做法是onError回调里统一走transitionToWakeWord()那里面对应着一次完整的清理和重新挂载。3.4 断网降级本地命令词表怎么兜底云识别断网时 SpeechRecognizer 会在几秒内回调 ERROR_NETWORK语音助手这时完全哑掉体验会很差。常见做法是保留一份高频命令词表断网时切到 PocketSphinx 的语法搜索做本地匹配。词表不用大“play、pause、stop、next、volume up”这类兜底命令足够。语法搜索要一张 JSGF 语法文件放在 assets 里#JSGF V1.0; grammar commands; public command play | pause | stop | next | volume up;recognizer.addGrammarSearch(commands, commands.gram) recognizer.startListening(commands)注意一个约束JSGF 语法里的词必须能在你当前的声学模型和词典里发音。上面用的是英文词条配合 en-us 声学模型没问题如果你要用“打开音乐”这类中文命令就得额外准备中文声学模型和对应词典成本明显更高。所以多数项目的降级方案做得更简单断网时提示“网络不可用仅支持快捷指令”然后用本地字符串匹配处理那五条命令。必须有一个方案在但不能什么都不做。4. 状态机串联从唤醒词到指令执行的完整链路4.1 三态模型与音频资源的独占问题两套引擎放进同一个进程后核心问题不是代码怎么拼而是麦克风只有一个。PocketSphinx 内部持有 AudioRecordSpeechRecognizer 的startListening同样要开 AudioRecord两个引擎同时监听轻则互相抢数据、识别率暴跌重则有一个始终拿不到音频。所以必须用状态机强制保证“同一时刻只有一个引擎持有输入”。常见做法是把助手定义成三个状态加一个执行态状态持有麦克风活动引擎退出条件IDLE无无收到启动指令后进入 WAKE_WORDWAKE_WORDPocketSphinx本地识别命中唤醒词后进入 COMMANDonTimeout 自动续挂COMMANDSpeechRecognizer系统识别服务onResults 或 onError 后回到 WAKE_WORDACTION无业务逻辑执行完成后回到 WAKE_WORD注意 ACTION 态里麦克风已经交还给唤醒监听。如果你想支持连续对话也就是用户说完“打开音乐”马上又说“不对换一首”那 COMMAND 结束之后要开一个两三秒的补话窗口允许再进一次 COMMAND过了窗口才回 WAKE_WORD。补话窗口里 SpeechRecognizer 的会话已经结束你必须重新startListening实现上就是延迟触发一次真正的监听启动。4.2 交接代码先停本地引擎再启动系统识别下面这段是一个放在 Service 里的核心编排逻辑“停止唤醒监听 → 错开时间 → 启动系统识别 → 拿结果 → 切回唤醒”。注意顺序class AssistantService : Service() { private val mainHandler Handler(Looper.getMainLooper()) private val wakeEngine by lazy { WakeWordEngine(this) } private val commandRecognizer by lazy { CommandRecognizer(this) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { startAsForeground() wakeEngine.onWakeWordDetected { transitionToCommand() } commandRecognizer.onError { code - Log.w(Assistant, asr error$code) transitionToWakeWord() } commandRecognizer.onResult { text - Log.d(Assistant, command$text) execute(text) transitionToWakeWord() } wakeEngine.start() return START_STICKY } private fun transitionToCommand() { // 顺序很重要先停本地引擎再启动系统识别 wakeEngine.stop() mainHandler.postDelayed({ commandRecognizer.startListening() }, 200L) } private fun transitionToWakeWord() { commandRecognizer.cancel() wakeEngine.start() } private fun execute(text: String) { // 规则匹配 动作执行见 4.4 } }逻辑说明transitionToCommand里必须“先 stop 再 start”。有人图省事在上一次识别还没结束时就调用startListening这在系统识别服务繁忙时几乎必然触发 ERROR_RECOGNIZER_BUSY。200 毫秒这个间隔是业界比较常用的妥协值给系统一个清理旧会话的缓冲又不至于让用户觉得卡顿。如果把 200 换成 0你会在部分机型上稳定复现“唤醒后识别不启动”的问题。4.3 前台服务与麦克风权限的硬性要求安卓系统对后台录音的限制是一层层收紧的Android 9 要求后台应用借用麦克风时必须处于可见状态或前台服务Android 14 进一步要求前台服务显式声明 microphone 类型。所以整个状态机必须跑在一个前台服务里且startForeground要在任何录音开始之前调用。Manifest 里服务声明这样写service android:name.AssistantService android:foregroundServiceTypemicrophone android:exportedfalse /Service 里启动前台通知的代码private fun startAsForeground() { val channelId assistant val notification NotificationCompat.Builder(this, channelId) .setContentTitle(语音助手) .setContentText(正在等待唤醒词) .setSmallIcon(R.drawable.ic_mic) .setOngoing(true) .build() if (Build.VERSION.SDK_INT 34) { startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE) } else { startForeground(1, notification) } }说明Notification 必须设setOngoing(true)否则用户从最近任务划过时前台服务连同麦克风会被系统一起回收。清单里加 WAKE_LOCK 是为了熄屏场景PocketSphinx 监听期间建议持有一个 PARTIAL_WAKE_LOCK否则手机进入 Doze 后 CPU 睡眠AudioRecord 取不到数据唤醒词直接失效。这个锁在 WAKE_WORD 态持有进入 COMMAND 或 ACTION 态释放不要在整个服务周期里一直抱着锁那会显著增加待机功耗。4.4 指令文本到执行动作的映射onResult拿到的是文本真正执行的动作在业务侧。常见做法是维护一张意图映射表先把指令文本做归一化去掉标点、统一全半角然后按关键词匹配命中就执行对应动作。指令场景词表有限规则匹配的可控性和排错成本都比重型 NLP 框架低我不会在语音助手里硬上大模型解析。配合 PocketSphinx 本地命令词和 SpeechRecognizer 的 Top-N 候选映射表通常能覆盖绝大多数日常指令用户说法归一化后匹配规则动作“打开音乐”“放首歌”打开音乐含“音乐”且含“打开”startMusic()“下一首”“切歌”下一首含“下一首”或“切歌”nextTrack()“设置十分钟提醒”设置十分钟提醒含“提醒”解析时长后 setAlarm()规则没命中的话就回一句兜底话术告诉用户哪些指令受支持同时把这条没解析出来的文本打进日志。这些日志是后面调优的重要素材。5. 上线前必查的三件事阈值校准、麦克风开关和链路日志5.1 用一套可复现流程校准唤醒阈值阈值校准最怕拍脑袋。我每次换机型都会跑一遍下面这个流程先在onPartialResult里把每次的bestScore打出来然后用真人语音念 20 遍唤醒词再用 5 分钟背景噪声、电视声、键盘声各做一组负样本把分数按高低排序。正样本分数的下沿和负样本分数的上沿之间取对数中点作为新阈值写入 2.3 的-kws_threshold。如果两个分布根本没有分离带说明录音环境不可用先解决信噪比再谈调参。校准过程中adb logcat -s WakeWord:V一条命令就能实时看到每次命中的分数比在代码里断点快得多。5.2 确认 Android 12 麦克风开关没有吞噬唤醒词Android 12 起系统设置里多了一个麦克风总开关也有快捷面板的麦克风按钮。用户一旦关掉所有 App 拿到的音频都是静音唤醒词检测不会报错也不会崩溃只是永远不命中这是最隐蔽的一种故障。启动时可以先做一次检查val audioManager getSystemService(AudioManager::class.java) if (audioManager.isMicrophoneMute) { // 提示用户打开设置 - 隐私 - 麦克风开关 }注意isMicrophoneMute反映的是快捷面板麦克风开关状态和运行时权限无关。权限丢了会收到 ERROR_INSUFFICIENT_PERMISSIONS而开关关了不会有任何回调所以这个检查必须在服务启动时主动做别等用户反馈“一直唤不醒”才发现。5.3 用一条 logcat 命令验证完整链路最后给出一组我在真机上验证链路用的过滤命令adb logcat -s WakeWord:V ASR:V Assistant:V正常一次唤醒加指令识别日志应该是这个顺序WakeWord: hit score-842.3 Assistant: transitionToCommand ASR: partial打 ASR: partial打开音 ASR: partial打开音乐 Assistant: command打开音乐 Assistant: actionstartMusic如果 WakeWord 一直没出现 hit问题出在常驻监听去查 Doze 和 wakelock如果 hit 有但 ASR 一条都没有优先查 4.2 里那 200 毫秒交接和系统识别服务是否可用如果 ASR 的 onError 里有 ERROR_NETWORK再检查网络降级路径有没有生效。这套日志顺序就是整个耳语助手的健康检查脚本上线前跑一遍线上出问题也先跑一遍。语音助手的可用性不是靠某一个引擎的识别率堆出来的而是靠唤醒阈值、音频交接、状态回退这三层工程细节共同托底。把这套日志和校准流程固化到项目里比每次换参数瞎试要可靠得多。本文还有配套的精品资源点击获取
返回列表