背景
ふと湧いてきた疑問にスマートスピーカーが答えてくれずもどかしい思いをすることってよくありますよね。
スマートスピーカーの横にテレビがあるので、テレビに映ったブラウザを声だけで操作できたら面白いかなと思って作ってみました。
アプケーション仕様
テレビに映したいのでChromecastを利用します。(テレビはHDMI-CEC対応 Chromecastから電源のON/OFFができます。)
音声指示にはGoogle Homeを利用します。
構成
構成イメージ
動作イメージ
- (1) 音声で検索キーワードなどをGoogle Homeへ指示
- (2) Google HomeからIFTTTへ連携
- (3) IFTTTからの今回作成のアプリの呼び出し
- (4) 今回作成のアプリがブラウザ(Google chrome)を操作
- (5) Chromecastへキャスト
- (6) 今回作成アプリからChromecastへブラウザ画面のMotion JPEGをストリーミング
- (7) ブラウザ画面のMotion JPEGをTVへ映す
主な機材・サービスと役割
| 名前 | 役割 | 
|---|---|
| Google Home | 音声指示 | 
| Chromecast | ブラウザ画面表示 | 
| Google Chrome | 今回作成のアプリより操作される | 
| IFTTT | Google Homeと今回作成のアプリ間の連携 | 
| 今回作成のアプリ | ブラウザ操作、ブラウザ画面のストリーミング | 
開発環境
- Windows 10 Pro
- Node.js v8.9.2
実装機能
今回は、実装を簡単にするために、以下のややプリミティブなコマンドだけを実装します。
- タブキー入力(フォーカス移動)
- 文字入力(テキスト領域への文字入力)
- エンターキー(リンク移動、ボタン等のクリック、検索 等)
実装を簡単にするために、以下に対応しません。
- LAN内にChromecastが複数ある場合
- ChromecastのIPアドレスが変更される場合
- セキュリティに関わるところ
アプリの実装
1.Web APIで操作できるブラウザを作る
動作イメージ(4)の機能を実装します。
headlessブラウザならなんでもよいので、chromeをselenium-webdriverより操作する実装としました。(seleniumは本来の用途とは違うような気もしますが、楽するために目をつむります)
動作させるにはChromeのWebDriverをパスが通った場所へ配置する必要があります。
(ダウンロード ChromeDriver - WebDriver for Chrome)
const fs = require('fs');
const express = require('express');
const { Capabilities, Condition, Builder, By, Key } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
const browserWidth = 1024;
const browserHeight = 768;
let app = express();
app.listen(3000);
//ヘッドレスでドライバを作成
let driver = new Builder()
    .withCapabilities(Capabilities.chrome())
    .setChromeOptions(new chrome.Options().headless().windowSize({width : browserWidth, height : browserHeight}))
    .build();
//ページロード完了待ち用のCondition
let pageLoadCondition = new Condition("wait for ready", function (driver) {
    return driver.executeScript('return document.readyState;').then(state => state == 'complete');
});
//Googleをとりあえずは表示
driver.get('https://www.google.co.jp/').then(_ => driver.wait(pageLoadCondition));
app.get("/api/ctl", async function (req, res, next) {
    console.log(req.query);
    //クエリパラメーター取得
    //c : コマンド, v : 検索キーワード
    let command = req.query['c'];
    let value = req.query['v'];
    if (command == null) {
        res.send('error');
        return;
    }
    let element = await driver.switchTo().activeElement();
    if (command == 'search') {
        await driver.get('https://www.google.co.jp/').then(_ => driver.wait(pageLoadCondition));
        if (value != null) {
            await driver.findElement(By.name('q')).sendKeys(value, Key.RETURN);
        }
    } else {
        if (command == 'tab') {
            await element.sendKeys(Key.TAB);
        } else if (command == 'key') {
            await element.sendKeys(value);
        } else if (command == 'enter') {
            await element.sendKeys(Key.ENTER);
        } else {
            res.send('unknown command');
            return;
        }
    }
    //ページのロード完了後にスクリーンショットを保存
    await driver.wait(pageLoadCondition);
    await driver.takeScreenshot().then(image => fs.writeFileSync('out.png', image, 'base64'));
    res.send('OK');
});
下記のURLへアクセスすると、ブラウザが操作され、スクリーンショットがout.pngファイルに出力されます。
http://localhost:3000/api/ctl?c=search&value=test
http://localhost:3000/api/ctl?c=tab
http://localhost:3000/api/ctl?c=key&value=test
http://localhost:3000/api/ctl?c=enter
2.ブラウザの画面をキャストする仕掛けを作る
動作イメージの(4)と(6)のMotion JPEGをストリーミングする機能を実装します。
Chromecastへ単発で画像をキャストすると、体感5秒ほどのタイムラグがあります。
ネットワークカメラのストリーミングと同じようにmultipart/x-mixed-replaceでMotion JPEGをChromecastへキャストすることで、少ないタイムラグで画面が表示されるよう実装してみました。
multipart/x-mixed-replaceの動作イメージ
Mixed-Replaceの動作は以下のようなイメージです。
Jpeg画像が、1秒に1回 延々とキャストされ、動画のように表示されます。
リクエストヘッダ
GET /image HTTP/1.1
[他リクエストヘッダ省略]
レスポンス(続く)
HTTP/1.1 200 OK
Content-type:multipart/x-mixed-replace; boundary=--myboundary
Connection:keep-alive
[他レスポンスヘッダ省略]
レスポンスの続き 以下を延々と繰り返し
Content-Type:image/jpeg
[JPEGファイルの中身]
--myboundary
ソースの実装
const fs = require('fs');
const express = require('express');
const { Capabilities, Condition, Builder, By, Key } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
const browserWidth = 1024;
const browserHeight = 768;
const framePerMilliseconds = 1000;
let imagebuf = null;
let app = express();
app.listen(3000);
//ヘッドレスでドライバを作成
let driver = new Builder()
    .withCapabilities(Capabilities.chrome())
    .setChromeOptions(new chrome.Options().headless().windowSize({ width: browserWidth, height: browserHeight }))
    .build();
//ページロード完了待ち用のCondition
let pageLoadCondition = new Condition("wait for ready", function (driver) {
    return driver.executeScript('return document.readyState;').then(state => state == 'complete');
});
//Googleをとりあえずは表示
driver.get('https://www.google.co.jp/').then(_ => driver.wait(pageLoadCondition));
//ブラウザ操作処理
app.get("/api/ctl", async function (req, res, next) {
    console.log(req.query);
    //クエリパラメーター取得
    //c : コマンド, v : 検索キーワード
    let command = req.query['c'];
    let value = req.query['v'];
    if (command == null) {
        res.send('error');
        return;
    }
    let element = await driver.switchTo().activeElement();
    if (command == 'search') {
        await driver.get('https://www.google.co.jp/').then(_ => driver.wait(pageLoadCondition));
        if (value != null) {
            await driver.findElement(By.name('q')).sendKeys(value, Key.RETURN);
        }
    } else {
        if (command == 'tab') {
            await element.sendKeys(Key.TAB);
        } else if (command == 'key') {
            await element.sendKeys(value);
        } else if (command == 'enter') {
            await element.sendKeys(Key.ENTER);
        } else {
            res.send('unknown command');
            return;
        }
    }
    //ページのロード完了後にスクリーンショットを保存
    await driver.wait(pageLoadCondition);
    await driver.takeScreenshot().then(image => imagebuf = new Buffer(image, 'base64'));
    res.send('OK');
});
//ストリーミング処理
app.get("/image", function (req, res) {
    var closed = false;
    //クライアントの切断検知
    req.on('close', _ => closed = true);
    //スクリーンショットなしの状態は Not Found
    if (imagebuf == null) {
        res.status(404).send("");
        return;
    }
    res.setHeader('Content-type', 'multipart/x-mixed-replace; boundary=--myboundary');
    framefunc = function () {
        //クライアントコネクションが切れている場合は終了する
        if (closed) {
            return;
        }
        //JPEG画像を出力
        res.write("Content-Type: image/jpeg\r\n\r\n");
        res.write(imagebuf);
        res.write("\r\n--myboundary\r\n");
        setTimeout(framefunc, framePerMilliseconds);
    }
    setTimeout(framefunc, framePerMilliseconds);
});
下記URLをブラウザで表示すると、操作されている方のブラウザの画面がMotion JPEGで表示されます。
http://localhost:3000/image
3.Chromecastのディスカバリ
動作イメージ(5)の一部の機能としてキャスト対象をディスカバリする機能を実装します。
LAN内のChromecastはMulticast DNSというプロトコルを用いてディスカバリできます。
nodejsではmdnsがありますが、ネイティブモジュールへの依存がありますので、今回は導入を簡単にするためにピュアJavaScriptで実装されたmdns-jsを利用してディスカバリを実装します。
mDNS-jsのexampleを実行してみます。
(引用元 : mdns-js - npm)
var mdns = require('mdns-js');
//if you have another mdns daemon running, like avahi or bonjour, uncomment following line
//mdns.excludeInterface('0.0.0.0');
 
var browser = mdns.createBrowser();
 
browser.on('ready', function () {
    browser.discover(); 
});
 
browser.on('update', function (data) {
    console.log('data:', data);
});
以下のような情報が出力されます。
※抜粋、一部機器の固有情報と思われる個所は書き換えています。
data: { addresses: [ '192.168.1.5' ],
  query: [],
  type:
   [ { name: 'googlecast',
       protocol: 'tcp',
       subtypes: [],
       description: 'Google Chromecast' } ],
  txt:
   [ 'id=1d7ddcf68a9c339d2580c2724cce2860',
     'cd=AFD8D7BDD577A95677AAE143F145E35C',
     'rm=1DFCE12E2C6EE876',
     've=05',
     'md=Google Home Mini',
     'ic=/setup/icon.png',
     'fn=浴室',
     'ca=2052',
     'st=0',
     'bs=FA8FCA67E821',
     'nf=1',
     'rs=' ],
  port: 8009,
  fullname: 'Google-Home-Mini-1d7ddcf68a9c339d2580c2724cce2860._googlecast._tcp.local',
  host: '1d7ddcf6-8a9c-339d-2580-c2724cce2860.local',
  interfaceIndex: 2,
  networkInterface: 'pseudo multicast' }
data: { addresses: [ '192.168.1.8' ],
  query: [],
  type:
   [ { name: 'googlecast',
       protocol: 'tcp',
       subtypes: [],
       description: 'Google Chromecast' } ],
  txt:
   [ 'id=0a309e320bd99823e844868a2b0cfe74',
     'cd=DE675BE855482A9DF0199D0CBC523894',
     'rm=330BBCF625639982',
     've=05',
     'md=Google Home',
     'ic=/setup/icon.png',
     'fn=リビング',
     'ca=2052',
     'st=0',
     'bs=FA8FCB5112987',
     'nf=1',
     'rs=' ],
  port: 8009,
  fullname: 'Google-Home-0a309e320bd99823e844868a2b0cfe74._googlecast._tcp.local',
  host: '0a309e32-0bd9-9823-e844-868a2b0cfe74.local',
  interfaceIndex: 2,
  networkInterface: 'pseudo multicast' }
data: { addresses: [ '192.168.1.9' ],
  query: [],
  type:
   [ { name: 'googlecast',
       protocol: 'tcp',
       subtypes: [],
       description: 'Google Chromecast' } ],
  txt:
   [ 'id=3e882e857cce09a7a7eb98708ac3ec27',
     'cd=02E013304A6EA91865369767A46B062C',
     'rm=F738A23B4FAC53CB',
     've=05',
     'md=Chromecast',
     'ic=/setup/icon.png',
     'fn=リビング 3',
     'ca=4101',
     'st=0',
     'bs=FA8FCA30AC15',
     'nf=1',
     'rs=' ],
  port: 8009,
  fullname: 'Chromecast-3e882e857cce09a7a7eb98708ac3ec27._googlecast._tcp.local',
  host: '3e882e85-7cce-09a7-a7eb-98708ac3ec27.local',
  interfaceIndex: 2,
  networkInterface: 'pseudo multicast' }
LAN内には、Google Home、Google Home Mini、Chromecastがあります。
今回は、Google HomeとChromecastを簡単に判別するために、fullnameの先頭がChromecastの文字列で始まるという条件で簡易的にChromecastを識別することにします。
var mdns = require('mdns-js');
 
var browser = mdns.createBrowser();
var chromecastip = null;
browser.on('ready', function () {
    browser.discover(); 
});
browser.on('update', function (data) {
    if(chromecastip == null 
        && data.fullname != null
        && data.fullname.startsWith("Chromecast")){
        chromecastip = data.addresses[0];
        console.log('chromecast : ' + chromecastip);
    }
});
4.Chromecastへの画面表示
動作イメージ(5)を実装します。
ブラウザ画面をストリーミングすることはできるようになりましたので、次はChromecastへキャストする部分を実装します。
castv2-clientを利用してLAN内のChromecastを検知します。
ブラウザの操作が行われるとChromecastへブラウザ画面のMotion JPEGをキャストします。
今回の開発機はWindowsです。ネイティブモジュールのビルドは避けたいので以下のコマンドでモジュールをインストールします。
npm install castv2 --no-optional
castv2-clientのexampleを少し修正して、Chromecastに画像を表示してみます。
(引用元 castv2-client)
const Client = require('castv2-client').Client;
const DefaultMediaReceiver = require('castv2-client').DefaultMediaReceiver;
const mdns = require('mdns-js');
var client = new Client();
var browser = mdns.createBrowser(mdns.tcp('googlecast'));
var chromecastip = null;
browser.on('ready', function () {
    browser.discover();
});
browser.on('update', async function (data) {
    if (chromecastip == null
        && data.fullname != null
        && data.fullname.startsWith("Chromecast")) {
        chromecastip = data.addresses[0];
        console.log('chromecast : ' + chromecastip);
        client.connect(chromecastip, function () {
            client.launch(DefaultMediaReceiver, function (err, player) {
                var media = {
                    contentId: 'https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png',
                    contentType: 'image/png',
                    streamType: 'LIVE'
                };
                player.load(media, { autoplay: true }, function (err, status) {
                    client.close();
                });
            });
        });
    }
});
HDMI-CECのおかげで、テレビが消えていても、別の入力になっていても切り替わって画像が表示されました。
5.アプリを完成させる
動作イメージ(4)~(6)を実装します。
これまでのソースを合わせてアプリを完成させます。
ブラウザで前述のURLを開くと、Chromecastにブラウザの画面が表示されます。
完成版ソース
const os = require('os');
const express = require('express');
const { Capabilities, Condition, Builder, By, Key } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
const mdns = require('mdns-js');
const { Client } = require('castv2-client');
const {DefaultMediaReceiver} = require('castv2-client');
const browserWidth = 1024;
const browserHeight = 768;
const framePerMilliseconds = 1000;
let imagebuf = null;
const browser = mdns.createBrowser(mdns.tcp('googlecast'));
var chromecastip = null;
browser.on('ready', function () {
    browser.discover();
});
browser.on('update', async function (data) {
    if (chromecastip == null
        && data.fullname != null
        && data.fullname.startsWith("Chromecast")) {
        chromecastip = data.addresses[0];
        console.log('chromecast : ' + chromecastip);
    }
});
let app = express();
app.listen(3000);
//ローカルIP取得
let interfaces = os.networkInterfaces();
let localaddress = Array.prototype.concat(Object.values(interfaces).reduce((a, b) => a.concat(b)))
    .find((a)=> !a.internal && a.family == 'IPv4');
//ヘッドレスモードでドライバを作成
let driver = new Builder()
    .withCapabilities(Capabilities.chrome())
    .setChromeOptions(new chrome.Options().headless().windowSize({ width: browserWidth, height: browserHeight }))
    .build();
//ページロード完了待ち用のCondition
let pageLoadCondition = new Condition("wait for ready", function (driver) {
    return driver.executeScript('return document.readyState;').then(state => state == 'complete');
});
//Googleをとりあえずは表示
driver.get('https://www.google.co.jp/').then(_ => driver.wait(pageLoadCondition));
//ブラウザ操作処理
app.get("/api/ctl", async function (req, res, next) {
    console.log(req.query);
    //クエリパラメーター取得
    //c : コマンド, v : 検索キーワード
    let command = req.query['c'];
    let value = req.query['v'];
    if (command == null) {
        res.send('error');
        return;
    }
    let element = await driver.switchTo().activeElement();
    if (command == 'search') {
        await driver.get('https://www.google.co.jp/').then(_ => driver.wait(pageLoadCondition));
        if (value != null) {
            await driver.findElement(By.name('q')).sendKeys(value, Key.RETURN);
        }
        let client = new Client();
        //検索時にキャストする
        client.connect(chromecastip, function () {
            client.launch(DefaultMediaReceiver, function (err, player) {
                var media = {
                    contentId: 'http://' + localaddress.address + ':3000/image',
                    contentType: 'image/jpeg',
                    streamType: 'LIVE'
                };
                console.log(media);
                player.load(media, { autoplay: true }, function (err, status) {
                    client.close();
                    console.log('closed');
                });
            });
        });
        client.on('error', function(err) {
            console.log('Error: %s', err.message);
            client.close();
        });
                
    } else {
        if (command == 'tab') {
            await element.sendKeys(Key.TAB);
        } else if (command == 'key') {
            await element.sendKeys(value);
        } else if (command == 'enter') {
            await element.sendKeys(Key.ENTER);
        } else {
            res.send('unknown command');
            return;
        }
    }
    //ページのロード完了後にスクリーンショットを保存
    await driver.wait(pageLoadCondition);
    await driver.takeScreenshot().then(image => imagebuf = new Buffer(image, 'base64'));
    res.send('OK');
});
//ストリーミング処理
app.get("/image", function (req, res) {
    var closed = false;
    //クライアントの切断検知
    req.on('close', _ => closed = true);
    //スクリーンショットなしの状態は Not Found
    if (imagebuf == null) {
        res.status(404).send("");
        return;
    }
    res.setHeader('Content-type', 'multipart/x-mixed-replace; boundary=--myboundary');
    framefunc = function () {
        //クライアントコネクションが切れている場合は終了する
        if (closed) {
            return;
        }
        //JPEG画像を出力
        res.write("Content-Type: image/jpeg\r\n\r\n");
        res.write(imagebuf);
        res.write("\r\n--myboundary\r\n");
        setTimeout(framefunc, framePerMilliseconds);
    }
    setTimeout(framefunc, framePerMilliseconds);
});
6.Google Homeとアプリ間の連携(IFTTT)
動作イメージ(3)の設定を行います。
IFTTTのWebhookを使って、今回作成のアプリとGoogle Homeを連携します。
コマンド
| コマンド(ウェイクワードを除く) | ブラウザ操作 | 
|---|---|
| ブラウザ検索 [キーワード] | 検索 | 
| ブラウザ エンター | エンターキーでボタン押下やリンク移動 | 
| ブラウザ タブ | タブキーでフォーカス移動 | 
外部からのアクセス準備
まずは準備として、ngrokを利用してIFTTTから今回作成のアプリへの一時的なアクセスを許可します。
(参考 ngrokが便利すぎる)
httpプロトコルで、ポート3000へのフォワーディングを設定します。
> ngrok http 3000
ngrok by @inconshreveable                                       (Ctrl+C to quit)
Session Status                online
Session Expires               7 hours, 49 minutes
Version                       2.2.8
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://1ab3c4ec.ngrok.io -> localhost:3000
Forwarding                    https://1ab3c4ec.ngrok.io -> localhost:3000
Connections                   ttl     opn     rt1     rt5     p50     p90
                              2       0       0.00    0.00    6.47    7.93
HTTP Requests
-------------
GET /api/ctl                   200 OK
IFTTTの設定
Google Assistant(Say a phrase with a text ingradient) の設定
Google Home からの音声コマンドを受ける IFTTTの this にあたる部分を設定します。
「OK Google ブラウザ検索 ミスターメンリトルミス」と指定された場合は、下記の$にあたるミスターメンリトルミスが後続のthatに該当するwebhookに渡されます。
| 項目 | 設定 | 
|---|---|
| What do you want to say? | ブラウザ検索 $ | 
| What's another way to say it? | |
| And another way? | |
| What do you want the Assistant to say in response? | |
| Language | Japanese | 
Webhook(Make a web request) の設定
前述のthisにあたるGoogle Assistantからのキーワードを受けて、今回作成するアプリのWebAPIへ指示を出します。
注意点としましては、クエリストリングはURLエンコードする必要があるため<<<と>>>で囲む必要があります。
(参考 IFTTTでif Google Assistant then WebhookでBad Requestが発生したときの対応メモ
| 項目 | 設定 | 
|---|---|
| URL | http://1ab3c4ec.ngrok.io/api/ctl?c=search&v=<<<{{TextField}}>>> | 
| Method | GET | 
| Content Type | text/plain | 
| Body | 
*他コマンドはURLが違うだけの登録なので、割愛します。
Google Homeへ指示を出すと、ブラウザの検索結果がテレビに出力されました。

使用感
良かったところ
- 手ぶらでブラウザが操作できました。
- テレビの電源が入っていなくても、HDMI-CECのおかげで検索時に自動で電源が入っていい感じです。
- 
OK Google TV消してで着いたテレビを後始末できます。
改良の余地があるところ
- 1操作事に毎回ウェイクワードを言わないならないため大変です。
- 1操作の度にGoogle HomeがOK アクションを実行します。と返事してしまいます。
- googleの検索結果画面は、タブキーでのフォーカス遷移を十数回行わないと検索結果のリンクに到達できず喉が痛くなります。
- 同音異義語は検索が難しいです。speech to textと、googleのもしかして?検索次第
- 
I'm feeling lucky機能があれば使い勝手がよくなるかもしれません。
まとめ
まずは動くということろを優先したので手抜きではありますが、音声操作でWebページを検索できるというところは実現はできました。
Webページを検索するという目的には、キー操作 や フォーカス移動 のような間接的な操作は余計なものでした。
実用的にするには、よくあるスマートスピーカーの操作例に習い 検索で第一候補を表示、次ページ、次ページと候補を表示できるのがベターです。
テレビに家族のカレンダーを映して、予定を音声で登録とかそんなユースケースも面白そうだと思いました。
なんだかんだ言っていろんな技術やモノを組合せて楽しめました。
