Kotlinで音声認識 + Nearby APIでオフライン通信するアプリ作った

クライアントに納品するプロジェクトで、タブレット/スマホ連動の音声認識アプリを作ることになった。 仕組みはこんな感じ。

f:id:amagitakayosi:20190121121614p:plain
アプリの仕組み

特定の単語を認識して、両デバイスでコンテンツを再生する、というヤツ。 音声認識タブレットでやっていて、単語を認識したらスマートフォンにイベントを送信している。

当初はWeb技術で実現しようとしていた。

PCでいい感じに動くところまではサクッと作れたんだけど、Android実機で動作確認した所、音声認識開始の「ピコン♪」という音が無限になり続ける事態に遭遇した。 まさにこの状況↓

ブラウザでマイク入力から書き起こしを行うツールを作った - mizchi's blog

Androidで開いたら無限にポホロンポロロン音が出続けていた

2019/01/20 23:33
b.hatena.ne.jp

てな感じで困っていたけど、試しにAndroid Studioで検証してみたら意外とサクッとできそうだったので、ラスト1週間でAndroidアプリを作ってみることにした。

音声認識

Web Speech API

Web Speech APIは、SpeechRecognition音声認識)と SpeechSynthesis音声合成)からなる、ブラウザのAPIだ。

developer.mozilla.org

SpeechRecognitionを使うと、ブラウザ上で音声認識して文字列に起こせる。 多言語対応もしている。 音声認識ライブラリで日本語対応してるものって全然無いんだけど、Web Speech APIなら何も考えずに使えて便利。

現状動くのはChromeのみ。 裏ではGoogle Cloud Speech-to-Textを呼んでるらしい。 あっちは利用回数に応じて料金がかかるんだけど、Web Speech APIだと無料だし回数制限も特に無い。 オフラインでも使えるけど、オンラインのときに比べるとかなり精度が落ちるのは、GoogleAPIを叩けないからだと思う。

Web Speech APIは使い方も異常に手軽で、関数を呼んだらコールバックに認識結果が返ってくる。 MediaStream系のAPI使ったことある人なら一瞬で使えると思う。

大抵どんな言葉も認識してくれるし、認識したい単語を指定する事もできる。 特定の単語をコマンドとして使いたいときに便利だ。

試しに、じゃんけんアプリを作ってみると、こんな感じになる↓

// じゃんけんデータ
const hands = ['グー', 'チョキ', 'パー'];
const winnerOf = {
  'グー': 'パー',
  'チョキ': 'グー',
  'パー': 'チョキ',
};

// 音声認識の初期化
const recognition = new SpeechRecognition();
recognition.lang = 'ja-JP';

// 認識する単語を指定
const speechRecognitionList = new SpeechGrammarList();
speechRecognitionList.addFromString(
  '#JSGF V1.0; grammar colors; public <color> = グー | チョキ | パー ;', 
  1
);
recognition.grammars = speechRecognitionList;

// 認識結果が返ってきた時の処理
recognition.onresult = (e) => {
  const yourHand = e.results[0][0].transcript;

  // CPU側の手をランダムに決定
  const myHand = hands[(Math.random() * 3) | 0];

  // 勝敗を判定
  if (yourHand === winnerOf[myHand]) {
    console.log('あなたの勝ち!');
  }
  else {
    console.log('あなたの負け!');
  }
};

// 認識開始
recognition.start(); 

最近だと、 id:mizchi がWeb Speech APIで文字起こしツール作ってバズってた。

mizchi.hatenablog.com

問題は、Android端末でこのAPIを呼ぶと、「ポポン」という通知音が鳴ってしまうことだ。 今のところこの通知音を消す方法は無いみたい。 root取得すれば回避できるらしいけど、仕事でやるのはちょっと……。

ただ、調べている過程で、どうやらAndroidAPIを叩けば通知音を消せるらしいことがわかった。 という訳で、今回はAndroid標準の音声認識APIを使うことにした。

Android.SpeechRecognizer

Androidには、OS標準で音声認識APIが用意されている。 Android 2.2 (API Level 8) から使えたらしい。

こちらもWeb Speech APIと同じく、裏ではGoogleAPIを呼んでいそう(推測だが)。 オフラインでも利用できるが、やはり精度はガクッと落ちる。

僕がAndroid開発に慣れていないせいか、APIの利用方法が少し複雑だと感じたが、Javaに慣れてくるとこんなもんなのかもしれない。

利用事例をググると、すぐに今回と同じケースのブログを見つけた。

kivantium.hateblo.jp

今回のアプリでは、基本的には↑の記事とほぼ同じ要領で音声認識APIを利用している。 ただ、エラー処理だけ少し気をつける必要があった。

具体的には、エラーコードが ERROR_RECOGNIZER_BUSY だった場合に即リスタートしてしまうと、無限にエラーが出まくった後に音声認識APIが全く成功しなくなり、端末を再起動するまで直らないという事があった。 原因は不明。もしかしたら端末側の問題だったりするのかなあ……。 今回は ERROR_RECOGNIZER_BUSY の時だけタイムアウトを入れることで対処した。

override fun onError(error: Int) {
    val delay = if (error == SpeechRecognizer.ERROR_RECOGNIZER_BUSY) 1000L else 100L

    val handler = Handler { restartRecognition(); true }
    Timer("restartRecognition", true).schedule(delay) {
        handler.obtainMessage().sendToTarget()
    }
}

また、今回はオフライン環境だったのと、認識したい単語が発音しづらいものだったため、普通に使うだけでは期待する認識精度に達しなかった。 例えば、「花」という単語を認識したいけど、認識結果は「棚」「穴」などが返ってきてしまう。 このような、発音の近くて誤認識されやすい単語も認識候補に追加することで、認識がうまく行かなくてもちゃんと動作するようになった。

Nearby Connections API

バイス間の通信にはNearby Connections APIを使用した。 タブレット側で音声認識を行い、特定の単語を検出すると、スマートフォン側にイベントを送り、2つのデバイスで同時にアクションを起こす。

developers.google.com

Nearby Connections APIは、オフライン状態の機器間でデータをやりとり出来るAPIGoogleのNearbyプロジェクトの一環らしい。 Nearbyプロジェクトには他にも2つAPIがあるので、ドキュメントを参照する際に間違えないよう注意。

  • Nearby Connections API: オフライン機器間の通信
  • Nearby Messages API: インターネットを介したメッセージング
  • Fast Pair: Bluetooth Low Energyで機器を同期するためのシステム

今回はインターネットに接続していないデバイスを用いるので、Nearby Connections APIを利用した。 一応Nearby Messages APIも一応試して見たけど、それなりに速い回線でも10sec以上ラグが出たりするので、今回の用途には向いていないと判断した。

Nearby Connections APIは、内部的にはWiFi DirectとBluetoothを組み合わせて機器間の通信を実現しているが、ユーザーは仕組みを意識する必要がない。 今回はごく短い文字列を送ったけど、結構大きめのファイルも送受信できるみたい。

インストールは、build.gradleに以下の行を追加するだけ。 APIキーの発行とかも不要。

implementation 'com.google.android.gms:play-services-nearby:16.0.0'

あとは、メッセージの受信や接続時のコールバックを書いてあげて接続するだけでよい。

// 接続時の処理を書く
val connectionCallback = object: ConnectionLifecycleCallback() { ... }

// メッセージ受信した時の処理を書く
val payloadCallback = object: PayloadCallback() { ... }

// トポロジー設定。スター型とクラスター型を選べる
val advertisingOptions = AdvertisingOptions.Builder().setStrategy(Strategy.P2P_STAR).build()

 mConnection
    .startAdvertising("hostNickname", "projectId", connectionCallback,  advertiseOptions)
    .addOnSuccessListener { /* 起動成功時の処理 */ }
    .addOnFailureListener { /* 失敗時の処理 */ }

このように便利なAPIだけど、欠点もいくつかある。

まず、たまにメッセージの取りこぼしがある。 メッセージによって状態遷移するようなアプリの場合、何もメッセージが無い時は一定の時間で自動的にデフォルト状態に戻るようにする必要がある。

また、なぜか接続に失敗することがある。 接続が切れたら自動で再接続するようにしたけど、同時エラーが出続けて、最悪の場合は端末を再起動するまで直らなかった。 一度接続してしまえば滅多に切れないので、アプリを起動した時さえ気をつければ大丈夫なんだけどね……。

オフラインで気軽に通信できて面白いので、興味のある人はゲームとか作ってみてください。

Androidについて

今回初めてAndroidアプリを作ったんだけど、Android Studioがメチャクチャ良くできてるし、Kotlinも書きやすかった。

Kotlin、便利ですね。 言語的にはScalaにめっちゃ似てる。case class的な物があったり、コンパニオンオブジェクトがあったり、valとvarの扱いが全く同じだったり。JVM言語だから当然か……。 僕はJavaはほとんど書いたことが無いんだけど、前職でScalaを書いていたので、Kotlinはほぼノーコストで書けた。

あと、Android開発の基本的な事をググると、古のジャバに関する情報や、怪しいSE塾みたいなページがたくさん出てきて困るんだけど、検索ワードに「Kotlin」を入れるだけで、ある程度信頼できる情報が出てくるのも便利だった。

とはいえ、UIはほぼモック画像を貼り付けただけだし、アプリ全体の設計をどうしたら良いか全然わからなかった。 次アプリやるときはその辺ちゃんと勉強したいな〜〜


今回のプロジェクトでは技術スタックは完全に僕に任せられており、短い納期でもいろいろ試行錯誤できたのはチャレンジングでとても楽しかった。 こんな感じでいろんな分野をつまみ食いして技術の幅を広げていけると良いな。

参考URL