2021年1月にGoogle Homeを購入して、家庭に便利なシステムの構築に励んでいます。とりあえず、第一弾として表題の通り、家庭内要望として、NAS内にあるmp3ファイルを再生するシステムの製作を開始しました。
前回までのまとめ
目標動作
- Google Homeに話しかけると、話しかけた内容に応じた曲を再生する。
- 再生する曲はLAN内に設置されたNASに記憶しているmp3から選択する。
システムの構成
必要な物理デバイスおよび環境
手持ち部品であるGoogle Nest Mini、RaspberryPi Zero W、IOデータNASの組合せで実現することを付帯条件として設定しています。そこで以下の構成を考えました。なお、()は本記事で使用している機種です。
- Google Home (機種:Google Nest Mini)
- RaspberryPi (RaspberryPi Zero W)
- NAS (IOデータlandisk)
- 無線LANとインターネット環境
- 開発用PC (Windows10搭載PC)
システム構成全体
今回やること
前回までで、RaspberryPiから、node.jsおよびgoogle-home-notifierを利用して、Google Homeに発話命令を送ることと、Google HomeにNAS上のmp3を再生する命令を送ることができました。
次に、Google Homeへの音声命令をトリガとして、NAS上のmp3を選択して再生することを考えていきます。Google Homeへの音声命令を何かしらの方法でRaspberryPiに伝達して、RaspberryPIでそれを受けて前回までで作成したスクリプトが実行できれば良いことになります。
Google HomeからRaspberryPiへの命令の伝達にはIFTTTを用いてslackに投稿し、slackへの投稿をトリガとする動作実行をhubotで行うことにします。
そこで今回はGoogle Home、IFTTT、slack、hubotの連係動作について説明していきます。なお、アカウント登録が必要なものは、全て無料アカウントを使用して機能を実現します。
hubotを設定する
参考記事
Raspberry PiにhubotをインストールしてSlackから呼び出すまでの手順
https://qiita.com/s_harada/items/c10b1322daec5521b261
実施した手順
上の参考記事に従って、hubotが動くように設定をしました。参考記事ではRaspberry Pi 2 Model Bを使用されているようですが、RaspberryPi Zero Wでhubotが動作することを確認できました。
bot nameはriremaにしています。
参考記事の/home/pi/hubot/pibot/bin/run_hubot.shを作成する箇所は、以下の通りに変更しています。
#!/bin/sh
cd /home/pi/hubot/pibot
sudo cp /mnt/nas_music/raspberrypi/hubotscripts/*.coffee /home/pi/hubot/pibot/scripts
sudo -u pi bin/hubot --adapter slack
cp~の箇所は、スクリプトファイルをNASから読み込むようにしています。
なお、run_hubot.shファイルは以下のコマンドで実行権限を追加しておく必要があります。
sudo chmod +x /home/pi/hubot/pibot/bin/run_hubot.sh
適当なチャンネルを作り、hubotをそのチャンネルに追加します。
動作確認
hubotを起動して、hubotを参加させたチャンネルにpingを送ってみます。
PONGが帰ってきたら成功です。
slack投稿内容による動作定義
slackに投稿する内容で、発話もしくは指定したmp3を再生するためのプロトコルを作成します。
param-1@param-2@param-3
という文字列で命令文を定義しました。これを踏まえて以下のように、2種類の動作を定義します。
文字列param-1による条件分岐 | 動作 |
“tts”に等しい | param-2で指定された文字列をgoogle homeに発話させる。 |
“music”に等しい | param-2で指定された文字列から、mp3ファイルを一つ選ぶ。 選んだmp3ファイルを再生する命令をgoogle homeに送る。 |
上記以外 | 何もしない |
なお、この時点ではparam-3は枠は確保していますが使用していません。また、後にわかったのですが、slack上でテスト的に命令を打ち込むときには”@”を打ち込むとメンション対象として会話参加者から誰かを選択するウィンドウが開いて使いにくいようです。ですが、一旦はこのプロトコルを使用する条件で話を進めます。
実際の命令文の例を見ていきます。例として
tts@あいうえお@
のようにslackへの投稿がなされたとき、google homeには「あいうえお」と発話する命令が送出されます。
次の例として、
music@あいうえお@
のようにslackへの投稿がなされたとき、キーワード”あいうえお”をもとに、関連付けられたmp3を特定し、google homeに対してそのmp3を再生する命令が送出されます。
go.jsの見直し
~/google-home-notifier/go.jsは実行時にコマンドライン引数で
music@あいうえお@
のような引数を受け付けることとします。コマンドの例として
node go.js music@あいうえお@
のような形式で実行すると、「あいうえお」に関連付けられたmp3ファイルを実行するように設計します。
console.log(process.argv);
const split_str = "@";
const input = process.argv[2];
const { exec } = require('child_process');
const googlehome = require('./google-home-notifier');
const language = 'ja'; // ここに日本語を表す ja を設定
googlehome.ip(undefined, language);
googlehome.device('Google Nest', language);
// 自分のIPアドレスを取得するためのlinuxコマンドを呼び出す。
var ip = "0.0.0.0";
exec("ip -4 a show wlan0 | grep -oP '(?<=inet\\s)\\d+(\\.\\d+){3}'", (err, stdout, stderr) => {
ip=stdout.trim();
console.log(ip);
});
// 指定時間、非同期で待機する関数
async function sleep(waitSec, callbackFunc) {
return new Promise((resolve, reject) => {
var spanedSec = 0;
var waitFunc = function () {
spanedSec+=0.1;
if (spanedSec >= waitSec) {
if (callbackFunc) callbackFunc();
resolve();
}
clearTimeout(id);
id = setTimeout(waitFunc, 100);
};
var id = setTimeout(waitFunc, 100);
});
}
// google-home-notifierのnotify関数を非同期実行
async function notify(str, callback){
return new Promise((resolve, reject)=>{
googlehome.notify(str, callback, resolve);
});
}
// google-home-notifierのplay関数を非同期実行
async function play(str, callback){
return new Promise((resolve, reject)=>{
googlehome.play(str, callback, resolve);
});
}
// メイン関数 非同期実行
async function main_func(){
// param-1@param-2@param-3の取得
const splitted_input = input.split(split_str);
// param-1@param-2@param-3を所定の区切り文字で分割
const mode = splitted_input[0];
const message = splitted_input[1];
const options = splitted_input[2];
if(mode == "music"){ // 音楽再生モードのとき
console.log("play music mode");
// messageに関連するmp3ファイルを読み出す。
var playFileObj = require('./select_music').selectMusicFile(message);
console.log(playFileObj);
var duration = 0;
var media_data;
// mp3再生前に読み上げる所定の文字列
await notify(playFileObj.comment, function(res, d) { console.log(res); duration = d.media.duration; });
await sleep(duration, null);
// 変数ipには自分のIPアドレスが入る。
// 厳密には、IPアドレス取得完了を待ってから変数を使用しなければならない。
// 前段のnotify関数のawaitで並列でipアドレス取得が十分に間に合うので一旦この方式で進める。
const dataServerAddress = ip;
const dataServerPort = 8091;
// 再生するmp3ファイルのURLを設定する。
var reqStr = 'http://' + dataServerAddress + ':' + dataServerPort + '/' + playFileObj.filename;
console.log(reqStr);
// 指定したmp3ファイルをgoogle homeから再生する命令。
await play(reqStr, function(res, d) { console.log(res);media_data = d; duration = d.media.duration; console.log(d);});
await sleep(duration, null);
console.log('process.exit(0);');
process.exit(0);
}else if(mode == "tts"){ // テキスト読み上げモードのとき
console.log("text to speech mode");
await notify(message , function(res, d) { console.log(res); duration = d.media.duration; });
await sleep(duration, null);
console.log('process.exit(0);');
process.exit(0);
}
}
main_func();
setTimeout(() => {
console.log('process.exit(1);');
process.exit(1);
}, 30000);
select_music.js
さらに、go.jsから呼び出されるselect_music.jsを以下のように設定します。実際の環境に合わせて、filename、comment、keysは適当に変更してください。
const musicData = [
{
filename: 'example01.mp3',
comment:'再生前にしゃべります01',
keys:[
'example01',
'keyword01-01',
'keyword01-02',
'keyword01-03',
'keyword01-04',
'keyword01-05',
]
},
{
filename: 'example02.mp3',
comment:'再生前にしゃべります02',
keys:[
'example02',
'keyword02-01',
'keyword02-02',
'keyword02-03',
'keyword02-04',
'keyword02-05',
]
},
{
filename: 'example03.mp3',
comment:'再生前にしゃべります03',
keys:[
'example03',
'keyword03-01',
'keyword03-02',
'keyword03-03',
]
},
{
filename: 'example04.mp3',
comment:'再生前にしゃべります04',
keys:[
'example04',
'keyword04-01',
'keyword04-02',
'keyword04-03',
'keyword04-04',
]
},
{
filename: 'example05.mp3',
comment:'再生前にしゃべります05',
keys:[
'example05',
'keyword05-01',
'keyword05-02',
'keyword05-03',
'keyword05-04',
'keyword05-05',
]
},
];
function selectMusicFile(str){
for(i=0; i < musicData.length; i++){
if(musicData[i].filename == str || musicData[i].comment == str) return musicData[i];
var keys = musicData[i].keys;
for(j=0;j<keys.length; j++){
if(str == keys[j]){
return musicData[i];
}
}
}
return {
filename:null,
comment:null,
};
}
hubotスクリプトの作成
それでは、実際にhubot用のスクリプトを作成していきます。
example.coffeeを以下のように変更します。
module.exports = (robot) ->
robot.hear /(.+?)@(.+?)@(.*)(@|$)/i, (res) ->
res.send "! ! command received ! !"
res.send res.match[0]
@exec = require('child_process').exec
command = "node /home/pi/google-home-notifier/go.js " + res.match[0].replace(' ', '\\ ')
res.send command
@exec command, (error, stdout, stderr) ->
# msg.send error
res.send stdout
# res.send stderr
ソースコードの
robot.hear /(.+?)@(.+?)@(.*)(@|$)/i, (res) ->
の箇所はチャンネルに投稿された内容が、正規表現/(.+?)@(.+?)@(.*)(@|$)/iに一致するとき、(res) ->…で定義される関数を実行するという意味になります。
command = "node /home/pi/google-home-notifier/go.js " + res.match[0].replace(' ', '\\ ')
の箇所は、文字列commandに、実行用のコマンドの文字列を代入します。res.match[0]は正規表現のマッチ文字列(.+?)@(.+?)@(.*)(@|$)全体が代入されます。スペースを含む場合は、コマンドライン引数として、複数の引数に分割されているものとして実行されてしまいますので、スペースをバックスラッシュ付のスペースに変換しておきます。
res.send command
の箇所は、チャンネルに文字列commandを投稿します。この場合は、実行用のコマンドが出力されます。
@exec command, (error, stdout, stderr) ->
# msg.send error
res.send stdout
# res.send stderr
外部コマンドを実行します。外部コマンドの実行結果の標準出力stdout、標準エラー出力stderrをslackチャンネルに投稿できるようです。errorが何なのか理解できていませんが、とりあえず先に進みます。
筆者は、coffee scriptはこれまで使ったことがなく、不慣れですので、引数をそのままgo.jsに渡して、処理の大半をgo.jsの中で実行することにしています。
動作確認
一旦、RaspberryPi上で
sudo reboot
として再起動します。
注意として、記事の場合は、/home/pi/hubot/pibot/bin/run_hubot.shの中に以下のコマンドを記載しています。
sudo cp /mnt/nas_music/raspberrypi/hubotscripts/*.coffee
再起動の都度、NASにexample.coffeeを配置している場合は、NAS上のファイルでRaspberryPi上のファイルを上書きするので注意が必要です。
再起動してから、hubotが有効になるまで筆者の環境では体感として一分程度時間がかかるようです。hubotが有効になった後にslackに
tts@これをしゃべります@
もしくは、
music@あいうえお@
などと入力し、tts~の場合には、テキスト読み上げ、music~の場合にはmp3の再生がなされれば成功です。