Voice Of The World開発記 第5回です。
前回のエントリはこちら:多言語読み上げブラウザVOTWをAndroid向けに作った話(4)
Voice Of The World Android版販売中です。
Voice Of The World(GooglePlay)
2026/7/4 ヘッドセット挿抜監視の内容を一部修正しました。
今度こそ読み上げます
前準備とテキストさえ整っていれば、Androidでのテキスト読み上げ(TTS)自体はシンプルです。
mTts.setSpeechRate( mTalkSpeed );
mTts.setPitch( mTalkPitch );
mTts.speak(text, TextToSpeech.QUEUE_FLUSH, null, TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID);
VOTWでは、1つのテキストを読み終えたらコールバック(UtteranceProgressListener)を発火させ、次のテキストを処理していく数珠つなぎの設計にしています。
ここで1つ、一般的なメディアプレイヤーと異なる「TTSの罠」に直面しました。 TTSにはネイティブの「ポーズ(一時停止)」機能が存在しない のです。
ながーい文章をそのまま読ませるとストレスに
紛らわしい名前の UtteranceProgressListener.onRangeStart というコールバックがありますが、これは「キューに詰め込んだ複数のリクエストのうち、このIDの処理を始めるよ」という通知に過ぎず、1つのテキスト内のどこを読んでいるかまでは教えてくれません。 そのため、文章を丸ごとTTSに渡してしまうと、「長い文章の途中でポーズ(実質は停止)すると、再開時にまた頭から読み直される」というストレスフルな挙動になってしまいます。
解決策:言語ごとのテキスト分割(TextSplitter)
この問題を解決するため、文章を適切な長さのリストに分割して1節ずつ読ませる設計に変更しました。
このクラスは、1つにまとめた文字列を文字列リストに分割して返します。日本語の処理クラスがこちら。
public class TextSplitterJapanese implements TextSplitterBase {
@Override
public List<String> split(String s) {
List<String> rtn = new LinkedList(Arrays.asList(s.split("。")));
return rtn;
}
}
日本語であれば句点(。)で分割します。英語の場合は Mr. や Ms. などのピリオドで誤分割されるケースもありますが、「ポーズ時の割り切り」として受容することにしました(※TTSのイントネーションへの影響は考慮の余地あり)。これにより、ポーズ時のイライラは大幅に改善されました。
ここでいう「分割」は、画面上に出てくるテキストリストの見た目を細分化しないのが実はミソです。たまに変なところで区切ってしまったとしても、聞き手にしてみれば、それは「ポーズ」であり、途中から読み上げを再開することが不自然に聞こえないという「利点」があります。
ユーザー体験を高める「読み替え辞書」
日本語のTTSで特に気になるのが「誤読」です。そこで、単純ながら強力な読み替え辞書機能を実装しました。
読もうとしているテキストに対し、登録されたキーワードを正規表現で順番に置換していくだけのシンプルな仕組みです。
読み上げ直前に全キーワードを走査するため、登録数が多すぎるとオーバーヘッドになります。そのため、現在は上限を100個に制限しています(個人的にはもう少し増やしたいところですが、処理負荷とのトレードオフです)。
文脈による読み分けなど完璧ではない部分もありますが、これがあるだけでユーザー体験は劇的に向上します。
各テキストを読み上げ始める頭のところで処理するのが都合がよいため、最初のテキスト読み始めと、上記のUtteranceProgressListenerの読み上げ完了コールバックの中で、次のテキストをセットアップする処理に挟み込むようにしています。
サウンド関連の処理をする
JavaScriptで取り出したテキストをようやく読み上げることができましたので、かなり読み上げブラウザっぽくなりました。
VOTWは「通勤中にハンズフリーで使えること」をコンセプトにしているため、バックグラウンドでの安定動作や、他の音声・システムとの競合をきっちり処理する必要がありました。
サウンドアプリとして押さえておくべき要件は以下の5つです。
- オーディオフォーカスの制御(他アプリの音との競合回避)
- 通知領域のコントロール配置(バックグラウンド操作)
- MediaSession対応(Bluetoothヘッドセット等のハードウェアボタン対応)
- ヘッドセット挿抜監視(不意の音漏れ防止)
- 電話の着信制御(着信時の自動ポーズ)
オーディオフォーカスを処理する
他のアプリが音を鳴らしたら自アプリをポーズし、自分が鳴らす時はフォーカスを取得する。この対応をして初めて「行儀の良いアプリ」と言えます。
オーディオフォーカスのAPIは Android 8.0 (API 26) を境に仕様が変わるため、バージョン分岐が必要です。以下はAPI 26以降のリスナー実装の抜粋です。
public class AudioFocusController80 extends AudioFocusControllerBase implements AudioManager.OnAudioFocusChangeListener {
@Override
public void onAudioFocusChange(int focusChange) {
switch (focusChange) {
case AudioManager.AUDIOFOCUS_GAIN:
case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT:
case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK:
case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE:
super.onFocusGain();
break;
case AudioManager.AUDIOFOCUS_LOSS:
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
super.onFocusLost();
// フォーカスを失ったら、ここで一律ポーズ処理をする。
break;
// AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK (通知音などで一瞬音量を下げる処理) は、
// 頻繁に止まると逆にストレスになるため、VOTWではあえて無視(ポーズしない)しています。
}
}
}
通知領域にコントロールを置く
VOTWの読み上げコア処理は、画面が閉じても動き続けるよう Service 上で実行しています。
Androidの仕様上、フォアグラウンドサービスに昇格する際は通知の表示が義務付けられるため、どうせならここで操作もできるようにUIを作り込みました。
ロック画面からもそのまま「再生・ポーズ・スキップ」ができるため、わざわざアプリ画面を起動しなくても快適に使えます。
MediaSession対応と「無音MP3」のトリック
今回の実装で一番苦労したのが、Bluetoothヘッドセットなどのハードウェアボタン対応(MediaSession)です。
MediaSession とは
MediaSession は、AOSP(Android Open Source Project)の成果物で、メディアプレイヤーの操作性の統合を目指した支援クラスの集合体です。
VOTWの MediaSession 実装は MediaSessionCompat / MediaBrowserServiceCompat を利用し、Bluetooth デバイス・ヘッドセットのハードウェアボタン操作と、通知領域・ロック画面の再生コントロールを統合しています。
今はAOSP版はAndroidXに取り込まれています(中身は基本的に同じです)。
ただ、最近はAndroidX版のMediaSessionもdeprecated扱いになっていて、次世代のMedia3(androidx.media3)が推奨されてきているようです。ExoPlayerと統合されているようですね。
💡 無音MP3による同期トリック
本来 MediaSession(および後継の Media3)は通常のメディアプレイヤーを想定しており、 TTSのような外部エンジンと連携させることは想定されていません。
そこで、以下の トリッキーな同期方法 を採用しました。
- アプリの隠しアセットとして 無音のMP3ファイル を用意し、オートループ再生させる。
- TTSの発声タイミングに合わせて、この無音メディアの「再生・ポーズ・停止」をTTSの再生状態と完全同期させる。
- ヘッドセット等から MediaSession 経由でリモコン操作(次のトラック、ポーズ等)が来たら、それをTTSの制御クラスへ転送する。
つまり、TTSがしゃべっている裏では、無音のメディアファイルが鳴っていて、操作モードはTTS制御とMediaSessionで同期しています。再生中、ポーズ中、停止中などについては内部でもステートマシンを持っているようなので、きちんと状態同期をしないと通知の内容も正しくなりません。
ただし、これでもポーズ通知が2回来てしまったりするヘッドセットがあったので、フェールセーフ処理は念入りに追加する必要がありました。
今後Media3に移行する必要があるかは不明ですが、TTSと連携することは考えていないと思うので、このトリックは今後も有効と思われます。
(メディアと独立したヘッドセット等の制御レイヤーが出てくれば別なんですが・・・)
ちなみにこのMediaSessionは、メディア再生の状態管理には使えますが、先に述べたオーディオフォーカスに関しては何もやってくれないので、別々に対応する必要があります。
接続の実装
MediaSessionのコーディングには、こちらの記事を参考にさせていただいています。処理内容的にはほとんど丸写しと言ってもよいくらいお世話になりました。
ヘッドセット挿抜監視
電車内などでイヤホンが抜けたり、Bluetoothの接続が切れた際、スピーカーから突然大音量で読み上げが始まったら大惨事です。これを防ぐために BroadcastReceiver で切断を監視します。
2026/7/4修正:ごく最近まで有線ヘッドセット抜線とBlueTooth切断は同じ通知で取れていたのですが、SDK更新に伴いBlueTooth側の動作が修正されたようです(元々別扱いが仕様)。仕様を反映したコードを以下に示します。
:
IntentFilter filter = new IntentFilter();
filter.addAction(Intent.ACTION_HEADSET_PLUG); // 有線ヘッドセット抜線
filter.addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY); // BT切断/電源OFF等でオーディオ出力先が消える直前
registerReceiver(mHeadsetReceiver, filter);
:
:
public class HeadsetStateReceiver extends BroadcastReceiver {
@Override
String action = intent.getAction();
boolean unplugged = false;
if( Intent.ACTION_HEADSET_PLUG.equals(action) )
{
// 有線ヘッドセット抜線
unplugged = (intent.getIntExtra("state",0) == 0);
}
else if( AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(action) )
{
// BT電源OFF/切断、A2DP切断など。有線抜線でもここに来る場合がある
unplugged = true;
}
if( unplugged && mListener != null ) {
mHandler.post(new Runnable() {
@Override
public void run() {
mListener.onUnplugged();
}
});
}
}
マニフェストでは application タグの内側に以下のreceiverを定義しておけばよいです。
<receiver
android:name=".HeadsetStateReceiver" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.HEADSET_PLUG" />
<action android:name="android.intent.action.PHONE_STATE" />
</intent-filter>
</receiver>
着信通知
最後に電話の着信の監視です。
着信通知のクラスは、TelephonyCallback クラスをベースとしたクラスで、同時に TelephonyCallback.CallStateListener を実装します。
この機能、API31を境にインターフェースが変わっているので、処理を枝分かれさせる必要があるのですが、API31以降の実装を以下に示します。
@RequiresApi(api = Build.VERSION_CODES.S)
public class IncomingCallReceiver12 extends TelephonyCallback implements TelephonyCallback.CallStateListener {
@Override
public void onCallStateChanged(int state) {
switch (state) {
case TelephonyManager.CALL_STATE_RINGING: // 電話が鳴ったタイミングでポーズ
// ここでTTSの停止処理をキックする
break;
case TelephonyManager.CALL_STATE_IDLE:
case TelephonyManager.CALL_STATE_OFFHOOK:
break;
}
}
}
マニフェストに必要な権限定義まとめ
今回紹介したバックグラウンドでのTTS読み上げや、各種状態検知(着信監視、外部連携)を実現するためには、以下のパーミッションと <queries> 宣言が必要です。
<!-- フォアグラウンドサービスでTTSを動かすための権限 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Android 14等を見据えた、メディア再生目的のフォアグラウンドサービス宣言 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- 電話の着信状態(RINGING)を取得するための権限 -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<!-- 通知領域へのコントロール表示権限 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<queries>
<!-- パッケージの可視性:外部のTTSサービスと連携するために必須 -->
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<!-- パッケージの可視性:MediaSessionを利用するために必須 -->
<intent>
<action android:name="android.media.browse.MediaBrowserService" />
</intent>
</queries>
まとめ
今回は、単にTTSでテキストを喋らせるだけでなく、「日常の移動中にストレスなく使えるサウンドアプリ」へと昇格させるための、泥臭くも重要な周辺実装について解説しました。
特にMediaSessionとTTSの同期ハックは、同じようなバックグラウンド音声系アプリを作る方の参考になれば幸いです。
アプリの内部的な技術解説はこれで一区切りとなります。
第1回で「昨今のAIエージェントのおかげで、アプリのメンテナンスコストが下がった」と書きましたが、今回のAPIバージョンの移行作業などでも、AIをフル活用しました。
次回は最終回として、VOTWの開発において 具体的にどのようにAIエージェントを使いこなしたか についてお話ししたいと思います。
ではまた。

