首页 / 资讯中心 / 文章详情

安卓智能语音对话机器人源码:唤醒识别到串口动作链路

安卓智能语音对话机器人源码:唤醒识别到串口动作链路 ★ FEATURED ARTICLE
简介智能语音对话机器人是一套面向安卓开发者与语音交互爱好者的完整项目源码围绕语音识别、对话理解与机器人大脑接口展开可用于学习如何把语音输入转为文本、再由自然语言处理生成应答并驱动提醒、音乐、天气等日常功能。压缩包共179个文件以java源码、class编译文件、xml布局与配置、png和jpg界面素材为主另含jar依赖、so库及少量配置文件整体约6.46MB结构接近可直接运行与二次改造的安卓工程。已有2634人学习下载说明其在语音助手类练手项目中具有一定参考热度。读者可从中梳理语音识别调用、聊天消息适配、网络请求封装、设置页面与表情动画等模块的实现思路并借助机器人大脑接口示例理解对话流程的组织方式适合作为课程设计、毕业设计或功能原型的对照材料。1. 接一台会说话的安卓机器人难点从来不在识别率去年冬天我帮一个做展陈机器人的小团队看代码他们那块安卓主板上装了三个语音 Demo离线识别率标称都在九成以上可到了展厅还是装傻用户说完话屏幕上有字底盘不动。最后查出来两件事——识别回调跑在子线程指令塞进 UI 队列后被紧接着的播报打断机器人接口的串口帧没有帧头校验现场噪声一大整包就被丢掉。这套智能语音对话机器人源码就是围着这两个问题搭的安卓端负责拾音、唤醒、识别、对话管理和播报另一层机器人接口把结果翻译成底盘、舵机和灯效动作适合想在自己硬件上做语音助手、又不愿意从零拼链路的开发者。2. 工程骨架与选型五个环节、三个接口、一棵目录树2.1 一条语音从麦克风到舵机的五段链路先把链路摊平后面所有代码都挂在这条线上。用户开口说话麦克风持续吐出 PCM 数据唤醒模块在本地判断是不是在叫它确认唤醒之后同一份 PCM 转手交给识别引擎识别出文本对话管理把句子拆成意图和槽位意图命中技能技能再去调用机器人接口下发动作帧动作执行的同时播报模块把回复念出来。五个环节里只有识别和播报是串行的其余都可以并行。环节输入到输出时间预算失败时怎么办拾音麦克风到 20ms PCM 帧常驻读失败不退出重开录音唤醒PCM 帧到唤醒事件小于 300ms无唤醒词直接开麦靠 VAD 收尾识别PCM 流到文本首字 500ms 内超时降级成没听清对话文本到意图 JSON小于 50ms兜底意图不让状态机卡住播报与执行文本到语音、到动作帧按动作时长超时抢占急停优先时间预算这件事在桌面调试时感受不到装到机器人上就非常明显唤醒晚半秒用户会以为没听见于是重复说一遍识别结果就变成两句话黏在一起意图解析直接失败。我一般会把唤醒和识别的首字延迟写进日志跑一百次取中位数超过预算就先查缓冲区大小而不是先换引擎。2.2 技术选型为什么把拾音、识别、对话、播报拆成可替换的接口新手常见的做法是买一套在线语音 SDK把唤醒、识别、语义、播报全包进去两周就能出 Demo。这套源码没有这么做原因很实际全包方案通常要求一次会话内说完而机器人对话是长连接的用户可能说两句停一下、被环境音打断、或者中途要急停一旦全包麦克风、音频焦点、播报抢占这三件事都由 SDK 内部的黑匣子决定出了问题只能等厂商发版。所以它的边界切得很硬拾音和唤醒是纯本地代码识别、对话、播报各定义一个接口默认实现走在线流式识别加本地规则对话也保留了换离线引擎的余地。核心依赖就是系统自带的录音和 TTS 能力加上一个第三方识别引擎机器人接口侧只有串口、蓝牙、Socket 三种通道。这种切法带来的直接好处是识别可以离线兜底网络断了机器人还能执行最后一条已确认的指令对话管理可以纯本地跑意图不明确的句子才走云端。线程模型也一并说清楚这是后面排查问题的关键。整套代码里只有一个常驻的采集线程、一个识别回调线程池、一个主线程 Handler 做状态分发动作队列是独立的一个写线程。凡是跨线程的地方都通过 Handler 或 BlockingQueue 转一次绝不允许在识别回调里直接操作 UI 或直接写串口。2.3 目录结构真正需要读的只有七八个文件app/src/main/java/com/example/voicebot/ ├── VoiceBotApp.java // 全局初始化引擎注册、崩溃兜底 ├── core/ │ ├── AudioCapture.java // AudioRecord 封装20ms 一帧 │ ├── WakeEngine.java // 唤醒词与 VAD │ └── DialogEngine.java // 四态状态机所有事件入口 ├── asr/ │ ├── AsrEngine.java // 识别接口 │ ├── AsrSession.java // 单次会话、超时、线程切换 │ └── impl/OnlineAsrEngine.java // 在线流式实现 ├── nlu/ │ ├── IntentRouter.java // 意图到技能的注册表 │ ├── Intent.java // 意图与槽位数据结构 │ └── skill/ // 每个技能一个 handler ├── tts/SpeechQueue.java // 播报队列与打断 ├── robot/ │ ├── RobotTransport.java // 通道接口 │ ├── Protocol.java // 组帧与解帧 │ ├── ActionQueue.java // 动作队列、单飞、急停 │ └── transport/ // Serial / Ble / Tcp 三个实现 └── ui/MainActivity.java // 权限申请、调试面板看这份结构能发现一个信号asr、nlu、tts、robot四个包之间没有任何直接引用全部靠DialogEngine串起来。这是二次开发最舒服的地方——想换识别引擎只需要动asr/impl下一个文件想加一个技能只加一个 handler 类再注册一行。真要读源码建议顺序是DialogEngine→AsrSession→Protocol这三处读完剩下的都是外围。3. 核心链路的代码实现唤醒、识别、对话与播报3.1 音频采集与唤醒20ms 一帧的缓冲区怎么算采集这一层最容易写错因为错的方式很隐蔽代码不报错只是延迟变大或者偶尔丢字。关键在于缓冲区大小和帧长的换算。16kHz 采样、16bit、单声道一毫秒是 32 字节20ms 一帧就是 640 字节。唤醒和 VAD 都需要稳定的小帧输入一帧太大响应就慢太小则系统调用过于频繁。public final class AudioCapture { public static final int SAMPLE_RATE 16000; // 16000 采样点/秒 * 0.02 秒 * 2 字节 640 字节 public static final int FRAME_BYTES 640; private static final int BUFFER_BYTES FRAME_BYTES * 5; // 100ms用来吸收调度抖动 private AudioRecord record; private volatile boolean running; public void start() { int minBuf AudioRecord.getMinBufferSize( SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); // 取最小缓冲和 100ms 里的较大值太小会丢帧太大会推高唤醒延迟 int bufSize Math.max(minBuf, BUFFER_BYTES); record new AudioRecord(MediaRecorder.AudioSource.VOICE_RECOGNITION, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufSize); record.startRecording(); running true; new Thread(this::loop, audio-capture).start(); } private void loop() { byte[] frame new byte[FRAME_BYTES]; while (running) { int n record.read(frame, 0, frame.length); if (n 0) continue; // 读失败不退出避免一次抖动打死整条链路 WakeEngine.feed(frame, n); // 唤醒与 VAD 吃同一份 PCM AsrSession active AsrSession.current(); if (active ! null) active.feed(frame, n); } } }这段代码有三个参数值得记住。MediaRecorder.AudioSource.VOICE_RECOGNITION会启用系统级的降噪和自动增益比MIC更适合远场但也意味着不同设备上的音量曲线不一样后面做能量阈值唤醒时要重新标定。BUFFER_BYTES给到 100ms 是有意为之安卓在高负载下调度会有几十毫秒抖动缓冲太小会直接丢块。n 0那句不能写成break真机上 USB 声卡热插拔会返回一次错误码退出循环就等于麦克风彻底哑了。3.2 语音识别封装接口抽象、超时与线程切换识别接口只保留五个方法把流式送数据和请求最终结果分开这样离线引擎非流式只要忽略feed也能接进来。public interface AsrEngine { void start(AsrListener listener); void feed(byte[] pcm, int len); // 流式送 PCM非流式实现可以为空 void finish(); // 用户说完了请求最终结果 void cancel(); void release(); interface AsrListener { void onPartial(String text); // 中间结果用来做正在听的反馈 void onFinal(String text); // 最终结果唯一可信的 void onError(int code, String msg); } }接口本身很简单麻烦的是会话管理引擎回调在哪个线程、超时怎么兜、重复回调怎么去重这三件事全压在AsrSession上。public final class AsrSession { private static final long HARD_TIMEOUT_MS 8000; // 整段会话兜底 private final Handler main new Handler(Looper.getMainLooper()); private final AsrEngine engine; private final DialogEngine dialog; private Runnable timeoutTask; public void start() { engine.start(new AsrEngine.AsrListener() { Override public void onPartial(String text) { main.post(() - dialog.onPartial(text)); } Override public void onFinal(String text) { main.removeCallbacks(timeoutTask); // 回调线程直接改状态会崩在 UI 上统一抛回主线程 main.post(() - dialog.onUserSaid(text)); } Override public void onError(int code, String msg) { main.removeCallbacks(timeoutTask); main.post(() - dialog.onAsrFailed(code, msg)); } }); timeoutTask () - { engine.cancel(); dialog.onAsrTimeout(); }; main.postDelayed(timeoutTask, HARD_TIMEOUT_MS); } }HARD_TIMEOUT_MS这条兜底在 Demo 里经常被省掉装到机器人上就成了血泪经验网络一抖动识别引擎既不回最终结果也不报错状态机永远停在 THINKING用户后面说什么都没反应。硬超时必须有而且超时后一定要先cancel()再切状态否则下一次start()会因为会话没释放而抛异常。另外onFinal只认第一次重复回调在dialog里做去重别指望引擎守规矩。3.3 对话管理意图、槽位与一个四态状态机对话管理是整个项目的中枢它只做两件事把文本变成意图把意图派给技能。状态机只有四个态够用且好排查。public enum DialogState { IDLE, // 没在听 LISTENING, // 正在收音/识别 THINKING, // 正在解析或执行 SPEAKING // 正在播报 }意图的数据结构用 JSON 最省事调试时能直接打印也方便以后换成云端语义。{ intent: robot.move, slots: { direction: forward, distance: 1.0, speed: 0.4 }, raw: 往前走一米 }分发逻辑用注册表不要写一长串 if-else。技能各自实现一个 handler注册进来就行。public final class IntentRouter { private final MapString, IntentHandler handlers new LinkedHashMap(); public void register(String intent, IntentHandler h) { handlers.put(intent, h); } public void dispatch(Intent it) { IntentHandler h handlers.get(it.intent); if (h null) { tts.speak(这个我还不会); dialog.toIdle(); return; } try { // 槽位缺失就回问一句比直接失败体验好得多 String missing h.validate(it.slots); if (missing ! null) { tts.speak(要往哪个方向); dialog.toListening(); return; } h.handle(it.slots); } catch (Exception e) { // 技能抛异常必须兜住否则状态机就停在 THINKING 出不来了 Log.e(IntentRouter, skill failed: it.intent, e); tts.speak(出了点问题); dialog.toIdle(); } } }这里有两个设计取舍值得说。一是槽位缺失走反问而不是猜机器人场景里猜错方向的代价比多问一句高得多。二是THINKING状态必须自带超时迁移任何进入THINKING的路径都要么在有限时间内跳到SPEAKING要么回IDLE绝不允许有第三条路径这是我在好几个项目里踩过同一个坑之后定下的规矩。3.4 TTS 播报队列、打断和麦克风抢占播报看起来最简单实际是整条链路里最容易出玄学问题的地方因为它和采集抢同一个硬件。做法是把 TTS 包成队列统一请求音频焦点并在播报前后显式释放麦克风。public final class SpeechQueue implements TextToSpeech.OnInitListener { private final ArrayDequeString queue new ArrayDeque(); private boolean speaking; public void speak(String text, boolean interrupt) { if (interrupt) { tts.stop(); queue.clear(); } // 新指令优先旧播报直接掐 queue.add(text); drain(); } private void drain() { if (speaking || queue.isEmpty()) return; speaking true; focus.requestAudioFocus(); // 播报前抢焦点避免被背景音乐盖住 audioCapture.pause(); // 让出麦克风减少自己的声音被识别回去 tts.speak(queue.poll(), TextToSpeech.QUEUE_FLUSH, null, utt); } Override public void onDone(String utteranceId) { speaking false; focus.abandonAudioFocus(); audioCapture.resume(); // 播报结束立刻还麦否则唤醒会聋两秒 drain(); } }pause()和resume()这一对是回声问题的低成本解法。安卓上做 AEC 需要设备厂商支持通用做法是播报期间不收音代价是用户在机器人说话时打断不了。想要支持打断就得保留收音并加一层能量比较只在用户音量明显高于播报残留时才切状态这个阈值在每台机器上都不一样必须现场调。onDone里那句resume()千万别漏漏了就会出现第一次对话正常第二次怎么喊都没反应的经典现象。4. 机器人接口层协议帧、通道抽象与动作队列4.1 指令帧设计帧头、长度、命令字与校验机器人主板通常是个单片机跑不了 HTTP串口帧是最通用的约定。这套源码用的帧格式很朴素但每个字段都有存在理由。字段字节数说明帧头2固定 0xAA 0x55用来在噪声里重新对齐长度1只算载荷长度不含帧头与校验命令字10x01 移动、0x02 转向、0x03 灯效、0x0F 急停序号10 到 255 循环用来对回执载荷N具体参数移动是方向加距离加速度校验1从长度字节累加到载荷末字节public static byte[] buildFrame(int cmd, int seq, byte[] payload) { int len payload null ? 0 : payload.length; byte[] f new byte[6 len]; f[0] (byte) 0xAA; f[1] 0x55; f[2] (byte) len; f[3] (byte) cmd; f[4] (byte) seq; if (len 0) System.arraycopy(payload, 0, f, 5, len); // 校验覆盖 长度命令字序号载荷不含帧头方便下位机从长度字节开始算 f[5 len] checksum(f, 2, 3 len); return f; } private static byte checksum(byte[] b, int from, int count) { int sum 0; for (int i from; i from count; i) sum b[i] 0xFF; return (byte) (sum 0xFF); }校验覆盖范围一定要写进文档并让上下位机对齐这是最容易翻车的地方上位机算到载荷末字节下位机算到校验位之前差一个字节就永远校验不过而且现象是偶尔能通因为某些载荷下两种算法结果恰好相同。我的习惯是给组帧函数配一个单元测试用固定输入断言输出的十六进制串改动协议时先看测试红不红。4.2 通道抽象串口、蓝牙、TCP 换一个实现就能跑不同批次的机器人主板接口不一样有的留 USB 串口有的只有蓝牙有的走 WiFi。把通道抽出来上层就完全不用关心底层是什么。public interface RobotTransport { boolean open(RobotConfig cfg); void send(byte[] frame); boolean isConnected(); void close(); interface Listener { void onFrame(byte[] frame); // 下位机回执 void onDisconnected(String reason); } }串口实现要处理 USB 权限广播和波特率匹配蓝牙实现要注意 Android 12 之后的新权限TCP 实现要处理粘包——因为帧头固定粘包其实是最好解的一种读到一个缓冲区之后从头扫0xAA 0x55按长度字段切就行。工厂按配置里的类型名反射创建加一种新通道只需要新增一个类加一行注册不用改ActionQueue。常见做法是给每个通道加一个写线程send只是把帧丢进队列真正的write在写线程里串行执行。串口在安卓上并发写会直接把帧拆散而且不会报错只会表现为机器人偶尔抽风。4.3 动作编排队列、单飞与急停抢占语音指令和机器人动作不是一对一。用户说往前走然后转个圈实际是两条动作要排队执行用户说到一半喊停必须立刻打断正在跑的动作。所以动作层要有一个队列外加一个不走队列的抢占通道。public final class ActionQueue { private final BlockingQueueRobotAction queue new LinkedBlockingQueue(); private final RobotTransport transport; private RobotAction running; public void submit(RobotAction a) { if (a.priority Priority.EMERGENCY) { // 急停无条件插队并清空后续 queue.clear(); transport.send(a.frame); running null; return; } queue.offer(a); } // 单飞同一时刻只允许一条动作在跑靠完成回执或超时推进 private void loop() throws InterruptedException { while (true) { running queue.take(); transport.send(running.frame); awaitDoneOrTimeout(running); // 回执、超时二者取先到 running null; } } }awaitDoneOrTimeout是必须的。如果只等回执一旦丢帧整个队列就永久阻塞机器人会保持最后一个动作一直跑这个后果在现场很严重。超时时长按动作本身的时长估比如转圈两秒就设三秒超时超了就当失败继续下一条同时打一条日志。急停之所以绕过队列是因为它要求的是立刻排队等前一条动作跑完就失去意义了。5. 真机跑通与排查五条现象对应的定位路径5.1 编译环境与最小可跑步骤工程是标准的单模块 Gradle 结构minSdk给到 24主要是为了串口和权限 API 的可用性compileSdk按当前主流版本给就行改高改低不影响核心逻辑。第一次跑通别急着接机器人先用手机验证语音链路。# 1. 先定位所有需要填配置的地方别急着编译 grep -rn APP_ID\|APP_KEY\|SERIAL_BAUD\|ROBOT_HOST app/src/main/java app/build.gradle # 2. 编 debug 包 ./gradlew :app:assembleDebug # 3. 安装并只盯三个 tag 的日志 adb install -r app/build/outputs/apk/debug/app-debug.apk adb logcat -s VoiceCore:V RobotIo:V AsrEngine:V日志过滤这三个 tag 是有讲究的VoiceCore打状态迁移AsrEngine打识别中间结果和错误码RobotIo打收发的十六进制帧。排查顺序永远是先看状态迁移对不对再看识别有没有结果最后看帧有没有发出去——顺序反过来会在无关的层浪费时间。5.2 权限与清单麦克风、蓝牙、USB Host 各自要什么uses-permission android:nameandroid.permission.RECORD_AUDIO/ uses-permission android:nameandroid.permission.INTERNET/ uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE/ !-- Android 12 起蓝牙要新权限老权限声明了也不生效 -- uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT/ uses-permission android:nameandroid.permission.BLUETOOTH_SCAN/ !-- USB 串口方案的必要声明机器人主板走 USB 时不能少 -- uses-feature android:nameandroid.hardware.usb.host android:requiredfalse/麦克风是运行时权限第一次点开就得申请别等用户点了开始对话再弹那时候采集线程已经调过startRecording了会静默失败。蓝牙权限要按系统版本分支低版本用老的BLUETOOTH和BLUETOOTH_ADMIN高版本必须申请BLUETOOTH_CONNECT。USB 串口除了usb.host特性还要配一个 device filter 文件否则设备插上去枚举不到。5.3 配置与密钥注入别把 appId 写死在 Java 里识别引擎的 appId、密钥、串口波特率、机器人地址这几项一律走构建配置注入源码里只留占位符。这样同一份代码可以出展厅版和测试版也不会出现把密钥提交进版本库的事故。// 读 local.properties 或命令行 -P 参数缺配置直接让构建失败 def prop(String key, def fallback null) { def v project.findProperty(key) ?: fallback if (v null) throw new GradleException(缺少配置项: ${key}) return v } android { defaultConfig { buildConfigField String, ASR_APP_ID, \${prop(asr.appId)}\ buildConfigField String, ASR_APP_KEY, \${prop(asr.appKey)}\ buildConfigField int, SERIAL_BAUD, ${prop(robot.baud, 115200)} buildConfigField String, ROBOT_HOST, \${prop(robot.host, 192.168.1.50)}\ } }local.properties本身不提交团队里每个人自己填CI 上通过命令行-P传。缺配置时抛异常而不是给默认空串这一点很重要——空串会让识别引擎初始化成功但不返回结果排查起来比直接构建失败费时得多。5.4 五条踩坑记录现象、原因、解决第一条唤醒后识别一直返回空文本。现象是日志里onPartial从来不打印只有onError的 101 码。原因是播报结束时没有resume()采集AudioRecord处于停止状态识别引擎收到的是空流另一种情况是采集对象被重复start()内部抛了IllegalStateException被吞掉。解决是把麦克风做成单例加一个状态断言start时如果已在录音就直接返回播报前后严格配对调用。第二条真机上识别率掉一半模拟器上却正常。现象是同样一句话手机上能识别装到机器人主板上就变成乱码或空。原因是采样率不匹配主板默认输出 44.1kHz代码没做重采样直接送给只认 16kHz 的引擎另外用了VOICE_COMMUNICATION音源远端通话降噪把近场人声也压掉了。解决是固定 16kHz 采集换回VOICE_RECOGNITION并在调试面板加一个 PCM dump 按钮把原始数据导出来对比波形。第三条机器人偶尔乱动或者干脆不动。现象是动作帧在日志里发出去了下位机没反应或者执行了错误的动作。原因有两个一是串口写操作被多个线程并发调用帧被拆散二是校验和的覆盖范围上下位机理解不一致某些载荷下恰好通过看起来像偶发。解决是给写操作加单线程串行化把组帧函数锁死并配单元测试用固定输入断言输出的十六进制串。第四条聊两句就卡住语音助手变哑巴。现象是第二次对话开始没有任何反应日志停在 THINKING。原因是某个技能 handler 抛了异常或者忘了回调状态机没有出口。解决是给每个状态加超时迁移dispatch整体包 try-catch兜底一律回 IDLE同时把状态迁移全部打日志出问题时看最后一条迁移就知道卡在哪。第五条Android 12 以上蓝牙连不上USB 串口枚举不到设备。现象是open()返回 false没有任何异常栈。原因是只声明了老的蓝牙权限新系统要求运行时申请BLUETOOTH_CONNECT和BLUETOOTH_SCANUSB 侧则缺少 device filter插上设备后拿不到权限广播。解决是按版本分支申请权限补上 filter 文件并把open()的失败原因细分到日志里别只返回一个布尔值。6. 二次开发换引擎、加技能、写一条自己的机器人通道这套代码的价值不在 Demo 本身而在于四个接口把可变的部分圈出来了。真要动手改按下面三个方向走基本不用碰核心状态机。改造目标需要动的文件工作量注意点换成离线识别引擎asr/impl下新增一个实现类半天非流式引擎忽略feed在finish里一次性送数据加一个自定义技能nlu/skill下新增 handler一小时必须实现validate槽位缺失要能回问接自家机器人主板robot/transport下新增通道一天写操作必须串行协议校验范围写进测试加技能是最常见的需求比如让机器人跳个舞。写一个 handler 注册进去就行public final class DanceHandler implements IntentHandler { private final ActionQueue actions; private final SpeechQueue tts; Override public String validate(MapString, Object slots) { return null; // 跳舞没有必填槽位 } Override public void handle(MapString, Object slots) { tts.speak(好的看我跳一段, true); actions.submit(RobotAction.of(Cmd.DANCE, Payload.dance(2))); // 跳两轮 } } // 在 DialogEngine 初始化处注册一行搞定 router.register(robot.dance, new DanceHandler(actionQueue, speechQueue));注册完之后记得验证两件事一是这句指令在播报期间说会不会被吞掉二是跳舞动作跑起来时来一条急停能不能立刻生效。第二个尤其要测因为DanceHandler提交的是长动作急停走的是抢占通道只要ActionQueue实现没被改坏就一定能插进去但很多人为了支持连续动作把EMERGENCY分支删了后果就是用户喊停机器人还在转。换识别引擎的时候有个隐藏约束AsrSession的超时逻辑是假设引擎最终一定会回调的如果你接的引擎在静音时压根不回调onFinal那就得在 VAD 判定静音后主动调finish()否则每次都要等满八秒硬超时。我一般的做法是先在onPartial里刷新一个最后活动时间超过 1.2 秒没新中间结果就主动收尾这样交互节奏才像人说话。从那以后我每次接新的机器人硬件都强制先跑一遍三帧测试发一条移动帧看底盘动不动、发一条错误校验帧看下位机是否丢弃、发一条急停帧看能不能打断上一条动作。这三步过了上层语音链路才有意义。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站