介绍我的第二个开源智能音箱项目:wukong-robot 以及配套的教程。

dingdang-robot 之殇

在两年前,我做了第一个智能音箱项目 dingdang-robot 。在去年 7 月加入上报统计后,在不到一年的时间里,这个项目已经运行在 1000+ 台设备中,被唤醒了 128,000+ 次。截至今天,这个项目的个人版和社区版在 Github 上总共获得了 2,600+ 个 stars ,820+ 次 forks。

在我去年的一篇年度总结中,我提到因为 dingdang-robot 本身维护上的困难,我将项目迁移到了 dingdang-robot 组织交由社区进行维护。很遗憾的是,即使迁到了 dingdang-robot 组织,由于组织维护者们都并不是全职维护这个项目,而且硬件和操作系统上的差异始终给 dingdang-robot 的维护带来了很大的问题,所以取得的效果并不理想。而且随着自己能力的不断提升,我对 dingdang-robot 里头的代码也越发不满意:

  1. dingdang-robot 是基于 Python 2 的,在 Python 3 环境里跑不起来。而 Python 2 已经停止维护了。
  2. dingdang-robot 的热词唤醒(KWS)复用的是 jasper-client 的那套,基于 PyAudio 自己实现录音和 VAD ,基于 PocketSphinx 实现热词唤醒。然而那套录音和VAD代码我个人觉得写得并不鲁棒,为了避免各种边界情况我不得不加了一些 try...catch ,虽然没人发现这一点,但我自己是过不了自己那一关的,每每想到自己在用一套有问题的代码作为别人的入口就像是留一个坑叫别人跳进来,内心觉得很有罪恶感;另外 PocketSphinx 的安装非常复杂,虽然我提供了树莓派的镜像,但是很多人还是希望手动安装,而 PocketSphinx 对环境要求也很苛刻,所以总会遇到各种奇怪的问题,而我又不能复现;
  3. 还有一些使用上的便利性问题。比如没有更新提示,有时候修了一个bug,别人不知道,提了issue后我得告诉他请更新到最新;再比如使用YAML作为配置文件,但是很多用户不懂YAML的语法格式,常见的比如把半角冒号(:)打成全角(),或者冒号后没有跟着空格再写键值;再比如当初我处理 log 的打印也设计得比较傻逼,为了写到文件里头,直接用的是重定向,完全没有考虑用 FileHandler 这种东西。

到了今年,我决定对 dingdang-robot 进行完全重写,做出一个更加 优雅、灵活、鲁棒 的版本。

为了区别于以前的版本,我决定起给这个新版本起一个新的名字。我觉得三个字的唤醒词误唤醒率和长度都是比较理想的,所以我想取一个三个字的名字,另外还要能提现新版本的强大之处。于是我想到了“孙悟空”(后来才发现又一次跟优必选和腾讯叮当的合作项目重名了,real尴尬 😹 )。

于是,利用整个春节的假期(你没看错,我整个春节都用来写代码去了)。正月初五那天,wukong-robot 1.0 正式发布了。

以下是一段 wukong-robot 的定制版本 ycy-robot 的演示视频(如果访问不了,可以前往观看):

wukong-robot 重生之路

按照惯例,下面总结一下这个项目的一些开发心得。

项目管理:Github project boards

project boards 是 Github 近期推出的一个新功能,它最大的用处是提供了类似 trello 的看板。我在开发维护 wukong-robot 的时候,也使用 project boards 管理这个项目。于是建了一个 wukong-project

wukong project boards
wukong project boards

我把项目分成了 To doIn ProgressDonePending 几个状态。在规划第一个版本的时候,我就在 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 的 automation 特性
project boards 的 automation 特性

不过,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 ,所以只能将就着这么用下去了。

热词唤醒:snowboy

如前面所述,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
2
3
4
5
6
7
8
def onAsk(input):
if not input:
self.say("指令有误,请重新尝试", cache=True)
return
# 执行响应
...

self.say("开始家庭助手控制,请在滴一声后说明内容", cache=True, onCompleted=lambda: onAsk(self.activeListen()))

利用这个方法可以很方便地实现多轮对话以及极客模式

关于如何在 Python 工程中接入 snowboy ,我在一门 Python 课程中有详细的介绍。如果你感兴趣的话,可以前往观看。课程的免费体验课部分已经包含了热词唤醒的完整内容。

技能插件重构:AbstractPlugin

原来的 dingdang-robot 在处理插件接口的时候,并没有考虑到多轮对话的情况。每一次 query 都会轮询一遍所有插件。如果要让某个插件在用户指示退出前持续响应用户的 query ,那么就得为这个插件实现一个内部循环。而在这个内部循环里头,用户只能响应有限的指令。

例如,NetEaseMusic 插件在一个 handleForever 方法中进入了一个循环,在这个循环里头,只能响应“上一首”、“下一首”等音乐播放相关的指令。而有时候,我们在播放音乐的时候,也会突然间想问一下天气再回来继续播放。对于这种情况,dingdang-robot 的插件交互模式就只能先退出音乐播放,再问天气,再重新要求播放音乐。这样的设计并不够人性化。

wukong-robot 重新考虑了插件的设计。你可以为 wukong-robot 开发两类技能插件:

  1. 普通技能插件,适用于普通的查询、助手类技能。通常的交互模式是唤醒 wukong-robot 后,说出指令并触发该技能插件,由其完成处理并汇报结果。如果需要询问用户问题,则可以利用 self.activeListen() 方法进入主动聆听,从而实现多轮对话。
  2. 沉浸式技能插件,适用于音乐、电台等技能。通常的交互模式是唤醒 wukong-robot 后,说出指令并触发该技能插件,由其进入该技能的沉浸式场景中。在该技能的沉浸式场景下,用户唤醒 wukong-robot 后,允许响应更多指令以完成更丰富的操作(例如“下一首歌”、“这是什么歌”等指令)。如果唤醒后只是简单的聊天,还允许 wukong-robot 在回答后恢复该技能的沉浸式场景(例如,用户在音乐场景中唤醒 wukong-robot 并问完时间后,wukong-robot 可以自动恢复音乐播放)。

不论是哪种类型的插件,都只需继承同一个基类 robot.sdk.AbstractPlugin ,并实现相应相关接口即可。其中:

  • 普通技能插件只需实现 isValid()handle() 两个接口,分别用来判断用户指令是否适合交给该技能插件处理,以及如何处理;
  • 沉浸式技能插件在普通技能插件的基础上,还需要设置 IS_IMMERSIVE 成员属性为 True ,此外还可以根据需求实现 isValidImmersive()restore() 两个方法,分别用来支持沉浸模式下更多指令的响应以及恢复技能。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class AbstractPlugin(metaclass=ABCMeta):
""" 技能插件基类 """

SLUG = 'AbstractPlugin'
IS_IMMERSIVE = False

def __init__(self, con):
if self.IS_IMMERSIVE is not None:
self.isImmersive = self.IS_IMMERSIVE
else:
self.isImmersive = False
self.priority = 0
self.con = con
self.nlu = self.con.nlu

def play(self, src, delete=False, onCompleted=None, volume=1):
self.con.play(src, delete, onCompleted, volume)

def say(self, text, cache=False, onCompleted=None):
self.con.say(text, cache=cache, plugin=self.SLUG, onCompleted=onCompleted)

def activeListen(self, silent=False):
return self.con.activeListen(silent)

def clearImmersive(self):
self.con.setImmersiveMode(None)

@abstractmethod
def isValid(self, query, parsed):
"""
是否适合由该插件处理

参数:
query -- 用户的指令字符串
parsed -- 用户指令经过 NLU 解析后的结果

返回:
True: 适合由该插件处理
False: 不适合由该插件处理
"""
return False

@abstractmethod
def handle(self, query, parsed):
"""
处理逻辑

参数:
query -- 用户的指令字符串
parsed -- 用户指令经过 NLU 解析后的结果
"""
pass

def isValidImmersive(self, query, parsed):
"""
是否适合在沉浸模式下处理,
仅适用于有沉浸模式的插件(如音乐等)
当用户唤醒时,可以响应更多指令集。
例如:“"上一首"、"下一首" 等
"""
return False

def pause(self):
"""
暂停当前正在处理的任务,
当处于该沉浸模式下且被唤醒时,
将自动触发这个方法,
可以用于强制暂停一个耗时的操作
"""
return

def restore(self):
"""
恢复当前插件,
仅适用于有沉浸模式的插件(如音乐等)
当用户误唤醒或者唤醒进行闲聊后,
可以自动恢复当前插件的处理逻辑
"""
return

经过这次重构,所有的插件都继承自同一个基类。即使是需要多轮交互的沉浸式插件,用户不再需要为其编写类似 handleForever() 的循环,只需要关注核心的 query 处理即可。在沉浸式插件工作期间,wukong-robot 也支持响应其他技能的 query ,交给其他适合处理的技能插件处理,并在处理完成后根据情况恢复当前沉浸式插件的处理。作为对比,你可以看看 LocalPlayer 插件,它的可读性要比 NetEaseMusic 插件强很多。

关于如何为 wukong-robot 开发技能插件,可以阅读 wukong-robot 的 插件开发教程 。另外,在我的 Python 课程大脑模块和技能系统实现 一章中将更加深入地介绍 wukong-robot 插件机制的实现原理。

后台管理端:tornado

早在 dingdang-robot 发布初期,我就有为它配套开发一个后台管理端的想法。但因为种种原因(主要是因为懒),这个想法一直拖着没有去做。于是借着这次项目重写,趁热打铁就把后台管理端也完成了。

因为对 Jinja 比较有好感,所以我起初是打算用 Flask 来写后台管理端。但后面发现 Flask 的信号机制不能直接在非主线程里工作,而直接放主线程又会跟另一个必须工作在主线程的 snowboy 有冲突。折腾了半天后我决定改为直接支持在非主线程工作的 tornado 。 后台管理端的技术栈主要包括:

wukong-robot 的后台管理端
wukong-robot 的后台管理端

比较费脑的是鉴权部分。除了后台管理端需要设计登录界面以避免非法访问之外,我希望后台的接口能够开放 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
2
3
4
5
6
7
8
9
class BaseHandler(tornado.web.RequestHandler):
def isValidated(self):
if not self.get_secure_cookie('validation'):
return False
return str(self.get_secure_cookie("validation"), encoding='utf-8') == config.get('/server/validate', '')
def validate(self, validation):
if '"' in validation:
validation = validation.replace('"', '')
return validation == config.get('/server/validate', '') or validation == str(self.get_cookie('validation'))

在配置页面,我在保存配置的时候加了 yaml.load() 检查,如果用户修改 YAML 有格式问题,将会被拒绝写入配置。另外,我还基于 watchdog 加入了对配置文件的监听:一旦配置文件发生修改,就触发配置的重新读取,从而实现无需重启更新大部分的配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# -*- coding: utf-8-*-

from robot import config
from watchdog.events import FileSystemEventHandler

class ConfigMonitor(FileSystemEventHandler):
def __init__(self, conversation):
FileSystemEventHandler.__init__(self)
self._conversation = conversation

# 文件修改
def on_modified(self, event):
if not event.is_directory:
config.reload()
self._conversation.reload()

要说不太满意的地方,主要是首页的聊天消息更新机制。目前我是直接使用轮询的方式实现的 —— 前端会每隔 5 秒调用一次 /gethistory 接口,从而更新聊天记录。这种方式无疑是低效且浪费资源的做法。我曾经尝试将更新机制改成用 websocket 来实现,但后来发现手机端的浏览器几乎都不支持 websocket ,考虑到便携性的重要程度,我就放弃了这种实现。

后面我将尝试使用 tordano 的 coroutine 来实现长连接通信以及后端的主动更新,这会是一种更好的实现方案。

我的 Python 课程的整个 Part 3 将更加系统地介绍 wukong-robot 的后台管理端开发过程,欢迎前往了解。

更新器:git tag + SCF

在即将发布 wukong-robot 的时候,我突然想到应该给 wukong-robot 一个提示升级的功能。当检测到版本更新时,提示用户进行升级。

wukong-robot 的提示升级
wukong-robot 的提示升级

于是我给 wukong-robot 的主仓库和插件仓库设计了一套基于 git 的更新机制:

  1. 在两个仓库的根目录各维护一个 VERSION 文件用于记录当前的版本号,版本号使用 Semantic Versioning 标准;
  2. 当要发布新版本时,更新 VERSION 的版本号,并为其打一个新的 tag ;
  3. 客户端检查到有更新时,拉取到最新的代码,然后再切到对应的 tag 。实际执行的命令为 git checkout master && git pull && git checkout TAG名

剩下的主要问题是检查更新的服务应该部署到哪里。当然,简单的搭一个更新检查服务器并不复杂,但服务器的维护成本比较高。如果后面我换了服务器,又得重新到另一个服务器搭一遍更新服务。另外,我并不太希望每次要发布新版本都得打开终端登录到我的服务器进行修改。最理想的应该是有个可以随时修改的 云 json 串 。于是我选择使用了腾讯云的无服务器函数(SCF):把最新版本信息写成一个SCF,通过向SCF发请求完成版本更新检查。这样的好处是无需购买和维护服务器,无需到服务器发布代码,而且SCF提供了方便的在线编辑、版本管理和测试验证的能力,这比自己发版本还要靠谱的多。

使用腾讯云SCF实现更新检查
使用腾讯云SCF实现更新检查

总结和展望

wukong-robot 的改动如下:

  1. 完全重写了 dingdang-robot 的大部分代码,新的架构我个人觉得足够漂亮。
  2. 原来的版本只能在 Linux 平台运行,而且 PocketSphinx 安装很苛刻,失败率很高,PocketSphinx 对中文的识别率也很一般。新版本使用 snowboy 取代 PocketSphinx ,无论是安装成本、稳定性、唤醒成功率都是质的飞跃。
  3. 提供了可视化的后台管理端,并且开放API。配套了配置页面、日志查看页面等管理页,大部分配置做到了免重启即改即生效。利用它可以轻松做出漂亮的交互界面,甚至开发出新的客户端,你可以类比为 Echo 一代到 Echo Show 的飞跃。
  4. 基于腾讯云 SCF 实现了版本更新检查,向专业的开源框架标准迈进。
  5. docker 镜像安装支持,另外金辉同学也为它贡献了一个一键式安装脚本
  6. 对技能插件接口进行了重构,支持了沉浸式插件,开发者可以轻松实现多轮对话、音乐播放,我近期支持的极客模式特性也是使用了沉浸式插件。另外还加入了NLU支持,开发者可以写出更加智能的插件,处理更复杂的语义。
  7. 将一些我认为有侵权嫌疑的特性移出仓库本体。例如不再自带网易云音乐技能,另外我也把微信功能移出了本体,而是改为利用 API 实现了一个基于 itchat 的客户端。所以 wukong-robot 是一个比 dingdang-robot 更加 “君子” 的版本。

wukong-robot 后续的重要计划是训练本地的 ASR 、TTS 、NLU 及对话系统,并引入 RNN 降噪来改善环境较嘈杂的情况下难以唤醒的问题。关于项目的计划,可以关注 wukong project board

而在近期,我正在腾讯课堂上推出一套 Python 开发教程,其中会用到 wukong-robot 作为一个开发案例。

Python 从入门到实战课程
Python 从入门到实战课程

这套视频课程将从零开始,一步步教你如何使用 Python 开发出 wukong-robot 。涉及 Python 的基础语法,以及离线唤醒、静音检测、语音识别、语音合成、对话机器人等知识背景的介绍及相关sdk和服务的接入,并在这个基础上如何通过一步步的重构优化,开发出一个灵活可配置的 wukong-robot 。另外,还介绍了如何使用 tornado + twitter bootstrap + jQuery + Ajax 开发后台管理端及前端页面。进阶版中还包括了爬虫技术及 Flask 等技术的相关实战。

现在这门课的基础篇完整篇都有打折优惠,想要学习 Python 开发的朋友千万别错过。

这门课的准备和录制几乎占据了我全部的业余时间,录制的过程是非常痛苦和煎熬的。比如,为了讲好 subprocess ,我把 subprocess 的老版本高级 API 、新的高级 API,再到底层的 Popen 以及涉及到的 Linux 的标准输入输出和管道的概念都讲了一遍。对于讲授的方式,我比较提倡授人以鱼不如授人以渔的主张,所以我并不是直接贴 API ,而是带着读者一起看 Python 的官方文档,着重培养阅读文档的能力。这种讲法非常的累,但却是我认为每个工程师应该掌握的学习方式。

wukong-robot开发
wukong-robot开发

参与这门课的制作也是为了完成我在去年的个人总结中立下的 flag 。Python 一直是我业余时间最常用的玩具语言,它非常适合用于原型开发。我有不少开源项目,比如 wukong-robot、dingdang-robot、LiveCV 都是用 Python 写的。而在我的工作中,它也帮助我完成了大量的工具和项目,这些工具和项目对个人或团队起到了非常大的作用(例如加班统计平台、已经在上百家中小银行中使用的fmanager),因此 Python 也无疑给我的职业发展起到了很大的推动作用。把我所掌握的 Python 知识分享给更多人,让更多人能够自如的使用这门语言来满足他们的需求,那也算是我对 Python 这门语言的回馈。

Comments