LINEでボイスメッセージを送信し、その音声ファイルをGoogle Homeに喋らせる


#背景
会社を出たときに妻に「今から帰る」LINEをするが、料理中で気づかないことが多いので、
LINEでメッセージを送ってGoogle Homeに読み上げてもらっていた。
ところが読み上げられるメッセージは、テキストから生成した音声でいかにも機械が喋ってる感じがして、なんとなく温かみがない。
そこで、LINEにはボイスメッセージという機能があるので、Google Homeで自分の声を喋ってもらうことにした。
その際、テキストメッセージの処理も残し、受け取ったのがテキストメッセージorテキストメッセージの場合によって処理を分けることとした。

#処理概要

##図
![image.png](https://qiita-image-store.s3.amazonaws.com/0/253915/ee6ea377-145b-4007-0bf0-1f2b4c336617.png)


##### 事前準備1,2とメイン処理1～4からなる
##事前準備
### 1. ngrokでポートに穴を空けておく
- LINE Messaging APIよりWebhook送信される音声orテキストファイルを自宅LAN内ラズパイが受信するため
- セキュリティ的にngrokは気が進まなかったが、まずは動くものをと思ってひとまず妥協

### 2. FlaskでMP3ファイルを返すサーバーを立てる
- Googleデバイスを操作するPyChromecastは、再生時にローカルファイルをそのまま指定(例: /home/pi/tmp.mp3など)して再生ができないため(←違ったら教えてください！)

##メイン処理
### 1. クライアント端末からLINE Developersで作成したアカウントにメッセージを送信
- ボイスメッセージ「今から帰るよ！」と喋る、もしくはテキストメッセージで「今から帰るよ！」)と送信

### 2. LINE Messaging APIのWebhookより自宅ラズパイに音声orテキストファイルを送信(転送？)

### 3. ラズパイにて、受信したファイルから読み上げ用MP3ファイルを作成
- 受け取ったファイルが音声ファイルなら、ffmpegでファイル形式をAAC->MP3に変換
- 受け取ったファイルがテキストファイルならば、gTTSを使ってテキスト->MP3を生成
 
### 4. PyChromecastを使って3.で作成したMP3ファイルをGoogle Homeに再生させる
- 事前準備2.で作成したサーバーから再生

#実装
上記 事前準備1,2とメイン処理1～4の番号に対応
##事前準備1 ngrok
ngrokとはローカル環境をLANの外からアクセスするためのツール
ポートを指定するだけでローカル環境を簡単に外部ネットワークに公開可能
####インストール
https://ngrok.com/download にて該当するファイルを選ぶ
任意のディレクトリにダウンロード

    wget -O ngrok-stable-linux-arm.zip 'https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-arm.zip'
    
#### Authトークンを登録
Sign upするとAuthtokenが発行される
![image.png](https://qiita-image-store.s3.amazonaws.com/0/253915/9f747f71-0548-4366-0168-fdda920fe345.png)


クライアント(ここではラズパイ)にて以下コマンドを実行し、トークンを登録することでアカウントと紐づける

    ./ngrok authtoken 3zb2a*****kK
    
#### ポート番号を指定して実行
    ./ngrok http 5000    
マスク部分の`Forwarding`が公開されたアドレスとなり、外部からアクセスすると`HTTP Requests`にステータスコードを返す
![image.png](https://qiita-image-store.s3.amazonaws.com/0/253915/c7446508-d5ad-d991-5906-f961d635c274.png)


##事前準備2 Flask
FlaskとはPython用のウェブアプリケーションフレームワーク
APIサーバーが簡単に立てられる

####インストール

    pip3 install flask-assistant    



### FlaskによるMP3ファイル供給サーバー
https://qiita.com/sousoumt/items/d815c813a91f3173c2a0
を参考にさせていただきました。port=8088はメインプログラムのpychromecastでMP3ファイルの場所を指定するところで使用します。

```mp3server.py
import os
from flask import Flask, make_response
app = Flask(__name__)

@app.route("/<string:file_name>", methods=['GET'])
def getMP3File(file_name):
    response = make_response()

    if not os.path.exists(file_name):
        return response

    response.data = open(file_name, "rb").read()
    response.headers['Content-Disposition'] = 'attachment; filename=' + file_name
    response.mimetype = 'audio/mp3'
    return response

if __name__ == '__main__':
    app.run(debug=False, host='0.0.0.0', port=8088)
```


##メイン処理1 クライアント端末からLINE Developersで作成したアカウントにメッセージを送信
下記「メイン処理2」で作成したアカウントにメッセージを送る


##メイン処理2 LINE Messaging APIのWebhookより自宅ラズパイに音声orテキストファイルを送信(転送？)
LINEでクライアント端末から、送信先に指定するアカウント
このアカウントに対して送ったボイス/テキストメッセージが、Webhookで送信(転送？)されて最終的にGoogle Homeで読まれる

####Messaging API：
https://developers.line.biz/ja/services/messaging-api/
プロバイダーから「新規プロバイダー作成」を選択
![image.png](https://qiita-image-store.s3.amazonaws.com/0/253915/d1ec97f5-87aa-cb59-774b-5ef75226cd2a.png)

作成したプロバイダーにチャネルを作成
![image.png](https://qiita-image-store.s3.amazonaws.com/0/253915/8970f360-5bb6-5035-70f0-0ff6d8af4f0c.png)

「メッセージ送受信設定」Webhook URLにngrokで公開したアドレスを指定
![image.png](https://qiita-image-store.s3.amazonaws.com/0/253915/0e6723f4-2f3b-5fa6-8edb-38bf5e824169.png)

その他、`Channel Secret`と`アクセストークン（ロングターム）`は後で使うので控えておく


##メイン処理3-1 ffmpegでAAC->MP3変換
- LINEのボイスメッセージはAAC(*.m4a)形式で送られてくる
- 一方、PyChromecastで再生可能なオーディオはMP3
- よって、受信した音声ファイルをAACからMP3に変換する必要がある
- ffmpegというツールを使えば簡単に可能
- ファイル名はtmp.m4a、tmp.mp3とする
- 実はエンコード時に<strong>Unknown encoder 'libmp3lame'</strong>のエラーで5時間くらいハマった。解決方法は後日

#####コード中ではsubprocessで実行

```py
subprocess.call([
    "/home/pi/opt/ffmpeg-git-20190331-armhf-static/ffmpeg",
    "-i", '/home/pi/work/tmp.m4a',
    "-acodec", "libmp3lame", "-b:a", "143k", 
    '/home/pi/work/tmp.mp3'
])
```

##メイン処理3-2 gTTSでテキスト->MP3変換
- テキストメッセージをそのままPyChromecastで再生できない
- そこで、テキストからMP3を生成する必要がある
- gTTSというツールを使えば簡単に可能
- ファイル名はtmp.mp3とする

#####テキストは`event.message.text`に入っている
いきなりメッセージが読み上げられるとびっくりするので「LINEです」とつけておく

```py
readtext = 'LINEです。'+event.message.text
tts = gTTS(text=readtext, lang='ja')
tts.save('/home/pi/work/tmp.mp3')
```

##メイン処理4 PyChromecastで再生
- 事前に作成したFlaskサーバーからtmp.mp3を呼び出す

##メイン処理3以降のファイルは以下

```main.py
from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage, AudioMessage
)
import pychromecast
from gtts_token import gtts_token
import urllib.parse
from flask import Flask, request, abort
import subprocess
import time

app = Flask(__name__)

# 各クライアントライブラリのインスタンス作成
line_bot_api = LineBotApi('4TQa***')  #LINE Developersページのアクセストークン（ロングターム）
handler = WebhookHandler('b9a0***') #LINE DevelopersページのChannel Secret

@app.route("/readmessage", methods=['POST'])
def receivemessage():
    # get X-Line-Signature header value
    signature = request.headers['X-Line-Signature']

    # get request body as text
    body = request.get_data(as_text=True)
    app.logger.info("Request body: " + body)

    # handle webhook body
    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)

    return 'OK'


#Google Homeを探す
googlehome_name = "Living Room"
chromecasts = pychromecast.get_chromecasts()
cast = next(cc for cc in chromecasts if cc.device.friendly_name == googlehome_name)


#テキストの場合
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    print(event.message.text)
    readtext = 'LINEです。'+event.message.text
    #テキストメッセージからmp3ファイルを生成
    tts = gTTS(text=readtext, lang='ja')
    tts.save('/home/pi/work/tmp.mp3')
    #変換するまで待つ
    time.sleep(3)
    #再生
    cast.wait()
    mc = cast.media_controller
    mc.play_media('http://192.168.0.18:8088/tmp.mp3', 'audio/mp3')
    mc.block_until_active()
    #読み込まれる前に削除されないよう待つ
    time.sleep(5)
    #音声ファイルを削除する
    subprocess.call(['rm', '/home/pi/work/tmp.mp3'])


#音声の場合→音声ファイルをそのまま再生
@handler.add(MessageEvent, message=AudioMessage)
def handle_audio_message(event):
    #音声ファイルを保存する
    content = line_bot_api.get_message_content(event.message.id)
    with open('/home/pi/work/tmp.m4a', 'wb') as f:
        for c in content.iter_content():
            f.write(c)
    #保存するまで待つ
    time.sleep(1)
    #音声ファイルをACC->MP3に変換する
    subprocess.call([
        "/home/pi/opt/ffmpeg-git-20190331-armhf-static/ffmpeg",
        "-i", '/home/pi/work/tmp.m4a',
        "-acodec", "libmp3lame", "-b:a", "143k", 
        '/home/pi/work/tmp.mp3'
    ])
    #変換するまで待つ
    time.sleep(3)
    #再生
    cast.wait()
    mc = cast.media_controller
    mc.play_media('http://192.168.0.18:8088/tmp.mp3', 'audio/mp3')
    mc.block_until_active()
    #読み込まれる前に削除されないよう待つ
    time.sleep(5)
    #音声ファイルを削除する
    subprocess.call(['rm', '/home/pi/work/tmp.m4a'])
    subprocess.call(['rm', '/home/pi/work/tmp.mp3'])


if __name__ == "__main__":
    app.run(host='127.0.0.1', port=5000)
```


#今後の課題
使用するだけなら問題ないが、気が向いたら対応したい

- クライアントまでステータスコードが返ってこないので、ラズパイが異常終了していた場合などGoogle Homeで読み上げられなくてもそれがわからない
- 投稿から再生まで時間がかかる
- 連続しての再生ができない
- セキュリティ的にngrokを使いたくない