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)
システム構成全体
今回やること
筆者はnode.js、javascript、RaspberryPiに不慣れなため、RaspberyPiでシステムを動かす前に、開発用のWindows 10でgoogle-home-notifierを動かしてみて、その処理内容を確認することにします。
参考記事
今回は以下の記事を参考にさせていただいております。
Windows PCを使ってGoogle Homeを喋らせてみた
https://qiita.com/t_watari/items/8f7ec603a0fe1a3a35da
今回試した内容として、参考記事と異なる手順となる箇所は以下の通りです。
- Node.jsのバージョンは推奨版v14.15.4を使用。
(本記事作成時点ではこちらが推奨版のバージョンとなっています) - package.jsonを変更する箇所は以下のようにする。
“google-tts-api”: “0.0.6“,
(この設定にしないと、発話するテキストを生成できません)
発話させてみる
node example.jsに若干手を加えます。
var language = 'pl'; // default language code
となっている箇所(2か所)を次のように変更します。
var language = 'ja'; // default language code
次に
var ip = '192.168.1.20'; // default IP
の箇所を、Google HomeのIPアドレスに変更します。Google Homeのスマホアプリから、IPアドレスが確認できます。
google-home-notifierをインストールしたフォルダでPowershellCoreを開きます。
node example.js
と打ち込むと、example.jsが起動し、次のように画面が表示されます。(xxx.xxx.xxx.xxxの箇所はGoogle HomeのIPアドレス)
Endpoints:
http://xxx.xxx.xxx.xxx:8091/google-home-notifier
undefined/google-home-notifier
GET example:
curl -X GET undefined/google-home-notifier?text=Hello+Google+Home
POST example:
curl -X POST -d "text=Hello Google Home" undefined/google-home-notifier
undefined/google-home-notifierの箇所は、ngrokによって取得されるURLが表示される想定でスクリプトが書かれているようですが、筆者の環境ではundefinedとなるようです。今回の製作物ではngrokは使わない想定なので、この箇所は無視して話を進めます。
example.jsを起動したままで別のPowershellCoreを開きます。この環境ではcurlコマンドが使用できないので、Invoke-Webrequestを使用してhttpリクエストを送ります。
Invoke-WebRequest http://localhost:8091/google-home-notifier -Method GET -Body @{text="この内容をしゃべります。"}
または
Invoke-WebRequest http://localhost:8091/google-home-notifier -Method POST -Body @{text="この内容をしゃべります。"}
google homeから指定した通りのテキストの発話が聞こえれば成功です。
ソースコードを改造してみる
google-home-notifierのソースコードを読んで、使いやすいように改造してみます。(google-home-notifierはMITライセンスのため、修正したソースコードを掲載しても著作権上、何ら問題無いと考えます)
ソースコードのフローを分析
ここまでで使用しているソースコードはgoogle-home-notifier.jsとexample.jsの二つです。各々どのような処理を行っているのかを見ていきます。
まず、以下は各々の持つ主な機能です。()は使用しているnodeのパッケージの名称です。ソースコードではrequire(…)の箇所がこれに対応しています。
- google-home-notifier.js
- google homeのIPアドレスを取得する。
(mdns使用) - 指定されたテキストからmp3を生成する。
(google-tts-api使用) - google homeに対して、特定のmp3を再生する命令を送出する。
(castv2-client使用)
- google homeのIPアドレスを取得する。
- example.js
- webサーバを起動し、httpリクエストへの応答動作を定義する。(express使用)
example.jsが動作していることで、httpリクエストを受け付け、リクエストの内容によって、適宜google-home-notifier.jsにて定義された関数を呼び出し、google homeへの発話命令を生成していることになります。
特定のテキストを発話する場合には、[機能2:テキストからmp3生成]→[機能3:指定mp3再生]の順に動作し、音楽等の特定のmp3が用意されているときは[機能3:指定mp3再生]のみが呼び出されます。
example.jsの想定用途は次のようなものと考えられます。
RaspberryPiなどを使用してexample.jsによるwebサーバが常駐する環境を構築する。このwebサーバに対してhttpリクエストを送ることにより、google homeへの発話命令を容易に生成できる。ngrok等を使用してwebサーバを外部公開すると、webサーバにWAN側からアクセスすることでLAN外部からgoogle homeに発話させる事が容易となる。
このようにgoogle-home-notifier.js, example.js双方は、そのままでも使い方によっては便利なものですが、今回の製作目標に沿って、適宜変更していきます。(本記事では、以降の説明にてexample.jsを変更しています。)
mdnsを使ってgoogle homeのIPアドレスを取得する
実験したLAN環境では、ひかり電話ルータ (PR-400KI)を介してインターネットにアクセスしており、LAN側はdhcpでIPアドレスを割り振っています。もちろん、LAN内にあるgoogle homeのIPアドレスはこのルータによるdhcpによって設定されることになります。筆者の調べた範囲では、PR-400KIでは特定のMACアドレスに対して固定されたIPアドレスを設定する機能がなく、google home (使用機種:googke nest mini)ではIPアドレスを固定する機能がない、ということがわかりました。IPアドレスを固定する機能のある新たな機器をLAN内に置くこともできますが、汎用性の観点から動的にgoogle homeのアドレスを取得することにします。
具体的にgoogle-home-notifier.jsのソースコードを見ていきます。
まず冒頭の
var mdns = require('mdns');
var browser = mdns.createBrowser(mdns.tcp('googlecast'));
の箇所ですが、mdnsを使用してgoogle homeのアドレス探索を行うためのbrowserオブジェクトを定義しています。mdns.tcp(‘googlecast’)の箇所は探索する機器の種類を指定していると思われます。
次にnotify関数です。
var notify = function(message, callback) {
if (!deviceAddress){
browser.start();
browser.on('serviceUp', function(service) {
console.log('Device "%s" at %s:%d', service.name, service.addresses[0], service.port);
if (service.name.includes(device.replace(' ', '-'))){
deviceAddress = service.addresses[0];
getSpeechUrl(message, deviceAddress, function(res) {
callback(res);
});
}
browser.stop();
});
}else {
getSpeechUrl(message, deviceAddress, function(res) {
callback(res);
});
}
};
変数deviceAddressにgoogle homeのアドレスが格納されていなければ、mdnsでアドレス解決を行ったのちにgetSpeechUrl関数を呼び出してgoogle homeに発話させます。
変数deviceAddressにgoogle homeのアドレスが格納されていれば、即座にgetSpeechUrl関数を呼び出してgoogle homeに発話させます。
次にexample.jsの
var deviceName = 'Google Home';
の箇所ですが、この'Google Home'
の名称が、のちに出てくる
googlehome.device(deviceName,language);
でgooglehomeオブジェクトにセットされて、mdnsによる探索に使用されます。しかし、ここでvar deviceName = 'Google Home';
とするとどうやらホスト名がGoogle-Homeで始まる機器のアドレス解決を試みるようで、今回使用しているGoogle Nest miniは補足されなくなります。この箇所は
var deviceName = 'Google Nest';
とします。これで、Google Nestがアドレス解決できるようになります。
元々
var ip = '192.168.1.20'; // default IP
となっていた箇所を
var ip;
とします。これによって、notify関数のif (!deviceAddress){の箇所からmdnsを使用してのアドレス解決実施のフローに分岐します。これで再度
node example.js
としてexample.jsを起動してから
Invoke-WebRequest http://localhost:8091/google-home-notifier -Method GET -Body @{text="この内容をしゃべります。"}
と入力し、Google Homeから音声が聞こえれば、mdnsによるGoogle Homeのアドレス解決が成功しています。
expressによる、NAS読出し専用webサーバの起動
引き続いて、example.jsの全体の話に移ります。expressを使用してwebサーバの動作を定義するソースコードが多く含まれていますが、今回は、mp3ファイルをNASから呼び出したいだけなのでその大部分は不要です。
一旦、expressに関連するコードを削除して、上記のmdnsを使ってgoogle homeに発話させるだけのテストコードを書いてみます。
example.jsは、NAS内にあるmp3を読み出すという最低限のものに限定します。そこで、以下の通りに変更しました。名称も変更し、mp3_webserver.jsとしています。
var path = process.argv[2];
var dataServerPort = process.argv[3];
console.log(process.argv);
var express = require('express');
var app = express();
app.get('/*.mp3', function(req, res) {
console.log(req.query);
const file = path + req.url;
console.log(file);
res.download(file); // Set disposition and send it.
});
app.listen(dataServerPort);
pathおよびdataServerPortは各々mp3ファイルを置くNASのパスとhttpリクエストを受け付けるポート番号をコマンドライン引数で与えるようにしています。RaspberryPiにソースコードをもっていったときには、NASをマウントしたパスをコマンドライン引数として与えてやるという算段です。さて、これでmp3_webserver.jsを起動し、mp3を読み出せるかを試してみます。まず、NASに適当なディレクトリを確保して、そこにmp3ファイルを入れておきます。
node .\mp3_webserver.js [NASのディレクトリのパス] [ポート番号]
として、expressを起動します。筆者の場合は
node .\mp3_webserver.js \\LANDISK-201129\disk1\music 8091
のように引数を設定して起動しました。
chrome等のwebブラウザを開いて、アドレスバーに
http://localhost:8091/example.mp3
のように入力します。(example.mp3は実際のファイル名に読み替えてください。)
httpリクエストによりmp3がダウンロードできれば成功です。
Windowsからのコマンドに応じて発話とmp3再生
さらに、google homeでmp3を鳴らせるかを試してみます。
google-home-notifier.jsは次のように変更しました。変更の意図として、async-awaitを使用した非同期処理を行うことを練習することを目的としています。発話させるためのnotity関数およびplay関数を、async-awaitを使用した非同期処理へ対応させています。(このような改良に必然性はありませんが、筆者がjavascriptに不慣れなため、技巧を試しています。)更に、callback(‘Device notified’, status);の箇所で、変数statusにmp3に関する情報(再生時間等)が取得できることがわかりましたので、このstatusをgoogle-home-notifierの外側で取得できるように改造しています。
var Client = require('castv2-client').Client;
var DefaultMediaReceiver = require('castv2-client').DefaultMediaReceiver;
var mdns = require('mdns');
var browser = mdns.createBrowser(mdns.tcp('googlecast'));
var deviceAddress;
var language;
var device = function(name, lang = 'en') {
device = name;
language = lang;
return this;
};
var ip = function(ip, lang = 'en') {
deviceAddress = ip;
language = lang;
return this;
}
var googletts = require('google-tts-api');
var googlettsaccent = 'us';
var accent = function(accent) {
googlettsaccent = accent;
return this;
}
async function GetGoogleHomeAddress(){
return new Promise((resolve, reject) => {
if (!deviceAddress){
browser.start();
browser.on('serviceUp', function(service) {
console.log('Device "%s" at %s:%d', service.name, service.addresses[0], service.port);
if (service.name.includes(device.replace(' ', '-'))){
deviceAddress = service.addresses[0];
resolve();
}
browser.stop();
});
}else{
console.log('Device "%s" (already resolved)', device);
resolve();
}
});
}
var notify = async function(message, callback, promise_resolve) {
await GetGoogleHomeAddress();
getSpeechUrl(message, deviceAddress, function(res, d) {
callback(res, d);
promise_resolve();
});
}
var play = async function(mp3_url, callback, promise_resolve) {
await GetGoogleHomeAddress();
getPlayUrl(mp3_url, deviceAddress, function(res, d) {
callback(res, d);
promise_resolve();
});
};
var getSpeechUrl = function(text, host, callback) {
googletts(text, language, 1, 1000, googlettsaccent).then(function (url) {
onDeviceUp(host, url, function(res, d){
console.log(url);
callback(res, d)
});
}).catch(function (err) {
console.error(err.stack);
});
};
var getPlayUrl = function(url, host, callback) {
//console.log(url);
onDeviceUp(host, url, function(res, d){
callback(res, d)
});
};
var onDeviceUp = function(host, url, callback) {
var client = new Client();
client.connect(host, function() {
client.launch(DefaultMediaReceiver, function(err, player) {
var media = {
contentId: url,
contentType: 'audio/mp3',
streamType: 'BUFFERED' // or LIVE
//streamType: 'LIVE' // or BUFFERED
};
player.load(media, { autoplay: true }, function(err, status) {
client.close();
callback('Device notified', status);
});
});
});
client.on('error', function(err) {
console.log('Error: %s', err.message);
client.close();
callback('error');
});
};
exports.ip = ip;
exports.device = device;
exports.accent = accent;
exports.notify = notify;
exports.play = play;
さらに、go.jsを以下のように作成します。
console.log(process.argv);
var dataServerAdress = process.argv[2];
var dataServerPort = process.argv[3];
var message = process.argv[4];
var playFileName = process.argv[5];
var googlehome = require('./google-home-notifier');
var language = 'ja'; // ここに日本語を表す ja を設定
googlehome.ip(undefined, language);
googlehome.device('Google Nest', language);
// 非同期でwaitする関数
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.jsからexportするときに、非同期属性を含めてexportがうまくいかないようなので、wrap用関数を作っておく。
async function notify(str, callback){
return new Promise((resolve, reject)=>{
googlehome.notify(str, callback, resolve);
});
}
async function play(str, callback){
return new Promise((resolve, reject)=>{
googlehome.play(str, callback, resolve);
});
}
async function main_func(){
var duration = 0;
var media_data;
// notifyの第二引数であるコールバック関数では、発話する音声の長さを取得し、変数durationに格納している。
await notify(message, function(res, d) { console.log(res); duration = d.media.duration; });
// 発話が完了するまで待つ
// Google Homeが発話用mp3データ取得時に遅延がある場合は考慮していない。
await sleep(duration, null);
// mp3を読み出すためのURLを組み立てる
var reqStr = 'http://' + dataServerAdress + ':' + dataServerPort + '/' + playFileName;
console.log(reqStr);
// playの第二引数であるコールバック関数では、発話する音声の長さを取得し、変数durationに格納している。
await play(reqStr, function(res, d) { console.log(res);media_data = d; duration = d.media.duration; });
// mp3が完了するまで待つ
// Google Homeが発話用mp3データ取得時に遅延がある場合は考慮していない。
await sleep(duration, null);
console.log('process.exit(0);');
process.exit(0);
}
// main_funcを非同期で起動
main_func();
// タイムアウト設定
setTimeout(() => {
console.log('process.exit(1);');
process.exit(1);
}, 30000);
ここで以下のコマンドでmp3_webserver.jsを起動し、
node .\mp3_webserver.js \\LANDISK-201129\disk1\music 8091
更に次のコマンド
node go.js xxx.xxx.xxx.xxx 8091 "演奏開始前に喋る内容" example.mp3
を打ち込みます。
なお、xxx.xxx.xxx.xxxは、開発機のWindows 10 PCのIPアドレスを記入してください。Google Homeから開発機のWindows 10 PCにアクセスするために使用されます。
上記コマンドの結果、\\LANDISK-201129\disk1\musicの中にあるexample.mp3が再生されます。
補足:今回のjavascriptを記述するにあたり、ネットで参考となる記事を調べたところ、javascriptにおいては、変数宣言にvar, let, constを適切に使い分けるべきとの記述が見つかりました。本記事のソースコードとではその使い分けが適切にできておりませんが、ひとまずは動作に問題が無いので次に進みます。