スマートスピーカ

2019年02月06日

スマートプラグ + ESP32 で超シンプルに Wake On Wan

はてなブックマークに登録

スマートスピーカーの普及に伴いスマートホーム系製品の低価格化が少しずつ進んでいます。 最近プライベートで次の製品を買いました。今のところ安定動作しており自宅で使っている Google Home Mini との相性もよく気に入っています。

※スマートプラグを使えば家庭用電源から所定の機器への給電を手軽に操作できるため便利ですが、個人的には何らかの原因で自分の意図しない動作が起こった場合に深刻な事態につながりかねない機器との併用は避けています。たとえば少なくとも現時点で電気ストーブなどをつなぐ勇気はありません ^^;

スマートプラグを PC の起動に利用する

スマートスピーカーへの音声指示で PC を起動するアイディアには実用性があり、ネットを検索すると参考になる多くの興味深い例に触れることができます。一方で、Meross Smart Plug Mini を触っているうちにこれを使えばとてもシンプルな道具立てで同様のことを実現できるのではないかと考えました。以下の発想によるものです。

  1. このプラグはもともとルータ越しの動作を前提とするクラウドベースのスマートホーム系製品である
  2. そのため自前で通信まわりを取り回すことなく所定の機器への給電を屋内外から指示することができる
  3. ということは、上記 2. の給電対象の機器を「起動すると LAN 上に所定のマジックパケットを送り出す内容のプログラムを書き込んだマイコンボード」としておけば、スマートスピーカー経由であれ、その他の方法であれ、給電指示ひとつで簡単に LAN 上の所定の PC を起動できるはず

電源投入からのスタートなのでボード側の初期処理に多少の時間がかかることは予想されるものの全体として筋は通っています。特に難しい要素もないため国内外のどこかにすでに同一の事例があるのではないかと想像しましたが、手元でざっと見渡した範囲では見当たらないようです。そんなわけで、まあもし先例があってもいいか、と思いながらざっくり形にしてみることにしました。

実装と動作の様子

スマートプラグからの給電先は何かと融通のきく ESP32 ボードとしました。AC アダプタと USB ケーブルごしにボード単体をプラグへ接続して使います。 プログラムは以下の内容としました。対象 PC が起動すれば当該ボードはお役御免につきプログラムから給電元のプラグをオフにすることで自らをシャットダウンします。

  1. 初期処理として WiFi AP との接続を確立
  2. 所定のマジックパケットを LAN へ送出
  3. マジックパケット送出後に対象 PC へ ping を継続的に発行
  4. ping への応答を検知したら自ボードへ給電中のスマートプラグの通電をオフにする

あわせてネットワーク接続時と電源切断前にメールでその旨を通知します。この通知はリモートで操作を行う場合には状況把握のために有用ですが、スマートスピーカーへ声をかけている在宅時にはいささか冗長なのでいずれ手を加えるかもしれません。

ソースコード

Arduino IDE + Arduino core for the ESP32 環境向けに用意したプログラムです。
前項に挙げたメール通知処理には SendGrid を、プラグのシャットダウンには IFTTT アプレットを利用しています。 これらは ESP32_WakeOnLan.h 冒頭の「#define USE_MAIL_NOTIFICATION」「#define USE_AUTO_SHUTDOWN」の定義を無効化すれば省略されます。

デモ

一式の動作の様子を収めた動画です。 (1分10秒)

(注:この動画には "OK Google" の発声が含まれます。
近くに Goole Home デバイスのある場合にはご注意下さい)

余談ながら、手元では過去に何度か Wake On Wan の試みを行いそれぞれ当ブログの記事として公開しています。見返してみると道具立てに微妙に当時の状況が反映されておりちょっと面白く感じました。

時代が加速を続けています。数年後の未来が楽しみです。


(tanabe)
klab_gijutsu2 at 04:50|この記事のURLComments(0)
2018年12月07日

Google Assistant Service プログラミング事始め

はてなブックマークに登録

前回の記事では Google Assistant Library ベースのプログラミングを通じて Google アシスタントの各機能を取り回す試みを行いました。今回はもうひとつの Google Assistant SDK である Google Assistant Service に目を向けてみます。

  • 当ブログ記事「Google Assistant Library プログラミングを楽しむ 」より
    Google LLC は Google Assistant SDK for devices として「Google Assistant Library」「Google Assistant Service」の二種類のセットを提供しています。前者は高水準で稼働環境は狭め、後者は低水準で広範な稼働環境に対応しており、この周到な構成に Google の本気度が窺えます。現時点では一日あたりのリクエスト数に制限はあるものの個人でも無償で利用できることが大きな魅力です。

Google Assistant Service と稼働環境

前回も引用した Google Assistant Library と Google Assistant Service の比較表を再掲します。2018年12月時点の公式記事より)

Google Assistant Service の最大の特長は、このように gRPC 対応のプラットフォーム+対応言語環境全般で利用可能であることです。現時点では Google Assistant Library ほどには多機能ではないものの、稼働環境が広く Windows PC や Mac はもとより小振りで消費電力が小さく IoT フロントエンドとして有用な Raspberry Pi Zero / Zero W 系ボードも Google アシスタントクライアントとして利用できることに関心を誘われます。

Google Assistant Service と Google Assistant Library の機能面での違いは SDK 全体のリリースノートを追うことで手早く俯瞰できます。

両者の機能差は今後変化する可能性がありますが、個人的には 2018年12月時点で Google Assistant Service 側にはない要素のうち実用面とのかねあいにおいて以下の三点に留意しています。

  1. Google Assistant Library とは異なりウェイクワードの待機・検知に対応していない
  2. ニュースの読み上げや Google Podcasts に未対応
  3. アラームに未対応 (リマインダは設定可能)

なお、上記 1. は外部の Hotword Detector との連携(後述)によりある程度補うことが可能です。

※余談ながら、Google Home デバイス実機とは異なり今のところ Google Assistant SDK ベースのプログラムの所作に google-home-notifier を絡ませることはできません。たしかに 8009 番ポートを使ったキャスト機構にはハードウェア側要件としての印象が強いものの、google-home-notifier を あち こち で利用しているファンとしてはちょっと残念です。

pushtotalk.py プログラムのこと

Google Assistant Service をセットアップ後の googlesamples/assistant/grpc/ フォルダには「pushtotalk.py」プログラムが配置されます。 このプログラムは名前の示すように Google Assistant Library に含まれる「hotword.py」とは異なり、ウェイクワード(ホットワード)の音声ではなく 'Press Enter to send a new request...' の CUI メッセージを添えた click.pause() API で物理的なトリガーを待って Google アシスタントとの対話を開始する内容で実装されています。
セットアップずみの SDK ディレクトリ下での pushtotalk.py の実行方法は以下の要領です。引数で指定したプロジェクト ID とモデル ID はホスト側へ記憶され、変更の必要がなければ次回以降は省略可能です。

$ pwd
/home/t/wk/GoogleAssistant

$ ls
env

$ source env/bin/activate

(env) $ googlesamples-assistant-pushtotalk --project-id [設定ずみのプロジェクトID] --device-model-id [設定すみのモデルID]

pushtotalk.py を手元の複数の環境で実行した様子の動画を以下に示します。

Windows
Mac
Linux (32bit)
Raspberry Pi 3 Model B+
Raspberry Pi Zero W

上の動画のRaspberry Pi 3 Model B+ / Zero W には手持ちの以下の以下のマイクとスピーカーを写真の要領で接続しています。

今回は、前回の hotword.py と対照的なこの pushtotalk.py を試作の下敷きとします。

Hotword Detector について

前掲の Google Assistant Service 側未対応の機能のうち、実用上の影響がもっとも大きいのはウェイクワードの待機・検知ができないことでしょう。Google Assistant Library プログラムにおいては Google Home デバイスと同様にハンズフリーでアシスタントとの応酬が可能であることを考えあわせると淋しく感じられますが、この点は外部の Hotword Detection ソフトウェアを併用することで補うことができます。

ただし、外部の Hotword Detector との連携は Google アシスタントネイティブでのウェイクワードサポートではなく、あくまでも Assistant API を呼び出すためのトリガーを外側に用意する手立てに他ならないため以下の注意が必要です。

  • ウェイクワード設定の柔軟性や認識精度はすべて Detector 側の要件である
  • Google アシスタントの発話中にウェイクワードで介入することはできない

こういった事情を理解した上で Hotword Detector を併用すれば、間口の広い Google Assistant Service を様々な環境でより便利に利用することができるでしょう。

Porcupine と Snowboy

現時点でのスマートスピーカー向きの代表的な Hotword Detector として、Picovoice (カナダ系)による Porcupine と、 KITT.AI (中国系) による Snowboy が挙げられます。後者はすでに随所で取り上げられていますね。どちらも機械学習に基づく精度の高さ・低負荷・マルチプラットフォーム対応・カスタマイズの柔軟性をアピールポイントとしており、後発の Picovoice は両者の比較記事を公開しています。

手元ではどちらも使い始めてまだ日が浅いのですが、今のところウェイクワードを認識する能力そのものに際立った性能差は感じておらず負荷の度合いについては未検証です。今の時点で把握している両者の一長一短を挙げてみます。

対応プラットフォームの広さ: Porcupine ◎

  • Porcupine
    • Raspberry Pi/ Android/ iOS/ watchOS/ Linux/ Mac/ Windows/ web browsers
  • Snowboy
    • Raspberry Pi/ 64bit Mac OS X/ 64bit Ubuntu 14.04/ iOS/ Android/ ARM64 (aarch64, Ubuntu 16.04)

カスタムウェイクワードへの対応: Snowboy ◎

  • Snowboy
    • 任意のカスタムウェイクワードの作成・使用が可能
    • 日本語を含む主要 15か国語 or "Other" から言語を指定可能
    • 肉声でのモデル作成に対応 〜 モデルはサーバへ保存され別話者分を含め追加学習が可能
    • 操作性のよいブラウザ I/F が提供されている
      (クリックで大きく表示)
  • Porcupine
    • モデル作成には専用の CUI ツール「Porcupine Optimizer」を使用する。同ツールのソースコードは非公開で Windows/ Mac/ Linux 向けバイナリが配布されている
    • ウェイクワードのバリエーションはライブラリに同梱の英単語辞書に含まれる語彙の範囲に限定される。複数単語の組み合わせは可。モデルファイルの作成方法は以下の要領
      $ pwd
      /home/t/wk/Porcupine

      $ tools/optimizer/linux/i386/pv_porcupine_optimizer -r resources/ -p linux -o . -w "OK google"
    • 特定の話者の肉声に基づくモデリング・学習を行うことはできない
    • モデルファイルはプラットフォームごとに独立しており(バンドル分も同様)、Windows/ Mac/ Linux 用以外のモデルファイルの作成には商用ライセンスが必要
    • 作成したモデルファイルには 90日間の使用期限が伴う。再作成に制限はない

手元では今のところ PC では Porcupine、Raspberry Pi では Snowboy の要領で使い分けています。下の動画はそれぞれの動作の様子です。

※ ふたつの動画でのウェイクワード検知時・対話終了時の応答音はいずれも Snowboy の resource/ ディレクトリ下の wave ファイルによるものです。これらが耳に馴染んだため Porcupine との連携においても同じ要領で使用しています

  • 32bit Linux 環境での Porcupine - pushtotalk.py 連携
  • Raspberry Pi Zero W での Snowboy - pushtotalk.py 連携

試作

前回記事での hotword.py (Google Assistant Library) ベースの試作に続き、今回は Google Assistant Service の pushtotalk.py を下敷きにプログラムを作成します。作業上の便宜から開発は PC で行いましたが、Google Assistant Service ベースなので指先に乗るサイズの Raspberry Pi Zero W ボードをはじめ広範な環境で実行可能であることに夢が広がります。オールインワンのダンボールキットも製品化されていますね。

今回はシンプルな切り口として前回の Google Assistant Library 版の各プログラムと同じ動きをするものを作ってみることにしました。Google Assistant Serivice プログラミングは奥が深くまだまだ習作の段階ではありますが、事始めとして取り組んだ内容を以下に掲載します。

1. 指定テキストに基づく音声合成と読み上げ

テーマ: Google アシスタントの音声合成機能を単体で利用する。いわゆる Text to Speech。

前回 Google Assistant Libray 版試作へのリンク

内容

  • プログラムを起動するとテキスト連続入力モードへ
  • エンターキー区切りで入力した任意のテキストがそのままアシスタントに読み上げられる
  • ウェイクワードは使用しない

デモ: 動画 27秒
  

(前回分)
  

考えたことなど

  • 前回と同様、まず TTS を試したいと考えた
  • ここでのポイントは、「音声データではなく任意のテキストで Assistant へ指示を渡す」ことにある。だが、Google Assistant Library に存在する send_text_query API と同等の機能を持つものが Google Assistant Service には見当たらず、現時点で Google Assistant Service プログラミングに関する情報はネットにも乏しい
  • ヒントを探してまず pushtotalk.py をあらためて読む。このコードでの指示と応答の応酬は事前に用意した対話用ストリーム上での start_recording() 〜 stop_recording() を経て start_playback() 〜 stop_playback() を呼び出す内容で巧みなく実装されており、Google Assistant Service においては音声データ以外の方法で Assistant とのやりとりが不可である可能性も頭をよぎった
  • 手を動かすうちに SDK ディレクトリ配下の「googlesamples/assistant/grpc/textinput.py」プログラムの存在に気づいた。コードへのリンクとこのプログラムの実行例を示す
    • textinput.py - github.com/googlesamples
      (env)t@PC-533:~/wk/GoogleAssistant$ ./textinput.sh 
      INFO:root:Connecting to embeddedassistant.googleapis.com
      : こんにちは
      <you> こんにちは
      <@assistant> こんにちは、TAnabeさん 
      どうしましたか?
      : 今何時?
      <you> 今何時?
      <@assistant> 時刻は、17:25です。
      : 今晩、雨降る?
      <you> 今晩、雨降る?
      <@assistant> 夜は、雨の心配はないでしょう
      今夜の山口は雨ではないでしょう。 気温10度、晴れるでしょう。
      ---
      (weather.com でもっと見る)
      : 
      
  • このように textinput.py は音声を一切使わずテキストの入出力のみで Assistant とやりとりを行う内容のシンプルな内容であり、Assistant への指示に音声ではなくテキストを指定可能であることをまっすぐ示している。それを実現するためには google.assistant.embedded.v1alpha2 の AssistConfig クラスの text_query メンバ("The text input to be sent to the Assistant. This can be populated from a text interface if audio input is not available.") を利用すればよいことを以下のコードで知った
    • textinput.py#L83-L100
      083|        def iter_assist_requests():
      084|            config = embedded_assistant_pb2.AssistConfig(
      085|                audio_out_config=embedded_assistant_pb2.AudioOutConfig(
      086|                    encoding='LINEAR16',
      087|                    sample_rate_hertz=16000,
      088|                    volume_percentage=0,
      089|                ),
      090|                dialog_state_in=embedded_assistant_pb2.DialogStateIn(
      091|                    language_code=self.language_code,
      092|                    conversation_state=self.conversation_state,
      093|                    is_new_conversation=self.is_new_conversation,
      094|                ),
      095|                device_config=embedded_assistant_pb2.DeviceConfig(
      096|                    device_id=self.device_id,
      097|                    device_model_id=self.device_model_id,
      098|                ),
      099|                text_query=text_query,
      100|            )
      
  • では、これに対する Assistant からの応答をテキストではなく音声データで受け取ることは可能か? textinput.py でのレスポンス処理箇所は以下の内容
    • textinput.py#L109-L121
      109|        text_response = None
      110|        html_response = None
      111|        for resp in self.assistant.Assist(iter_assist_requests(),
      112|                                          self.deadline):
      113|            assistant_helpers.log_assist_response_without_audio(resp)
      114|            if resp.screen_out.data:
      115|                html_response = resp.screen_out.data
      116|            if resp.dialog_state_out.conversation_state:
      117|                conversation_state = resp.dialog_state_out.conversation_state
      118|                self.conversation_state = conversation_state
      119|            if resp.dialog_state_out.supplemental_display_text:
      120|                text_response = resp.dialog_state_out.supplemental_display_text
      121|        return text_response, html_response
      
  • ここでふたたび pushtotalk.py のコードを追い、上と同じ self.assistant.Assist() ループ中の次の記述に注目した
    • pushtotalk.py#L149-L153
      149|            if len(resp.audio_out.audio_data) > 0:
      150|                if not self.conversation_stream.playing:
      151|                    self.conversation_stream.stop_recording()
      152|                    self.conversation_stream.start_playback()
      153|                    logging.info('Playing assistant response.')
      
  • textinput.py での self.assistant.Assist() レスポンスのループにおいては audio 要素にまったく触れていないが、あるいはテキストベースで指示を受けた場合の応答においても resp.audio_out.audio_data がセットされるのではないかと根拠なく想像。とりあえずループ内に print(len(resp.audio_out.audio_data)) 文を挟んで実行してみた。以下はその画面表示結果
    : 今何時?
    <you> 今何時?
    0
    1600
    1600
    (引用中略)
    1600
    1600
    244
    <@assistant> 時刻は、18:43です。
    
  • どうやら正解らしい。実際に resp.audio_out.audio_data を順次ファイルへ出力し、それを Audacity に Raw データとして取り込んだところ 16ビット 16000 Hz のモノラル PCM データであることが確認された。つまり、textinput.py では音声でのレスポンスが不要であるため参照されていないものの、入力データが音声であってもテキストであっても Assistant からは常に音声形式とテキスト形式の応答が返されるらしい
       
          (mp3 形式へ変換したもの)
  • それなら話は早い。ここでは音声ストリームデータの再生のみができれば良い。コードを追い、pushtotalk.py での対話に使用されている conversation_streamsource, sink の両メンバが googlesamples.assistant.grpc.audio_helpers.py の SoundDeviceStream のインスタンスであることを確認し、同クラス内部の _audio_stream の実体が sounddevice パッケージの RawStream であることを知り 前掲のコードに次の要領で再生処理を加えた
    import sounddevice as sd
    
               :
            text_response = None
            html_response = None
            s = sd.RawStream(
                samplerate=audio_helpers.DEFAULT_AUDIO_SAMPLE_RATE, 
                dtype='int16', 
                channels=1,
                blocksize=audio_helpers.DEFAULT_AUDIO_DEVICE_BLOCK_SIZE)
            for resp in self.assistant.Assist(iter_assist_requests(),
                                              self.deadline):
                assistant_helpers.log_assist_response_without_audio(resp)
                s.write(resp.audio_out.audio_data)
                s.start()
                if resp.screen_out.data:
                    html_response = resp.screen_out.data
                if resp.dialog_state_out.conversation_state:
                    conversation_state = resp.dialog_state_out.conversation_state
                    self.conversation_state = conversation_state
                if resp.dialog_state_out.supplemental_display_text:
                    text_response = resp.dialog_state_out.supplemental_display_text
            return text_response, html_response
               :
    
  • 以上の経緯を経て Google Assistant Service ベースでの TTS プログラムが形になった。前回の Google Assistant Library 版と同じく IFTTT 上に用意した「オウム返しアプレット」を併用

ソースコード

2. 利用者の発話内容をテキストへ変換

テーマ: Google アシスタントの音声認識機能を単体で利用する。いわゆる Speech to Text。

前回 Google Assistant Libray 版試作へのリンク

内容

  • ウェイクワードの検知に Porcupine を利用。対象ワードは「picovoice」
  • 「エディタを起動」でテキストエディタを起動
  • 発話内容のテキストを連続してエディタへ書き出す
  • この手のことはひと昔以上前から商用化されていたが、認識精度が段違いに向上しておりすでに口述筆記や印刷物のテキスト書き起こしの下書きにも使える水準と言ってよいだろう
  • いろいろ肉付けすれば PC 上で Google アシスタントを Cortana, Siri 風のツールとして利用できるかも

デモ: 動画 41秒
  

(前回分)
  

考えたことなど

  • 利用者の発話内容をアシスタント側が認識した結果のテキストを取得したい
  • そのためには以下の手順を踏めばよいことを pushtotalk のコードで知った
    • pushtotalk.py#L138-L148
      138|        for resp in self.assistant.Assist(iter_log_assist_requests(),
      139|                                          self.deadline):
      140|            assistant_helpers.log_assist_response_without_audio(resp)
      141|            if resp.event_type == END_OF_UTTERANCE:
      142|                logging.info('End of audio request detected.')
      143|                logging.info('Stopping recording.')
      144|                self.conversation_stream.stop_recording()
      145|            if resp.speech_results:
      146|                logging.info('Transcript of user request: "%s".',
      147|                             ' '.join(r.transcript
      148|                                      for r in resp.speech_results))
      

ソースコード

  • PicovoiceWithGoogleAssitantService_input.py - github.com/mkttanabe
    • Porcupine をインストールしたディレクトリ下へ配置して実行のこと
    • Porcupine/resource ディレクトリ下に前掲の ding.wav, dong.wav が必要
    • デモ動画ではパラメータに "--keyword_file_paths resources/keyword_files/picovoice_linux.ppn" を指定

3. 利用者の発話内容を他言語へ連続翻訳

テーマ: Google アシスタントの 音声認識 / 文意解釈 / 言語翻訳 / 応答文生成 / 音声合成 の各機能を利用する

前回 Google Assistant Libray 版試作へのリンク

内容

  • ウェイクワードの検知に Porcupine を利用。対象ワードは「picovoice」
  • 今回の Google Assistant Service 版では口頭指示により翻訳先言語を切り替える機能を追加した
  • 利用者が日本語の文章を発話するとアシスタントがその翻訳文を読み上げる
  • 一文ごとにウェイクワード等の指示は不要
  • 操作に手がかからないのでオフィスや店舗等の多言語環境用に翻訳専用端末として仕立てられるかも

デモ: 動画 2分30秒
  

(前回分)
  

ソースコード

  • PicovoiceWithGoogleAssitantService_translate.py - github.com/mkttanabe
    • Porcupine をインストールしたディレクトリ下へ配置して実行のこと
    • Porcupine/resource ディレクトリ下に前掲の ding.wav, dong.wav が必要
    • デモ動画ではパラメータに "--keyword_file_paths resources/keyword_files/picovoice_linux.ppn" を指定

4. 利用者の発話内容を復唱

テーマ: Google アシスタントの音声認識 / 音声合成機能を利用する

前回 Google Assistant Libray 版試作へのリンク

内容

  • ウェイクワードの検知に Porcupine を利用。対象ワードは「picovoice」
  • 利用者が発話したフレーズをオウム返しする
  • 前掲の「指定テキストに基づく音声合成と読み上げ」の音声入力版。いわば翻訳を伴わない Speech to Speech

デモ: 動画 39秒
  

(前回分)
  

ソースコード

  • PicovoiceWithGoogleAssitantService_echo.py - github.com/mkttanabe
    • Porcupine をインストールしたディレクトリ下へ配置して実行のこと
    • Porcupine/resource ディレクトリ下に前掲の ding.wav, dong.wav が必要
    • デモ動画ではパラメータに "--keyword_file_paths resources/keyword_files/picovoice_linux.ppn" を指定

しりとり

テーマ: Google アシスタントの音声認識 / 音声合成機能を利用する

前回 Google Assistant Libray 版試作へのリンク

内容

  • ウェイクワードの検知に Porcupine を利用。対象ワードは「picovoice」
  • 日本語の単語でアシスタントとしりとりを行う
  • しりとり向きの適当な単語辞書を使用
  • 日本語形態素解析に Janome ライブラリを利用

デモ: 動画 60秒
  

(前回分)
  


現時点ではプログラミングを行うための実践的な情報をあまり目にすることのない Google Assistant Library と Google Assistant Service を題材に手元で行った試みを二度に分けて紹介しました。幼児の年齢を迎えたばかりの Google アシスタントはこれからこれらの SDK とともに成長を重ねて行くことでしょう。未来へ向かう道すがらの愉しみがまたひとつ増えた思いです。


(tanabe)
klab_gijutsu2 at 09:23|この記事のURLComments(0)
2018年11月30日

Google Assistant Library プログラミングを楽しむ

はてなブックマークに登録

Google LLC は Google Assistant SDK for devices として 「Google Assistant Library」「Google Assistant Service」 の二種類のセットを提供しています。前者は高水準で稼働環境は狭め、後者は低水準で広範な稼働環境に対応しており、この周到な構成に Google の本気度が窺えます。現時点では一日あたりのリクエスト数に制限はあるものの個人でも無償で利用できることが大きな魅力です。

Google Home デバイスやスマートフォンアプリを通じて Google アシスタントの存在感はどんどん大きくなっています。その一方で現時点では Google Assistant SDK プログラミングのための実践的な情報を国内外を通じてあまり見かけないことが残念に思われました。ネットの奥深くに息づいている Google アシスタント陣営の一連の機能を本 SDK ごしに柔軟に呼び出すことができればこの強力で魅力的なサービスをさらに活用できるのではないでしょうか。

  • 音声認識
  • 文意の解釈
  • 応答文の生成
  • 音声合成
  • 言語翻訳

そんなわけで手元ではここしばらく Google Assistant SDK に向き合っています。今回はまず Google Assistant Library ベースでこれまでに行った調査と実験の内容を紹介します。

Google Assistant Library と稼働環境

前掲の表にも記載のあるように、今のところ Google Assistant Library の稼働環境は Python を利用可能な linux-armv7l または linux-x86_64 なプラットフォームに絞られます。周辺機器としてマイクとスピーカが必須。セットアップは下記ページからの説明にそって行えば問題ないでしょう。導入手順の詳細は随所で紹介されているためここでは省略します。

手元では作業用に 64bit lubuntu 環境の PC と Raspberry Pi 3 Model B+ を使っています。

hotword.py プログラムのこと

Google Assistant Library をセットアップ後の googlesamples/assistant/library/ フォルダには「hotword.py」プログラムが配置されます。実質 100行ほどの短い内容ですが、`sample` と言いながらこのプログラムを利用すればウェイクワードの検知を含め Google アシスタントとのいつものやりとりをそのまま実現することができます。抽象度が高くフロントエンドをこのようにシンプルに実装できることが Google Assistant Library の大きな特長です。
セットアップずみの SDK ディレクトリ下での hotword.py の実行方法は以下の要領です。引数で指定したプロジェクト ID とモデル ID はホスト側に記憶され、変更の必要がなければ次回以降は省略できます。

$ pwd
/home/t/wk/GoogleAssistant

$ ls
env

$ source env/bin/activate

(env) $ googlesamples-assistant-hotword --project-id [設定ずみのプロジェクトID] --device-model-id [設定すみのモデルID]

hotword.py を PC / Raspberry Pi 3 で実行した様子の動画です。ここでは個人的な好みからウェイクワードへの反応音「ポコッ」の再生をコードに加えています。

  
上の動画のラズパイには手持ちの以下のマイク (USB 接続) とスピーカー (3.5mm オーディオジャック接続) を写真の要領でつないでいます。

一連の試作においてはこの hotword.py を下敷きにすることにしました。コンパクトで見通しのよい内容でありながらアシスタントとの応酬に必要な要素の一式が収められているためカスタマイズを試みるための土台としてはまさに好適でしょう。この記事の最後の項目に Google Assistant Library プログラミングを行う上での基本的な作法をまとめています。あわせて参照して下さい。

試作

今回手がけた試作をデモ動画と素のままのソースコードを添えて以下に掲載します。

1. 指定テキストに基づく音声合成と読み上げ

テーマ: Google アシスタントの音声合成機能を単体で利用する。いわゆる Text to Speech。

内容

  • プログラムを起動するとテキスト連続入力モードへ
  • エンターキー区切りで入力した任意のテキストがそのままアシスタントに読み上げられる
  • ウェイクワードは使用しない

デモ: 動画 48秒
  

考えたことなど

  • まず最初に任意のフレーズをアシスタントに自由に喋らせてみたいと考えた。あの耳に馴染んだ声での発話はなかなか流暢で実用性があり、また、Google アシスタントの普及を背景とする記号的な存在感もある
  • だが、アシスタントの音声合成機能は利用者との対話を通じての一連の処理の中で内部的に呼び出されるものであり通常の操作でこれを単体で利用することはできない。また、SDK である Google Assistant Library にもそれを実現可能とする API は用意されていない
  • ここで TTS を実現するための要件をあらためて整理してみると、以下の二点に集約される
    1. 音声データではなく所定のテキストをアシスタント側へ引き渡すこと
    2. 上のテキストの内容をアシスタントに音声合成させてその音声データを再生すること
  • 上記要件 1. については Google Assistant Library にそのものずばりの「send_text_query」 API が存在することをリファレンスで知った。これは使える
    send_text_query(query)

    Sends |query| to the Assistant as if it were spoken by the user.

    This will behave the same as a user speaking the hotword and making a query OR speaking the answer to a follow-on query.

    Parameters: query (str) - The text query to send to the Assistant.
  • 問題は要件 2. だが、任意のフレーズを発話させる方法をひとつ思いついた。Google Assistant サービスをトリガーとする IFTTT アプレットにおいて 「What do you want the Assistant to say in response?」フィールドへ指定したフレーズは利用者が任意に指定すること可能であり、その内容は当該アプレット実行時のレスポンスとしてアシスタントによって発話される。ということは、Assistant サービストリガーを text ingredient オプションつきでアサインし、その text 引数の内容をそのまま response に指定することで「オウム返し」を行う IFTTT アプレットを用意すればよいのではないか? 指定必須の固定文言を「オウム返し」などと設定した上で「オウム返し ○○○○○」と話しかければ、アシスタントは当該アプレット実行のレスポンスとして「○○○○○」と発話するはず
  • さっそく以下の要領で「オウム返し」アプレットを用意して試したところ動画のように期待どおりの結果が得られた
  • あとは、任意のテキストの頭に "オウム返し " の文字列を挿入した上で前述の send_text_query API 経由でアシスタントへ引き渡せば良いだろう

ソースコード

2. 利用者の発話内容をテキストへ変換

テーマ: Google アシスタントの音声認識機能を単体で利用する。いわゆる Speech to Text。

内容

  • 「エディタを起動」でテキストエディタを起動
  • 発話内容のテキストを連続してエディタへ書き出す
  • この手のことはひと昔以上前から商用化されていたが、認識精度が段違いに向上しておりすでに口述筆記や印刷物のテキスト書き起こしの下書きにも使える水準と言ってよいだろう
  • いろいろ肉付けすれば PC 上で Google アシスタントを Cortana, Siri 風のツールとして利用できるかも

デモ: 動画 41秒
  

考えたことなど

  • 利用者の発話内容をアシスタント側が認識した結果のテキストを取得したい
  • そのテキストは下のように hotword.py 実行時にコンソールへきっちりエコーバックされるためプログラムで拾えそうだが、リファレンスをみてもその方法がわからない。ほしいのはこの中の「'今何時'」の部分なのだが、、
    ON_CONVERSATION_TURN_STARTED
    ON_END_OF_UTTERANCE
    ON_RECOGNIZING_SPEECH_FINISHED:
        {'text': '今何時'}
    ON_RESPONDING_STARTED:
        {'is_error_response': False}
    ON_RESPONDING_FINISHED
    ON_CONVERSATION_TURN_FINISHED:
        {'with_follow_on_turn': False}
    
  • hotword.py#L61-L63 にヒントがあった。以下のコードでは ON_CONVERSATION_TURN_FINISHED イベントの発生時に 「event.args['with_follow_on_turn'])」というパラメータを評価しており、それは上のコンソール出力内容末尾の二行に符合する
     61 |    if (event.type == EventType.ON_CONVERSATION_TURN_FINISHED and
     62 |            event.args and not event.args['with_follow_on_turn']):
     63 |        print()
    
  • ということは、「'今何時'」の部分を取得するには、ON_RECOGNIZING_SPEECH_FINISHED の発生時に「event.args['text']」というパラメータを参照すればよいのではないか?
  • 正解! これでアシスタント側が音声認識した結果のテキストを自由に拾えるようになった
  • せっかくデスクトップ環境なのでエディタの起動やテキスト入力といった GUI 操作の要素も加味することにした

ソースコード

3. 利用者の発話内容を他言語へ連続翻訳

テーマ: Google アシスタントの 音声認識 / 文意解釈 / 言語翻訳 / 応答文生成 / 音声合成 の各機能を利用する

内容

  • 利用者が日本語の文章を発話するとアシスタントがその英訳文を読み上げる
  • 一文ごとにウェイクワードや翻訳のための指示を入れる不要はない
  • 操作に手がかからないのでオフィスや店舗等の多言語環境用に翻訳専用端末として仕立てられるかも

デモ: 動画 52秒
  

考えたことなど

  • 周知のように Google アシスタントには 「"今何時ですか?" を英語に訳して」の要領で話しかけることでその文章を所定の言語へ翻訳する機能がある
  • ただ、毎回ウェイクワードを使うのも毎回「を○○語に」などと指定するのも微妙に面倒。発話した内容を自動的に翻訳してくれれば手間が省けるだろう
  • 手元では、前掲の「利用者の発話内容をテキストへ変換」と「指定テキストに基づく音声合成と読み上げ」の試作を通じて、次のふたつの処理を行う方法を把握している
    1. 利用者が喋った内容をアシスタントが認識した結果のテキストを取得する
    2. 音声ではなくテキストでアシスタントへ指示を送る
  • なので、発話したあとに上記 1. でその内容をテキストとして取得し、その末尾に「 を○○語に訳して」と加えた上で 2. の手順でアシスタントへ渡してやればいいだろう

ソースコード

4. 利用者の発話内容を復唱

テーマ: Google アシスタントの音声認識 / 音声合成機能を利用する

内容

デモ: 動画 42秒
  

考えたことなど

  • 手元では、前掲の「利用者の発話内容をテキストへ変換」と「指定テキストに基づく音声合成と読み上げ」の試作を通じて次のみっつの処理を行う方法を把握ずみ
    1. 利用者が喋った内容をアシスタントが認識した結果のテキストを取得する
    2. 音声ではなくテキストでアシスタントへ指示を送る
    3. アシスタントに「オウム返し」をさせる
  • これらを利用する。自分の発話内容をテキストとして取得し、その先頭に前掲の IFTTT アプレット用のフレーズ「オウム返し 」を挿入してアシスタントへ渡してやる

ソースコード

しりとり

テーマ: Google アシスタントの音声認識 / 音声合成機能を利用する

内容

  • 日本語の単語でアシスタントとしりとりを行う
  • しりとり向きの適当な単語辞書を使用
  • 日本語形態素解析に Janome ライブラリを利用

デモ: 動画 58秒
  

考えたことなど

  • スマートスピーカーまわりで Actions on Google や Alexa スキルを使った「しりとり」アプリを見かけるが、ここまでの内容を応用すれば Google Assistant Library ベースで実装できるのではないか
  • 単語の既出チェックや品詞の制限、単語の学習機能などちゃんとしたものを作ろうとすればそれなりに手がかかるが、必要最小限の単語リレーをするだけなら割と手早く作れるかも
  • しりとり向きの単語辞書が必要。ネット上のリソースをもとにそこそこの語彙数のものを用意することにする
  • 下記要領での処理を想定
    1. 利用者が単語を発話
    2. その内容をアシスタントが認識した結果のテキストを取得
    3. それを形態素解析にかけて読みを取得
    4. その読みの末尾文字に合致する一件の単語を辞書からピックアップしてアシスタントに発話させる
    5. 単語の末尾が「ン」なら終了

ソースコード

  • 別途

メモ: Google Assistant Library プログラミングの基本

hotword.py を通じて

前述のように Google Assistant Library のインターフェイスは抽象度が高くシンプルに扱うことができる。実ロジック 100 行程度の hotoword.py が Google Home デバイスとほぼ同等に Google Assistant と連携可能であることは興味深い。hotoword.py の内容にあらためて目を向けると、デバイス ID 等の管理情報の取り回し以外の実質的な処理は以下のごく短い内容のみであることが見てとれる。

  • hotword.py#L122-L145 より
    122 |     with Assistant(credentials, device_model_id) as assistant:
    123 |         events = assistant.start()
                     :
    144 |         for event in events:
    145 |             process_event(event)
    
    (リファレンスより)
    • class google.assistant.library.Assistant(credentials, device_model_id) - developers.google.com
      Client for the Google Assistant Library.

      Provides basic control functionality and lifecycle handling for the Google Assistant. It is best practice to use the Assistant as a ContextManager:
      with Assistant(credentials, device_model_id) as assistant:
      
      This allows the underlying native implementation to properly handle memory management.

      Once start() is called, the Assistant generates a stream of Events relaying the various states the Assistant is currently in, for example:
      ON_CONVERSATION_TURN_STARTED
      ON_END_OF_UTTERANCE
      ON_RECOGNIZING_SPEECH_FINISHED:
          {'text': 'what time is it'}
      ON_RESPONDING_STARTED:
          {'is_error_response': False}
      ON_RESPONDING_FINISHED
      ON_CONVERSATION_TURN_FINISHED:
          {'with_follow_on_turn': False}
      
      See EventType for details on all events and their arguments.

  • hotword.py#L47-L66 より
     47 | def process_event(event):
                      :
     56 |     if event.type == EventType.ON_CONVERSATION_TURN_STARTED:
     57 |         print()
     58 | 
     59 |     print(event)
     60 | 
     61 |     if (event.type == EventType.ON_CONVERSATION_TURN_FINISHED and
     62 |             event.args and not event.args['with_follow_on_turn']):
     63 |         print()
     64 |     if event.type == EventType.ON_DEVICE_ACTION:
     65 |         for command, params in event.actions:
     66 |             print('Do command', command, 'with params', str(params))
    
    (リファレンスより)
    • ON_CONVERSATION_TURN_STARTED - developers.google.com
      Indicates a new turn has started.

      The Assistant is currently listening, waiting for a user query. This could be the result of hearing the hotword or start_conversation() being called on the Assistant.
      • start_conversation() - developers.google.com
        Manually starts a new conversation with the Assistant.

        Starts both recording the user’s speech and sending it to Google, similar to what happens when the Assistant hears the hotword.

        This method is a no-op if the Assistant is not started or has been muted.
      • stop_conversation() - developers.google.com
        Stops any active conversation with the Assistant.

        The Assistant could be listening to the user’s query OR responding. If there is no active conversation, this is a no-op.
    • ON_CONVERSATION_TURN_FINISHED - developers.google.com
      The Assistant finished the current turn.

      This includes both processing a user’s query and speaking the full response, if any.

ポイント

  • Google Assistant との一連の応酬を開始するには google.assistant.library.Assistant クラスのインスタンスを生成し start() をコールする
  • 応酬中の状況はイベントベースで捕捉可能
    • 対話開始: ON_CONVERSATION_TURN_STARTED
    • 対話終了: ON_CONVERSATION_TURN_FINISHED
    • 後は EventType ごとに必要な処理を記述
  • start_conversation() によりウェイクワードなしで Assistant との対話を開始
  • send_text_query() により発話に代え任意のテキストを Assistant へ送出可能
  • stop_conversation() により Assistant との対話を任意に終了

(tanabe)
klab_gijutsu2 at 10:04|この記事のURLComments(4)
2018年08月08日

Google Home でローカルの MP3 ファイルをプレイリスト再生する方法

はてなブックマークに登録

前回の記事では ほりひろ 様による esp8266-google-home-notifier ライブラリと ESP32 ボードを使って構成した Google Home 用キャストエージェントとその利用例を紹介しました。記事ではあわせて同ライブラリをプライベートにカスタマイズした内容として以下を挙げました

  1. 元の google-home-notifier に存在する ip 関数を追加
  2. 元の google-home-notifier に存在する play 関数 (public)を追加
  3. play 関数による Google Home での MP3 データ再生終了を検知するためのコールバック機構を追加

今回はみっつめの話題について紹介します。

話のきっかけ

Google Home Mini で Spotify など試しているうちに、昔買った音楽 CD をこういう手軽さで聴くことができればと思うようになりました。そのためには楽曲データを MP3 形式等に変換して Google Play Music へアップロードすれば無料で実現できると随所で紹介されています。なかなか魅力的なサービスではありますが、その一方で Google LLC 側の利用規約とのかねあいが気になりました。

手元では日常的に Google LLC の多くの優れたサービスの便宜を享受しており、ひとりのユーザとして同社には敬意と信頼感を持っています。しかし、自分にすべての権利のあるデータであればともかく、自分には所有権しかない楽曲のデータを丸ごとこの規約のもとへ預けることには個人的にためらいがありました。そこで注目したのが google-home-notifier の MP3 データ再生機能です。

google-home-notifier には URL で指定された MP3 データを Google Home へキャストすることのできる play 関数が用意されています。そのため、LAN 上にローカル Web サーバをを立ててそこへ所定の MP3 データを配置し、そのアドレスをこの関数へ渡せば手元の環境からデータを持ち出すことなく Google Home で再生することが可能です。このやり方なら市販の楽曲に対する自分の権利の範囲を踏み越える心配はないでしょう。

ただ、play 関数は一件のデータをワンショットでキャストすることを前提とするもので複数データの連続再生には対応していません。そのため何曲かを順番に流すといったことはできないのが残念です。そこで、この部分に手を加え、所定のプレイリストを参照して複数の MP3 データの順次再生・シャッフル再生ができるようにしてみました。

まずその動作の様子を動画で紹介します。自宅では向かって左側の Raspberry Pi Zero W をローカル Web サーバとして運用しています。このデモでは soundorbis 様によるフリージングル曲を使用させて頂いています。

動画:1分15秒
  

ちなみにここでは以下の IFTTT アプレット (クリックで可読大表示) 経由でローカル Web サーバから再生リスト「demo.txt」内容を読み込んで処理を行っています。
トリガー
アクション

再生リストとその周辺の話題は末尾の「エージェントプログラム」の項に記述しています。
以下、実現に至るまでの経緯を控えます。

対応方法の調査

google-home-notifier の処理内容

まず google-home-notifier の処理を追うことから始めました。以下はそのまとめです。esp8266-google-home-notifier はオリジナルの google-home-notifier が堅実に移植されたライブラリであるためここでは後者をターゲットとしています。

初期設定
  • 呼び出し側から ip() 関数により対象とする Google Home デバイスの IP アドレスと言語が指定されればそれを設定
  • 呼び出し側から device() 関数により対象 Google Home デバイスのデバイス名と言語が指定されればそれを保持

主処理

  • notify() 関数は渡されたテキストを所定の言語で TTS 処理した結果の MP3 ファイルの URL を対象 Google Home デバイスへキャストする
  • play() 関数は指定された MP3 ファイルの URL をを対象 Google Home デバイスへキャストする
  • notify(), play() はいずれも呼び出された時点で対象 Google Home デバイスの IP アドレスが未設定状態であれば前出 device() 関数経由で保持しているデバイス名で mDNS 照会により対象 Google Home デバイスの IP アドレスを得る  =>  GitHub: mdns

キャストの手順

注目すべき点と考え方

ポイントは、上記「キャストの手順」の最後の「load() が終わったら各仮想チャネルを close してコネクションを切断」という部分にあります。該当箇所のソースコードを引用します。

  • google-home-notifier.js (#L88-L96)
                  :
    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
          };
          player.load(media, { autoplay: true }, function(err, status) {
            client.close();
            callback('Device notified');
          });
                  :
    

このように、Google Home 側の DefaultMediaReceiver へ再生対象データの引き渡し(player.load())を終えるとプログラムは直ちにデバイスとの接続を閉じそのまま終了しています。あとは Google Home まかせということになりますが、Google Home はデータ再生中に別のデータのキャストが指示されると現在の再生の終了を待つことなくあっさり新しいデータの再生を開始します。そのため、単に google-home-notifier を繰り返し実行しても、その都度データ再生が中断され別データの再生に移るだけで、プレイリスト再生のように「一件の再生が終了〜次のデータを再生」という動きにはなりません。

一方、手元で上記の player.load() 後も接続を維持した状態で観察したところ、Google Home 側でデータ再生が終了すると前掲の仮想チャネル「urn:x-cast:com.google.cast.media」経由でデバイス側からプログラムへ下記要領のメッセージが送出されることを確認しました。

{"type":"MEDIA_STATUS","status":[{"mediaSessionId":1,"playbackRate":1,"playerState":"IDLE","currentTime":0,"supportedMediaCommands":15,"volume":{"level":1,"muted":false},"currentItemId":1,"idleReason":"FINISHED"}],"requestId":0}

このように、接続を維持していればプログラムはデバイス側の再生終了の捕捉が可能であることがわかりました。 この件を含めここまでの一連の事情を踏まえると、google-home-notifier でデータの連続再生を実現するために必要な措置は大きく次のふたつであることがわかります。

  • デバイスへの再生指示を終えたあともデバイスとの接続を維持する
  • デバイスから上記の再生終了メッセージを受け取ったら次のデータの再生を指示する

なお、Google Cast の仕様にはデータの連続再生を行うためのネイティブの機構が存在するのではないかとも思うのですが、手元では今のところその方面の具体的な情報に行き着いておらず、いずれにせよここではあくまでも現行の google-home-notifier の機能を拡張することにしました。

実装まわりの話題

方針

実装にあたっては次の方針を基本としました。

  • 複数 MP3 データの連続再生はあくまでも拡張機能として取り回す。従来の TTS 機能および単一の MP3 再生機能との互換性を保ち両者の処理を衝突させない
  • デバイスからの再生終了メッセージを受け取るためのコネクション維持と監視は他の処理を止めないためにバックグラウンドタスク内で実施する
  • バックグラウンドタスクから再生終了通知を受け取るためにコールバック機構を設置する。ライブラリ側へ追加するのはこの「一件の MP3 データ再生が終了した場合、または中断・スキップされた場合にコールバック関数経由で通知する」処理にとどめ、それ以外の処理はアプリケーション側の要件としてエージェントプログラムへ実装する
  • つまりライブラリ側の改変は必要最小限の内容とする

処理のイメージ

左側のフローは手元での p8266-google-home-notifier とエージェントプログラムによる処理内容を要約したものです。右側はそこに調査から得られた情報を加味して MP3 データのリスト再生機能を追加したイメージで、ざっくり、赤文字の「キャスト処理」「再生監視タスク」の部分をライブラリ側の処理要素と想定しています。なお灰色のブロックは右フローとの共通部分です。 (クリックで可読大表示)

Google Home からのメッセージについて

上記右フローの「再生監視タスク」パート内の条件分岐部分でも触れていますが、データ再生の監視中には Google Home デバイスから仮想チャネル経由でさまざまなメッセージが送られてきます。手元での観察結果から、今回の実装では以下のメッセージを利用しています。

  • (右フロー中の「※1」) データの再生終了は、前掲のとおり当該デバイスからの「urn:x-cast:com.google.cast.media」チャネル経由での以下のメッセージによって判定できる。これを受け取ったらエージェント側は次の曲の再生へ移行する
    {"type":"MEDIA_STATUS","status":[{"mediaSessionId":1,"playbackRate":1,"playerState":"IDLE","currentTime":0,"supportedMediaCommands":15,"volume":{"level":1,"muted":false},"currentItemId":1,"idleReason":"FINISHED"}],"requestId":0}
  • (右フロー中の「※2」) デバイスが他のアプリ等とのキャストセッションを開始した場合には「urn:x-cast:com.google.cast.tp.connection」チャネルから以下のメッセージが届く。これを受け取ったらエージェント側は現在の連続再生処理を停止する
    {"type":"CLOSE"}
    • なお、Google Cast 機構においてはデバイス側との接続の維持・確認のために上記チャネル上で相互に PING, PONG メッセージの応酬を継続的に実施することが流儀のひとつ。バックグラウンドタスクでの待機中は 5秒毎に PING を送ることにしたが、タイミングによってはデバイス側から PING が届くこともある。その場合は PONG を送る
      {"type":"PING"}
      {"type":"PONG"}
  • (右フロー中の「※3」) ユーザが「OK, Google。ストップ」「OK, Google。やめて」等の再生停止を求めるフレーズを発した場合は「urn:x-cast:com.google.cast.media」チャネル経由でデバイスから下記要領のメッセージが届く。これを受け取ったらエージェント側は連続再生を停止する
    {"type":"MEDIA_STATUS","status":[{"mediaSessionId":1,"playbackRate":1,"playerState":"PAUSED","currentTime":1.880214,"supportedMediaCommands":15,"volume":{"level":1,"muted":false},"activeTrackIds":[],"currentItemId":1,"repeatMode":"REPEAT_OFF"}],"requestId":388729138}
  • (右フロー中の「※4」) ユーザが「OK, Google。スキップ」「OK, Google。次の曲」等のフレーズで次データへのスキップをリクエストした場合は「urn:x-cast:com.google.cast.media」チャネル経由でデバイスから下記要領のメッセージが届く。この状況でのステータスは「"playerState":"PLAYING"」かつ、「"requestId":」の値が 0 以外となる。 これを受け取ったらエージェント側は次のデータの再生へ移行する
    {"type":"MEDIA_STATUS","status":[{"mediaSessionId":1,"playbackRate":1,"playerState":"PLAYING","currentTime":15.665338,"supportedMediaCommands":15,"volume":{"level":1,"muted":false},"activeTrackIds":[],"currentItemId":1,"repeatMode":"REPEAT_OFF"}],"requestId":388729139}
    なお「"requestId":0」のメッセージがデータの再生開始時点で一度発行されるため上記と混同しないよう注意
    {"type":"MEDIA_STATUS","status":[{"mediaSessionId":1,"playbackRate":1,"playerState":"PLAYING","currentTime":1.384362,"supportedMediaCommands":15,"volume":{"level":1,"muted":false},"activeTrackIds":[],"currentItemId":1,"repeatMode":"REPEAT_OFF"}],"requestId":0}

ソースコード

カスタマイズした esp8266-google-home-notifier

オリジナルの esp8266-google-home-notifier ライブラリのコードへプライベートに手を加えています。変更箇所は「#ifdef TANABE」部分で、fork した GitHub リポジトリへ反映しています。変更内容はこの記事の冒頭、および前回の記事で触れたとおりです。

$ git clone -b private https://github.com/mkttanabe/esp8266-google-home-notifier.git

また、同リポジトリの examples/esp32/SimpleUsage/ 下に play 関数用のサンプルスケッチを加えました。どちらもオリジナルの SimpleUsage.ino のコピーを小さく書き替えたものです。

このふたつの追加サンプルを実行した様子の動画です。
  

エージェントプログラム

Arduino core for the ESP32 環境で作成したエージェントの Adruino スケッチです。前回分と互換性のある機能拡張版です。

  • GoogleHomeNotifierESP32AgentEx - github.com/mkttanabe

    GoogleHomeNotifierESP32Agent.ino の 以下の箇所を環境にあわせて書き替える
    //------- ユーザ定義 ------------------

    // Google Home デバイスの IP アドレス, デバイス名
    // 有効にすれば指定 IP アドレスを直接使用
    // 無効にすれば指定デバイス名で mDNS 照会
    //#define USE_GH_IPADRESS

    IPAddress myGoogleHomeIPAddress(192,168,0,110);
    #define myGoogleHomeDeviceName "room02"

    // WiFi アクセスポイント
    #define ssid "ssid"
    #define password "pass"

    // Beebotte 情報
    #define mqtt_host "mqtt.beebotte.com"
    #define mqtt_port 8883
    #define mqtt_topic "test01/msg"
    #define mqtt_pass "token:" "token_************"

    // MP3 データを配置する Web サーバ
    #define dataServer "192.168.0.127"
    #define dataServerPort 80
    // MP3 データの URL
    #define MP3DataFmt "http://" dataServer "/sound/%s.mp3"
    // MP3 再生リストのパス
    #define MP3ListFmt "/sound/list/%s.txt"
    //-------------------------------------

    上の記述の場合、自分の Beebotte アカウントの Channel "test01", Resource "msg" のメッセージの data キーの値に応じて以下が行われる
    • data 値の先頭文字がアスタリスクであれば音声合成用のテキストとみなして google-home-notifier の TTS 処理にかける
      • 例) {"data":"*こんにちは"} => 「こんにちは」と発話
    • data 値の先頭文字がアスタリスクまたは "@" でなければ MP3 ファイル名とみなし所定の Web サーバの URL に編集して再生
      • 例) {"data":"file01"} => "http://192.168.0.127/sound/file01.mp3" をキャスト
    • data 値の先頭文字が "@" であれば MP3 データ再生リスト名とみなし所定の Web サーバの URL に編集して読み込み、記述されている各 MP3 データエントリを再生
      • 例) {"data":"@demo"} => "http://192.168.0.127/sound/list/demo.txt" を再生リストとして読み込んで処理
        • 下記の指定があればシャッフル再生を行う
          {"data":"@demo,1"} , {"data":"@list01,ランダム"}
        • 上記以外ならリスト上の記述順に再生
      • 再生リストの記述例
        demo.txt
        # 各行の '#' 以降はコメントとして扱われる
        # 空行は無視、有効行先頭末尾のスペース・タブは除去される

        # soundorbis 様によるフリー BGM 作品より
        #
        # 【フリーBGM】リコーダージングル【01〜05】
        # https://www.youtube.com/watch?v=ztm1CSZEpY8
        #
        # 利用規約
        # https://www.soundorbis.net/license
        #

        demo/[1]nc150689
        demo/[2]nc150690
        demo/[3]nc150691
        demo/[4]nc150692
        demo/[5]nc150693

    なお、上の「ユーザ定義」箇所において「#define USE_GH_IPADRESS」が無効な場合は前回版と同様に mDNS 照会によりデバイス名から IP アドレスを取得するが、このやや時間のかかる照会のコストを軽減するために、今回の拡張版においては一旦取得した IP アドレスを ESP32 のフラッシュメモリ領域 (SPIFFS) へ記録し次回はまずそれを参照する処理を加えている。当該アドレスへアクセスできない場合や当該アドレスのデバイスを Google Home と識別できない場合はあらためて照会と記録を行う。詳細は DeviceAddress.cpp を参照のこと

こういった経緯を経て形になったのが冒頭のデモ動画で稼働しているエージェントです。Raspberry Pi Zero W のローカル Web サーバとペアでとても快適に利用しています。


(tanabe)
klab_gijutsu2 at 09:54|この記事のURLComments(0)
2018年08月03日

Google Home を拠点間の双方向コミュニケーションに利用する

はてなブックマークに登録

ESP32 版 google-home-notifier のこと

google-home-notifier の ESP8266 移植版「esp8266-google-home-notifier」の作者である ほりひろ 様が、今年(2018年)6月に ESP32 への対応を実施されました。node.js プログラムであるオリジナルの google-home-notifier を実行可能なもっとも小振りなプラットフォームはラズベリーパイですが、さらにコンパクトで消費電力の少ない ESP32 ボードで自由に Google Home へのキャストを実現できることには大きな魅力があります。自宅ではこの素晴らしいライブラリを使って MQTT メッセージをトリガーにキャスティングを行う内容のエージェントを構成し様々な要件で利用させて頂いています。

「声のアバター」によるやりとり?

そんな中で先日ふと、google-home-notifier の「Google Home で任意の音を出せる機能」と、Google Home 側の「音声で所定の処理を実行できる機能」の組み合わせを、離れた拠点間での定型的なコミュニケーションに利用できるのではないか? と思い立ちました。図のようなイメージです。

双方のエージェントがそれぞれ MQTT ブローカー上の別のトピックを Subscribe しておき、必要に応じて相手のトピックへメッセージを Publish することで現地の Goole Home が所定のアナウンスを行います。その内容に対する所定のフレーズでの応答で相手へ向けての処理を発動する形にしておけば、いわば「声のアバター」を通じて両者間のやりとりが成立すると考えました。同じく音声による連絡方法であっても電話とは異なりこのやり方には直接的な拘束感やある種の重さがないため双方ともより手軽により淡々と利用できるでしょう。

試作のシナリオ

いろいろ使途がありそうですが、手元ではまず個人的に現在もっとも身近なテーマである「高齢者世帯の安否確認」を想定したシナリオを形にしてみました。以下の内容です。

A:見守り側
B:高齢者世帯側

  • A => Google Home A:「ねえ Google、安否を確認」
    • Google Home A => A:『はい、これから声をかけてみます』

  • Google Home B => B:『身のまわりは大丈夫ですか?問題がない場合は "ねえ Google、順調です" と話しかけて下さい。もし何かご相談があれば、"ねえ Google、コールして" と話しかけて下さい』

    • パターン 1: B => Google Home B:「ねえ Google、順調です」
      • Google Home B => B:『それはよかったです。さっそく大丈夫と伝えておきますね』
        • Google Home A => A:『先方から "元気です"と音声連絡がありました。メールを確認して下さい』
        • A あてにメールが届く


    • パターン 2: B => Google Home B:「ねえ Google、コールして」
      • Google Home B => B:『はい、すぐに電話をするようにと伝えます』
        • Google Home A => A:『先方から "電話してほしい"と音声連絡がありました。メールを確認して下さい』
        • A あてにメールが届き携帯電話が数回コールされる

相手が高齢者でもあるため Google Home からのアナウンスにはとりわけ「聞き取りやすさ」が求められます。定型の文言なので毎回音声合成を行う必然性もなく、ここでは、google-home-notifier の notify 関数から呼び出される Google 翻訳 TTS を使うのではなく、複数のオンライン TTS サービスを試した中で個人的にもっとも自然に感じられた音声を収録した MP3 データを play 関数で再生することにしました。

動作の様子

後出のプログラムリソース一式を使って二組の「Google Home Mini + エージェント」間でやりとりを行った様子の動画です。

(パターン 1: 「安否を確認」 〜 「順調です」 1分17秒

(パターン 2: 「安否を確認」 〜 「コールして」) 1分16秒

リソース

esp8266-google-home-notifier のカスタマイズ

前述のように手元では作成したエージェントをさまざまな要件で利用しており、この間にオリジナルの esp8266-google-home-notifier ライブラリのコードへ何点かプライベートに手を加えています。変更箇所は「#ifdef TANABE」部分で、fork した GitHub リポジトリへ反映しています。

$ git clone -b private https://github.com/mkttanabe/esp8266-google-home-notifier.git

変更内容

  1. 元の google-home-notifier に存在する ip 関数を追加
    • Google Home デバイスアドレスの mDNS 照会に比較的時間がかかるため IP アドレスの直指定もできると嬉しいため
  2. 元の google-home-notifier に存在する play 関数 (public)を追加
    • 手元では TTS 機能だけでなく MP3 再生機能も必要であるため
  3. play 関数による Google Home での MP3 データ再生終了を検知するためのコールバック機構を追加
    • 元の google-home-notifier にもないが欲しいと思っていた機能。理由は後日あらためて、、

エージェントプログラム

Arduino core for the ESP32 環境で作成したエージェントの Adruino スケッチです。MQTT ブローカーに Beebotte を使用しています。

  • GoogleHomeNotifierESP32Agent - github.com/mkttanabe
    GoogleHomeNotifierESP32Agent.ino の 以下の箇所を環境にあわせて書き替える
    //------- ユーザ定義 ------------------
    // Google Home デバイスの IP アドレス, デバイス名
    // 有効にすれば指定 IP アドレスを直接使用
    // 無効にすれば指定デバイス名で mDNS 照会
    #define USE_GH_IPADRESS

    IPAddress myGoogleHomeIPAddress(192,168,0,121);
    #define myGoogleHomeDeviceName "room01"

    // WiFi アクセスポイント
    #define ssid "ssid"
    #define password "pass"

    // Beebotte 情報
    #define mqtt_host "mqtt.beebotte.com"
    #define mqtt_port 8883
    #define mqtt_topic "test01/msg"
    #define mqtt_pass "token:" "token_************"

    // MP3 データを配置する Web サーバとデータパス
    #define dataServer "192.168.0.126"
    #define dataServerPort 80
    #define MP3DataFmt "http://" dataServer "/sound/%s.mp3"

    //-------------------------------------

    上の記述の場合、自分の Beebotte アカウントの Channel "test01", Resource "msg" のメッセージの data キーの値に応じて以下が行われる.
    • data 値の先頭文字がアスタリスクであれば音声合成用のテキストとみなして google-home-notifier の TTS 処理にかける
      • 例) {"data":"*こんにちは"} => 「こんにちは」と発話
    • data 値の先頭文字がアスタリスクでなければ MP3 ファイル名とみなし所定の Web サーバスペースの URL を編集して再生
      • 例) {"data":"file01"} => "http://192.168.0.126/sound/file01.mp3" をキャスト

用意した IFTTT アプレット

今回の試作では、トリガーに Google Assistant サービス、アクションに WebHooks サービスをアサインしたみっつの IFTTT アプレットを使っています。 ※ いずれもクリックで可読大表示

  • 問い合わせフレーズ「安否を確認」対向のアプレット
    アクションで Beebotte 上の相手側 "test02/msg" トピックへ "AnpiQuery.mp3" のキャスト再生を指示するメッセージを Publish
    トリガー
    アクション
  • 応答フレーズ「順調です」および「コールして」に対向のアプレット
    アクションで次項の Google Apps Script による Web アプリコードを所定のパラメータを添えて実行
    トリガー
    アクション
    トリガー
    アクション

Google Apps Script による Web アプリコード

上のふたつの応答用アプレットから呼び出している GAS コードです。

  • 「順調です」の場合
    アプレットから渡される res パラメータの値は「safe」。 Beebotte 上の相手側 "test01/msg" トピックへ "AnpiSafe.mp3" のキャスト再生を指示するメッセージを Publish して「元気です」と Gmail を送信
  • 「コールして」の場合
    アプレットから渡される res パラメータの値は「doCall」。まず Twillio 経由で相手の携帯電話を数回コールしてから Beebotte 上の相手側 "test01/msg" トピックへ "AnpiDoCall.mp3" のキャスト再生を指示するメッセージを Publish して「電話がほしい」と Gmail を送信


/*
  AnpiResponse
  2018-07
*/
var ADMIN = "***********@gmail.com";

function doGet(e) {
  return  ContentService.createTextOutput("??");
}

function doPost(e) {
  return doIt(e);
}

function doIt(e) {
  var msg, mp3Name;
  var res = e.parameter.res;
  var where = e.parameter.where;

  if (res == "safe") {
    msg = "元気です";
    mp3Name = "AnpiSafe";
  } else if (res == "doCall") {
    msg = "電話がほしい";
    mp3Name = "AnpiDoCall";
    doPhoneCall(); // twilio
  } else {
    return  ContentService.createTextOutput("res not found");
  }

  doPublish(mp3Name); // MQTT

  // gmail
  doSendMail(ADMIN, "Anpi", ADMIN, ADMIN,
           "安否連絡 [" + res + "]", 
           curDate() + " " + curTime() + "\n" +
           where + " より 「" + msg + "」と連絡がありました");

  return  ContentService.createTextOutput("OK");
}

function curDate() {
  var d = new Date();
  return d.getFullYear() + '-' +
    ('00' + (d.getMonth()+1)).slice(-2) + '-' + ('00' + d.getDate()).slice(-2);
}

function curTime() {
  var d = new Date();
  return ('00' + d.getHours()).slice(-2) + ':' +
    ('00' + d.getMinutes()).slice(-2) + ':' +  ('00' + d.getSeconds()).slice(-2);
}

function doPhoneCall() {
  var url = "https://api.twilio.com/2010-04-01/Accounts/****************/Calls";
  var data = "To=%2B************&From=%2B***********0&Url=http://demo.twilio.com/docs/voice.xml&Timeout=10";
  var options = {
    method: "POST",
    headers: {
      "Authorization":"Basic QUMxZjdhYTIzZWM2YTdkNWM*************************",
      "Content-Type":"application/x-www-form-urlencoded"
    },
    payload: data,
    muteHttpExceptions: true
  };
  var response = UrlFetchApp.fetch(url, options);
}

function doPublish(mp3Name) {
  var url = "https://api.beebotte.com/v1/data/publish/test01/msg";
  var data = '{"data":"' + mp3Name + '"}';
  var options = {
    method: "POST",
    headers: {
      "X-Auth-Token":"token_****************",
      "Content-Type":"application/json"
    },
    payload: data,
    muteHttpExceptions: true
  };
  var response = UrlFetchApp.fetch(url, options);
}

function doSendMail(from, fromName, to, cc, subject, body) {
  GmailApp.sendEmail(
    to,
    subject,
    body,
    {
      from: from,
      name: fromName,
      cc: cc
    }
  );
}

現行の Google Home ではサポートされていない「能動的な発話」を擬似的に実現する google-home-notifier を利用しなければできないことであり、こういった使い方も同プログラムの実用的な応用例のひとつではないかと思います。さらに発展させることもできるでしょう。面白い時代になりました。



余談:「うるさいアラーム」機能を DIY した話

手元での google-home-notifier の利用例をもうひとつ紹介します。 Google Home のアラーム機能は手軽で何かと便利なので目覚まし用に使ったりしていました。ところがプリセットのアラーム音がいささか上品すぎるため手元では音量を最大にしても目覚めにつながらないケースが何度かありました。同じ経験をした方はおそらく少なくないのではないかと思います。

ちなみに、Google は 2018-02-01 のアップデートで Google アシスタントのアラーム音を変更可能としたようですが、この機能を利用できるのは現時点では言語設定が英語の場合のみで日本語環境では利用できません。

後日の対応に期待しつつ、前掲のエージェントを利用して耳障りでうるさいアラームを鳴らす機能を自作してみました。

まず、アラーム設定用・解除用の下のふたつの IFTTT アプレットを用意しました。所定のシートの A1 セルを使って音を鳴らす時刻の設定・解除を行う内容です。設定用のアプレットでは Google Assistant トリガーのオプション "Say a phrase with both a number and a text ingredient" での "a number" と "a text" を使って時・分のふたつの音声指示要素を受け入れています。
トリガー
アクション
トリガー
アクション

あわせて当該シートに以下の GAS コードを付与、スクリプト実行のトリガー「時間主導型」の「分タイマー」で「1分ごと」に doNotify() 関数の呼び出しを設定しました。シートに記録された時刻と現在の時刻が一致すると、Beebotte 上の "test01/msg" トピックへ "noise01.mp3" のキャスト再生を指示するメッセージが Publish されます。


// Beebotte へ MQTT メッセージを Publish
function doPublish(mp3Name) {
  var url = "https://api.beebotte.com/v1/data/publish/test01/msg";
  var data = '{"data": "' + mp3Name + '"}';
  var options = {
    method: "POST",
    headers: {
      "X-Auth-Token":"token_*************", 
      "Content-Type":"application/json"
    },
    payload: data,
    muteHttpExceptions: true
  };
  var response = UrlFetchApp.fetch(url, options);
}

function doNotify() {
  // シートをチェック。A1 に時刻が設定されていれば騒音を鳴らす
  var spreadSheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spreadSheet.getSheets()[0];
  var val = sheet.getRange("A1").getValue();
  Logger.log(val);
  if (val == 0) {
    return;
  }
  var hm = val.split("_");
  Logger.log("set => " + hm[0] + ":" + hm[1]);  
  if (hm[0] >= 24 || hm[1] >= 60){
    //Logger.log("invalid");
    return;
  }
  var dt = new Date();
  var min = dt.getMinutes();
  var h = dt.getHours();
  if (String(h) == hm[0] && String(min) == hm[1]) {
    sheet.getRange("A1").setValue("0");
    doPublish("noise01");
  }
}

動作の様子: 動画 32秒

 

このように単機能でごくシンプルなものですが、手元では結構役に立っています。


(tanabe)
klab_gijutsu2 at 18:26|この記事のURLComments(0)
Blog内検索
Archives
このブログについて
DSASとは、KLab が構築し運用しているコンテンツサービス用のLinuxベースのインフラです。現在5ヶ所のデータセンタにて構築し、運用していますが、我々はDSASをより使いやすく、より安全に、そしてより省力で運用できることを目指して、日々改良に勤しんでいます。
このブログでは、そんな DSAS で使っている技術の紹介や、実験してみた結果の報告、トラブルに巻き込まれた時の経験談など、広く深く、色々な話題を織りまぜて紹介していきたいと思います。
最新コメント