スポンサーリンク

Androidで,音声入力と音声合成をシンプルに記述するためのライブラリ案


Androidアプリ開発時に,スピーチ周りのコードをシンプルに記述したい。

  • 音声入力・音声認識(ASR
  • 音声合成・Text To Speech(TTS

これらの両者について,便利なラッパークラスとDSLを作り,コード量を削減してみる。

サンプルコード

下記のような記述ができる。

それぞれ,処理が完了したときのタイミングでイベントを発行できる。


音声入力:

final Activity context = this;

// 音声入力
new ASRUtil(context)
    .lang(Locale.US)
    .events(new ASRUtil.ASREventsListener(){

        @Override
        public void beforeSpeech() {
            Toast.makeText(context, "お話し下さい。", Toast.LENGTH_SHORT).show();
        }

        @Override
        public void handleResults(List<String> results)
        {
            // 認識結果を表示
            String s = "";
            for( String word : results )
            {
                s += word + "\n";
            }
            Toast.makeText(context, s, Toast.LENGTH_SHORT).show();
        }

        @Override
        public void onNoInputError() {
            Toast.makeText(context, "入力されませんでした。", Toast.LENGTH_SHORT).show();
        }

        @Override
        public void onNoResultsMatch() {
            Toast.makeText(context, "入力内容を理解できませんでした。", Toast.LENGTH_SHORT).show();
        }

    })
    .listen()
;


音声合成:

// 音声合成
new TTSUtil(context)
    .lang(Locale.US)
    .pitch(1.2f)
    .speed(1.0f)
    .forceSoundIfSilent() // マナーモードでも強制的に音を出す場合
    .words( "Hello, World !" )
    .onSpeakCompleted(new TTSUtil.AfterSpeakHandler(){
        @Override
        public void exec(){
            Toast.makeText(context, "発話終了", Toast.LENGTH_SHORT).show();
        }
    })
    .speak()
;


端末内で,特定の言語の音声合成を利用可能かどうか判定:

final Context context = this;

TTSUtil.judgeLocaleAvailable(context, Locale.US, new TTSUtil.JudgeListener(){
    @Override
    protected void onJudgeLocale( boolean usable )
    {
        if( usable )
        {
            Toast.makeText(
                context, 
                "このロケールの音声合成は利用可能です。", 
                Toast.LENGTH_SHORT
            ).show();
        }
        else
        {
            Toast.makeText(
                context, 
                "このロケールの音声合成は利用不可能です。\n音声データをインストールしてください。", 
                Toast.LENGTH_SHORT
            ).show();
        }
    }
});


それぞれの機能を個別に,便利クラスの中に閉じ込めた感じ。

このサンプルが動作するためのライブラリ


ASRUtil

package com.example.speechtest;

import java.util.List;
import java.util.Locale;

import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.speech.RecognitionListener;
import android.speech.RecognizerIntent;
import android.speech.SpeechRecognizer;
import android.util.Log;

/**
 * Speech Recognitionに関する便利クラス。
 * @author id:language_and_engineering
 *
 */
public class ASRUtil {

    private Activity activity;
    private Locale locale;
    private ASREventsListener asrEventsListener;


    public ASRUtil(Activity activity) {
        this.activity = activity;
    }


    /**
     * ロケール・言語を設定
     */
    public ASRUtil lang(Locale locale) {
        this.locale = locale;
            // ※ロケールの一覧表
            //   http://docs.oracle.com/javase/jp/1.5.0/api/java/util/Locale.html
        return this;
    }


    /**
     * 各種イベントのリスナを設定
     */
    public ASRUtil events(ASREventsListener asrEventsListener) {
        this.asrEventsListener = asrEventsListener;
        return this;
    }


    /**
     * 音声認識の各種イベントのリスナ。
     * 必要なメソッドをOverrideすること
     * @author id:language_and_engineering
     *
     */
    public static abstract class ASREventsListener implements RecognitionListener {

        @Override
        public void onReadyForSpeech(Bundle params) {
            this.beforeSpeech();
        }


        @Override
        public void onResults(Bundle results) {
            // 結果を受け取る
            List<String> candidates = results.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION);

            // 処理する
            handleResults(candidates);
        }


        @Override
        public void onError(int errorCode){
            // エラーコードの一覧表
            // http://developer.android.com/intl/ja/reference/android/speech/SpeechRecognizer.html#ERROR_AUDIO

            // NOTE: 主要なエラーのシナリオは,実装を強制させる。
            // その他のエラーは,必要に応じて実装させる。
            switch (errorCode)
            {
            case SpeechRecognizer.ERROR_SPEECH_TIMEOUT: //6
                this.onNoInputError();
                break;

            case SpeechRecognizer.ERROR_NO_MATCH: //7
                this.onNoResultsMatch();
                break;

            default:
                onOtherErrors(errorCode);
            }

        };


        // ------- 必ず実装すべきもの -------


        /**
         * 音声認識の準備が完了した時
         */
        public abstract void beforeSpeech();


        /**
         * 認識結果の文字列リストを処理
         */
        public abstract void handleResults(List<String> results);


        /**
         * 音声入力されなかったエラー
         */
        public abstract void onNoInputError();


        /**
         * 認識結果に候補がなかったエラー
         */
        public abstract void onNoResultsMatch();


        // ------- 必要ならOverride -------


        /**
         * その他のエラー
         */
        public void onOtherErrors(int errorCode){
            switch(errorCode)
            {
            case SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS: //9
                // RECORD_AUDIOなどパーミッション不足
                break;
            case SpeechRecognizer.ERROR_NETWORK_TIMEOUT: //1
                // ネットワークタイムアウト
                break;
            case SpeechRecognizer.ERROR_NETWORK: //2
                // その他ネットワーク関係
                break;
            case SpeechRecognizer.ERROR_AUDIO: //3
                // 録音データの保存に失敗
                break;
            case SpeechRecognizer.ERROR_SERVER: //4
                // サーバ側エラー
                break;
            case SpeechRecognizer.ERROR_CLIENT: //5
                // クライアント側のその他のエラー
                break;
            case SpeechRecognizer.ERROR_RECOGNIZER_BUSY: //8
                // サービスがビジー
                break;
            }

            Log.e("ASRUtil", "エラーコード:" + errorCode);
        }

        @Override
        public void onBeginningOfSpeech() {
            // 音声入力が開始
        }

        @Override
        public void onPartialResults(Bundle partialResults) {
            // 部分的な認識結果を処理
        }

        @Override
        public void onEndOfSpeech() {
            // 音声入力が終了
        }


        // ------- 無視してよいメソッド -------


        @Override
        public void onBufferReceived(byte[] buffer) {
            // 入力内容に対するユーザへのフィードバック用だが,
            // このメソッドが呼ばれる保証はないし,呼ばれても処理内容は端末依存
        }

        @Override
        public void onEvent(int eventType, Bundle params) {
            // 将来の拡張のための予備メソッド
        }

        @Override
        public void onRmsChanged(float rmsdB) {
            // 音量が変わった場合に呼ばれることになっているが
            // このメソッドが本当に呼ばれるという保証はない。
        }

    }


    /**
     * 認識を開始
     */
    public void listen() {
        // 音声認識APIに自作リスナをセット
        SpeechRecognizer sr = SpeechRecognizer.createSpeechRecognizer(this.activity);
        sr.setRecognitionListener(this.asrEventsListener);

        // インテントを作成
        Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
        intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM);
        intent.putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, this.activity.getPackageName());

        // 入力言語のロケールを設定
        intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, this.locale.toString());

        // 音声認識APIにインテントを処理させる
        sr.startListening(intent);
    }
}

TTSUtil

package com.example.speechtest;

import java.util.HashMap;
import java.util.Locale;

import android.app.Activity;
import android.content.Context;
import android.media.AudioManager;
import android.speech.tts.TextToSpeech;
import android.speech.tts.TextToSpeech.OnInitListener;
import android.speech.tts.TextToSpeech.OnUtteranceCompletedListener;

/**
 * Text To Speechに関する便利クラス。
 * @author id:language_and_engineering
 *
 */
public class TTSUtil {

    //@see http://language-and-engineering.hatenablog.jp/entry/20121022/p1
    // http://www.techdoctranslator.com/resources/articles/articles-index/tts

    // TODO: Androidが3.0以上なら,読み上げの音量も制御できる。
    // http://techbooster.jpn.org/andriod/application/4017/


    private static TextToSpeech ttsForStatic;

    private Activity activity;
    private Locale locale;
    private float pitch = 1.0f;
    private float speed = 1.0f;
    private boolean forceSoundIfSilentFlag = false;
    private String words;
    private AfterSpeakHandler afterSpeakHandler;
    private TextToSpeech tts;


    // ---- クラメソ ----


    /**
     * 特定のロケールが利用可能か,非同期で判定する
     */
    public static void judgeLocaleAvailable(Context context, final Locale target_locale,
            final JudgeListener judgeListener) {

        // onInitだけのために初期化
        ttsForStatic = new TextToSpeech(context, new TextToSpeech.OnInitListener(){
            @Override
            public void onInit(int status) {

                if(
                    (status == TextToSpeech.SUCCESS)
                     &&
                    (ttsForStatic.isLanguageAvailable(target_locale) >= TextToSpeech.LANG_AVAILABLE)
                )
                {
                    judgeListener.onJudgeLocale(true);
                }
                else
                {
                    judgeListener.onJudgeLocale(false);
                }

            }
        });

    }


    /**
     * ロケールの利用可能判定のコールバック処理をするクラス
     * @author id:language_and_engineering
     *
     */
    public abstract static class JudgeListener {
        abstract protected void onJudgeLocale( boolean usable );
    }


    // ---- インメソ ----


    /**
     * 初期化
     */
    public TTSUtil(Activity activity) {
        this.activity = activity;
    }


    /**
     * ロケール・言語を設定
     */
    public TTSUtil lang(Locale locale) {
        this.locale = locale;
            // ※ロケールの一覧表
            //   http://docs.oracle.com/javase/jp/1.5.0/api/java/util/Locale.html
        return this;
    }


    /**
     * 音の高低を設定
     */
    public TTSUtil pitch(float pitch) {
        this.pitch = pitch;
        return this;
    }


    /**
     * 話す速さを設定
     */
    public TTSUtil speed(float speed) {
        this.speed = speed;
        return this;
    }


    /**
     * マナーモードでも強制的に音を出す
     */
    public TTSUtil forceSoundIfSilent() {
        this.forceSoundIfSilentFlag = true;
        return this;
    }


    /**
     * 話す文章を設定
     */
    public TTSUtil words(String s) {
        this.words = s;
        return this;
    }


    /**
     * 発音終了後の挙動を設定
     */
    public TTSUtil onSpeakCompleted(AfterSpeakHandler afterSpeakHandler) {
        this.afterSpeakHandler = afterSpeakHandler;
        return this;
    }


    /**
     * 発音終了後の挙動を記述するクラス
     */
    public abstract static class AfterSpeakHandler {
        public abstract void exec();
    }


    /**
     * 発音を実行
     */
    public void speak() {
        final TTSUtil ttsUtil = this;

        tts = new TextToSpeech(this.activity, new OnInitListener(){

            // 端末のマナーモード設定のキャッシュ用
            private AudioManager audioManager;
            private int ringerModeCache;

            @Override
            public void onInit(int status) {

                // 音声の設定
                tts.setPitch(ttsUtil.pitch);
                tts.setSpeechRate(ttsUtil.speed);
                tts.setLanguage(ttsUtil.locale);

                // 発話終了イベントのリスナを登録
                tts.setOnUtteranceCompletedListener(new OnUtteranceCompletedListener() {
                    // http://d.hatena.ne.jp/rudi/20100810/1281447191

                    public void onUtteranceCompleted(String utteranceId) {

                        // 必要ならマナーモード設定を元に戻す
                        if( ttsUtil.forceSoundIfSilentFlag )
                        {
                            // マナーモード設定を復元
                            audioManager.setRingerMode(ringerModeCache);
                        }

                        // 発話完了イベントの処理をユーザ指定されていればUIスレッド上で実行
                        if( afterSpeakHandler != null )
                        {
                            activity.runOnUiThread(new Runnable(){
                                // http://u6kyu1.blogspot.jp/2012/09/android-ttsonutterancecompleted.html

                                @Override
                                public void run() {
                                    ttsUtil.afterSpeakHandler.exec();
                                }
                            });
                        }

                        // リソースを自動解放
                        tts.shutdown();
                    }
                });

                // 発話終了イベントを有効化する
                HashMap<String, String> params = new HashMap<String, String>();
                String utteranceId = ttsUtil.words + System.currentTimeMillis(); // この発話を指すユニークな識別子
                params.put(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, utteranceId); // イベント発行のために必要

                // マナーモードでも音を出すか
                if( ttsUtil.forceSoundIfSilentFlag )
                {
                    audioManager = (AudioManager) activity.getSystemService(Context.AUDIO_SERVICE);
                    int ringerMode = audioManager.getRingerMode();
                    if(ringerMode != AudioManager.RINGER_MODE_NORMAL ) {
                        // マナーモード解除
                        audioManager.setRingerMode(AudioManager.RINGER_MODE_NORMAL);
                    }
                    this.ringerModeCache = ringerMode;
                }

                // 音声合成して発音
                if(tts.isSpeaking()) {
                    tts.stop();
                }
                tts.speak(ttsUtil.words, TextToSpeech.QUEUE_FLUSH, params);

            }
        });

    }

}


音声合成の結果を聞くだけのために,毎回マナーモードの設定を手動で操作する,というのはよろしくない。

音が出るときだけ,自動的にマナー解除して,あとから自動的に設定を復元する,という処理があれば便利。


もちろん,マナーモードなら音を出したくない場合もあるはずだから,そのへんはアプリの仕様によって使い分ける。


参考:

[AndroidSDK]マナーモード判定
http://ameblo.jp/yolluca/entry-109098...


マナーモード状態の取得
http://d.hatena.ne.jp/hyoromo/2010100...

利用例

以前,素のAPIを使って「音声入力した内容をそのまま音声合成」というアプリを作った。

Androidで音声入力した内容を認識し,そのまま音声合成。「おうむ返し」アプリのソースコード
http://language-and-engineering.hatenablog.jp/entry/20121022/p1


あのときのソースコードを,今回のライブラリを使って書き換えてみる。

package com.example.speechtest;

import android.os.Bundle;
import android.app.Activity;

import java.util.List;
import java.util.Locale;

import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.Toast;

/**
 * 音声入力(Input)と音声読み上げ(Output)のテスト。
 * マイクに入った音声を認識して,そのまま音声合成し,おうむ返しにスピーカ出力を試みる。
 * @author id:language_and_engineering
 *
 */
public class MainActivity extends Activity implements OnClickListener
{

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

        Button button1 = (Button) findViewById(R.id.button1);
        button1.setOnClickListener( this );

    }


    @Override
    public void onClick(View v)
    {
        final Activity context = this;

        // 音声入力
        new ASRUtil(context)
            .lang(Locale.US)
            .events(new ASRUtil.ASREventsListener(){

                @Override
                public void beforeSpeech() {
                    Toast.makeText(context, "お話し下さい。", Toast.LENGTH_SHORT).show();
                }

                @Override
                public void handleResults(List<String> results)
                {
                    // 結果を表示
                    String s = "";
                    for( String word : results )
                    {
                        s += word + "\n";
                    }
                    Toast.makeText(context, s, Toast.LENGTH_SHORT).show();

                    // 音声合成
                    new TTSUtil(context)
                        .lang(Locale.US)
                        .pitch(1.2f)
                        .speed(1.0f)
                        .words( results.get(0) )
                        .onSpeakCompleted(new TTSUtil.AfterSpeakHandler(){
                            @Override
                            public void exec(){
                                Toast.makeText(context, "発話終了", Toast.LENGTH_SHORT).show();
                            }
                        })
                        .speak()
                    ;
                }

                @Override
                public void onNoInputError() {
                    Toast.makeText(context, "入力されませんでした。", Toast.LENGTH_SHORT).show();
                }

                @Override
                public void onNoResultsMatch() {
                    Toast.makeText(context, "入力内容を理解できませんでした。", Toast.LENGTH_SHORT).show();
                }

            })
            .listen()
        ;
    }

}

きわめてシンプル。

エラーハンドリングもしっかりやっている。


記述すべきなのは,アプリの仕様の本質的な部分にかかわることだけ。

SDKには,始めからこういうfluentなコーディングができる仕様であってほしい。


雑感

私が書くコードって,下位レイヤ向けのプログラムだと,ほとんど下記の組み合わせなんだよね。

  • コンポジション。
  • Builderパターン。
  • fluent interface。

面倒なAPI仕様のクラスを,インスタンス化してメンバで持っておく。

そして,各種の設定事項を,直感的に分かりやすい名前のsetterメソッドでセットして,ラッパークラス内に持っておく。

すべてのsetterはthisを返すので,メソッドチェインによる利用が前提になる。

次いで,設定項目が出そろったら,ワンコールで一気に組み立てて実行する。


これにより,「できるだけ短くて,できるだけ変更しやすくて,できるだけ再利用しやすいコード」が書けるようになってゆく。



そして上位レイヤでは,下記のような雰囲気のコードを書こうとするんだよね。

  • まるで設定ファイルのようなコード。縦幅も,横幅も,拍子抜けするほど短いコード。
  • 機能仕様書を書いたら,その仕様書がそのまま動くようなコード。
  • 上から下に読み流すだけで,処理の順番のシナリオを把握できるような,人間の思考に最適化された,直列化されたコード。
  • どんな言語を使っていようと,RailsとjQueryをミックスしたようなコード。

ちなみに,上記のBuilderうんぬんというのは,静的型付け言語の場合。

JavaScriptとかRubyのような,柔軟な動的言語であれば,ハッシュ・JSONが大活躍する。

ハッシュの中に,好きなだけ情報を詰め込むことができる。

JSONが,設定ファイルの役目を果たすのである。

だから,わざわざ1項目ごとにsetterメソッドを切り分ける必要など生じない。

逆にJavaのような静的言語だと,複数の型を1箇所に混在させることがしづらいから,仕方なく1タイプごとにsetterをチマチマ作っているのである。




毎回,この作業の繰り返し。

下位レイヤと上位レイヤを,うまく連結させようと試みている。

この段階を通過すれば, いつも生産性は激増する。


下位レイヤが十分隠ぺいされているので,システムの設計すなわちプログラミングという事になってくる。

設計をプログラムで表現できるようになる,ということ。


(ドキュメント大好きでコーディングが超苦手な人がよくいるけど,そういう人々には理解しがたい作業フローだろう。)



コツコツ書き溜めたDSLが蓄積され,そのプラットフォームの上で自由に遊びまわれるようになる。

これは楽しい。



プログラミングにおいて,

  • 下位レイヤを深く掘り下げる計算機科学的な楽しみ

というのももちろん存在するのだが,

  • 上位レイヤをサクサク取り扱って,高度なものを量産する楽しみ

というのも存在するんだよね。

私は,この両方とも好きだ。