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が蓄積され,そのプラットフォームの上で自由に遊びまわれるようになる。
これは楽しい。
プログラミングにおいて,
- 下位レイヤを深く掘り下げる計算機科学的な楽しみ
というのももちろん存在するのだが,
- 上位レイヤをサクサク取り扱って,高度なものを量産する楽しみ
というのも存在するんだよね。
私は,この両方とも好きだ。