资讯详情

安卓语音助手语音链路实战:VAD断句、ASR/TTS与低延迟工程化

📅 2026/10/31 12:01:07 | 华诺云谱 👁 阅读
安卓语音助手语音链路实战:VAD断句、ASR/TTS与低延迟工程化
简介这是一份面向安卓开发者的智能语音对话机器人工程源码包适合想入门语音交互、自然语言处理或二次开发机器人助手的中级学习者参考。工程围绕语音指令采集与识别、文本语义理解、对话响应生成以及机器人大脑接口调用展开可用于搭建具备聊天、问答与日常任务辅助能力的安卓语音助手原型。压缩包共179个文件约6.46MB以64个png界面素材、48个class编译文件、22个xml布局与配置、18个java源码及12个jar依赖库为主另含so原生库与图片资源基本覆盖界面、逻辑与依赖三部分。已有2633人学习下载具备一定参考基数。读者可从中了解语音识别API与NLP模块的衔接方式、对话消息适配与Html内容展示、Gif动画与设置页的实现思路并借助现成的接口封装与模型配置快速跑通demo再按自身场景替换知识库或扩展硬件控制功能。1. 一条“把灯调暗”的指令为什么卡在第三秒在安卓上做智能语音对话机器人真正让人抓狂的不是识别不准而是链路太长。麦克风采到一段音频交给 ASR做意图解析调一次机器人接口等回执再合成 TTS 播出来中间任何一环超时用户听到的就是“它没理我”。这份安卓语音助手源码包解决的正是这条链路的落地问题把录音采集、唤醒与断句、ASR/TTS 对接、意图槽位、机器人接口调用、前台服务与音频焦点串成一条能跑通的管线。它适合已经能写安卓界面、但没做过完整语音对话闭环的开发者也适合想把语音入口塞进现有 App、又不想从零补链路的团队拿去二次改造。2. 链路选型采样率、VAD 阈值和 ASR 端云分工怎么定2.1 先把延迟预算摊开再决定哪一段放端侧语音助手这类产品体验分水岭在 1.5 秒从用户闭嘴到听见第一声回应。超过这条线人就会重复说话重复说话又会触发第二轮识别整条链路雪上加霜。所以选型的第一步不是挑模型而是把预算按环节摊开看哪一段值得花钱优化。环节典型耗时卡点压榨手段采集 VAD 断句300~800 ms静音判定太保守静音阈值压到 600 ms长句靠语义切分ASR 首字返回200~500 ms上行分片太大20 ms 一帧、200 ms 一个上行包意图识别 接口80~300 ms串行调用本地先做高频意图预判接口并行TTS 首包150~400 ms等全文合成完才播流式合成按句切、按句播播放起播50~150 ms缓冲区过大起播缓冲 100 ms 左右后续边播边填端云分工的结论一般是这样唤醒词必须端侧因为它要常驻监听放云端既费电也没必要ASR 走云侧流式端侧小模型只在弱网时兜底识别固定的几十条指令TTS 的常用短句“好的”“已打开”“没听清”在端侧预合成缓存长句走云侧流式合成。这套分工的好处是断网时助手不会彻底变哑巴至少还能响应高频指令并给出明确提示。提示别一上来就追求全端侧离线。端侧 ASR 的模型体积、热词更新、方言覆盖都是长期维护成本先跑通云侧链路把弱网降级当作第二阶段的事。2.2 采集参数定死16 kHz、单声道、16 bit别在客户端加戏主流流式 ASR 的输入约定基本是 16 kHz 采样率、单声道、PCM 16 bit 小端。有些开发者图省事直接拿手机默认的 48 kHz 录音上传服务端要么拒绝要么在服务端重采样白白多传三倍带宽还会因为重采样算法差异引入失真。采集端就该把参数钉死让上行格式和服务端约定完全一致。class PcmRecorder(private val onFrame: (ByteArray, Int) - Unit) { // 16 kHz / 单声道 / 16 bit 是流式 ASR 的通行输入约定客户端不要随意改动 private val sampleRate 16_000 private val frameSamples sampleRate / 50 // 20 ms 一帧 320 个采样点 private val frameBytes frameSamples * 2 // 16 bit 占 2 字节一帧 640 字节 private var record: AudioRecord? null private var running false fun start() { val minBuf AudioRecord.getMinBufferSize( sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT ) // 缓冲区给到 8 帧太小会 read 丢帧太大直接抬高端到端延迟 val bufSize maxOf(minBuf, frameBytes * 8) record AudioRecord( MediaRecorder.AudioSource.VOICE_RECOGNITION, // 带系统侧降噪比 MIC 源干净 sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufSize ) if (record?.state ! AudioRecord.STATE_INITIALIZED) return running true record?.startRecording() Thread { val buf ByteArray(frameBytes) while (running) { val n record?.read(buf, 0, buf.size) ?: -1 if (n 0) onFrame(buf.copyOf(n), n) // 必须拷贝buf 会被下一次 read 覆盖 } }.start() } fun stop() { running false record?.stop() record?.release() record null } }这段代码有三个参数值得逐条盯AudioSource.VOICE_RECOGNITION它比MIC多一层系统级降噪和自动增益代价是部分机型会做轻微处理做声纹类需求时要换回MIC自己做前端。frameBytes 640这个值决定了上行分片粒度。20 ms 一帧配合 200 ms 一包上行是延迟和包数量的平衡点改成 10 ms 一帧包数翻倍改成 40 ms 一帧首字延迟会明显变差。bufSizegetMinBufferSize返回的只是“不报错的最小值”实际取值必须放大到几帧以上否则在低端机上会周期性丢帧表现出来就是识别结果缺字。另外read的返回值必须判-1表示参数错误-3表示对象未初始化-6表示录音线程被抢占。返回非正数时应当立刻停掉本轮采集并上报错误码而不是继续往 ASR 推空帧。2.3 VAD 断句把“一直在听”变成“听到人说话才上送”不做 VAD 的语音助手要么一直推流烧流量要么靠固定的录音时长切句用户说快了被截断说慢了白等。工程上最常见的是能量法加静音超时每帧算一次 RMS超过底噪阈值开始计语音连续静音超过设定时长的位置断句。// 简易能量 VAD底噪自适应 起始/结束双阈值避免固定阈值换机就翻车 private fun rms(frame: ByteArray, len: Int): Double { var sum 0.0 var i 0 while (i 1 len) { // PCM 16 bit 小端低字节在前 val s ((frame[i 1].toInt() shl 8) or (frame[i].toInt() and 0xFF)).toShort() sum s.toDouble() * s i 2 } return sqrt(sum / (len / 2)) } // 前 500 ms 当成底噪采样阈值取底噪的 3 倍限制在 [300, 3000] 之间 val threshold (noiseFloor * 3).coerceIn(300.0, 3000.0)参数含义起始判定建议连续 3 帧60 ms超阈值才算“开口”可以过滤掉键盘声和碰麦结束判定用 600 ms 静音这个值偏小会切碎长句偏大用户会觉得反应慢同时要加一条 15 秒上限强制切句防止有人在麦克风前聊天把一轮对话拖成几十秒。换机型后音量和增益差异很大所以阈值必须是相对底噪的倍数不能写死绝对值这也是很多语音助手在自家样机上正常、一到用户手里就失灵的根因。2.4 对话状态机意图、槽位和多轮补全的最小闭环链路跑通之后第二个坑是状态管理。语音交互不像点按钮用户会在任意时刻打断、重复、改口。没有状态机的话代码里会出现大量if (isPlaying !isRecording)这类互相打架的标志位。常见做法是收敛成五个状态任何事件只允许在特定状态下触发。状态进入条件允许动作退出条件IDLE服务启动完成等待唤醒词命中唤醒 → LISTENINGLISTENING拿到音频焦点采集、VAD、流式上送断句 → THINKING超时 → IDLETHINKINGASR 出最终结果意图识别、调机器人接口拿到回执 → SPEAKING失败 → ERRORSPEAKINGTTS 开始播报播报、监听打断词播完或被打断 → IDLEERROR任一环节失败播报兜底话术3 秒后 → IDLE多轮补全的关键是槽位。用户说“把会议室灯调暗一点”意图是light_set槽位有room会议室、actiondim如果用户只说“调暗一点”缺room服务端返回need_slot客户端把上一轮的room作为上下文带上只追问缺的那一项。上下文里带上round字段做轮次上限一般 3 轮追问还没补齐就主动放弃并给出兜底话术比无限追问更体面。3. 机器人接口对接一次意图如何变成一条可回执的动作3.1 接口契约一次对话请求里到底该放什么字段机器人接口在这里扮演两个角色一是对话服务把识别文本变成意图和槽位二是动作服务把意图变成机器人真正要执行的一条指令。两者可以是一个端点也可以拆开但契约字段的设计原则一致——凡是排错需要的都必须在请求头或请求体里显式带上。{ session_id: s-20240612-0001, request_id: r-7f3c9a, device_id: dev-anon-01, text: 把会议室的灯调暗一点, asr: { final: true, confidence: 0.91 }, context: { last_intent: light_on, round: 2 }, trace_id: t-9a1b }字段的用途要分清request_id是幂等键重试时必须复用同一个值服务端按它去重否则一次超时重试会在机器人侧执行两遍动作session_id用来串多轮上下文用户重新唤醒时应更换trace_id只用于日志串联可以在客户端生成并一路透传到服务端日志。context不要塞整个历史对话只放上一轮意图和轮次历史记录由服务端按session_id自己维护客户端传全量历史会让请求体越滚越大还会暴露不必要的对话内容。字段生成方重试时是否复用缺失后果request_id客户端复用必须一致动作重复执行session_id客户端复用多轮上下文断裂trace_id客户端复用服务端日志无法串联asr.finalASR 流式结果不复用服务端误判中间结果context.round客户端递增追问死循环3.2 安卓端网络层超时分级与只对安全错误重试语音场景的网络层和普通业务最大的差别是超时不能一刀切。连接超时短一点没关系3 秒足够读超时要按接口性质分开普通对话接口 8 秒封顶流式接口则要允许首个分片快速返回、整体连接长期保持。private val client OkHttpClient.Builder() .connectTimeout(3, TimeUnit.SECONDS) .readTimeout(8, TimeUnit.SECONDS) // 普通对话接口别超过用户忍耐上限太多 .writeTimeout(3, TimeUnit.SECONDS) .retryOnConnectionFailure(true) // 只处理连接层抖动不做业务层重试 .build() fun ask(body: DialogRequest, requestId: String): Call { val req Request.Builder() .url($BASE/dialog) .header(X-Request-Id, requestId) // 幂等键服务端按它去重 .header(X-Trace-Id, body.traceId) .post(gson.toJson(body).toRequestBody(application/json.toMediaType())) .build() return client.newCall(req) }重试策略要写清楚边界只对连接失败、DNS 失败和 502/503/504 重试最多两次间隔 300 ms 和 900 ms对 400、401、409 这类错误绝不重试说明请求本身有问题对读超时也不重试因为服务端可能已经执行了动作重试会造成重复下发。这套规则看着啰嗦但少了它弱网环境下机器人会“一次指令动两下”这是现场最容易背锅的问题之一。3.3 异步回执动作下发之后别假装已经完成机器人动作很少是同步的。灯可以秒开但“移动到 A 点”“抓取物品”这类动作要几秒到几十秒。接口设计上一般返回202 Accepted加一个task_id客户端再按task_id查状态最终把结果转成一句播报。// 退避轮询300 ms 起翻倍到 5 s 封顶总时长不超过 20 s suspend fun awaitTask(taskId: String): TaskState { var wait 300L val deadline SystemClock.elapsedRealtime() 20_000 while (SystemClock.elapsedRealtime() deadline) { val state api.getTask(taskId) // GET /robot/task/{task_id} when (state.status) { done, failed - return state } delay(wait) wait minOf(wait * 2, 5_000L) } return TaskState.timeout(taskId) // 超时不等于失败播报要说“还在执行” }轮询参数的经验值起始间隔 300 ms 覆盖绝大多数秒级动作指数退避避免长任务把服务端打满20 秒总上限对应“用户还能等”的心理阈值。状态码的含义要统一409表示任务已被取消429表示限流需要退避503表示机器人离线。客户端拿到离线状态时应该直接播报“设备暂时连不上”而不是继续轮询否则用户会看到助手沉默十几秒。4. 安卓工程接线权限、前台服务、TTS 与四段延迟埋点4.1 权限与音频焦点被电话打断是必修课安卓上的语音助手权限清单比想象中长。录音权限是运行时权限必须在使用前申请音频焦点不是权限但同样关键用户接电话、系统播报导航、别家助手被唤醒都会抢走音频通道。不处理焦点的结果是电话来了助手还在录音或者播报声和导航声叠在一起。uses-permission android:nameandroid.permission.RECORD_AUDIO / uses-permission android:nameandroid.permission.MODIFY_AUDIO_SETTINGS / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_MICROPHONE / uses-permission android:nameandroid.permission.POST_NOTIFICATIONS /private val focusRequest AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) .setAudioAttributes( AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_ASSISTANT) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build() ) .setWillPauseWhenDucked(false) // 语音不适合压低音量直接暂停更合适 .setOnAudioFocusChangeListener { change - when (change) { AudioManager.AUDIOFOCUS_LOSS_TRANSIENT - stopListening() // 来电、导航播报 AudioManager.AUDIOFOCUS_LOSS - stopAll() // 别的助手长期占用 } } .build() getSystemService(AudioManager::class.java).requestAudioFocus(focusRequest)USAGE_ASSISTANT这个 usage 值值得单独说明它让系统在混音策略上把这段音频当作助手类音频处理蓝牙耳机和车机的路由行为会不一样。AUDIOFOCUS_GAIN_TRANSIENT表示短暂独占播报结束就该释放不要用GAIN长期持有。4.2 前台服务Android 14 之后不声明类型就收不到录音语音助手几乎必然要在后台常驻监听唤醒词这就必须用前台服务。从 Android 14 起前台服务要显式声明类型麦克风类型需要在清单里申请对应权限并在startForeground时传类型参数否则应用切到后台后录音会静默失效日志里还看不出明显异常。class VoiceService : Service() { override fun onCreate() { super.onCreate() val mgr getSystemService(NotificationManager::class.java) mgr.createNotificationChannel( NotificationChannel(voice, 语音助手, NotificationManager.IMPORTANCE_LOW) ) val notification Notification.Builder(this, voice) .setContentTitle(语音助手运行中) .setSmallIcon(R.drawable.ic_mic) .setOngoing(true) .build() // Android 14 起必须带 microphone 类型否则进后台麦克风被系统掐断 startForeground( 1001, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE ) } override fun onBind(intent: Intent?): IBinder? null }配套要做的还有电池优化白名单提示。国产 ROM 的后台策略差异很大这一点没法靠代码完全解决只能在首次启动时引导用户把应用加入后台运行白名单并在设置页放一个“自检”入口检测前台服务是否还活着。这属于血泪经验不做的话用户反馈永远是“用一会儿就不灵了”。4.3 TTS 播报与打断别让助手自己打断自己播报环节有两个经典问题一是助手刚说完“好的”就被自己的麦克风录进去触发新一轮识别二是长句播报时用户插话助手坚持把整段说完。前者的解法是在播报期间暂停采集或开启回声消除后者靠 TTS 的队列策略和打断词识别。val tts TextToSpeech(context) { status - if (status TextToSpeech.SUCCESS) { tts.language Locale.CHINESE tts.setAudioAttributes( AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_ASSISTANT) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build() ) } } // QUEUE_FLUSH新一句立即顶掉旧的一句避免助手把上一段念完才回应 tts.speak(text, TextToSpeech.QUEUE_FLUSH, Bundle(), utt-$requestId)utteranceId一定要带业务标识UtteranceProgressListener的onStart和onDone里才能准确判断“是不是当前这一句”。常见翻车是多个请求共用一个固定 id回调里分不清是哪句播完了于是在错误的时机恢复录音用户刚开口就被自己上一句的回声打断。4.4 延迟埋点把一次对话拆成四段可对比的数字语音链路的问题最难复现因为它是时序问题。没有埋点就只能靠猜猜出来的优化往往是玄学。我一般会在客户端埋七个时间点用同一个trace_id串起来服务端日志也打这个 id出问题时按 id 拉一条时间线。埋点名打点位置关注阈值t_frame_first首帧 PCM 上送唤醒后 200 ms 内t_vad_endVAD 判定断句用户闭嘴后 600 ms 内t_asr_first收到首个识别分片断句后 500 ms 内t_nlu_done意图槽位返回首字后 300 ms 内t_api_done机器人接口回执意图后 300 ms 内t_tts_firstTTS 首包到达回执后 400 ms 内t_play_start实际出声首包后 150 ms 内这套埋点的价值在于定位“慢在哪一段”。如果t_vad_end到t_asr_first特别长问题在上行分片或网络如果t_api_done到t_tts_first长问题在合成服务如果t_tts_first到t_play_start长问题在本地播放缓冲。四段拆开看优化才有靶子。5. 排查清单录音静音、识别错字、重试打爆接口的五次翻车5.1 现象录音文件全是静音波形一条直线原因通常有三种运行时权限只在清单里声明而没在代码里申请AudioRecord初始化后state不是STATE_INITIALIZED但代码没判或者有线和蓝牙耳机抢占了音频路由导致麦克风实际采集的是空通道。解决方式是初始化后立刻判state拿不到就释放重试采集前用AudioManager打印当前输入设备并做一次setPreferredDevice权限申请回调里再启动录音不要在onCreate里直接开。5.2 现象ASR 返回空结果或一串乱码服务端日志显示音频格式不匹配原因是采集参数和服务端约定不一致最常见的是拿到 48 kHz 的默认配置当 16 kHz 上传或者把 WAV 文件头和 PCM 数据一起推流服务端把 44 字节头当成音频样本解出来自然全是噪声。解决方式是把采样率、声道数、位深、字节序四个参数写成常量并在握手时随请求上报服务端校验不通过直接拒绝比默默返回乱码更容易定位。5.3 现象弱网下一条“开灯”指令让灯闪了三次原因是网络层对读超时也做了重试服务端其实已经执行了动作只是响应丢包。解决方式是只对连接层错误和 5xx 重试读超时不重试同时所有动作类请求必须带幂等键服务端按request_id做去重窗口窗口时长覆盖重试间隔即可。5.4 现象助手回一句“好的”紧接着又识别到自己这句话进入死循环原因是播报期间没有暂停采集或者 AEC 在部分机型上没生效。解决方式是播报开始时停止上行推流onDone后再恢复恢复前加 200 ms 静音保护期避免上一句的尾音被录进去。如果必须边播边听就要在服务端开启回声消除并在客户端把播报文本作为参考信号传过去。5.5 现象真机上用了十几分钟麦克风突然失效重启应用又好了原因是后台被系统限制前台服务没有声明正确的类型或者厂商 ROM 的省电策略把服务冻结了。解决方式是声明FOREGROUND_SERVICE_TYPE_MICROPHONE并在清单里同步加权限加一个心跳检测发现服务被回收就自动拉起并给出提示同时引导用户关闭针对本应用的电池优化这一步在国产 ROM 上基本绕不开。6. 联调验收用 50 次请求压出 P95再上真机联调阶段最容易犯的错是一上来就在真机上点着玩。人耳对延迟的感觉很不稳定今天觉得快明天觉得慢最后谁也说不清有没有退步。我一般先在服务端用固定文本压一轮把接口层的 P95 先量出来再上真机量端到端两个数字对不上时差值就是端侧成本。#!/usr/bin/env bash # 固定文本压 50 次看 P95 而不看平均值平均值会被几条快请求带偏 BASEhttp://127.0.0.1:8080 for i in $(seq 1 50); do start$(date %s%3N) curl -s -X POST $BASE/dialog \ -H Content-Type: application/json \ -H X-Request-Id: bench-$i \ -d {\session_id\:\bench\,\request_id\:\bench-$i\,\text\:\把灯调暗\,\trace_id\:\bench-$i\} \ -o /dev/null -w %{http_code}\n end$(date %s%3N) echo $((end - start)) done | sort -n | awk {a[NR]$1} END {print P50a[int(NR*0.5)]ms P95a[int(NR*0.95)]ms}注意date %s%3N在部分系统上不支持毫秒跑之前先单独执行一次确认输出是 13 位数字否则算出来的耗时全是 0。压测时把request_id设成唯一值避免被服务端幂等窗口吞掉想看真实动作耗时就把X-Request-Id固定成同一个再看服务端去重后的表现两种测法对应两种问题。真机验收我固定看五个指标缺一个都不算通过验收项测法通过线唤醒率安静环境说 30 次唤醒词不低于 95%端到端首响从闭嘴到听见第一个字取 P95小于 2 秒打断响应播报中说“停”到实际停止小于 300 ms弱网降级关掉 Wi-Fi 用移动网络并限速3 秒内给出明确提示长时稳定性连续运行 2 小时并穿插唤醒不断连、不漏录从那以后我每次改完语音链路的任何一段都会强制走一遍这个流程先跑 50 次固定文本拿接口 P95再上真机量端到端最后对照七个埋点时间戳确认没有哪一段偷偷变慢。不这么做改动带来的性能回退会藏在平均值里等用户反馈时已经过去两个版本了。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑