dingdang-robot 之殇前端
wukong-robot 重生之路python
项目管理:Github project boardsgit
热词唤醒:snowboyweb
技能插件重构:AbstractPlugindocker
后台管理端:tornadojson
更新器:git tag + SCFbootstrap
总结和展望后端
dingdang-robot 之殇
在两年前,我作了第一个智能音箱项目 dingdang-robot 。在去年 7 月加入上报统计后,在不到一年的时间里,这个项目已经运行在 1000+ 台设备中,被唤醒了 128,000+ 次。截至今天,这个项目的我的版和社区版在 Github 上总共得到了 2,600+ 个 stars ,820+ 次 forks。浏览器
在我去年的一篇年度总结中,我提到由于 dingdang-robot 自己维护上的困难,我将项目迁移到了 dingdang-robot 组织交由社区进行维护。很遗憾的是,即便迁到了 dingdang-robot 组织,因为组织维护者们都并非全职维护这个项目,并且硬件和操做系统上的差别始终给 dingdang-robot 的维护带来了很大的问题,因此取得的效果并不理想。并且随着本身能力的不断提高,我对 dingdang-robot 里头的代码也愈加不满意:安全
dingdang-robot 是基于 Python 2 的,在 Python 3 环境里跑不起来。而 Python 2 已经中止维护了。
dingdang-robot 的热词唤醒(KWS)复用的是 jasper-client 的那套,基于 PyAudio 本身实现录音和 VAD ,基于 PocketSphinx 实现热词唤醒。然而那套录音和VAD代码我我的以为写得并不鲁棒,为了不各类边界状况我不得不加了一些
try...catch
,虽然没人发现这一点,但我本身是过不了本身那一关的,往往想到本身在用一套有问题的代码做为别人的入口就像是留一个坑叫别人跳进来,心里以为颇有罪恶感;另外 PocketSphinx 的安装很是复杂,虽然我提供了树莓派的镜像,可是不少人仍是但愿手动安装,而 PocketSphinx 对环境要求也很苛刻,因此总会遇到各类奇怪的问题,而我又不能复现;还有一些使用上的便利性问题。好比没有更新提示,有时候修了一个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 ]
我把项目分红了 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 的 automation 特性 ]
不过,project boards 实际用起来仍是有一些问题:由于整个 wukong-robot 项目不只包括本体,还包含了第三方插件库 wukong-contrib ,以及未来可能有的其余一些衍生客户端。因此我但愿用一个 wukong-project 来同时管理几个仓库。因此 wukong-project 并非挂靠在 wukong-robot 仓库下的,而是直接挂在个人帐户下。但不知道是否是 Github 设计上的疏忽:即便我在 wukong-project 里 link 了多个仓库,那些仓库下的 project 页面并无展现 wukong-project :dizzy_face: 。这种状况下,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()
方法获得即拿到用户的指令内容。例如:
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 开发两类技能插件:
普通技能插件,适用于普通的查询、助手类技能。一般的交互模式是唤醒 wukong-robot 后,说出指令并触发该技能插件,由其完成处理并汇报结果。若是须要询问用户问题,则能够利用
self.activeListen()
方法进入主动聆听,从而实现多轮对话。沉浸式技能插件,适用于音乐、电台等技能。一般的交互模式是唤醒 wukong-robot 后,说出指令并触发该技能插件,由其进入该技能的沉浸式场景中。在该技能的沉浸式场景下,用户唤醒 wukong-robot 后,容许响应更多指令以完成更丰富的操做(例如“下一首歌”、“这是什么歌”等指令)。若是唤醒后只是简单的聊天,还容许 wukong-robot 在回答后恢复该技能的沉浸式场景(例如,用户在音乐场景中唤醒 wukong-robot 并问完时间后,wukong-robot 能够自动恢复音乐播放)。
不管是哪一种类型的插件,都只需继承同一个基类 robot.sdk.AbstractPlugin
,并实现相应相关接口便可。其中:
普通技能插件只需实现
isValid()
和handle()
两个接口,分别用来判断用户指令是否适合交给该技能插件处理,以及如何处理;沉浸式技能插件在普通技能插件的基础上,还须要设置
IS_IMMERSIVE
成员属性为True
,此外还能够根据需求实现isValidImmersive()
和restore()
两个方法,分别用来支持沉浸模式下更多指令的响应以及恢复技能。
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 。
后台管理端的技术栈主要包括:
开发框架:tornado
前端框架:twitter-bootstrap + jQuery
Material Design 风格的悬浮录音按钮:material-floating-button
录音:opus-recorder
录音过程当中的 spin:spin.js
右上角的 toast 提示:toastr
进度条:progress.js
[ 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
值是否相等便可。因此最终我完成了以下的一个带鉴权的基类:
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 加入了对配置文件的监听:一旦配置文件发生修改,就触发配置的从新读取,从而实现无需重启更新大部分的配置。
# -*- 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 的主仓库和插件仓库设计了一套基于 git 的更新机制:
在两个仓库的根目录各维护一个
VERSION
文件用于记录当前的版本号,版本号使用 Semantic Versioning 标准;当要发布新版本时,更新
VERSION
的版本号,并为其打一个新的 tag ;客户端检查到有更新时,拉取到最新的代码,而后再切到对应的 tag 。实际执行的命令为
git checkout master && git pull && git checkout TAG名
。
剩下的主要问题是检查更新的服务应该部署到哪里。固然,简单的搭一个更新检查服务器并不复杂,但服务器的维护成本比较高。若是后面我换了服务器,又得从新到另外一个服务器搭一遍更新服务。另外,我并不太但愿每次要发布新版本都得打开终端登陆到个人服务器进行修改。最理想的应该是有个能够随时修改的 <q>云 json 串</q> 。因而我选择使用了腾讯云的无服务器函数(SCF):把最新版本信息写成一个SCF,经过向SCF发请求完成版本更新检查。这样的好处是无需购买和维护服务器,无需到服务器发布代码,并且SCF提供了方便的在线编辑、版本管理和测试验证的能力,这比本身发版本还要靠谱的多。
[ 使用腾讯云SCF实现更新检查 ]
总结和展望
wukong-robot 的改动以下:
彻底重写了 dingdang-robot 的大部分代码,新的架构我我的以为足够漂亮。
原来的版本只能在 Linux 平台运行,并且 PocketSphinx 安装很苛刻,失败率很高,PocketSphinx 对中文的识别率也很通常。新版本使用 snowboy 取代 PocketSphinx ,不管是安装成本、稳定性、唤醒成功率都是质的飞跃。
提供了可视化的后台管理端,而且开放API。配套了配置页面、日志查看页面等管理页,大部分配置作到了免重启即改即生效。利用它能够轻松作出漂亮的交互界面,甚至开发出新的客户端,你能够类比为 Echo 一代到 Echo Show 的飞跃。
基于腾讯云 SCF 实现了版本更新检查,向专业的开源框架标准迈进。
docker 镜像安装支持,另外金辉同窗也为它贡献了一个一键式安装脚本。
对技能插件接口进行了重构,支持了沉浸式插件,开发者能够轻松实现多轮对话、音乐播放,我近期支持的极客模式特性也是使用了沉浸式插件。另外还加入了NLU支持,开发者能够写出更加智能的插件,处理更复杂的语义。
将一些我认为有侵权嫌疑的特性移出仓库本体。例如再也不自带网易云音乐技能,另外我也把微信功能移出了本体,而是改成利用 API 实现了一个基于 itchat 的客户端。因此 wukong-robot 是一个比 dingdang-robot 更加 “君子” 的版本。
wukong-robot 后续的重要计划是训练本地的 ASR 、TTS 、NLU 及对话系统,并引入 RNN 降噪来改善环境较嘈杂的状况下难以唤醒的问题。关于项目的计划,能够关注 wukong project board 。
而在近期,我正在腾讯课堂上推出一套 Python 开发教程,其中会用到 wukong-robot 做为一个开发案例。
[ Python 从入门到实战课程 ]
这套视频课程将从零开始,一步步教你如何使用 Python 开发出 wukong-robot 。涉及 Python 的基础语法,以及离线唤醒、静音检测、语音识别、语音合成、对话机器人等知识背景的介绍及相关sdk和服务的接入,并在这个基础上如何经过一步步的重构优化,开发出一个灵活可配置的 wukong-robot 。另外,还介绍了如何使用 tornado + twitter bootstrap + jQuery + Ajax 开发后台管理端及前端页面。进阶版中还包括了爬虫技术及 Flask 等技术的相关实战。
如今这门课的基础篇和完整篇都有打折优惠,想要学习 Python 开发的朋友千万别错过。
基础篇:https://ke.qq.com/course/387931?tuin=1b8113f4
完整篇:https://ke.qq.com/course/384790?tuin=1b8113f4
这门课的准备和录制几乎占据了我所有的业余时间,录制的过程是很是痛苦和煎熬的。好比,为了讲好 subprocess ,我把 subprocess 的老版本高级 API 、新的高级 API,再到底层的 Popen 以及涉及到的 Linux 的标准输入输出和管道的概念都讲了一遍。对于讲授的方式,我比较提倡授人以鱼不如授人以渔的主张,因此我并非直接贴 API ,而是带着读者一块儿看 Python 的官方文档,着重培养阅读文档的能力。这种讲法很是的累,但倒是我认为每一个工程师应该掌握的学习方式。
[ wukong-robot开发 ]
参与这门课的制做也是为了完成我在去年的我的总结中立下的 flag 。Python 一直是我业余时间最经常使用的玩具语言,它很是适合用于原型开发。我有很多开源项目,好比 wukong-robot、dingdang-robot、LiveCV 都是用 Python 写的。而在个人工做中,它也帮助我完成了大量的工具和项目,这些工具和项目对我的或团队起到了很是大的做用(例如加班统计平台、已经在上百家中小银行中使用的fmanager),所以 Python 也无疑给个人职业发展起到了很大的推进做用。把我所掌握的 Python 知识分享给更多人,让更多人可以自如的使用这门语言来知足他们的需求,那也算是我对 Python 这门语言的回馈。
本文分享自微信公众号 - HaHack(gh_12d2fe363c80)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。