小爱音箱上的私人云村 ♫

源码 | 演示

2019/05/24 更新:添加 Docker Image。

1、前言

因为受不了 Siri 这个人工智障,年初入了一个小爱音箱,用来听歌和控制家中的设备。小爱音箱如果不当成蓝牙音箱的话,总体体验还算过得去,但无奈小爱不支持网易云音乐,曲库还算小事,听不了收藏和日推就很难过了,所以打算自己动手实现一个。

小米提供了 小爱开放平台 供开发者实现自定义技能,小读了一下(很烂的)开发者文档,觉得可行,同时搜到有人已经 做了类似的实现 来搜索云村的歌曲,所以参考 原作者的代码 做了一些改动。

1.1、 演示视频

先来看一下实际效果吧 XD

1.2、 实现功能

由于小爱开放平台的一些限制,最后成果的体验并不算特别理想。

比如理想状态下,想要听日推的话,只需要语音指令『播放推荐』即可,实际需要『用网易音乐播放推荐』或者『打开网易音乐』->『播放推荐』一系列繁琐步骤。小爱虽然提供了类似于 Siri 捷径的小爱训练计划,但并不支持自定义技能…

目前已经实现的功能如下:

需要注意的是用户信息都是写死在服务端的(可以配置但只能有一个用户),所以一套服务端应用只能绑定一个网易账号

虽然小爱提供了 OAuth 认证来绑定用户,但考虑到我用的网易云音乐的私有接口,小爱不太可能审核通过一个要求你提供网易云用户名密码的第三方应用…所以留着开发者自娱自乐就好

2. Docker 安装

由于小爱开放平台只支持 HTTPS 协议的 API 接口,所以第一步我们需要生成自己的 HTTPS 证书,这里推荐使用 Let’s Encrypt 的 Wildcard 证书,生成证书的过程可以参见这篇文章

然后拉取和运行我生成好的 Docker Image

docker run --restart unless-stopped -d -it --name=xiaoai \
  -e EMAIL="网易云音乐邮箱地址" -e PASSWORD="网易云音乐密码" \
  -v /var/log/xiaoai:/var/log/xiaoai -e DOMAIN="小爱开放平台访问服务的域名,比如 hk2.ihainan.me" \
  -p 5000:5000 -p 5001:5001 ihainan/netease-xiaoai:0.1

配置 nginx(注意把 server_name 改成你自己的域名):

server {
    listen  443 ssl;
    server_name hk2.ihainan.me;

    if ($server_port !~ 443){
        rewrite ^(/.*)$ https://$host$1 permanent;
    }

    location / {
        client_max_body_size    1000m;
        proxy_pass http://localhost:5000;
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;


        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;


        proxy_http_version 1.1;
        proxy_set_header Upgrade "websocket";
        proxy_set_header Connection "Upgrade";
    }

    ssl_certificate    /etc/letsencrypt/live/ihainan.me/fullchain.pem;
    ssl_certificate_key    /etc/letsencrypt/live/ihainan.me/privkey.pem;
}

测试接口:

curl -X POST \
  https://hk2.ihainan.me/xiaoai \
  -H 'Accept: */*' \
  -H 'Cache-Control: no-cache' \
  -H 'Connection: keep-alive' \
  -H 'Content-Type: application/json' \
  -H 'Host: hk2.ihainan.me' \
  -H 'accept-encoding: gzip, deflate' \
  -H 'cache-control: no-cache' \
  -H 'content-length: 1401' \
  -d '{
    "version": "1.0", 
    "session": {
        "is_new": false, 
        "session_id": "407957116666513408_ab649ff6a53f4a1b98731b9a0ec796b3", 
        "application": {
            "app_id": "407957116666513408"
        }, 
        "user": {
            "user_id": "f9WLG9kanf0XZQ9qF3YQ3Q==", 
            "is_user_login": true, 
            "gender": "unknown"
        }
    }, 
    "request": {
        "type": 0, 
        "request_id": "0169b89b27584271ba6068dc5416ef16", 
        "timestamp": 1548903653315, 
        "intent": {
            "query": "打开云村播放器", 
            "score": 0.800000011920929, 
            "complete": true, 
            "domain": "openplatform",
                "confidence": 1, 
                "skillType": "Custom", 
                "sub_domain": "1012946", 
                "app_id": "407957116666513408", 
                "request_type": "Start", 
                "need_fetch_token": false, 
                "is_direct_wakeup": false, 
                "slots": "{\"intent_name\":\"Mi_Welcome\"}", 
                "is_qc": false
        }, 
        "locale": "zh-CN", 
        "slot_info": {"intent_name": "Mi_Welcome"}, 
        "is_monitor": true
    }, 
    "query": "打开云村播放器", 
    "context": {
        "device_id": "xaWzTlGDIEgMe53B2eeg7g==", 
        "device_category": "soundbox"
    }
}'

3、源码实现

需要跑到后端的服务实际有三个:

中间的三个服务

3.1、网易云音乐 API 服务(Node.js

网易官方并没有对外提供 API,所以我们需要借助 网易云音乐 NodeJS 版 API 这个第三方项目。

项目提供的 API 真的是非常全面,不仅能搜索歌曲,还能获得用户的收藏、日推、电台、歌单等等信息,你可以根据自己的需求借助这些接口来实现自己想要的功能。

这套服务需要搭建在自己服务器上,或者你也可以直接使用我 已经部署好的 API 服务

3.2、小爱请求处理服务(Python

小爱的指令封装成 JSON 传给本服务,程序会分析指令内容,而后去调用对应的 API 服务。

比如『播放推荐』这个指令,后台收到的 JSON 数据是这样的:

{
    "version": "1.0",
    "session": {
        "is_new": false,
        "session_id": "xxxxx_xxxxx",
        "application": {
            "app_id": "xxx"
        },
        "user": {
            "user_id": "xxx",
            "is_user_login": true,
            "gender": "male"
        },
        "attributes": {}
    },
    "request": {
        "type": 1,
        "request_id": "35e0d2f859161433880ae93d372c47c9",
        "timestamp": 1550119127943,
        "intent": {
            "query": "播放收藏",
            "score": 0.800000011920929,
            "complete": true,
            "domain": "openplatform",
            "confidence": 1,
            "skillType": "Custom",
            "sub_domain": "1012946",
            "app_id": "xxx",
            "request_type": "Intent",
            "need_fetch_token": false,
            "is_direct_wakeup": false,
            "slots": "{\"intent_name\":\"Favorites\",\"slots\":[]}"
        },
        "locale": "zh-CN",
        "slot_info": {
            "intent_name": "Favorites",
            "slots": []
        },
        "is_monitor": false
    },
    "query": "播放收藏",
    "context": {
        "device_id": "xxx",
        "bind_id": "xxx",
        "device_category": "soundbox"
    }
}

我们的程序主要想从 JSON 里面拿到三个信息。

第一个是 request.type,0 表示是唤醒词指令(比如『打开网易音乐』),1 表示正常指令(比如『播放收藏』),2 表示退出指令(比如『退出』)。

第二个是 request.query,存储小爱识别到的用户命令。

最后一个是 request.intent,表示命中的意图,这个意图是在小爱开放平台的控制台中配置的,关于意图更详细的说明可以参考 官方文档 或者 这篇文章,讲得很详细了。

虽然理论上我们可以自己去解析这个 JSON,但小爱本身已经封装了一个 Python 版本的库 来将 JSON 转换为对应的类,同时小爱提供了 函数计算 这个免费的运行时来跑 Python Code,所以小爱请求的处理我们干脆就用 Python 来实现。

不过需要注意的是,函数计算这个平台有很多很多的坑,最典型的就是日志文件必须得过了一个小时之后才能看到(服了)。

个人建议是把 Python 服务跑到自己的服务器上,加上 Let’s Encrypt 的免费证书,在小爱开放平台的控制台上指定 HTTPS 地址即可,方便后续的开发和调试。

3.3、请求解释器(Node.js

最后一个中间件服务理论上可以集成到上述两个服务当中,但实际上还是单独拆出来比较合适。

比如,考虑到我们想要获取用户的前 20 首红心歌曲,需要先获取用户的所有歌单,然后取出第一个歌单,根据歌单 ID 获取歌单里面的歌曲 ID,每首歌再单独调请求获取真实的 mp3 地址(并且地址是动态的,可能会发生变化,不能缓存)。

如果把这块代码放到网易云音乐 API 服务中,就会破坏原有代码的架构,改动过大。如果放到 Python 端,因为小爱每个请求的总时间不允许超过 2.5 秒,20 首歌肯定是会超时的。

综合考虑还是单独弄一个服务比较好。

4、接入平台

关于如何接入平台可以参考 这篇文章,不过文章中提到 『带意图的唤醒词实际用起来完全无效』有可能是因为配置不正确导致的。

首先需要在 技能信息 页面配置唤醒词:

然后需要在 意图 里面添加这个__语料__:

注意添加的语料必须是 『让网易音乐』后面的部分。

最后,最重要的,这时候后台收到的 JSON,request.type0(唤醒词),而不再是 1 了,需要做额外的处理。

5、一些坑

因为小爱平台的限制或者 Bug,开发过程其实遇到一些坑。

5.1、小爱意图判断错误

按理说小爱会根据用户的指令和我们填写的语聊来判断意图,实际上…这个判断过程似乎有不确定性。

比如我创建了两个意图:播放收藏(Favorites)和随机播放收藏(Ramdom_Favorites),并分别配置语料『播放收藏』和『随机播放收藏』。

实际上你说『随机播放收藏』,后来收到的意图名仍然是 Favorites 而不是 Ramdom_Favorites,并且类似的问题不会出现在推荐相关的意图上(Recommendation 和 Ramdom_Recommendation),即使是关掉模糊匹配也解决不了,个人觉得是小爱开放平台自身的 Bug。

目前只能在一个意图里面根据 Query 内容来做具体处理。

    elif req.request.type == 1:
        # Issue: http://www.miui.com/thread-21675179-1-1.html
        if req.request.slot_info.intent_name == 'Favorites':
            if '随机' in req.query:
                return get_random_favorites()
            else:
                return get_favorites()

5.2、 小爱 Python Module 源码的获取

小爱的函数空间内置了 xiaoai.py 这个模块,用于将 JSON 数据转为 Python class,坑爹的是,小米并没有提供对应的源码…导致根本就没法做本地测试,必须把代码传到函数空间中,而这玩意一个小时才能看到日志输出…

最后把我弄烦了,用 inspect.getsource (xiaoai) 拿到了源码,放在本地测试…

5.3、小爱时间限制的一些处理

前文说到,小爱请求从发出到收到回复的时间限制在了 2.5 秒,而我们如果要处理 20 首歌,每首歌都发请求去获取真实地址的话,超时是肯定的。

我的解决办法是每次返回给小爱的是一个动态地址,比如 /song?id=17950503,小爱播放到这首歌的时候,才实际去获取真实地址,并将请求跳转到真实的 mp3 地址,单个请求的话,2.5 秒就绰绰有余了。

// Get song's real URL based on its ID
app.get('/song', function (req, res) {
    // 1 second blank sound file
    const blankMP3 = 'http://iij.ihainan.me/tools/blank.mp3'

    // Get & redirect to the real URL
    const id = req.query.id
    console.log('song\'s ID = ' + id)
    rp({
        headers: {
            Cookie: cookieString,
            xhrFields: { withCredentials: true }
        },
        resolveWithFullResponse: true,
        method: 'GET',
        uri: apiService + '/song/url?id=' + id,
    }).then(function (response) {
        if (response.statusCode != 200) {
            console.log('Failed to get the URL of song ' + id + ', code = ' + response.statusCode)
            res.redirect(307, blankMP3)
        } else {
            const urlResponseJson = JSON.parse(response.body)
            const url = urlResponseJson.data[0].url
            if (url == null) {
                console.log('[WARN] Song with id ' + id + ' is not available')
                res.redirect(307, blankMP3)
            } else {
                console.log(id + ' -> ' + url)
                res.redirect(307, url)
            }
        }
    }).catch(function (error) {
        console.log('Failed to get the URL of song ' + id + ', error: ' + error)
        res.redirect(307, blankMP3)
    })
})

参考