Google Homeで音声操作してNASの音楽を再生する

Google HomeやAmzon Echoなどを使えば、SpoitfyとかAmazon musicとかの音楽を再生できます。しかし、サブスクリプションのサービスに加入していないと「〜のステーションを再生します」「〜をランダムに再生します」とかいって、自分の気に入っているアルバムをピンポイントで再生できなかったりするわけです。NASにはもう入っているんだから、それを再生できればいいじゃん。せっかくのスマートスピーカーなのだから、音声で制御したい、というのも条件に入れました。

調べてみたら一筋縄ではできないですね。Google Play Musicに音源を上げる…ってもうサービス終了だし、Echoの音声コマンドでファイルを返すってのもS3に入れる事例とかはありましたが、NASにあるそこそこ量の多いデータを指定するにはちょっと応用が効きにくそうだなという感じです。

そうこうしているうちに見つけたのが「[mpd2chromecast](https://github.com/dresdner353/mpd2chromecast)」。これを使うとmpdの音楽再生をChromecastを通じてGoogle Homeにキャストしてくれます。よし、あとは音声で制御するだけだ！

##前提とすること
mpdを音楽サーバーとして使います。ラズパイオーディオをやっている人には説明無用ですね。例えば「[moode audio](https://moodeaudio.org/)」をというディストリビューションをRaspberry Piに入れて、NASに格納した音楽ファイルをソースを読み込ませておきます。

あとこのサーバーで前述のmpd2chromecastを組み込んでおきます。もうこれで適当なスマホのmpdクライアント使ったり、moode audioのWebクライアント使って制御すればいいじゃんか…とも思いましたが、それだと話が終わっちゃうので続けます。

##基本的な構成
Google Homeで音声コマンドを認識させて、それでIFTTTを使ってWebhookを呼び出してMQTTにパブリッシュ。ラズパイでサブスクライブして状態の変化を受け取ってmpdを制御してChromecastで配信する、という構成を考えました。![スクリーンショット 2021-01-24 0.01.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1048712/5a26476c-48fa-3a36-dcfa-f3b8d7a2fba6.png)最初Webhookの呼び出しはLambdaを介してMQTTとしてパブリッシュしないといけないかと思っていましたが、BeeBotteならWeb+JSONでパブリッシュできるのですね。ありがたや。おかげでラズパイでMQTTをサブスクライブする処理だけ考えればよいということがわかりました。

##アプリケーション層プロトコル
こういうと大げさですが、要するに何をどう手渡すかというデータフォーマットの決めごとです。実際の細かいIFTTTやBeeBotteの詳細な設定方法については、こちらの記事を参照してください。ほぼこの記事の丸パクリでできました。

[IFTTTとBeebotteを使ってGoogleHomeからRaspberryPiを操作する](https://qiita.com/msquare33/items/9f0312585bb4707c686b)

さてとりあえずできなければいけないこととして
- 再生開始／停止
- アーティストの指定
- できればアルバムくらい指定したい
ということを考えました。一方で音声コマンドはシンプルに設計したい。AlexaだとIFTTTを介して呼び出すのに、「アレクサ、IFTTTで〇〇で〇〇して」みたいな言い方になりますが、Google Homeだとこの「IFTTTで」という呼び出しワードが不要になります。半面、IFTTTで定義したワードがすでに使われているとうまく働かないという欠点はあります。ここでは「ラズパイでほにゃらら」という呼び出し方にしました。ちなみにディストリビューションに即して「ムーデで」という言い方にしようとしたのですが、「ムードで」と解釈されてしまうので諦めました。
具体的には
- ラズパイで再生して／ラズパイを止めて
- ラズパイで〜を再生して
- ラズパイで〜の〜を再生して
という感じです。IFTTTでいうと最初のは「Say a Simple Phrase」を使い、残りは「Say a phrase with a text gradient」を使います。Text Gradientで使えるのは一つの文字列だけなので、「〜」なのか「〜の〜」なのかはラズパイ側の受け取るスクリプトで判別することにします。

BeeBotteの方ではチャネルを適当に定義（ここでは「moodeVoiceControl」）して、リソースは2つ定義しました。「PlayStatus」と「music」としています。再生して／止めてでPlayStatusをon／off、〜を再生しての〜をmusicで渡す感じです。
例えばオン／オフのアプレットであれば、THATにWebhookを指定して、URLに

```
https://api.beebotte.com/v1/data/publish/moodeVoiceControl/PlayStatus?token=[トークン]
```
そしてBodyに

```JavaScript
{"data":"on"}
```
を記述すればよいわけです。
再生の方はアーティスト名やアルバム名を指定すると同時に再生もさせたいので、

```
https://api.beebotte.com/v1/data/publish/moodeVoiceControl?token=[トークン]
```

```JavaScript
{"records":[{"resource":"PlayStatus","data":"on"},{"resource":"music","data":" {{TextField}}"}]}
```
を指定することになります。これで音声コマンドを発生した結果がMQTTサーバーであるBeeBotteまで届きます。
##MQTTで受け取る
ここまでくればあとは受け取って、それに対応してMPDを操作すればOKです。PythonのMQTTライブラリとしてPahoを使いました。ベースはこんな感じ。

```python
import paho.mqtt.client as mqtt
import json

TOKEN = "トークン"
HOSTNAME = "mqtt.beebotte.com"
PORT = 8883
TOPIC = "moodeVoiceControl/+"
CACERT = "クライアント証明書のファイル"

def on_connect(client, userdata, flags, respons_code):
    print('status {0}'.format(respons_code))
    client.subscribe(TOPIC)

def on_message(client, userdata, msg):
    data = json.loads(msg.payload.decode("utf-8"))["data"]
    # ここに処理を記述

client = mqtt.Client()
client.username_pw_set("token:%s"%TOKEN)
client.on_connect = on_connect
client.on_message = on_message
client.tls_set(CACERT)
client.connect(HOSTNAME, port=PORT, keepalive=60)
client.loop_forever()
```
これでメッセージが「data」に格納されます。ただText Gradientで渡される文字列には、なぜか空白がいろいろと交ざります。きっとGoogle Assistantが認識した過程を反映したものなのでしょう。こんな感じ。

```
 米津 玄 師 の bootleg
 谷山 浩子 の しっぽ の 気持ち
 バック ナンバー の アンコール
```
なので、受け取った文字列から適宜空白を抜いたりして検索キーとなる文字列を作ります。「の」で区切ってアーティスト名とアルバム名を識別しています。それでmpdにある曲データベースを検索して、合致するフォルダーを指定して再生させればいいわけです。検索はmpcのsearchコマンドを使います。

```shell
$ mpc search artist '谷山浩子' album 'しっぽの'
NAS/NASの名前/谷山浩子/しっぽのきもち/01 しっぽのきもち.wma
NAS/NASの名前/谷山浩子/しっぽのきもち/02 おはようクレヨン.wma
NAS/NASの名前/谷山浩子/しっぽのきもち/03 秋ぎつね.wma
NAS/NASの名前/谷山浩子/しっぽのきもち/04 キャロットスープの歌.wma
NAS/NASの名前/谷山浩子/しっぽのきもち/05 まっくら森の歌.wma
NAS/NASの名前/谷山浩子/しっぽのきもち/06 エッグムーン.wma
```
searchメソッドを使っているので、アルバム名は一部でも合致していれば検索結果として取得できるので、うろ覚えのときに便利かと思いました。検索結果から再生対象を組み立てて指定すればよいわけです。
まとめると、

```python
    data = json.loads(msg.payload.decode("utf-8"))["data"]
    if ('music' in msg.topic):
        data = data.strip()
        if (data.isascii()):
           playerData = data
           albumData = ''
        else:
          tempData = data.replace(' ', '')   # 間にある空白を除く
          splitData = tempData.split('の', 1)
          if (len(splitData) == 1):
            playerData = splitData[0]
            albumData = ''
          else:
            playerData = splitData[0]
            albumData = splitData[1]
        if(albumData == ''):
            cmdStr = 'mpc search artist \"'+playerData+'\"'
            process = subprocess.run(cmdStr, shell=True, stdout=PIPE, text=True)
            playtarget = process.stdout.split('/')
            if(len(playtarget) > 1):
            	subprocess.call('mpc stop', shell=True)
            	subprocess.call('mpc clear', shell=True)
            	subprocess.call('mpc add \"'+playtarget[0]+'/'+playtarget[1]+'/'+playtarget[2]+'/\"', shell=True)
            	subprocess.call('mpc play', shell=True)
        else:
            process = subprocess.run('mpc search artist \"'+playerData+'\" album \"'+albumData+'\"', shell=True, stdout=PIPE, text=True)
            playtarget = process.stdout.split('/')
            if(len(playtarget) > 1):
            	subprocess.call('mpc stop', shell=True)
            	subprocess.call('mpc clear', shell=True)
            	subprocess.call('mpc add \"'+playtarget[0]+'/'+playtarget[1]+'/'+playtarget[2]+'/'+playtarget[3]+'/\"', shell=True)
            	subprocess.call('mpc play', shell=True)
            	
    if ('PlayStatus' in msg.topic):
        if ('on' in data):
        	subprocess.call('mpc play', shell=True)
        else:
        	subprocess.call('mpc stop', shell=True)
```

というメソッドになります。subprocessはshell=Trueをお忘れなく。
##課題
すでに出ているサンプルでお気づきかとも思いますが、とにかく音による指定と文字列の正確な一致というのは相性が悪いです。例えば谷山浩子さんの「しっぽのきもち」は全部ひらがなですが、Google Assistantは「しっぽの気持ち」と認識します。

もっと問題なのは「バックナンバー」です。ID3タグなどに書かれるアーティスト名としては「Back Number」になっているはずで、これでは全く合致しません。ちなみにこれはIFTTTのGoogle Assistantのイベントで言語を「英語」とすると、正しく発音すれば「back number」で文字列を飛ばしてくれるので、ワークアラウンド的に英語のアプレットもIFTTTに定義することで一応回避できます。ただ今度は半角空白をオミットするわけにいかなくなります。ここまでのワークアラウンドは上のコードにも入れてありますが、「の」に当たるものが英語だとなんだろう（ofかな？）とも思ったのでalbumの指定はいれていません。

また英文のタイトルがつけられている場合、カタカナなのかスペルなのか、スペルなら全角か半角かという問題もあります。ここはちゃんとそこまで意識して音楽データベースを構築しておけばいいのですが、実際には適当なデータベースで合致しているものを取り込んでいたりするので（主に私の場合）バラバラで統一されていないので頭が痛いです。

もう一つはグループ名に「の」が入っていると対応できない点です。例えば「いきものがかかり」なんかですね。「の」が入っているグループは例外として先に処理しちゃうのが現実的かなと思っています。

今回の方式の根本的な課題としては、再生できる音楽の形式がChromecastで認知されているものに限られるというのもあります。mp3やflacはOKみたいですが、例えばwmaは再生できません。私の場合昔取り込んだCDはほぼwmaだったりするので、これが聞けないのはどうもなあという感じです。