0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

多言語読み上げブラウザVOTWをAndroid向けに作った話(4)

0
Posted at

Voice Of The World開発記 第4回です。

前回のエントリはこちら:多言語読み上げブラウザVOTWをAndroid向けに作った話(3)

Voice Of The World Android版販売中です。
Voice Of The World(GooglePlay)

いよいよ読み上げます

前回までで、コンテンツからテキストはそれらしく取り出せるようになりました。
今回からは、しゃべらせる方のお話です。

VOTWでは第1回でお話した通り、Android本体機能のTTS(Text To Speech)を使っているため、オフラインでもサクサクしゃべることができるようになっています。
近年のGoogle純正TTSは日本語でもかなり優秀になってきて、イントネーションも自然になってきています(たまに「ん?」と思うことがないではないんですが……)。

TTSを使った実用ツールを作る場合、単にしゃべらせること自体の他に、周辺機能として考えなければならないことがいくつかあります。今回はそれらを整理していきたいと思います。

  • 発声のための準備をする
  • 発声そのものを実現する
  • サウンドアプリとして、他のアプリとの競合を処理する

やりたいこと

VOTWのTTS周りでは、おおまかに以下のような要件を満たす方針を立てました。

  • インストールされているTTSが複数あったら、ユーザーが選択できるようにしたい
  • メディアプレイヤーみたいに扱いたい
    • 複数テキストを順番に読み上げたい
    • 再生、停止、ポーズ操作のUIを作ってコントロールしたい
    • テキスト単位を「トラック」に見立てて、ランダムプレイ、巻き戻し、早送りをしたい
  • 再生速度やピッチ(声の高さ)を動的に変えたい

これを踏まえてTTSの機能要件をリストアップすると、だいたい以下のような感じになります。

  1. インストールされているTTSの情報(パッケージ名)と、それがどの言語をサポートしているかという情報を得る手段を持つこと
  2. 読み上げ中の再生設定(再生速度・ピッチ)を変更できること
  3. 再生完了コールバックを設定できること(再生終了トリガーで、UIを書き換えたり次のテキストの再生準備をしたりするため)

発声のための準備をする

上記のうち、「インストールされているTTSを変更できるようにしたい」という点について。
実行時にそのTTSのパッケージ名さえ分かれば、初期化と実行自体は可能です。

ただ、TTSが実際にしゃべるためには 「そのTTS固有の音声データ」 が必要になります。一般的には、TTSアプリの設定画面から事前にインストールするか、持っていない言語の音声データが要求されたときに、TTS自体がバックグラウンドで自動インストールする形になります。

音声データインストールの実態は「半自動」だけど信用ならない

AndroidのTTSは、アプリ側から音声データのインストールを直接キック(要求)することはできません。
再生しようとしたときに音声データがない場合、OSやエンジンによって挙動が分かれます。

  • Google製TTSの場合
    • 「音声データがない」というエラーは吐かず、バックグラウンドで自動インストールしてくれます。
    • ただし、これが数秒〜十数秒何も言わずに待たされることがあるため、ユーザー視点では「あれ?フリーズした?」と感じることがあります。
    • また、TTSの設定で「WiFi使用時のみダウンロード」が有効だと自動インストールしてくれないことがあり、古いAndroid端末だとこのチェックを外していても何故か入らないケースもありました(実体験)。
  • サードパーティTTSの場合
    • 基本的には必要な言語の音声データをユーザー自身で事前に入れないと動きません。

初期化コード側の動き

TextToSpeech クラスには TextToSpeech.OnInitListener というリスナーがあるので、初期化の際にこれを渡します。

mTts = new TextToSpeech( mContext, onInitListener, ttsPacakgeName );

初期化が完了すると onInitListener.onInit() が呼ばれます。
実装を少し簡略化したスニペットは以下の通りです。

@Override
public void onInit(int status) {
    try {
        if (status == TextToSpeech.SUCCESS) {
            int check = 0;
            if ((check = mTts.isLanguageAvailable(mLocale)) >= TextToSpeech.LANG_AVAILABLE) {
                mTts.setLanguage(mLocale);
            } else {
                if (check == TextToSpeech.LANG_MISSING_DATA) {
                    //.... 「言語データがないのでインストールが必要」とユーザーに提示
                } else {
                    //.... 「指定言語はサポートされていない」とユーザーに提示
                }
                return;
            }
        }
    } catch(Exception e){
        //.... 「指定言語はサポートされていない」とユーザーに提示
    }
}

この通りきれいにステータスを返してくれればUI側でいくらでもフォローできるのですが、経験上、そううまくはいきませんでした。(言語データダウンロードを促すUIも作ってあるのですが……)

  • 初期化成功を返してきて、speak メソッドもエラーを返さないのに、なぜか声が出ないことがある(これが一番厄介)。
  • Google TTSがWiFi環境下でないとデータダウンロードを諦めてしまい、声が出ない。しかし諦めないときはバックグラウンドで粘って待たされるため、「単にダウンロード待ちで遅いのか」「本当に音がでないバグなのか」がアプリ側から判別できない(ダウンロード中などのステータスが取れないため)。
  • サードパーティのTTSでは、サポートしていると主張するロケールの内容が本当にめちゃくちゃなことがある。

結局、うまくいかないときの症状はすべて「再生ボタンを押しても声が出ない」という現象に集約されてしまいます。

アプリ側での泥臭いUXハック

これを解決するため、VOTWでは言語別に「初めて読む言語か」というチェックフラグをPreferenceの中に用意しました。
アプリインストール後に指定言語を初めて読み上げる場合は、「初めて読み上げる言語は準備に時間がかかるかもしれないので、開始まで少しお待ちください」という Toast表示 を入れるようにしています。

さらに、解決のための「FAQページ」をアプリ内に内蔵しました。

内蔵FAQで提示している内容は以下の通りです。

  • 音量確認や、他のアプリの音声出力が正常に鳴っているかの確認(お約束)
  • TTSは動作の安定している「Google製」でまずは試すこと
  • WiFi環境下で再生を開始し、しばらく待ってみること
  • それでもダメなら、OSの設定アプリの読み上げ設定から、手動で言語データをダウンロードすること

なお、アプリ内の設定画面には「テキスト読み上げ設定画面」という項目があり、ワンタップでAndroid標準の設定画面に直接遷移できるようにしてあります。端末によって設定アプリの階層はバラバラですが、インテントのアクション名は共通で使えます。

インテントに与えるアクション名は以下の通りです。

com.android.settings.TTS_SETTINGS

Toast、FAQページ、設定アプリへの誘導。このあたりの仕様は読み上げのトラブルシューティングのためにできることを押し込んだといえます。ある意味TTSを利用するアプリの暗黒面ですね。

インストールされているTTSのパッケージ名を得る

端末にインストールされているTTSのパッケージ一覧は PackageManager から取得可能です。主要な処理の抜粋を以下に挙げます。

private final int REQ_CHECK_TTS=123; // 適当なマジックナンバー

/*
 * パッケージ名リスト準備の要求
 */
public void prepare()
{
    mItems.clear();

    PackageManager pm = mActivity.getPackageManager();
    Intent intent = new Intent(TextToSpeech.Engine.INTENT_ACTION_TTS_SERVICE);
    List<ResolveInfo> resolveInfos = pm.queryIntentServices( intent, PackageManager.MATCH_DEFAULT_ONLY);
    if( resolveInfos != null )
    {
        int i=0;
        mBusy = new boolean[resolveInfos.size()];
        for( ResolveInfo resolveInfo : resolveInfos )
        {
            mBusy[i] = true;
            ServiceInfo service = resolveInfo.serviceInfo;
            if( service != null )
            {
                String appName;
                String appDesc;
                appName = service.applicationInfo.loadLabel(mActivity.getPackageManager()).toString();
                mItems.add( new Item( REQ_CHECK_TTS+i, appName, service.packageName ));
                intent = new Intent(TextToSpeech.Engine.ACTION_CHECK_TTS_DATA);
                intent.setPackage(service.packageName );
                try {
                    mActivity.startActivityForResult(intent, REQ_CHECK_TTS + i);
                }
                catch(ActivityNotFoundException e)
                {
                    // 例外が出たら、待ち状態をクリアして、スキップする
                    mBusy[i] = false;
                    mItems.set(i, null);
                }
            }
            i++;
        }
    }
}

/*
 * prepare()を呼んだあと、Activity.onActivityResultからこのメソッドを呼ぶこと
 */
public void onActivityResult(int requestCode, int resultCode, Intent data) {
    {
        int index;
        // parepare()が投げたリクエストでなければ無視する。
        if( requestCode < REQ_CHECK_TTS || requestCode >= REQ_CHECK_TTS+mItems.size())
        {
            return; // requestCode is out of range.
        }
        index = requestCode - REQ_CHECK_TTS;
        Item item = mItems.get(index);
        mBusy[index] = false;
        if( item == null )
        {
            return; // Not found.
        }

        if (resultCode == TextToSpeech.Engine.CHECK_VOICE_DATA_PASS) {
            ArrayList<String> list= data.getStringArrayListExtra( TextToSpeech.Engine.EXTRA_AVAILABLE_VOICES );
            if( list != null )
            {
                for( String str : list )
                {
                    Lang lang = parseResult( str );
                    if( lang == Lang.UNKNOWN )
                    {
                        continue;
                    }
                    item.appendLang( lang );
                }
            }
        }
    }
}

これを MainActivity などから呼び出します。これらの処理は Activity.onActivityResult を必要とする制約上、UIが起動しているコンテキスト(Activity等)に配置しなければならない点に注意が必要です。一度パッケージ名や対応ロケールさえ取得できれば、実際の音声発声時にActivityが必ずしもフォアグラウンドである必要はありません。

このコードの大まかなパイプラインは以下の通りです。

  1. prepare メソッド: INTENT_ACTION_TTS_SERVICE を投げて利用可能なTTSサービス一覧を要求し、返ってきたサービス各々に対して ACTION_CHECK_TTS_DATAstartActivityForResult で投げて属性情報を要求する。
  2. onActivityResult メソッド: 要求した回数分だけ非同期で呼ばれ、通知されたインテントの EXTRA_AVAILABLE_VOICES から音声情報の名前(ロケール文字列)をパースする。アプリ側が想定している言語とマッチすれば、サポート言語リストにプールする。

なお、上記スニペットには含まれていませんが、parseResult メソッドに渡される言語データの名前(文字列フォーマット)も、TTSエンジンによってかなり表記にばらつきがあるため、実機検証を重ねた正規表現での堅牢なパース処理が必要になります。

次回こそしゃべります

今回はTTSの選定と非同期なセットアップ周りのお話で終わってしまいました。
次回はいよいよ、実際に音声をコントロールして「再生」するコア部分についてお話したいと思います。

ではまた。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?