自動化厨のプログラミングメモブログ │ CODE:LIFE

Python/ExcelVBA/JavaScript/Raspberry Piなどで色んなことを自動化

Google HomeをPythonで自在に喋らせてみた(VoiceTextを使ってるので声も選べます)

f:id:maru0014:20180923220401p:plain

昨年末から愛用してきたGoogle Home Mini。

手に入れて最初にやったのがNode.jsの「google-home-notifier」で色んな通知を喋らせるというもの。

しかし、自分のjavascriptの知識なんてjQueryでウェブサイトに動きをつけて遊んだりする程度。よくわからないまま先人の知恵たるブログを参考にして実装しましたが、仕組みがよくわからなくてモヤモヤしていました。

でもようやく出来ました!Pythonで!これを「google-home-notifier-vt」と名付けます。

できるようになること

  • Slack→ngrok→Raspberry Pi→VoiceText→Google Homeを喋らせる
  • 声や読み上げ速度を選べる(Voice Text API)

参考にした記事

このQiita記事を見てこれはやれる!と思いました。

Pythonを使って、Google Homeに喋らせてみる - Qiita

決定打はこれ。Flaskで作られていますが素晴らしい出来栄え。

Chromecastデバイスに喋らせるPython スクリプト · GitHub

最初はBottleで自分なりにやってみたもののキャストしたあとの遅延が長くて3日間くらい悩みましたが、Slash Nephyさんのようにファイル名を時間から出力してユニークなものにすることで多分解決?

そして楽したいのでそのままFlaskでやります笑

改良点

Voice Text API対応

GoogleのText To Speechでは細かな調整ができないっぽい?ので色々いじれるVoice Text APIで音声ファイルを生成できるように機能を置き換えました。

また、VoiceTextのライブラリでは音声ファイルがWAVで出力されるためWAVをキャストするパターンにも対応するよう書き換えました。

slackの特定チャンネルに投稿したら喋る

slackの発信Webhooksによってslackの特定チャンネルに投稿されたメッセージは自宅のGoogle Homeで喋らせる。

我が家では#googlehomeという専用チャンネルを用意してここに投稿されたものは全てGoogle Homeが読み上げるようにしてあります。 例えばIFTTTやヤフーのmyThingsで天気・雨雲接近注意報・熱中症警報・地震・外出・帰宅などの通知を全てSlackに投稿するようにしておけばngrokのURLが再起動などで変更になってしまった場合も発信webhookのURLを変更するだけで済みます。

もっと言えば発言者のIDを判別させてVoiceTextの声を使い分けさせることも可能です。(これはまた今度実装して記事にします)

文字化け対策

投げたあとのレスポンスがJSON形式にするとき文字化けしていたが以下の記事を参考に修正

Flask で Restful API を作る - jsonify で日本語が文字化けする時の解決方法 - datalove’s diary

謎の空白対策

Google Assistant→IFTTTで文字列を受け取ると何故か不要な空白が入るのでPythonで受け取った後に置換して削除。

コード全文

# encoding: utf-8
import os
import json
import time
import pychromecast
import requests
from threading import Thread
from datetime import datetime
from flask import Flask, request, send_from_directory, jsonify
from voicetext import VoiceText
  
slack_webhook_url = "ここにSlackのWebhookURL"
  
VT_APIKEY = "ここにVoiceTextのAPIキー"  # VoiceText API Key
VT_DEFAULT_SPEAKER = "hikari" # Speaker
  
CACHE_DIR = "cache"
CACHE_WIPE_INTERVAL = 60 * 5
  
IP_ADDRESS = "192.168.1.64"  # Chromecast IP Address
device = next(x for x in pychromecast.get_chromecasts() if x.host == IP_ADDRESS)
device.wait()
  
app = Flask(__name__)
app.config['JSON_AS_ASCII'] = False  # Avoid garbled characters
app.config['port'] = 5000
url = None
  
  
def get_ngrok_address():
    global url
    if url is None:
        localhost_url = "http://localhost:4040/api/tunnels"  # Url with tunnel details
        url = requests.get(localhost_url).text  # Get the tunnel information
        j = json.loads(url)
        url = j['tunnels'][0]['public_url']  # Do the parsing of the get
        url = url.replace("http:", "https:")
        print(f"GET ngrok Address: {url}")
        requests.post(slack_webhook_url, data=json.dumps({'text': f'Google-Home-Notifier Address: {url}'}))
    return url
  
  
def play_vt(url, text, speaker, emotion, speed, pitch, volume):
    filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}.wav"
    vt = VoiceText(VT_APIKEY).speaker(speaker)
    if speed:
        vt.speed(int(speed))
    if emotion:
        vt.emotion(emotion)
    if pitch:
        vt.pitch(int(pitch))
    if volume:
        vt.volume(int(volume))
  
    with open(f"cache/{filename}", 'wb') as f:
        f.write(vt.to_wave(text))
        cast(f"{url}/cache/{filename}", "audio/wav")
  
  
def cast(url, mimetype):
    print(f"wav URL is {url}.")
    mc = device.media_controller
    mc.play_media(url, mimetype)
    mc.block_until_active()
  
  
@app.route("/cache/<path:path>")
def send_cache(path):
    return send_from_directory("cache", path)
  
  
@app.route("/notifier")
def notifier():
    text = request.args.get("text").replace(" ", "")
    if not text:
        audiourl = request.args.get("url")
        if not audiourl:
            return jsonify({"status": "ERROR", "message": "text is required."})
        cast(audiourl, "audio/mp3")
        return jsonify({"status": "OK", "cast": audiourl})
  
    speaker = request.args.get("speaker") or VT_DEFAULT_SPEAKER
    emotion = request.args.get("emotion")
    speed = request.args.get("speed")
    pitch = request.args.get("pitch")
    volume = request.args.get("volume")
    print(f"ChromeCast device will speak \"{text}\" in {speaker}. by Voice_Text")
    play_vt(get_ngrok_address(), text, speaker, emotion, speed, pitch, volume)
    return jsonify({"status": "OK",
                    "text": text,
                    "speaker": speaker,
                    "emotion": emotion,
                    "speed": speed,
                    "pitch": pitch,
                    "volume": volume
                    })
  
  
@app.route('/slack', methods=['POST'])
def slack():
    text = request.form['text'].replace(" ", "")[0:199]
    if not text:
        return jsonify({"status": "ERROR", "message": "text is required."})

    speaker = VT_DEFAULT_SPEAKER
    emotion = None
    speed = None
    pitch = None
    volume = None
    print(f"ChromeCast device will speak \"{text}\" in {speaker}. by Voice_Text")
    play_vt(get_ngrok_address(), text, speaker, emotion, speed, pitch, volume)
    return ''
  
  
@app.route("/")
def index():
    return "[example] " + get_ngrok_address() + "/notifier?text=テストメッセージ"
  
  
def wipe_cache_task():
    print(f"Cache wiping task started. Cache wiping interval is {CACHE_WIPE_INTERVAL} seconds.")
    while True:
        for path in os.listdir(CACHE_DIR):
            os.remove(f"{CACHE_DIR}/{path}")
        time.sleep(CACHE_WIPE_INTERVAL)
  
  
if __name__ == "__main__":
    if not os.path.isdir(CACHE_DIR):
        os.makedirs(CACHE_DIR)
  
    Thread(target=wipe_cache_task).start()
    Thread(target=get_ngrok_address).start()
    app.run()

導入手順

① 各種ライブラリをインストール

必要なライブラリ - flask・・・webサーバー - requests・・・ngrokのAddressやSlack投稿など - pychromecast・・・pythonでクロームキャストを可能に - python-voicetext・・・VoiceTextWEBAPIをお手軽に実行

それぞれインストールしておきます。

$ pip install flask
$ pip install requests
$ pip install pychromecast
$ pip install python-voicetext

python-voicetextのインストールでエラーが発生した場合

おそらく以下のようなエラーではないでしょうか。

Looking in indexes: https://pypi.org/simple, https://www.piwheels.org/simple
Collecting PyAudio
  Using cached https://files.pythonhosted.org/packages/ab/42/b4f04721c5c5bfc196ce156b3c768998ef8c0ae3654ed29ea5020c749a6b/PyAudio-0.2.11.tar.gz
Building wheels for collected packages: PyAudio
  Running setup.py bdist_wheel for PyAudio ... error
  Complete output from command /usr/bin/python3 -u -c "import setuptools, tokenize;__file__='/tmp/pip-install-2t3wdpz1/PyAudio/setup.py';f=getattr(tokenize, 'open', open)(__file__);code=f.read().replace('\r\n', '\n');f.close();exec(compile(code, __file__, 'exec'))" bdist_wheel -d /tmp/pip-wheel-_30pg5kd --python-tag cp35:
  running bdist_wheel
  running build
  running build_py
  creating build
  creating build/lib.linux-armv6l-3.5
  copying src/pyaudio.py -> build/lib.linux-armv6l-3.5
  running build_ext
  building '_portaudio' extension
  creating build/temp.linux-armv6l-3.5
  creating build/temp.linux-armv6l-3.5/src
  arm-linux-gnueabihf-gcc -pthread -DNDEBUG -g -fwrapv -O2 -Wall -Wstrict-prototypes -g -fdebug-prefix-map=/build/python3.5-RUbMX3/python3.5-3.5.3=. -fstack-protector-strong -Wformat -Werror=format-security -Wdate-time -D_FORTIFY_SOURCE=2 -fPIC -I/usr/include/python3.5m -c src/_portaudiomodule.c -o build/temp.linux-armv6l-3.5/src/_portaudiomodule.o
  src/_portaudiomodule.c:29:23: fatal error: portaudio.h: そのようなファイルやディレクトリはありません
   #include "portaudio.h"
                         ^
  compilation terminated.
  error: command 'arm-linux-gnueabihf-gcc' failed with exit status 1
  
  ----------------------------------------
  Failed building wheel for PyAudio
  Running setup.py clean for PyAudio
Failed to build PyAudio
Installing collected packages: PyAudio
  Running setup.py install for PyAudio ... error
    Complete output from command /usr/bin/python3 -u -c "import setuptools, tokenize;__file__='/tmp/pip-install-2t3wdpz1/PyAudio/setup.py';f=getattr(tokenize, 'open', open)(__file__);code=f.read().replace('\r\n', '\n');f.close();exec(compile(code, __file__, 'exec'))" install --record /tmp/pip-record-cbmc9nru/install-record.txt --single-version-externally-managed --compile:
    running install
    running build
    running build_py
    creating build
    creating build/lib.linux-armv6l-3.5
    copying src/pyaudio.py -> build/lib.linux-armv6l-3.5
    running build_ext
    building '_portaudio' extension
    creating build/temp.linux-armv6l-3.5
    creating build/temp.linux-armv6l-3.5/src
    arm-linux-gnueabihf-gcc -pthread -DNDEBUG -g -fwrapv -O2 -Wall -Wstrict-prototypes -g -fdebug-prefix-map=/build/python3.5-RUbMX3/python3.5-3.5.3=. -fstack-protector-strong -Wformat -Werror=format-security -Wdate-time -D_FORTIFY_SOURCE=2 -fPIC -I/usr/include/python3.5m -c src/_portaudiomodule.c -o build/temp.linux-armv6l-3.5/src/_portaudiomodule.o
    src/_portaudiomodule.c:29:23: fatal error: portaudio.h: そのようなファイルやディレクトリはありません
     #include "portaudio.h"
                           ^
    compilation terminated.
    error: command 'arm-linux-gnueabihf-gcc' failed with exit status 1
    
    ----------------------------------------
Command "/usr/bin/python3 -u -c "import setuptools, tokenize;__file__='/tmp/pip-install-2t3wdpz1/PyAudio/setup.py';f=getattr(tokenize, 'open', open)(__file__);code=f.read().replace('\r\n', '\n');f.close();exec(compile(code, __file__, 'exec'))" install --record /tmp/pip-record-cbmc9nru/install-record.txt --single-version-externally-managed --compile" failed with error code 1 in /tmp/pip-install-2t3wdpz1/PyAudio/

要はpython-voicetextを使うために必要なライブラリ「pyaudio」をインストールしようとしたけど、さらにpyaudioに必要な「portaudio」が見つかりませんということ。

$ apt-get install portaudio19-dev

これで改善するはずですのでもう一度python-voicetextをインストールしてみましょう。

$ pip install python-voicetext

② ngrokの準備

ngrokとは 簡単にいうと、ローカルPC上で稼働しているネットワーク(TCP)サービスを外部公開できるサービスです。例えば、ローカルPCのWebサーバを外部公開することができます。

引用 https://qiita.com/mininobu/items/b45dbc70faedf30f484e

インストール

wgetでzipファイルを取得し解凍→binフォルダに移動することで利用可能になります。

$ wget https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-arm.zip
$ unzip ngrok-stable-linux-arm.zip
$ sudo mv ngrok /usr/local/bin/
$ ngrok version

2019/03/11追記

ngrokの新バージョンとRaspberry PiのCPUの相性によっては「illegal instruction」というエラーが発生して実行できない場合がありました。その場合旧バージョンを利用することになりそうです。

Raspberry Pi Zero WHで発生したため各バージョンを検証したところVersion 2.2.8以前であれば動作しました。

↓こちらのVersion 2.2.8/Linux/.ZIP/ARM64 https://bin.equinox.io/a/nmkK3DkqZEB/ngrok-2.2.8-linux-arm64.zip を使いましょう。 https://dl.equinox.io/ngrok/ngrok/stable/archive

ユーザー登録・認証

ngrokのユーザー登録と認証を行います。

未登録だと利用時間に制限があるため必ず登録しましょう。GoogleアカウントやGithubアカウントでログインすることも可能です。

https://dashboard.ngrok.com/user/login

ログインしたあとの画面中③にあるコマンドをコピーします。

f:id:maru0014:20180923210704p:plain

$ cd /usr/local/bin
$ ./ngrok authtoken XXXXXXXXXXXXXXXXX_XXXXXXXXXXXXXXXXXXX
Authtoken saved to configuration file: /home/pi/.ngrok2/ngrok.yml

これで認証作業は完了。時間制限なく利用できるようになりました。

③ VoiceText APIユーザー登録

VoiceTextがGoogleのTextToSpeech的なものなんですが、声や読み上げ速度、ピッチなどが細かく選べる点が優秀です。

ちなみにモヤモヤさまぁ~ずのナレーションはVoiceTextのショウくんが務めています。つまりGoogle Homeで喋らせる声をshowと指定しておけばモヤさまっぽく色んな通知をしてもらえます。

利用にはユーザー登録が必要なので以下から登録。

https://cloud.voicetext.jp/webapi

登録が完了するとAPIキーがメールで送られてきます。大事なので無くさないように保存しておきましょう。

f:id:maru0014:20180923210729p:plain

④ Slackのチャンネルと着信Webフックを用意

関係ない会話まで読み上げられてはウザいので専用チャンネルを作りましょう。#googlehomeや#notifierなどがいいんではないでしょうか。

次にカスタムインテグレーションを作成します。

着信 Webフックの「設定を追加」で例として以下のように設定します。アイコンもGoogleAssistantにしたりすると雰囲気が出ますね。

f:id:maru0014:20180923213312p:plain

このときWebhook URLをコピーしておいてください。次のコード編集時に必要です。

⑤ Pythonコード編集

環境に合わせて変更する箇所は以下の通り

12行目にslackの設定を書き込む

slack_webhook_url = "ここにSlackのWebhookURL"

14-15行目にVoicetextの設定を書き込む

VT_APIKEY = "ここにVoiceTextのAPIキー" # VoiceText API Key  
VT_DEFAULT_SPEAKER = "hikari"  # Speaker

speakerはshow(男性)haruka(女性)hikari(女性)takeru(男性)santa(サンタ)bear(凶暴なクマ) から選べます。

20行目にGoogleHomeのIPアドレスを書き込む

IP_ADDRESS = "192.168.1.64" # Chromecast IP Address

IPアドレスはスマホのHomeアプリ→デバイス→設定の最下部に記載されています。

⑥ 実行

ngrokを起動

$ ngrok http 5000

もう一つコンソールを立ち上げてgoogle-home-notifier-vtを起動

$ python vt.py

数秒後にSlackへngrokで生成されたURLが投稿されるのでアクセスしてテスト。

https://xxxxxxxxx.ngrok.io/notifier?text=テストメッセージ

SSHで実行する場合

SSH接続して実行した場合はクライアントを閉じるとプロセスも終了してしまいます。

これをバックグラウンドで実行し、クライアント終了後も持続させるにはnohupを先頭につければOKです。

nohup ngrok http 5000
nohup pipenv run python vt.py

⑦ Slackの発信Webフックを用意

URLの項目にhttps://xxxxxxxxx.ngrok.io/slackのように入力します。

f:id:maru0014:20180923213738p:plain

これで#googlehomeチャンネルに投稿されたメッセージは全てRaspberry PiへpostされてVoiceTextで音声ファイルに変換後、Google Homeにクロームキャストすることで喋らせることが可能になりました。

その他オプションなど

slackからは細かい声の設定は出来ない状態になっていますが、今後できるように変更していこうと思います。

また、直接パラメーターを付与してアクセスすればVoiceTextの仕様どおりに各種調整が可能です。

例えばhttps://xxxxxxxxx.ngrok.io/notifier?speaker=show&text=テストメッセージとすれば声をshow君に変更できます。

その他にもpitchで高さ、volumeで音量、emotionで感情、speedで読み上げ速度などを調整可能です。

パラメータ 説明 制限 初期値
text 合成するテキスト。エンコーディングは UTF-8。 制限 初期値
speaker 話者名。後述の「話者一覧」の中のいずれかを指定します。 必須  
emotion 感情カテゴリの指定。 話者 haruka、hikari、takeru、santa、bear にのみ使用できます。 以下のいずれかを指定します。
happiness 喜anger 怒sadness 悲
   
pitch 音の高低を数値で指定します。値が小さいほど低い音になります。 50から200(%)まで 100(%)
volume 音量を数値で指定します。値が小さいほど小さい音になります。 50から200(%)まで 100(%)

詳しくは公式のマニュアルをご確認ください。

VoiceText Web API

あとがき

今回はflaskを使ったことでwebサービスの仕組みに触れられて大いに勉強になりました。

そしてやっぱりRaspberry Piは色んな可能性を秘めていて楽しいですね。

近々Raspberry PiにおけるPython環境構築(pipenv)についても書こうかなと思っています。探り探りなので気になった点などあればご指摘いただければ幸いです。

Raspberry Pi Zero W Starter Kit

Raspberry Pi Zero W Starter Kit