チカラの技術

電子工作やプログラミング

ラップの韻を自動で踏んでくれるIoTシステムを作りました

こんにちは、元気です!

私は日本語ラップが好きで毎日聞いています。
f:id:powerOfTech:20180820232444p:plain:w200
ラップを聴いてて一番楽しいと思うときはうまい韻(いん)を聞いたときですね。
良い韻は何十年も頭に残るものです。
「Kick the verse!歌詞蹴っ飛ばす! まるでストレス飛ばすジェットバス!」
ね?

単純に韻を聞いたり考えたりするのが楽しいので、自分の工学分野の用語で
韻を考えたりもするのですが、声で韻を教えてくれるシステムがあったら面白いと思って、
今回はGoogle Homeに頼むと自動でwebサイトから韻を探して踏んでくれるIoTシステムを作りました。

そもそも韻(いん)ってなに?

韻(いん,ライム)とは簡単に言うと「同じ母音で別の言葉を繰り返すこと」です。
要するに下の表の横列の言葉を使います。
f:id:powerOfTech:20180820221841p:plain:w400

例として私が凄いと思った韻を紹介します。
「一網打尽 REMIX (SHING02, MEISO, CANDLE, SPIN MASTER A-1) - 韻踏合組合」MEISOさんパート から
『I Got The 天然クイックルワイパー MEISOはカミソリSchick二枚刃(シックにまいば) 伝授しに来た首振る快感
韻もフロー(リズムの取り方、音程)もヤバい!

システムの紹介

私が温まってきたところで作ったIoTシステムの紹介動画です。

設計現場で使える韻のダイジェスト付きです。 打ち合わせなどで積極的に使っていきましょう。
「ここで使うなフォトカプラ、遅延が酷くて情報格差、世代交代iCoupler、これが正しい開発だ。Yeah...」

これは便利ですね。なかなか実用性のあるシステムになった感があります。
文字で検索する場合と比べたメリットは、音声入力なので文字入力不要ということ、
声で入力し結果も声で聞くことで音のイメージを正確に把握できることですね。
デメリットは「勢至菩薩」などの複雑な単語は音を聞いただけでは理解できないことですが、
一応、raspberry PiSSHで接続すれば以下のように結果をモニターする事も出来ます。
(声の回答より先に結果がでます)
f:id:powerOfTech:20180820231809p:plain:w300

これから毎朝起き掛けに使って韻のレパートリーを増やしていきたいですね。

技術解説

動画内にもあった概要図です。

f:id:powerOfTech:20180821000050p:plain

 ① Google Homeが声を認識「電子工作」へ文字変換、IFTTTに送信。
 ② IFTTTは文字をWeb hooksでAzureへPOST転送
 ③ Azureはwebサーバーとして、クライアント(RaspberryPi)に文字情報「電子工作」を転送
 ④ Raspberry Piは文字情報を韻ノートに送信して結果を文字情報で受け取る。
 ⑤ 受け取った入力と韻をGoogle Homeに送信、喋ってもらう。

本システムではwebサービス韻ノート」で韻を検索しています。
韻ノートでは形態素解析やwebワードの自動分析により、高い精度と最新の語彙で韻を踏んでくれるとのことです。

文字と音との相互変換という点でも韻ノートとGoogle Homeはかなり高度な解析をしています。

①では音声の「でんしこうさく」を「電子工作」に変換、④の韻ノート内部では「電子工作」を「でんしこうさく」に戻して音を解析、韻を検索してます。韻ノートから漢字の結果を受け取り、⑤でGoogle Homeは「てんしとだんす」という音にまた変換して発声しています。
技術の進歩は凄いですね・・・

ちなみに韻ノートはひらがなの結果も返してくれるのですが、漢字の方がアクセントなどが正確になると考えてGoogle Homeには漢字で送っています。(勢至菩薩近藤春菜がちゃんと読めたことは驚きました)

それからAzureのクラウドwebサーバーを間に置いているのは、
私の住処は集合住宅でセキュリティやポート設定の問題があるため、 RaspberryPiをwebサーバーとして公開したくなかったからです。

ソースコードと解説

IFTTT

IF : Google Assistant
f:id:powerOfTech:20180821002218p:plain:w300

THEN : Webhooks
Tokenを持つPOSTだけ後段で受け付けられます。
なおこのTokenは現在無効です。
f:id:powerOfTech:20180821002325p:plain:w300

Azureのwebサーバー

サーバーサイドの全コードです。説明はコメントをご覧ください。
前回の「アイアイ機能」の認証も入っています。

// このプログラムはIFTTTから来たデータをSocketIOを通じて機能ごとに割り当てられたクライアントに送信する。
// IFTTTはトークンをデータと共に送付して認証する。
// クライアントはまずPOSTメソッドで認証用トークンを送り、サーバーが認証し機能用トークン(JWT)を返す。
// クライアントはこの機能用トークンを用いてsocketIOを接続する。

var app = require('express')();
var http = require('http').Server(app);
var io = require('socket.io')(http);
var bodyParser = require('body-parser');
var jwt = require("jsonwebtoken");

// 認証用トークン。IFTTT及びクライアントから同じトークンが送られて来た場合のみ本プログラムは動作する
const authToken = "D462253D54FB3C4BE77AE1992341A279";
const jwtKey = authToken;
const funcs = { aiai: "aiai", getRyme: "getRyme" };

// setting body pearser
app.use(bodyParser.urlencoded({
    extended: true
}));
app.use(bodyParser.json());

//AzureにアップロードするときはPORTを環境変数で決めるため、必ず下記が必要になる。
var port = process.env.PORT || 8080;

//IFTTTからのデータ受信
app.post(`/ifttt`, (req, res) => {
    console.log('IFTTT posted!' + req);
    //check token (authenticated User)
    if (req.body.token == authToken) {
        const func = req.body.func;
        //アイアイ機能
        if (func == funcs.aiai) {
            req.body.name = req.body.name.trim();
            var check = req.body.name;
            var sendData;
            // verify word from IFTTT(google home)
            if (check == "II" || check == "ii" || check == "I愛" || check == "I 愛" || check == "i愛") {
                sendData = "アイアイ";
            } else {
                sendData = req.body.name;
            }
        }
        //ライムを得る機能
        else if (func == funcs.getRyme) {
            sendData = req.body.origin.replace(/\s+/g, "");;
        }
        // send to raspberry pi to specific socket.IO rooom
        io.to(func).emit('ifttt', sendData);
    }
});

//クライアントからのfunctionトークン取得依頼を処理
app.post(`/aiai`, (req, res) => {
    processToken(funcs.aiai, req, res)
});

app.post(`/getRyme`, (req, res) => {
    processToken(funcs.getRyme, req, res)
});

//認証と関数用トークンの発行(トークンのfuncでsocket.ioのルーム割り当てが決まる)
function processToken(func, req, res) {
    //send token
    if (req.body.authToken == authToken) {
        //トークンには機能とユーザー名(未使用)を含める。
        var funcToken = jwt.sign({func:func, user:req.body.user}, jwtKey,
            {
                expiresIn: '24h'
            });
        res.send(funcToken);
    } else {
        res.send("err");
    }
}

//接続先時、クライアントのトークンを確認して対応したルームにjoinさせる。
io.sockets.on('connection', (socket) => {
    jwt.verify(socket.handshake.query.token, jwtKey, (err, decoded) => {
        if (err) {
            //エラーメッセージを返す
            socket.emit('join', { data: "error" });
            console.log('connection error:' + err);
        } else {
            //正規のトークンを受信
            socket.join(decoded.func);
            socket.emit('join', { data: "welcome function:" + decoded.func });
            console.log('room joined! func:' + decoded.func);
        }
    });
});

//start server
http.listen(port, function () {
    console.log('listening on *:' + port);
});

クライアント(RaspberryPi)

クライアントサイドのソース全文です。
今回はアイアイの時と違って、韻ノートにRestAPIがないため、
puppeteerというwebブラウザをコントロールする
ライブラリを使ってブラウザ操作をエミュレートして韻の検索結果を得ました。
また、puppeteerをRaspberry Piで扱う上でハマッたポイントは別の記事にまとめています。
(なお、結果の応答が10秒程度と長かった主要因はpuppeteerの処理がRaspberryPiにとって重かったからです。まぁ韻を予想して楽しむ時間だと思えば良いですね)

// このプログラムはwebサーバーにPOSTで認証トークンを送信し、機能トークンを受け取る。
// 機能トークンをSocketIOで渡し、サーバーと接続する。
// 接続したサーバーからオリジナルの文字を受け取り、puppeteerで韻ノートへ送信。結果を受け取り
// google-home-notifierでGoogle Homeへ送信する。

// google home settings
var googlehome = require('google-home-notifier');
var language = 'ja';
googlehome.ip('192.168.123.45');
googlehome.device('Google Home', language);

// server settings
const BaseUrl = 'https://iotwebappraiot.azurewebsites.net/';
const aiaiUrl = BaseUrl + 'getRyme/';
const authQuery = {
    authToken: "D462253D54FB3C4BE77AE1992341A279",
    user: "nullo"
}

// InNote setting
const addressOfInNote = 'http://in-note.com/';

// serverから機能トークンを得る
const axios = require('axios');
axios.post(aiaiUrl, authQuery)
    .then((res) => {
        if (res.data != "err") {
            //サーバー認証用クエリに機能トークンを設定
            let funcQuery = {
                query: {
                    token: res.data
                }
            };
            //ソケット通信開始
            startSocket(funcQuery);
        } else {
            console.log('auth error:', err);
        }
    })
    .catch(err => {
        console.log('err:', err);
    });

// socket.IO通信の開始とイベント登録
function startSocket(funcQuery) {
    // socket IOを接続先(Azureサーバー)指定で読み込む
    const io = require('socket.io-client');
    let socket = io(BaseUrl, funcQuery);

    // 機能トークンの認証結果がサーバーから返ってくる
    socket.on('join', (result) => {
        console.log("join:" + result.data);
    });

    socket.on('ifttt', (origin) => {
        console.log("\nI received original word:\n" + origin + "\n");
        // サーバから受け取った語句をwebサービスの韻ノートに送り、韻を受け取る
        (async () => {
            const say = await getAndModifyRyme(origin)
                .catch(() => 'ごめん、韻が見つからなかったよ');
            sayGoogleHome(say);
        })();
    });
}

//sayの内容をgoogle homeへ送信する。(話させる)
function sayGoogleHome(say) {

    try {
        googlehome.notify(say, (res) => {
            console.log(res);
        });
        //error
    } catch (err) {
        console.log(err);
    }
}

// 韻ノートにoriginから韻を得て、発話内容に加工する。
async function getAndModifyRyme(origin) {
    label = "exec"
    console.time(label);
    getRyme = new ScrapeRymeInNote();
    let data = await getRyme.scrapeRhyme(origin, addressOfInNote);
    let say = origin + "\n";
    for (let i = 0; i < data.length; i++) {
        say += (i + 1) + ": " + data[i] + " (" + data[i] + ")\n";
    }
    console.log(say);
    console.timeEnd(label);
    return say;
}

// 韻ノートからスクレイピングするクラス
class ScrapeRymeInNote {
    // アクセス
    async _initPuppeteer(puppeteer, address) {
        const browser = await puppeteer.launch({
            headless: true,
            executablePath: '/usr/bin/chromium-browser',
            args: ['--no-sandbox', '--disable-setuid-sandbox']
        });
        const page = await browser.newPage();
        await page.goto(address, { waitUntil: "domcontentloaded" });
        return { page: page, browser: browser };
    }

    // データ入手
    async _getRhyme(origin, page) {
        await page.type("body > div.main > div.main-search > div > input", origin);// テキスト入力
        //検索ボタンクリック
        await page.evaluate(() => {
            document.querySelector("body > div.main > div.main-search > div > button").click();
        });
        await page.waitFor('span[class="word-main"]', { timeout: 10000 });// 画面遷移を待つ    
        // 結果のテキストを入手
        let data = await page.$$eval('span.word-main', items => {
            // 得た複数の結果を配列化して返す
            const resultNumber = 4;
            let texts = [];
            for (let i = 0; i < resultNumber; i++) {
                if (items[i]) {
                    texts.push(items[i].textContent);
                }
            }
            return texts;
        });
        return data;
    }

    //originキーワードから韻を入手する公開関数
    async scrapeRhyme(origin, address) {
        const puppeteer = require('puppeteer');
        const pupp = await this._initPuppeteer(puppeteer, address);
        let data = await this._getRhyme(origin, pupp.page);
        await pupp.browser.close();
        return data;
    }
}

まとめ

  • 音声操作で韻を音声で返してくれるIoTシステムを作った。
  • 韻、その繰り返しはプログラミングや人生に通ずるものがある。
  • これから自分の韻のレパートリーを増やしていきたい。

質問など有ればコメント頂ければと存じます。
それでは!