Intel Edison から Google Calendar のスケジュールを Amazon Polly で喋らせる

 Intel Edison の環境を触れる機会があったので、Edison上でスケジュールリマインダーを作ってみました。

software.intel.com

やったこととしては、

  1. Google Calendar API でスケジュール情報を取得
  2. 開始10分前の予定があれば Amazon Polly で音声ファイル作成
  3. 音声ファイルを mp3 から wav に変換して再生

という感じで、構成としては簡単ですが下記の図のような形です。

f:id:akanuma-hiroaki:20170819111340p:plain

Google Calendar API の有効化

 Google の API を使用するには、まず該当の API を有効化する必要があります。

 まずは GCP のコンソールにアクセスし、プロジェクトを作成します。

f:id:akanuma-hiroaki:20170821074959p:plain:w450

 GCP のダッシュボードが表示されますので、「APIの概要」をクリックします。

f:id:akanuma-hiroaki:20170821075254p:plain:w450

 すると API のダッシュボードが表示されますが、初回はまだ有効なAPIがないので、下記のような表示になります。左メニューの「ライブラリ」をクリックして API のリスト画面に遷移します。

f:id:akanuma-hiroaki:20170821075449p:plain:w450

 G Suite APIs の中の Calendar API を選択します。

f:id:akanuma-hiroaki:20170821075549p:plain:w450

 API についての説明が表示されますので、 有効にする をクリックします。

f:id:akanuma-hiroaki:20170821075842p:plain:w450

 次に 認証情報を作成 ボタンを押して、認証情報の作成画面に遷移します。

f:id:akanuma-hiroaki:20170821075943p:plain:w450

 下記のように選択して、 必要な認証情報 ボタンをクリックします。

f:id:akanuma-hiroaki:20170821080218p:plain:w450

 クライアントIDの名前を決めて クライアントIDの作成 ボタンをクリックします。

f:id:akanuma-hiroaki:20170821080315p:plain:w450

 メールアドレスとサービス名を入力して 次へ をクリックします。

f:id:akanuma-hiroaki:20170821080417p:plain:w450

 作成された認証情報を ダウンロード をクリックして取得したら、 完了 をクリックしてひとまず GCP コンソールからの作業は終了です。

Google Calendar API でスケジュール情報取得

 では Google Calendar API でスケジュール情報を取得します。Google API は OAuth2 での認証が複雑ですが、認証処理については下記サイトを参考にそのまま実装しています。

Ruby Quickstart  |  Google Calendar API  |  Google Developers

 ただ、私の環境では実行すると下記のようなエラーが出ました。

/home/root/.local/lib/ruby/2.2.0/net/http.rb:923:in `connect': SSL_connect returned=1 errno=0 state=SSLv3 read server certificate B: certificate verify failed (Faraday::SSLError)
        from /home/root/.local/lib/ruby/2.2.0/net/http.rb:923:in `block in connect'
        from /home/root/.local/lib/ruby/2.2.0/timeout.rb:73:in `timeout'
        from /home/root/.local/lib/ruby/2.2.0/net/http.rb:923:in `connect'
        from /home/root/.local/lib/ruby/2.2.0/net/http.rb:863:in `do_start'
        from /home/root/.local/lib/ruby/2.2.0/net/http.rb:852:in `start'
        from /home/root/.local/lib/ruby/2.2.0/net/http.rb:1375:in `request'
        from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/adapter/net_http.rb:80:in `perform_request'
        from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/adapter/net_http.rb:38:in `block in call'
        from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/adapter/net_http.rb:85:in `with_net_http_connection'
        from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/adapter/net_http.rb:33:in `call'
        from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/request/url_encoded.rb:15:in `call'
        from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/rack_builder.rb:141:in `build_response'
        from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/connection.rb:386:in `run_request'
        from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/connection.rb:186:in `post'
        from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/signet-0.7.3/lib/signet/oauth_2/client.rb:960:in `fetch_access_token'
        from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/signet-0.7.3/lib/signet/oauth_2/client.rb:998:in `fetch_access_token!'
        from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/googleauth-0.5.3/lib/googleauth/signet.rb:69:in `fetch_access_token!'
        from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/googleauth-0.5.3/lib/googleauth/user_authorizer.rb:178:in `get_credentials_from_code'
        from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/googleauth-0.5.3/lib/googleauth/user_authorizer.rb:200:in `get_and_store_credentials_from_code'
        from test.rb:30:in `authorize'
        from test.rb:39:in `<main>'
root@edison:~/work/schedule_reminder# 

 これはSSLアクセスの際に証明書が見つからないことによるエラーのようです。色々と調べた結果、下記サイトの情報をみつけました。

github.com

 上記サイトで紹介されているように、証明書のパスを下記のように設定することで解決しました。

cert_path = Gem.loaded_specs['google-api-client'].full_gem_path+'/lib/cacerts.pem'
ENV['SSL_CERT_FILE'] = cert_path

 あとは Calendar の API を呼んでスケジュールを取得します。イベントのリストを取得する API では開始時間の上限(いつより前に開始したか)や終了時間の下限(いつより後に終了するか)、ソート順を条件に指定できますので、下記のような条件を指定しています。

  • 開始時間上限:現在時刻から10分後
  • 終了時間下限:現在時刻
  • ソート順:開始時間の昇順

 ただこれだと今実行中の予定(開始済みでまだ終了していない予定)を除外することができないので、自前で除外処理を実装しています。

 ここまでを一つのクラスとして実装していて、コードの全体は下記のようになります。

require 'bundler/setup'

require 'google/apis/calendar_v3'
require 'googleauth'
require 'googleauth/stores/file_token_store'

require 'date'
require 'fileutils'
require 'active_support'
require 'active_support/core_ext'

class Calendar
  OOB_URI             = 'urn:ietf:wg:oauth:2.0:oob'
  APPLICATION_NAME    = 'ScheduleReminder'
  CLIENT_SECRETS_PATH = 'client_secret.json'
  CREDENTIALS_PATH    = File.join(Dir.home, '.credentials', "schedule_reminder.yaml")
  SCOPE               = Google::Apis::CalendarV3::AUTH_CALENDAR_READONLY
  DEFAULT_TIME_RANGE  = 10.minutes

  def initialize(calendar_ids)
    cert_path = Gem.loaded_specs['google-api-client'].full_gem_path+'/lib/cacerts.pem'
    ENV['SSL_CERT_FILE'] = cert_path

    # Initialize the API
    @service = Google::Apis::CalendarV3::CalendarService.new
    @service.client_options.application_name = APPLICATION_NAME
    @service.authorization = authorize

    @calendar_ids = calendar_ids
  end

  def authorize
    FileUtils.mkdir_p(File.dirname(CREDENTIALS_PATH))

    client_id   = Google::Auth::ClientId.from_file(CLIENT_SECRETS_PATH)
    token_store = Google::Auth::Stores::FileTokenStore.new(file: CREDENTIALS_PATH)
    authorizer  = Google::Auth::UserAuthorizer.new(client_id, SCOPE, token_store)
    user_id     = 'default'
    credentials = authorizer.get_credentials(user_id)

    if credentials.nil?
      url = authorizer.get_authorization_url(base_url: OOB_URI)
      puts "Open the following URL in the browser and enter the resulting code after authorization"
      puts url
      code = gets
      credentials = authorizer.get_and_store_credentials_from_code(user_id: user_id, code: code, base_url: OOB_URI)
    end

    credentials
  end

  def list_events(max_results, time_range = DEFAULT_TIME_RANGE, exclude_already_started = true)
    now = DateTime.now
    time_min = now.iso8601
    time_max = (now + time_range).iso8601
    events = []
    @calendar_ids.each do |calendar_id|
      response = @service.list_events(
                   calendar_id,
                   max_results:   max_results,
                   single_events: true,
                   order_by:      'startTime',
                   time_min:      time_min,
                   time_max:      time_max
                 )
      events.concat(response.items)
    end

    events.sort! do |event_a, event_b|
      event_a.start.date_time <=> event_b.start.date_time
    end

    return events unless exclude_already_started

    events.select do |event|
      event.start.date_time >= now
    end
  end
end

Amazon Polly で音声ファイルを作成

 Google Calendar から取得した予定のタイトルを喋る音声ファイルを生成するため、 Amazon Polly にアクセスします。まずは Edison 上で日本語を扱えるようにロケールを設定します。下記サイトを参考にそのまま実行しました。(実行結果については割愛)

qiita.com

 それでは Polly にアクセスします。今回は AWS の Ruby SDK を使っていますので、事前に ACCESS_KEY と SECRET_ACCESS_KEY を取得して環境変数に設定し、それを認証情報として使用します。

  ACCESS_KEY_ID     = ENV['ACCESS_KEY_ID']
  SECRET_ACCESS_KEY = ENV['SECRET_ACCESS_KEY']

  def initialize
    Aws.config.update({
      region:      REGION,
      credentials: Aws::Credentials.new(ACCESS_KEY_ID, SECRET_ACCESS_KEY)
    })

    @client = Aws::Polly::Client.new
  end

 あとは音声ファイル生成のAPIを呼び出せばOKです。 Polly では言語ごとに使用できる音声が決まっていて、日本語では今のところ Mizuki というIDの女性の音声のみ使用できます。また、直接 Web API を呼び出すと結果は音声ストリームとして返ってきますが、SDKからのアクセスであれば、出力先をファイルとして指定できるので、ストリームの処理を行う必要がなく手軽に使えます。

音声ファイルの変換と再生

 Amazon Polly では音声ファイルの出力形式として mp3, ogg_vorbis, pcm が指定できますが、 Edison で用意されているオーディオプレイヤーの aplay では、 wav 形式のファイルしか再生できません。そこで Polly からは mp3 として音声ファイルを出力し、それを mpg123 というツールを使って wav に変換します。

mpg123, Fast MP3 Player for Linux and UNIX systems

 mpg123 でそのまま mp3 を再生したかったのですが、再生しようとするとライブラリが不足していてエラーになってしまい、すんなり解決できなかったのでとりあえず変換してしまうことにしました。

  def convert(target_file)
    Open3.capture3("mpg123 -w #{target_file}.wav #{target_file}.mp3")
  end

 変換後の wav ファイルを aplay で実行すれば、再生することができます。

  def play(target_file)
    Open3.capture3("aplay #{target_file}.wav")
  end

 Polly からの音声ファイル取得と、wav への変換、再生までを一つのクラスとして実装しています。コードの全体は下記のようになっています。

require 'bundler/setup'
require 'aws-sdk'
require 'open3'
require 'digest/md5'

class Notifier
  VOICE_DIR = '/home/root/work/schedule_reminder/voices'

  REGION            = 'us-east-1'
  ACCESS_KEY_ID     = ENV['ACCESS_KEY_ID']
  SECRET_ACCESS_KEY = ENV['SECRET_ACCESS_KEY']
  VOICE_ID_JP       = 'Mizuki'

  def initialize
    Aws.config.update({
      region:      REGION,
      credentials: Aws::Credentials.new(ACCESS_KEY_ID, SECRET_ACCESS_KEY)
    })

    @client = Aws::Polly::Client.new
  end

  def speech(text)
    text_hash = Digest::MD5.hexdigest(text)
    target_file = "#{VOICE_DIR}/#{text_hash}"

    synthesize(text, target_file)
    convert(target_file)
    play(target_file)
  end

  def synthesize(text, target_file)
    resp = @client.synthesize_speech({
      response_target: "#{target_file}.mp3",
      output_format:   'mp3',
      voice_id:        VOICE_ID_JP,
      text:            text
    })
  end

  def convert(target_file)
    Open3.capture3("mpg123 -w #{target_file}.wav #{target_file}.mp3")
  end

  def play(target_file)
    Open3.capture3("aplay #{target_file}.wav")
  end
end

リマインダーを毎分実行

 ここまででスケジュール情報の取得と音声ファイルの生成、再生の準備はできたので、あとは一分おきにこれらを実行するようにします。取得したスケジュール情報をローカルでDBに取り込んだりすれば、再起動した時や cron での実行にも柔軟に対応できそうですが、今回はそこまでやると手間がかかってしまうので、起動したら60秒スリープで無限ループを回して、一度リマインド済みの予定はIDを記録しておいて、複数回通知されないようにしました。再起動時に直近の予定が再度リマインドされてしまうことは許容しています。

 コードの全体は下記の通りです。

require 'bundler/setup'
require 'active_support'
require 'active_support/core_ext'
require './calendar.rb'
require './notifier.rb'

class Reminder
  MAX_RESULTS        = 10
  TIME_RANGE         = 10.minutes
  SPEECH_TEMPLATE_JP = '間も無く、%sが始まる時間です。'
  CALENDAR_IDS       = ['XXXXXXXXXXXXXXXXXXXXXXXX']

  def initialize
    @calender = Calendar.new(CALENDAR_IDS)
    @notifier = Notifier.new
    @reminded_event_ids = []
    @log = Logger.new('logs/reminder.log')
    @log.debug('Initialized Reminder.')
  end

  def remind
    events = @calender.list_events(MAX_RESULTS, TIME_RANGE)

    if events.empty?
      @log.debug('No events found.')
      return
    end

    event = events.first
    start = event.start.date || event.start.date_time

    if @reminded_event_ids.include?(event.id)
      @log.debug("AlreadyReminded: #{start} - #{event.summary} id: #{event.id}")
      return
    end

    @notifier.speech(SPEECH_TEMPLATE_JP % event.summary)
    @reminded_event_ids << event.id
    @log.info("Reminded: #{start} - #{event.summary} id: #{event.id}")
  end
end

if $0 == __FILE__
  reminder = Reminder.new
  loop do
    reminder.remind
    sleep(1.minute)
  end
end

 これをバックグラウンド実行しておけば、次の予定の10分前になると音声での通知が行われ、下記のようにログに出力されます。

D, [2017-08-20T22:25:50.281798 #695] DEBUG -- : Initialized Reminder.
D, [2017-08-20T22:25:51.838564 #695] DEBUG -- : No events found.
D, [2017-08-20T22:26:52.503386 #695] DEBUG -- : No events found.
I, [2017-08-20T22:27:59.064125 #695]  INFO -- : Reminded: 2017-08-21T07:30:00+09:00 - 開発部定例ミーティング id: 25rq8i063ao99thjissss8i211
D, [2017-08-20T22:28:59.827868 #695] DEBUG -- : AlreadyReminded: 2017-08-21T07:30:00+09:00 - 開発部定例ミーティング id: 25rq8i063ao99thjissss8i211
D, [2017-08-20T22:30:00.609354 #695] DEBUG -- : AlreadyReminded: 2017-08-21T07:30:00+09:00 - 開発部定例ミーティング id: 25rq8i063ao99thjissss8i211

まとめ

 Intel Edison については先日残念ながら販売が終了になることが明らかになりました。

hackaday.com

 Edison を利用しているデバイスは多そうなので、影響も大きそうですが、まだしばらくは Edison を触る機会もあるのではないかと思います。

 また、 Google Calendar API や Amazon Polly API は認証部分さえクリアしてしまえば実際の機能を利用する API は手軽に使えるので、色々なことに活用できそうな気がしています。特に Polly については Lex との組み合わせも色々とできそうなので、また遊んでみたいと思います。

 今回のコードは下記リポジトリに公開してありますので、よろしければご参照ください。

github.com