【Android】楽曲情報の読み上げがしたい！

この記事はレコチョク [Advent Calendar 2022](https://qiita.com/advent-calendar/2022/recochoku) の22日目の記事になります。

https://qiita.com/advent-calendar/2022/recochoku

# はじめに
株式会社レコチョクでAndroidアプリ開発をしている寺島です。
最近、ReoNaさんのお歌にハマっています。
（ReoNaさんが曲のことをお歌と言います）
気になったら、聴いてみてください。

最近思ってた事を解決すべく、挑戦した事を記事にしました！

# 最近思ってた事について
みなさん、音楽って聴きますか？
自分は移動中に聴くことや、ながら聴きをすることが多いです。

自分のように移動中に聴いたり、何かをしながら聴いたりする人は多いと思います。その場合、音楽を聴きながら何の曲を聴いているか知りたくなるときはないでしょうか？

自分はあります。
**『スマホで画面を確認せずに、今流れている曲の曲名やアーティスト名が分かればいいのにな～』**
と思うことが、多々あります。


今回はこの自分の悩みを、解決するために試した事の記事になります！

# どのように実現できそうか？
ということで、
**『スマホで画面を確認せずに、今流れている曲の曲名やアーティスト名が分かればいいのにな～』**
上記を実現するために、何をすればよいかを考えてみました。

画面を見ずに、曲名・アーティスト名を確認するには、ほぼ音で確認するしかないです。曲と曲の合間で、楽曲情報を音声で読み上げることで実現できそうです。

次は実現のために、**再生中の楽曲情報を音声で読み上げられるようにする方法**を調べていきます！

# 読み上げを行う機能について調査
今回は私用で使うスマホがAndroidのこともあり、Androidでの実現に絞っています。その上で、指定の文字列を読み上げることをさせるにはどうすればいいか、簡単に調査しました。

調べた結果は以下です。

1. TextToSpeech (Google)
2. TextToSpeech (Azure) -> Androidで実装できるか分からず

フリーで提供されているものも多くありましたが、テキストが豊富そうなものを2つ上げました。

2つの方法の中で、Androidで読み上げを実装するならば、Googleが公式で提供しているTextToSpeechが一番使い勝手が良さそうでした。2はMicrosoftで提供している読み上げツールでした。しかし、Androidで実装をするならば、Googleが提供しているものの方が良さそうです。

今回は**Text To Speech(Google)**で楽曲情報を読み上げるにはどうするかを考えます！

# TextToSpeechについて
## 概要
AndroidのAPIとして提供されています。
テキストから音声を合成することができ、その音声を再生したり、サウンドファイルにしたりできるようです。

https://developer.android.com/reference/kotlin/android/speech/tts/TextToSpeech

## 使用時の流れ
1. 初期化
2. 初期化後に関する処理
3. テキストの読み上げ
4. テキスト読み上げを使用終了時のリソース解放

## TextToSpeechの実装の参考サイト
nyanのアプリ開発

https://akira-watson.com/android/tts.html

Qiita: @maKunugiさん

https://qiita.com/maKunugi/items/059861da848219859f34

以上がTextToSpeechに関する情報です。

# TextToSpeechの実装
## 初期化
まずは `TextToSpeech` の初期化を行います。
初期化は簡単で、以下のように行います。
```kotlin
val textToSpeech = TextToSpeech(this, this)
```
第1引数は、 `Context` です。
第2引数は、 `lisner` です。このリスナは初期化時の通知を受け取ります。
[コンストラクタの詳細](https://developer.android.com/reference/kotlin/android/speech/tts/TextToSpeech#texttospeech_1)

## 初期化後に関する処理
初期化後に行う処理は以下のようにしました。
`Activity` に `TextToSpeech.OnInitListener` を継承している場合は、下記の関数をオーバーライドする必要があります。この関数で初期化された後の処理を行います。

```kotlin
override fun onInit(status: Int) {
    // 初期化が成功している場合
    if (status == TextToSpeech.SUCCESS) {
       // 言語の設定をする
       val locale = Locale.JAPAN
       // 端末で使用できる言語か確認
       if (textToSpeech.isLanguageAvailable(locale) > TextToSpeech.LANG_AVAILABLE) {
           // 言語を指定
           textToSpeech.language = Locale.JAPAN
       } else {
           // 日本語が使用できない場合はトーストで警告
           Toast.makeText(
               this,
               getString(R.string.not_available_japanese),
               Toast.LENGTH_SHORT
           ).show()
       }
    } else {
        // 初期化が失敗していればトーストで警告
        Toast.makeText(
            this,
            getString(R.string.error_init_tts),
            Toast.LENGTH_SHORT
        ).show()
    }
}
```
上記で注意すべきは、読み上げに使用する言語が端末側で使用できる言語か確認する点です。
指定した使用言語の使用可否は数字で表せます。`TextToSpeech.LANG_AVAILABLE` よりも数値が大きい場合は、使用可能な言語です。したがって、if文は下記のようになります。
`if (tts.isLanguageAvailable(locale) > TextToSpeech.LANG_AVAILABLE)` 

## テキストの読み上げ
```kotlin
private fun startSpeak(text: String, id: String) {
	textToSpeech.speak(text, TextToSpeech.QUEUE_FLUSH, null, id)
}
```
自分はActivityの中に上記のような関数を作成しました。Activity内で初期化した `textToSpeech` を使用して、好きな文字列を読み上げをリクエストする関数です。
[TextToSpeechのspeak()](https://developer.android.com/reference/kotlin/android/speech/tts/TextToSpeech#speak)

引数は以下のようになります。

1. text: String → 読み上げたい文字列
2. queueMode: Int → 読み上げたい文字列をどう追加するか
3. params: Bundle → リクエストのパラメータ（nullにできる）
4. utteranceId: String → リクエストの識別子

`queueMode` では、読み上げる文字列を追加していく際に、今までの読み上げる文字列をどうするかを設定できます。 `TextToSpeech.QUEUE_FLUSH` では、リクエストのたびに、読み上げ内容をすべてリセットします。

`params` は、今回は `null` にしています。 `null` で動かせるため説明も省きます。

`utteranceId` は、このリクエストの名前になります。特別、決まりは無いので楽曲情報の読み上げを意味する文字列にしました。今回は外からidを渡せるようにしてます。idを分けることで、読み上げされる状況の使い分けが可能になりそうです。

## テキスト読み上げを使用終了時のリソース解放
今回は `Activity` が破棄されたタイミングで、テキスト読み上げ機能も破棄します。その場合は `Activity` の `onDestroy()`で以下のように処理をかけます。

```kotlin
override fun onDestroy() {
    textToSpeech.shutdown()
    super.onDestroy()
}
```

# 読み上げ機能だけ試してみた

楽曲情報を読み上げる準備が完了しました！
下記のような楽曲リストを用意して、読み上げができるかを確認しました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521457/241e586a-2279-0562-6456-035cdd85cac8.png" width=300>

読み上げボタンを押せば、読み上げが実行できます。

少し気になるのは、「feat」のような英語の部分がどう読まれるかです。英字を1文字ずつ読み上げられる場合にはあまり嬉しい結果ではないかもしれないです。

下の動画から確認可能です。

<iframe width="459" height="816" src="https://www.youtube.com/embed/cpAxu0hrbh8" title="楽曲情報読み上げてみた" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

実際に読み上げしている様子をみると、英語でもしっかり日本語の読みになっていました。「feat」が「フィート」と読まれていて感動しました。時折、曲名・アーティスト名で想定と異なる読み上げになっている事がありました。楽曲情報の読みをひらがなで取得できるとベストでしたが、今回は行いません。

ここまでの実装をベースに、楽曲再生をしつつ、楽曲の始まりには楽曲情報を読んでくれるような状態にしていきます！

# 楽曲再生と楽曲情報読み上げの連携について
ここからは楽曲の再生と合わせて、楽曲情報の読み上げを行えるようにします。

やりたいことは以下のような流れです。
1. 楽曲情報を読み上げる
2. 楽曲の再生を行う

上記を繰り返し行えば、自分が実現したい事を叶えられます。

## どのように実現するか？

### 読み上げ開始から終了まで
1曲目の楽曲情報読み上げは、先ほど読み上げボタンを押して音声を再生したように行えば、問題ありません。

しかし、楽曲情報を読み終えたタイミングで楽曲を再生する必要があります。そこで使用するのが `UtteranceProgressListener()` です。これはTextToSpeechが用意しているリスナです。

以下のタイミングで処理を行うように出来ます。
- 読み上げの開始時 `onStart()` 
- 読み上げの終了時 `onDone()` 
- 読み上げの失敗時 `onError()` 

今回、使用するのは `onDone()` になります。
この `onDone()` の中で、楽曲を再生する処理を呼び出してあげます。

### 再生開始から再生終了まで
次に考えるのは、楽曲の再生が終了したタイミングで、次の楽曲情報を読み上げる方法です。

今回、楽曲の再生にはExoPlayerを使用しています。ExoPlayerは、コンテンツのUriを渡すことで楽曲の再生が可能です。

また、楽曲の再生終了時に処理を行うために `onEvents()`を使用します。
この関数を使って、曲が終わったタイミングで処理を行うようにします。
したがって、この関数の中で `textToSpeech` で、楽曲情報を読み上げる処理を書いてあげれば、OKです。 

### 全体をみて
簡単に図で表すと以下のようになります。

![読み上げと再生の連携.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521457/2b5b0b3f-a824-d2a9-c536-b5c438b4df6a.png)

これで音声で楽曲情報を読み上げる処理と楽曲再生処理を切り替えていけそうです！


## 実装について
それでは先ほどの方針で実装を進めていきます。今回はExoPlayerの詳細な情報には触れないです。`player` という変数がExoPlayerで、 `player.start()` で再生を行ったりできます。

以下は次の処理が行われてます。
- 楽曲の取得処理
- 楽曲の再生処理
- 楽曲情報の読み上げ処理

```kotlin
musicList.addAll(getMediaMetadataInfo())

var count = 0

// 次に再生される楽曲の情報を読み上げてPlayerを動かす
viewModel.doneSpeak.observe(this) { index ->
    if (index < musicList.size) {
        player.addMediaItem(
            MediaItem.fromUri(
                musicList[index].contentUri
            )
        )
        player.prepare()
        player.play()
    }
}

textToSpeech = TextToSpeech(this, this)
textToSpeech.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
    // 今回は省略
    override fun onStart(p0: String?) {
    }

    // 発話が完了したタイミングでViewModelに発話が完了したことを伝える
    override fun onDone(p0: String?) {
        viewModel.viewModelScope.launch {
            if (p0.equals(MUSIC_PLAYER_AND_TEXT_TO_SPEECH)) {
                viewModel.doneSpeak(count)
            }
        }
    }

    // 今回は省略
    override fun onError(p0: String?) {
    }
})

player = ExoPlayer.Builder(this).build()
player.addListener(object : Player.Listener {
    override fun onEvents(player: Player, events: Player.Events) {
        // 楽曲が終わったタイミングで処理を入れる
        if (player.playbackState == Player.STATE_ENDED){
            // 次の曲に切り替える
            count++
            
            // 楽曲が切り替わるタイミングで再生を停止
            player.stop()

           // 1曲目以降で読み上げをするように調整
           if (count >= 0 && count < musicList.size) {
               // 次の曲を読み上げ
               startSpeak(
                   generateReadingText(musicList[count]),
                   MUSIC_PLAYER_AND_TEXT_TO_SPEECH
               )
           }

            // 楽曲リストの最後まで読み上げたら、最初の曲を指すように変更する
            if (count == musicList.size) {
                count = 0
            }
        }
    }
})
```
`count` は現在再生されている曲を表します。読み上げが完了したタイミングで次の楽曲を指すようになります。

1曲目の読み上げ開始は画面上に配置したボタンを押して行います。読み上げが終わったら、次に再生したい楽曲をExoPlayerに渡します。

この時、 `onDone()` の中でViewModelを使っています。これはTextToSpeechのリスナが使われるスレッドで、ExoPlayerを直接呼ぶ事ができないため、ViewModel内で監視しています。
`doneSpeak()` という関数は現在の再生曲が何番目かを渡しています。オブザーブされた時は、再生曲の番号で、次に再生する楽曲を準備します。実際にViewModelは以下のように定義してます。

```kotlin
class MainViewModel : ViewModel() {

    private val _doneSpeak = MutableLiveData<Int>()
    val doneSpeak: LiveData<Int> = _doneSpeak
    fun doneSpeak(index: Int) {
        _doneSpeak.value = index
    }
}
```

下記のコードはViewModel側で楽曲情報読み上げを検知した際に呼ばれる処理です。楽曲情報読み上げ終了が検知されると、再生が開始されます。
```kotlin
// 次に再生される楽曲の番号を渡しPlayerを動かす
viewModel.doneSpeak.observe(this) { index ->
    // 楽曲のセット
    player.addMediaItem(
        MediaItem.fromUri(
            musicList[index].contentUri
        )
    )
    // プレイヤーの準備
    player.prepare()
    // 再生開始
    player.play()
}
```

次に楽曲の再生が終了した時に、楽曲情報の読み上げを開始する処理をみていきます。
```kotlin
player.addListener(object : Player.Listener {
    override fun onEvents(player: Player, events: Player.Events) {
        // 楽曲が終わったタイミングで処理を入れる
        if (player.playbackState == Player.STATE_ENDED){
            // 次の曲に切り替える
            count++
            
            // 楽曲が切り替わるタイミングで再生を停止
            player.stop()

           // リストの終わりを検知する
           if (count < musicList.size) {
               // 次の曲を読み上げ
               startSpeak(
                   generateReadingText(musicList[count]),
                   MUSIC_PLAYER_AND_TEXT_TO_SPEECH
               )
           }

            // 楽曲リストの最後まで読み上げたら、最初の曲を指すように変更する
            if (count == musicList.size) {
                count = 0
            }
        }
    }
})
```
`onEvents()` で楽曲が再生終了したのを受け取るためには、`player.playbackState` が `Player.STATE_ENDED` であることを確認する必要があります。もし、一致すれば処理を行うように書いてます。 
リストの終わりを検知した時や、楽曲が最後まで再生された時の処理を入れています。 

これで実装についても全体を見る事ができました。
次に実装したものを試しに動かした様子を見ていきます！！

## 楽曲再生と楽曲情報読み上げ試してみた

<iframe width="459" height="816" src="https://www.youtube.com/embed/xVnvKMGBWUE" title="楽曲情報読み上げと再生してみた" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>


楽曲情報を読み上げた後で、曲が流れています。曲の再生が終わると、次の曲の曲名とアーティスト名を読み上げてくれてます！

これで自分の願いはなんとか形になりました！！
（不具合等はありますが...）

# まとめ
実装の細かい詳細は下記のGitHubを確認ください。コードは綺麗でないので、ご了承下さい。

https://github.com/mnhiro/AdventCalendarSample2022

なお、今回端末はAndroid12のPixel3a を使用しました。
楽曲の取得処理でAndroidのOSによる処理を分けたりしていないので、別OSバージョンですとクラッシュする事があります。

また、端末によってはTextToSpeechがうまく動かない端末もあるかと思いますが、そこもご了承ください。

今回は画面を見ずに、再生中の楽曲の曲名・アーティスト名が分かるようになりました。
しかし、課題としては別のアプリを立ち上げた時の挙動や画面を消灯した時の対応などは、考慮できていません。

その辺りはService等を使ってやると、対応できそうです！

また、TextToSpeechとExoPlayerはActivityが生成される度に作られるので、インスタンスが重複してしまいます。その辺りは、シングルトンにしてあげると綺麗に動作するようになるかと思います。

ひとまず、目標達成という形にさせてください！！

# 最後に
最後まで読んでいただき、ありがとうございました！

明日の記事は[レコチョク Advent Calendar 2022](https://qiita.com/advent-calendar/2022/recochoku) の23日目 「DangerでPull Requestの確認を効率化した」になります。お楽しみに！！

---
この記事はレコチョクのエンジニアブログの記事を転載したものになります

https://techblog.recochoku.jp/8928