読者です 読者をやめる 読者になる 読者になる

kivantium活動日記

プログラムを使っていろいろやります

Androidで音声認識を繰り返し実行する

先日声優ハッカソンにActive Geeksとして参加しました。ハッカソンでは声優さんの声を利用して料理のレシピを読み上げるアプリを作ったのですが、単なる読み上げアプリとの差別化のために、手を使わずに操作できる機能を追加しようと考えました。これは濡れた手でスマホを操作したときまともに操作できなかった自分自身の経験から提案したものです。(ちなみに発表資料はActive Geeks 声優ハッカソン 最終成果発表にあります)

タッチしないで操作する方法としては音声認識・画像認識などが考えられますが、画像認識はAndroidOpenCVで画像をバックグラウンドで取得する方法が分からなかった(stackoverflowに質問しても回答がなかった)ので断念し、音声認識による方法を採用することにしました。ちなみに、展示していたときに教えてくれた人によればiPhoneでは近接センサーを使った非接触インターフェースが作れるらしいです。

音声認識画面を立ち上げる方法 (RecognizerIntent)

音声認識を自分で実装するのはほぼ不可能なのでGoogleAPIを叩くことを考えます。検索して最初に出てきた音声認識(RecognizerIntent)を使用するには - 逆引きAndroid入門を参考にプログラムを書きました。この方法ではRecognizerIntent(リファレンス:RecognizerIntent | Android Developers)を使って音声認識画面を呼び出します。簡単のために画面のどこかをタッチしたら音声認識画面が立ち上がることにします。

以下のプログラムは、Android Studio(手元のバージョンは1.5.1)をインストールしてNew ProjectのEmpty Activityを選択して作られたファイルを書き換えるようにすれば動くと思います。


activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="com.example.kivantium.myapplication.MainActivity">
</RelativeLayout>


MainActivity.java

package com.example.kivantium.myapplication;

import android.content.Intent;
import android.speech.RecognizerIntent;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.MotionEvent;
import android.content.ActivityNotFoundException;
import android.widget.Toast;

import java.util.ArrayList;

public class MainActivity extends AppCompatActivity {
    // リクエストを識別するための変数宣言。適当な数字でよい
    private static final int REQUEST_CODE = 0;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    // タッチイベントが起きたら呼ばれる関数
    public boolean onTouchEvent(MotionEvent event) {
        // 画面から指が離れるイベントの場合のみ実行
        if (event.getAction() == MotionEvent.ACTION_UP) {
            try {
                // 音声認識プロンプトを立ち上げるインテント作成
                Intent intent = new Intent(
                        RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
                // 言語モデルをfree-form speech recognitionに設定
                // web search terms用のLANGUAGE_MODEL_WEB_SEARCHにすると検索画面になる
                intent.putExtra(
                        RecognizerIntent.EXTRA_LANGUAGE_MODEL,
                        RecognizerIntent.LANGUAGE_MODEL_FREE_FORM);
                // プロンプトに表示する文字を設定
                intent.putExtra(
                        RecognizerIntent.EXTRA_PROMPT,
                        "話してください");
                // インテント発行
                startActivityForResult(intent, REQUEST_CODE);
            } catch (ActivityNotFoundException e) {
                // エラー表示
                Toast.makeText(MainActivity.this,
                        "ActivityNotFoundException", Toast.LENGTH_LONG).show();
            }
        }
        return true;
    }

    // startActivityForResultで起動したアクティビティが終了した時に呼び出される関数
    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        // 音声認識結果のとき
        if (requestCode == REQUEST_CODE && resultCode == RESULT_OK) {
            // 結果文字列リストを取得
            ArrayList<String> results = data.getStringArrayListExtra(
                    RecognizerIntent.EXTRA_RESULTS);
            // 取得した文字列を結合
            String resultsString = "";
            for (int i = 0; i < results.size(); i++) {
                resultsString += results.get(i)+";";
            }
            // トーストを使って結果表示
            Toast.makeText(this, resultsString, Toast.LENGTH_LONG).show();
        }

        super.onActivityResult(requestCode, resultCode, data);
    }
}

実行結果のスクリーンショットは以下の通りです。(順番に、起動直後の画面、音声認識中の画面、音声認識後の画面)
f:id:kivantium:20160229153145j:plain:w300f:id:kivantium:20160229153153j:plain:w300f:id:kivantium:20160229153200j:plain:w300

しかし、この方法だといちいち音声認識画面が立ち上がって鬱陶しいうえにとてもレスポンスが悪いので入力インターフェースとして使うには不満がありました。

音声認識をバックグラウンドで連続して行う方法 (SpeechRecognizer, RecognitionListener)

さらに検索したところろモバイル開発系(K)-Android開発 Tips 音声認識(SpeechRecognizer) RecognitionListener | M-noteに書いてあるSpeechRecognizerを行う方法を発見しました。ハッカソンの時には良い方法が分からなくてこれを一定の時間ごとに実行する無理やりな方法を採用しましたが、終了後にさらに調べたところContinous Speech Recognitionによりよい方法が書いてあるのを見つけました。このページのサンプルコードはコードが分割されていて分かりにくいので必要最小限のコードで再構成しました。

この方法ではSpeechRecognizer(リファレンス:SpeechRecognizer | Android Developers)をIntent経由で待機させ、結果をRecognitionListenerで受け取って音声認識を実行します。RecognitionListener内でエラーが発生するか正しく結果を受け取るかすると待機状態が終了してしまうので再度待機状態にすることで連続的に音声認識が行えるようになります。

activity_main.xmlはさっきと同じです。

AndroidManifest.xml

<uses-permission android:name="android.permission.RECORD_AUDIO"/>

を追記します。

MainActivity.javaは以下のようになります。

package com.example.kivantium.myapplication;

import android.speech.RecognitionListener;
import android.speech.SpeechRecognizer;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.widget.Toast;
import android.content.Intent;
import android.speech.RecognizerIntent;

import java.util.ArrayList;

public class MainActivity extends AppCompatActivity {
    private SpeechRecognizer sr;

    // 音声認識を開始する
    protected void startListening() {
        try {
            if (sr == null) {
                sr = SpeechRecognizer.createSpeechRecognizer(this);
                if (!SpeechRecognizer.isRecognitionAvailable(getApplicationContext())) {
                    Toast.makeText(getApplicationContext(), "音声認識が使えません",
                            Toast.LENGTH_LONG).show();
                    finish();
                }
                sr.setRecognitionListener(new listener());
            }
            // インテントの作成
            Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
            // 言語モデル指定
            intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL,
                    RecognizerIntent.LANGUAGE_MODEL_WEB_SEARCH);
            sr.startListening(intent);
        } catch (Exception ex) {
            Toast.makeText(getApplicationContext(), "startListening()でエラーが起こりました",
                    Toast.LENGTH_LONG).show();
            finish();
        }
    }

    // 音声認識を終了する
    protected void stopListening() {
        if (sr != null) sr.destroy();
        sr = null;
    }

    // 音声認識を再開する
    public void restartListeningService() {
        stopListening();
        startListening();
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    @Override
    protected void onResume() {
        super.onResume();
        startListening();
    }

    @Override
    protected void onPause() {
        stopListening();
        super.onPause();
    }

    // RecognitionListenerの定義
    // 中が空でも全てのメソッドを書く必要がある
    class listener implements RecognitionListener {
        // 話し始めたときに呼ばれる
        public void onBeginningOfSpeech() {
            /*Toast.makeText(getApplicationContext(), "onBeginningofSpeech",
                    Toast.LENGTH_SHORT).show();*/
        }

        // 結果に対する反応などで追加の音声が来たとき呼ばれる
        // しかし呼ばれる保証はないらしい
        public void onBufferReceived(byte[] buffer) {
        }

        // 話し終わった時に呼ばれる
        public void onEndOfSpeech() {
            /*Toast.makeText(getApplicationContext(), "onEndofSpeech",
                    Toast.LENGTH_SHORT).show();*/
        }

        // ネットワークエラーか認識エラーが起きた時に呼ばれる
        public void onError(int error) {
            String reason = "";
            switch (error) {
                // Audio recording error
                case SpeechRecognizer.ERROR_AUDIO:
                    reason = "ERROR_AUDIO";
                    break;
                // Other client side errors
                case SpeechRecognizer.ERROR_CLIENT:
                    reason = "ERROR_CLIENT";
                    break;
                // Insufficient permissions
                case SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS:
                    reason = "ERROR_INSUFFICIENT_PERMISSIONS";
                    break;
                // 	Other network related errors
                case SpeechRecognizer.ERROR_NETWORK:
                    reason = "ERROR_NETWORK";
                    /* ネットワーク接続をチェックする処理をここに入れる */
                    break;
                // Network operation timed out
                case SpeechRecognizer.ERROR_NETWORK_TIMEOUT:
                    reason = "ERROR_NETWORK_TIMEOUT";
                    break;
                // No recognition result matched
                case SpeechRecognizer.ERROR_NO_MATCH:
                    reason = "ERROR_NO_MATCH";
                    break;
                // RecognitionService busy
                case SpeechRecognizer.ERROR_RECOGNIZER_BUSY:
                    reason = "ERROR_RECOGNIZER_BUSY";
                    break;
                // Server sends error status
                case SpeechRecognizer.ERROR_SERVER:
                    reason = "ERROR_SERVER";
                    /* ネットワーク接続をチェックをする処理をここに入れる */
                    break;
                // No speech input
                case SpeechRecognizer.ERROR_SPEECH_TIMEOUT:
                    reason = "ERROR_SPEECH_TIMEOUT";
                    break;
            }
            Toast.makeText(getApplicationContext(), reason, Toast.LENGTH_SHORT).show();
            restartListeningService();
        }

        // 将来の使用のために予約されている
        public void onEvent(int eventType, Bundle params) {
        }

        // 部分的な認識結果が利用出来るときに呼ばれる
        // 利用するにはインテントでEXTRA_PARTIAL_RESULTSを指定する必要がある
        public void onPartialResults(Bundle partialResults) {
        }

        // 音声認識の準備ができた時に呼ばれる
        public void onReadyForSpeech(Bundle params) {
            Toast.makeText(getApplicationContext(), "話してください",
                    Toast.LENGTH_SHORT).show();
        }

        // 認識結果が準備できた時に呼ばれる
        public void onResults(Bundle results) {
            // 結果をArrayListとして取得
            ArrayList results_array = results.getStringArrayList(
                    SpeechRecognizer.RESULTS_RECOGNITION);
            // 取得した文字列を結合
            String resultsString = "";
            for (int i = 0; i < results.size(); i++) {
                resultsString += results_array.get(i) + ";";
            }
            // トーストを使って結果表示
            Toast.makeText(getApplicationContext(), resultsString, Toast.LENGTH_LONG).show();
            restartListeningService();
        }

        // サウンドレベルが変わったときに呼ばれる
        // 呼ばれる保証はない
        public void onRmsChanged(float rmsdB) {
        }
    }
}

こうすることで音声認識が繰り返し呼び出され、入力インターフェースとして使えるようになります。精度は簡単な単語を拾うには十分な程度に高いようです。

元ネタのブログについたコメントを見るとServiceとして実現した方が筋がよいようですが、方法が分からなかったのでやりませんでした。


ここで紹介した方法だとGoogle音声認識機能を使っているのでインターネットにつながっていないときは使えませんし、通信容量も結構食いそうなので気をつけてください。Juliusを使うオフラインの方法(日本語連続音声認識エンジン"Julius"をAndroidで動作させる 1などで紹介されている)もあるようですが実現にはハードルが高かったです。

音声入力インターフェースは未来感があるのでまたどこかで使えたらといいなと思っています。