連続音声認識でインコアプリを作ろう


最初に

本記事はQiita Android Advent Calendar 2016の記事です。

インコをAndroidアプリで・・・

インコってかわいいですよね。私は飼ったことないですけど、自分の声をそのままリピートしてくれるペットなんてかわいいだろうなとよく想像します。自分の声をそのままリピートしてくれるペット・・・なんかアプリでできそうな気がしてきました。

自分の声をそのままリピートするには

普通にAndroidのSpeechRecognizerで声を認識させたあと、TextToSpeechで認識結果をそのまま再生させればいいんじゃないか、とまず思うと思います。
これだと、まず認識開始時のピコンという音が邪魔ですし、常時音声認識していないので、音声認識がタイムアウトしたら再度認識開始し・・・という手続きが必要になります。その間ももちろんピコンという音がなっているので、これはもううるさいインコになるわけです。
また、覚えさせたくないキーワードも拾ってしまうので、例えば独り言で「みくりさん」とか言ってしまった場合でもそっくりそのまま「みくりさん」と返してきます。これはちょっと恥ずかしいと思います。
よってインコアプリを作る場合、下記の要件を満たすことが必要になります。

  • ピコンピコン鳴らずに常に音声を認識してくれる状態(OK googleのようなイメージ)
  • 覚えて欲しいキーワードだけ覚えてもらって、インコに発話してくれる状態

Pocketsphinxで実現可能

大分前の記事で連続音声認識について記事を書きましたが、そのときに紹介したPocketsphinxを使って見ることにします。
連続音声認識とは、「OK Google」のようなものだとイメージしてください。常にマイクが周囲の音を観測して音声認識をする状態です。
日本語に対応していません(というかモデルがありません)ので、英語で試します。

デモ用リポジトリをいじってみる

Pocketsphinxが公式にAndroidのサンプルアプリのリポジトリを公開しているので、とりあえず動かしてみます。
https://github.com/cmusphinx/pocketsphinx-android-demo
Android NDKは入れておく必要があります。

setUpRecognizerについて

サンプルコードを開くとPocketSphinxActivity.javaというファイルがあります。ここを見るとだいたいの流れがわかります。
そして重要な部分であるsetupRecognizerを見てみます。


    private void setupRecognizer(File assetsDir) throws IOException {
        // The recognizer can be configured to perform multiple searches
        // of different kind and switch between them

        recognizer = SpeechRecognizerSetup.defaultSetup()
                .setAcousticModel(new File(assetsDir, "en-us-ptm"))
                .setDictionary(new File(assetsDir, "cmudict-en-us.dict"))
                // To disable logging of raw audio comment out this call (takes a lot of space on the device)
                .setRawLogDir(assetsDir)
                // Threshold to tune for keyphrase to balance between false alarms and misses
                .setKeywordThreshold(1e-45f)
                // Use context-independent phonetic search, context-dependent is too slow for mobile
                .setBoolean("-allphone_ci", true)
                .getRecognizer();
        recognizer.addListener(this);

        /** In your application you might not need to add all those searches.
         * They are added here for demonstration. You can leave just one.
         */

        // Create keyword-activation search.
        recognizer.addKeyphraseSearch(KWS_SEARCH, KEYPHRASE);

        // Create grammar-based search for selection between demos
        File menuGrammar = new File(assetsDir, "menu.gram");
        recognizer.addGrammarSearch(MENU_SEARCH, menuGrammar);

        // Create grammar-based search for digit recognition
        File digitsGrammar = new File(assetsDir, "digits.gram");
        recognizer.addGrammarSearch(DIGITS_SEARCH, digitsGrammar);

        // Create language model search
        File languageModel = new File(assetsDir, "weather.dmp");
        recognizer.addNgramSearch(FORECAST_SEARCH, languageModel);

        // Phonetic search
        File phoneticModel = new File(assetsDir, "en-phone.dmp");
        recognizer.addAllphoneSearch(PHONE_SEARCH, phoneticModel);
    }


setAcousticModel()では音響モデルをセットしています。このデモアプリでは英語の音響モデルがデフォルトで入っているので、それを指定しています。

setDictionary()では、その名の通り辞書をセットしています。cmudict-en-us.dictをみると英語の巨大な辞書ファイルが見られます。アプリのサイズを軽くするにはここを削って必要な分だけ定義します。

setRawLogDir(assetsDir)はセットすると人間の声を検出したらそれをファイルとしてガンガン保存していくようになります。この行は削除すると良いと思います。

setKeywordThreshold(1e-45f)では後述するKeyPhraseSearchモードにおける検出閾値をセットしています。小さいほどよく検出しますが、誤検出が多くなります。1e+60から1e-60くらいまでセット出来た気がします。

setBooleanは試してもないですが、今回特に不要なので削除しても大丈夫です。

次にSearchモードをaddしてるところです。
recognizer.addKeyphraseSearch(KWS_SEARCH, KEYPHRASE)はまさにOK Googleのような音声認識を想定しています。上記検出閾値が適用されるのもこのモードです。KEYPHRASEに好きなワードを設定すると、そのワードを検出できるようになります。ただし、ワードは一つのみです。
一つのみなので、今回の要件である覚えさせたい複数の言葉という要件は満たせないので使えなさそうです。

recognizer.addGrammarSearch(MENU_SEARCH, menuGrammar)はmenuGrammarで定義された複数ワードの検出を行います。しかしmenuGrammarで定義されたワードを必ず一つ検出して返します。

menu.gram
#JSGF V1.0;

grammar menu;

public <item> = digits | forecast | phones;

例えばここで定義されているのはdigits, forecast, phonesですが、「でぃー」と言った場合でもこの3つのうちで正解に近いものを返します。おそらくdigitsが返されることでしょう。

というわけで、このGrammarSearchは惜しい感じがしますが、今回は使えそうにありません。

そこでもう一つあるSearchモードが、KeywordSearchです。

File inkoGrammar = new File(assetsDir, "inko.gram");
recognizer.addKeywordSearch(MENU_SEARCH, inkoGrammar);

このKeywordSearchはKeyPhraseSearchとは違って複数ワード使えるようにしたものです。
.gramファイル使ってますが、中身はこんな感じにします。

yo drivemode/1e-40/
woon ko/1e-40/
my uncle is a good tennis player/1e-50/

インコに覚えさせたい単語/しきい値/という設定をすることで検出するキーワードを複数してします。
また、それぞれ検出閾値を設定することができるので、各ワードの検出のバラ付きが調整出来ます。
当然、ここにOK Googleを加えるとOK Googleも検出できます。
※Androidではマイクリソースが共有出来ないので、この音声認識を行っている場合は、従来のOK Googleの検出ができません。

また、gramファイルやdictファイルの編集が適用されない場合はmd5ハッシュ値も更新してあげます。

なので最終的にsetupRecognizerはコンパクトにしちゃいます。


private void setupRecognizer(File assetsDir) throws IOException {
    recognizer = SpeechRecognizerSetup.defaultSetup()
            .setAcousticModel(new File(assetsDir, "en-us-ptm"))
            .setDictionary(new File(assetsDir, "cmudict-en-us.dict"))
            .getRecognizer();
    recognizer.addListener(this);
    File menuGrammar = new File(assetsDir, "menu.gram");
    recognizer.addKeywordSearch(MENU_SEARCH, menuGrammar);
    recognizer.startListening(MENU_SEARCH);
}

検出後の処理

もうこれはそのままTTSで発音させましょう!
とりあえず動く前提でonCreateでこうやって

mTextToSpeechInko = new TextToSpeech(getApplicationContext(), new TextToSpeech.OnInitListener() {
    @Override
    public void onInit(int status) {
    }
});

onPartialResultでインコに発話させる!

@Override
public void onPartialResult(Hypothesis hypothesis) {
    if (hypothesis == null)
        return;
    String text = hypothesis.getHypstr();
    if (mTextToSpeechInko.isSpeaking()) {
        mTextToSpeechInko.stop();
    }
    mTextToSpeechInko.speak(text, TextToSpeech.QUEUE_FLUSH, null);
    recognizer.stop();
    recognizer.startListening(MENU_SEARCH);
}

これで出来ました。

注意

  • 認識精度悪いときはしきい値を低く設定するといいと思います。
  • 誤検出多すぎるときはしきい値を高く設定するといいと思います。
  • 短すぎるワード(upとか)は誤検出すごく多いです。これはエンジンによらずそういうものです。
  • TTSでspeakさせるときに英語を設定すれば良さそうですね。
  • 厳密にはTTSが喋り終わってから認識再スタートをするほうが良いです。TTSの声を認識してしまう時があります。

おわりに

筆者自身インコを作るつもりだったわけではなく、Pocketsphinxのデモアプリを勉強&デバッグしてたらこんなものが出来上がってしまい、それが予想外に可愛いと思ってしまったのでシェアさせていただきました。

なお、この連続音声認識周りについて、DroidKaigi 2017にて登壇させていただく予定でございます。

それでは皆さん良いお年を。

                              
         ,. -‐'''''""¨¨¨ヽ
         (.___,,,... -ァァフ|
          |i i|    }! }} //|
         |l、{   j} /,,ィ//|
        i|:!ヾ、_ノ/ u {:}//ヘ
        |リ u' }  ,ノ _,!V,ハ |
       /´fト、_{ル{,ィ'eラ , タ人
     /'   ヾ|宀| {´,)⌒`/ |<ヽトiゝ
    ,゙  / )ヽ iLレ  u' | | ヾlトハ〉
     |/_/  ハ !ニ⊇ '/:}  V:::::ヽ
    // 二二二7'T'' /u' __ /:::::::/`ヽ
   /'´r -―一ァ‐゙T´ '"´ /::::/-‐  \
   / //   广¨´  /'   /:::::/´ ̄`ヽ ⌒ヽ
  ノ ' /  ノ:::::`ー-、___/::::://       ヽ  }
_/`丶 /:::::::::::::::::::::::::: ̄`ー-{:::...       イ