介绍我的第二个开源智能音箱项目:wukong-robot 以及配套的教程。
在两年前,我做了第一个智能音箱项目 dingdang-robot 。在去年 7 月加入上报统计后,在不到一年的时间里,这个项目已经运行在 1000+ 台设备中,被唤醒了 128,000+ 次。截至今天,这个项目的个人版和社区版在 Github 上总共获得了 2,600+ 个 stars ,820+ 次 forks。
在我去年的一篇年度总结中,我提到因为 dingdang-robot 本身维护上的困难,我将项目迁移到了 dingdang-robot 组织交由社区进行维护。很遗憾的是,即使迁到了 dingdang-robot 组织,由于组织维护者们都并不是全职维护这个项目,而且硬件和操作系统上的差异始终给 dingdang-robot 的维护带来了很大的问题,所以取得的效果并不理想。而且随着自己能力的不断提升,我对 dingdang-robot 里头的代码也越发不满意:
try...catch
,虽然没人发现这一点,但我自己是过不了自己那一关的,每每想到自己在用一套有问题的代码作为别人的入口就像是留一个坑叫别人跳进来,内心觉得很有罪恶感;另外 PocketSphinx 的安装非常复杂,虽然我提供了树莓派的镜像,但是很多人还是希望手动安装,而 PocketSphinx 对环境要求也很苛刻,所以总会遇到各种奇怪的问题,而我又不能复现;:
)打成全角(:
),或者冒号后没有跟着空格再写键值;再比如当初我处理 log 的打印也设计得比较傻逼,为了写到文件里头,直接用的是重定向,完全没有考虑用 FileHandler 这种东西。到了今年,我决定对 dingdang-robot 进行完全重写,做出一个更加 优雅、灵活、鲁棒 的版本。
为了区别于以前的版本,我决定起给这个新版本起一个新的名字。我觉得三个字的唤醒词误唤醒率和长度都是比较理想的,所以我想取一个三个字的名字,另外还要能提现新版本的强大之处。于是我想到了“孙悟空”(后来才发现又一次跟优必选和腾讯叮当的合作项目重名了,real尴尬 😹 )。
于是,利用整个春节的假期(你没看错,我整个春节都用来写代码去了)。正月初五那天,wukong-robot 1.0 正式发布了。
以下是一段 wukong-robot 的定制版本 ycy-robot 的演示视频(如果访问不了,可以前往观看):
按照惯例,下面总结一下这个项目的一些开发心得。
project boards 是 Github 近期推出的一个新功能,它最大的用处是提供了类似 trello 的看板。我在开发维护 wukong-robot 的时候,也使用 project boards 管理这个项目。于是建了一个 wukong-project 。
我把项目分成了 To do
、In Progress
、Done
、Pending
几个状态。在规划第一个版本的时候,我就在 To do
栏中提了 10 个左右的需求。这使得我的项目可以朝着明确的目标演进。不过,在开发的时候,时常还会有一些新的想法冒出来,这时候我也会尽快写入需求池中。到真正发布 1.0 的时候,我已经完成了 21 个需求。
project boards 的另一个作用在于充当了项目的 roadmap 。你可以看到这个项目有哪些计划要做的需求,有哪些则是我正在开发中的需求。有兴趣的朋友还能参与进来帮忙完成其中的部分需求任务。
project boards 还有一个很有意思的特性:可以和 Github 的 issues 和 pull requests 等板块打通。当有人给你提 issue 或 pull request 的时候,可以设置自动追加到 To do
栏里。而当 issue 被 close 或者 pull request 被 accept 后,相应的条目可以自动挪入 Done
一栏。
不过,project boards 实际用起来还是有一些问题:因为整个 wukong-robot 项目不仅包括本体,还包含了第三方插件库 wukong-contrib ,以及将来可能有的其他一些衍生客户端。所以我希望用一个 wukong-project 来同时管理几个仓库。所以 wukong-project 并不是挂靠在 wukong-robot 仓库下的,而是直接挂在我的账户下。但不知道是不是 Github 设计上的疏忽:即使我在 wukong-project 里 link 了多个仓库,那些仓库下的 project 页面并没有展示 wukong-project 😵 。这种情况下,project boards 的 automation 也玩不起来 —— wukong-robot 的新 issue 也不会自动往 wukong-project 里新增 To do
条目。等我发现这个问题的时候,我早已创建了几十个条目,而 Github 又不支持将 transfer project boards ,所以只能将就着这么用下去了。
如前面所述,dingdang-robot 早期沿用了 jasper-client 的那套热词唤醒和静音检测的逻辑。虽然后来我也尝试给 dingdang-robot 加入了 snowboy 的支持,但让我很失望的是它在树莓派上使用效果很糟糕,所以我一直没有把 snowboy 作为默认的热词唤醒引擎。后来我发现其实我错怪了 snowboy :官方文档已经清楚地提到了问题的原因:而树莓派上或者其他板子上接的麦克风可能和 PC 上的麦克风的声音畸变差异非常大,所以现有的模型更加不能直接在树莓派上工作,否则效果会非常糟糕。
This is due to the acoustic distortion that results from the different microphones. If you record your voice with two different microphones (one on your laptop and the other on your Pi) and then play them (play t.wav), you will hear that they sound very differently (even though it is the same voice)!
了解到原因后,我在这个版本中去除了安装繁琐且中文识别较差的 PocketSphinx ,将 snowboy 作为主要的热词唤醒引擎。因为 snowboy 还提供了静音检测(VAD)的功能,所以我把原来 VAD 的代码全部去除,改为了直接使用 snowboy 的 VAD 。经过改写后,整个系统的稳定性和响应速度都有了质的提升。
不过,接入了 snowboy 后,整个交互模式就是先热词唤醒触发一个 detected_callback
的响应,说完指令后通过 audio_callback
将语音指令返回。有些时候我们并不想完全遵循这个形式:例如当我们希望 wukong-robot 能主动询问并澄清话术的时候,总是要求用户唤醒再说指令就显得整个交互很不智能了。于是我对 snowboydecorder 做了一点 hack :仿照 HotwordDetector 写了一个 ActiveListener 用来实现主动询问用户的功能。有了这个 ActiveListener 之后,当插件需要主动询问用户问题时,可以在 self.say()
的 onCompleted
回调方法中直接执行 self.activeListen()
方法得到即拿到用户的指令内容。例如:
1 | def onAsk(input): |
利用这个方法可以很方便地实现多轮对话以及极客模式。
原来的 dingdang-robot 在处理插件接口的时候,并没有考虑到多轮对话的情况。每一次 query 都会轮询一遍所有插件。如果要让某个插件在用户指示退出前持续响应用户的 query ,那么就得为这个插件实现一个内部循环。而在这个内部循环里头,用户只能响应有限的指令。
例如,NetEaseMusic 插件在一个 handleForever
方法中进入了一个循环,在这个循环里头,只能响应“上一首”、“下一首”等音乐播放相关的指令。而有时候,我们在播放音乐的时候,也会突然间想问一下天气再回来继续播放。对于这种情况,dingdang-robot 的插件交互模式就只能先退出音乐播放,再问天气,再重新要求播放音乐。这样的设计并不够人性化。
wukong-robot 重新考虑了插件的设计。你可以为 wukong-robot 开发两类技能插件:
self.activeListen()
方法进入主动聆听,从而实现多轮对话。不论是哪种类型的插件,都只需继承同一个基类 robot.sdk.AbstractPlugin
,并实现相应相关接口即可。其中:
isValid()
和 handle()
两个接口,分别用来判断用户指令是否适合交给该技能插件处理,以及如何处理;IS_IMMERSIVE
成员属性为 True
,此外还可以根据需求实现 isValidImmersive()
和 restore()
两个方法,分别用来支持沉浸模式下更多指令的响应以及恢复技能。1 | class AbstractPlugin(metaclass=ABCMeta): |
经过这次重构,所有的插件都继承自同一个基类。即使是需要多轮交互的沉浸式插件,用户不再需要为其编写类似 handleForever()
的循环,只需要关注核心的 query 处理即可。在沉浸式插件工作期间,wukong-robot 也支持响应其他技能的 query ,交给其他适合处理的技能插件处理,并在处理完成后根据情况恢复当前沉浸式插件的处理。作为对比,你可以看看 LocalPlayer 插件,它的可读性要比 NetEaseMusic 插件强很多。
大脑模块和技能系统实现一章中将更加深入地介绍 wukong-robot 插件机制的实现原理。
早在 dingdang-robot 发布初期,我就有为它配套开发一个后台管理端的想法。但因为种种原因(主要是因为懒),这个想法一直拖着没有去做。于是借着这次项目重写,趁热打铁就把后台管理端也完成了。
因为对 Jinja 比较有好感,所以我起初是打算用 Flask 来写后台管理端。但后面发现 Flask 的信号机制不能直接在非主线程里工作,而直接放主线程又会跟另一个必须工作在主线程的 snowboy 有冲突。折腾了半天后我决定改为直接支持在非主线程工作的 tornado 。 后台管理端的技术栈主要包括:
比较费脑的是鉴权部分。除了后台管理端需要设计登录界面以避免非法访问之外,我希望后台的接口能够开放 API 以支持其他配套客户端的接入,所以后端代码需要考虑两种访问来源的鉴权。
最初我使用 cookie 来鉴权,管理端登录成功后,就把用户设置的鉴权密钥 validation
字段存到 cookie 里头。前端在 Ajax 调用后端 API 时,可以直接从 cookie
里取出 validation
然后作为鉴权字段发给后台。然而 cookie 本身是明文保存的,这种做法会直接暴露用户的密钥,因此是一种很不安全的做法。
然后我尝试了使用 secure_cookie
来保存鉴权信息,然而因为 secure_cookie
是加了密的字段,前端没办法直接解析并传回给后端,所以又暂时放弃了这个做法。
再后来我发现还有一个 csrf_cookies
,可以用来防止跨站请求的问题。于是我很兴奋地加入了这个校验。但后面我发现这个跨站请求保护也只适用于站点本身的保护,因为 xsrf_cookies
的校验会在调用我们的接口实现方法前就完成,一旦加了这个校验后,其他客户端在调用 API 时也必须带上 csrf_cookies
,否则会直接抛出 '_xsrf' argument missing from POST
的错误。因此这个校验更适合用于纯 Web 站点,而不适合用于开放 API 的应用。
最后我转念一想:虽然前端没办法直接解析 secure_cookie
得到 validation ,但是 secure_cookie
也只是一个加了密的 cookie ,我依然可以取出 secure_cookie
里这个加了密的 validation
的值然后传给后台,而后台则可以使用 get_cookie
(而不是 get_secure_cookie
)取出期望的加了密后的 validation
的值并与前端传过来的值进行比对,这样就实现了前端页面的鉴权;对于 API 的鉴权,则可以直接使用明文的 validation
并将其作为第三方客户端的一个配置。后端在鉴权时直接判断这个 validation
与后端的配置里的 validation
值是否相等即可。所以最终我完成了如下的一个带鉴权的基类:
1 | class BaseHandler(tornado.web.RequestHandler): |
在配置页面,我在保存配置的时候加了 yaml.load()
检查,如果用户修改 YAML 有格式问题,将会被拒绝写入配置。另外,我还基于 watchdog 加入了对配置文件的监听:一旦配置文件发生修改,就触发配置的重新读取,从而实现无需重启更新大部分的配置。
1 | # -*- coding: utf-8-*- |
要说不太满意的地方,主要是首页的聊天消息更新机制。目前我是直接使用轮询的方式实现的 —— 前端会每隔 5 秒调用一次 /gethistory
接口,从而更新聊天记录。这种方式无疑是低效且浪费资源的做法。我曾经尝试将更新机制改成用 websocket 来实现,但后来发现手机端的浏览器几乎都不支持 websocket ,考虑到便携性的重要程度,我就放弃了这种实现。
后面我将尝试使用 tordano 的 coroutine 来实现长连接通信以及后端的主动更新,这会是一种更好的实现方案。
在即将发布 wukong-robot 的时候,我突然想到应该给 wukong-robot 一个提示升级的功能。当检测到版本更新时,提示用户进行升级。
于是我给 wukong-robot 的主仓库和插件仓库设计了一套基于 git 的更新机制:
VERSION
文件用于记录当前的版本号,版本号使用 Semantic Versioning 标准;VERSION
的版本号,并为其打一个新的 tag ;git checkout master && git pull && git checkout TAG名
。剩下的主要问题是检查更新的服务应该部署到哪里。当然,简单的搭一个更新检查服务器并不复杂,但服务器的维护成本比较高。如果后面我换了服务器,又得重新到另一个服务器搭一遍更新服务。另外,我并不太希望每次要发布新版本都得打开终端登录到我的服务器进行修改。最理想的应该是有个可以随时修改的 云 json 串
。于是我选择使用了腾讯云的无服务器函数(SCF):把最新版本信息写成一个SCF,通过向SCF发请求完成版本更新检查。这样的好处是无需购买和维护服务器,无需到服务器发布代码,而且SCF提供了方便的在线编辑、版本管理和测试验证的能力,这比自己发版本还要靠谱的多。
wukong-robot 的改动如下:
wukong-robot 后续的重要计划是训练本地的 ASR 、TTS 、NLU 及对话系统,并引入 RNN 降噪来改善环境较嘈杂的情况下难以唤醒的问题。关于项目的计划,可以关注 wukong project board 。
而在近期,我正在腾讯课堂上推出一套 Python 开发教程,其中会用到 wukong-robot 作为一个开发案例。
这套视频课程将从零开始,一步步教你如何使用 Python 开发出 wukong-robot 。涉及 Python 的基础语法,以及离线唤醒、静音检测、语音识别、语音合成、对话机器人等知识背景的介绍及相关sdk和服务的接入,并在这个基础上如何通过一步步的重构优化,开发出一个灵活可配置的 wukong-robot 。另外,还介绍了如何使用 tornado + twitter bootstrap + jQuery + Ajax 开发后台管理端及前端页面。进阶版中还包括了爬虫技术及 Flask 等技术的相关实战。
这门课的准备和录制几乎占据了我全部的业余时间,录制的过程是非常痛苦和煎熬的。比如,为了讲好 subprocess ,我把 subprocess 的老版本高级 API 、新的高级 API,再到底层的 Popen 以及涉及到的 Linux 的标准输入输出和管道的概念都讲了一遍。对于讲授的方式,我比较提倡授人以鱼不如授人以渔的主张,所以我并不是直接贴 API ,而是带着读者一起看 Python 的官方文档,着重培养阅读文档的能力。这种讲法非常的累,但却是我认为每个工程师应该掌握的学习方式。
参与这门课的制作也是为了完成我在去年的个人总结中立下的 flag 。Python 一直是我业余时间最常用的玩具语言,它非常适合用于原型开发。我有不少开源项目,比如 wukong-robot、dingdang-robot、LiveCV 都是用 Python 写的。而在我的工作中,它也帮助我完成了大量的工具和项目,这些工具和项目对个人或团队起到了非常大的作用(例如加班统计平台、已经在上百家中小银行中使用的fmanager),因此 Python 也无疑给我的职业发展起到了很大的推动作用。把我所掌握的 Python 知识分享给更多人,让更多人能够自如的使用这门语言来满足他们的需求,那也算是我对 Python 这门语言的回馈。