既存のアプリを魔改造して、音声入力対応のデジタルサイネージを作ったお話

https://tech.i3-systems.com/entry/2023/10/31/kaigi_on_rails_2023　

このイベントに持ち込んだ、フラッシュ暗算をどのようにして作ったのか、という実装寄りのお話です。

## 既存: 手でポチポチと入力するフラッシュ暗算アプリ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/6485e33e-c5ab-98bd-8cee-7e0754468bf3.png)

もともとは、Androidアプリ開発を始めたエンジニアが練習で作ったものでした。画面数もこの絵のくらいで、非常に簡素なものでした。Jetpack Composeも、Navigationコンポーネントも使われていない、素直にActivityを並べて作ったアプリでした。

## Step0: ViewModel + LiveData の構成にする

これはデジタルサイネージ関係なく、ただただ画面とロジックの分離です。

もともと、Activityにベタ書きで乱数発生させて問題を作成するロジックや、コマ送り処理、正解判定などが実装されていました。初めてAndroidアプリを作るとまぁこうなるよね、という状態のものです。

まず、クイズは乱数列ではなく「クイズ」っていうデータ型で扱うようにし、 Percelizeで画面を越えてもそのまま扱えるように変更。

```kotlin
@Parcelize
data class Quiz (val randomArray: IntArray) : Parcelable {
    val answer: Int
        get() = randomArray.sum()

    companion object {
        const val BUNDLE_KEY = "quiz"
    }
}
```

Activity側でタイマー処理をしていたものは、ViewModelに移動。LiveDataを使って今どの数字を表示すべきなのか、次画面に遷移すべきかどうか、あたりを投影できるようにします。

```kotlin
class FlashDisplayViewModel : ViewModel() {
    val currentDisplayNumber = MutableLiveData<Int>()
    val finished = MutableLiveData(false)

    // タイマーが内部で使う用
    private var timer: IntervalTimer? = null
    private var currentDisplayNumberIndex : Int = 0

    fun start(quiz: Quiz, intervalMs: Long) {
        timer?.cancel()

        val newTimer = IntervalTimer(intervalMs) {
            if (currentDisplayNumberIndex < quiz.randomArray.size) {
                currentDisplayNumber.value = quiz.randomArray[currentDisplayNumberIndex++]
            } else {
                timer?.cancel()
                finished.postValue(true)
            }
        }

        currentDisplayNumberIndex = 0
        newTimer.start()
        timer = newTimer
    }

    fun reset() {
        timer?.cancel()
        currentDisplayNumber.value = null
        finished.value = false
    }
}
```

(MutableLiveDataをそのまま公開しちゃってるのは完全に手抜きですw)

Activityからはタイマー処理がなくなり、ViewModelにもらってきたクイズデータを投げて、あとはLiveDataを監視するだけになりました。

```kotlin
/*
 * 数字を表示する画面
 */
class FlashDisplayActivity : AppCompatActivity() {
    @SuppressLint("MissingInflatedId")
    private lateinit var binding: ActivityFlashDisplayBinding

    lateinit var viewModel : FlashDisplayViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        viewModel = ViewModelProvider(this).get(FlashDisplayViewModel::class.java)
        val quiz : Quiz = intent.getParcelableExtra(Quiz.BUNDLE_KEY)!!
        val settings = SettingsReader(this)

        binding = ActivityFlashDisplayBinding.inflate(layoutInflater)
        setContentView(binding.root)

        viewModel.currentDisplayNumber.observe(this) { value : Int? ->
            if (value != null) {
                binding.textView1.text = value.toString()
            }
        }

        viewModel.finished.observe(this) { value : Boolean ->
            if (value) {
                gotoInputAnswerActivity(quiz)
            }
        }
        viewModel.start(quiz, 3000L / settings.speed)
    }
```

## Step1: 展示会用のActivityたちを用意する

展示会で使う処理と、ポチポチと手入力をする処理は、フラッシュ暗算する部分だけが同じで、ほかは全く同じ機能がありません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/4f98c737-f608-c3ad-6479-7d4843d28fb6.png)

もともとActivityが細かく分かれていたので、展示会で使うような機能は無理にあちこちでif文で分岐させるのではなく、完全に別のActivityとして作るのが良さそうです。

Step0でフラッシュ表示のロジックはViewModelに移動していたこともあり、フラッシュ表示用のActivityも展示会用とそれ以外とで分けました。
これは、従来のモードではステータスバーやナビゲーションバーをそのままにする一方で、展示会モードではステータスバーやナビゲーションバーを非表示にしたかったためです。
(よくよく考えるとおなじActivityでも良かったかな...という気はします...w)

## Step2: 音声入力に対応する

https://qiita.com/kaleidot725/items/8fe6b6f137a9a8710d21

この記事を大いに参考にさせていただきました。

AndroidManifestへのパーミッションなどの追加、基本的なRecognitionListenerなどの使い方は、ほぼこの記事のとおりです。

### 日本語音声認識してくれる状態じゃないときは、アプリを終了する

Androidの音声認識というのは、デバイス側にその学習データが入っていないとできません。
言語ごとにデータは分かれており、必ずしも日本語データが入っているとは限りません。入っていない場合には、アプリを終了して日本語データをダウンロードし直してから再度使ってもらう必要があります。

これは、RecognitionListenerのonErrorで以下のように実装できます。

```kotlin
        recognitionListener = object : RecognitionListener {

            
            override fun onError(error: Int) {
                when(error) {
                    SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> {
                        // パーミッションがない
                        requestFinishApp()
                    }
                    SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED, SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> {
                        // 言語がサポートされていない
                        Toast.makeText(this@AnswerActivity, "日本語の認識ができないようです。デバイスの設定を確認してください", Toast.LENGTH_SHORT).show()
                        requestFinishApp()
                    }
                    SpeechRecognizer.ERROR_NO_MATCH -> {
                        // その他のエラー
                        handleError(error)
                    }
                }
            }
```

### 一定時間応答がなければ音声入力を終了する

フラッシュ暗算の制限時間の考え方として、「制限時間５秒」は、５秒でバチッと切ってしまうと結構厳しいです。たとえば4秒くらい考えて「さんびゃくにじゅうご!」と叫んだとしても、音声認識に若干時間がかかるため５秒のタイムアウトが先に来てしまいます。

これを避けるため、全く音声入力がない状態が５秒続いたときに時間切れと判定するようにします。

`EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS` を5000に指定すると、そのような挙動になります。

`EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS` という似た名前のタイムアウトがありますが、こちらは**機種によって挙動が異なり、５秒たたないと音声認識を完了してくれないような挙動**になります。

```kotlin
            val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
            intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, "ja-JP")
            intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
            intent.putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, timeLimitMs)
            speechRecognizer.setRecognitionListener(recognitionListener)
            speechRecognizer.startListening(intent)
```

### ピン♪ ポポン♪ を消す

これも機種によって起きたり起きなかったりする問題ですが、音声認識の開始音（ピン♪）と終了音（ポポン♪）が展示会では確実に耳障りです。

```kotlin
    private fun muteBeepSound() {
        val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
        audioManager.adjustStreamVolume(AudioManager.STREAM_SYSTEM, AudioManager.ADJUST_MUTE, 0)
        audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_MUTE, 0)
    }
```

```kotlin
speechRecognizer.startListening(intent)
muteBeepSound()
```

あまりいい方法ではありませんが、展示会では他に音楽を流したりはしないという割り切りのもと、音量を0にしてしまうという回避方法をとりました。

### 音声入力における正解判定

たとえば「さんびゃくにじゅうご！」と叫んだとしても、音声認識は精度が100%ではありませんので、325ではなく「３１５」や「３２０号」などと誤って認識されることがあります。ただ、これを不正解としてしまうのはあまりに不誠実です。

音声入力においては、なるべく正解させてあげる（正解は認識された瞬間に正解とし、不正解は最後までずっと認識されなかった場合にのみ不正解とする）ような作りとする必要があります。

RecognitionListenerには、引数が全く同じ `onPartialResults` と `onResults` の２つのコールバックがあります。

onResultは認識完了時に１回呼ばれるだけなのに対し、onPartialResultsというのは認識結果が刻一刻と変わるのに応じて呼ばれます。制限時間を設定する際に見て使わなかった方の `EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS` がデフォルトだと1,2秒のはずで、**認識完了とみなされonResultが呼ばれるのは1,2秒ダンマリしたときだけ**です。そのため、今回のようにクイズの解答目的で使う場合には、**onResultで正解判定をするのではなく、onPartialResultで正解っぽいものがあれば積極的に拾うよう実装**をします。

```kotlin
            override fun onPartialResults(partialResults: Bundle?) {
                partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.let { resultStrings ->
                    val answerString = resultStrings.joinToString(" ")
                    viewModel.answerDisplayText.value = answerString
                    finishIfCorrectAnswer(answerString)
                }
            }

            override fun onResults(results: Bundle?) {
                results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.let { resultStrings ->
                    val answerString = resultStrings.joinToString(" ")
                    viewModel.answerDisplayText.value = answerString
                    viewModel.requestFinish()
                }
            }
```

```kotlin
    private fun extractAnswerStringFromResult(result: String?): Int? {
        if (result == null) return null

        val regex = Regex("""(\d+)""")
        val matchResult = regex.find(result)
        return matchResult?.groupValues?.get(1)?.toIntOrNull(10)
    }

    /**
     * 数字として認識できて、正解の場合だけfinishフラグを立てる
     */
    private fun finishIfCorrectAnswer(answerString: String?) {
        val inputAnswer = extractAnswerStringFromResult(answerString)
        val quiz : Quiz = intent.getParcelableExtra(Quiz.BUNDLE_KEY)!!
        if (inputAnswer == quiz.answer){
            viewModel.requestFinish()
        }
    }
```

このように、onPartialResultsでは、結果文字列から正規表現で数字列を取り出して、それが正解の数字であれば正解扱いにしています。

いっぽうで、onResultsでは、そもそも onPartialResultsのほうで正解するケースはすべて拾われているため、不正解の場合に次の画面に進む処理をすることがだけが責務です。

このあたりが、APIリファレンスを読むだけではピンとこなくて、実デバイスでチューニングを重ねた結果、多分こうするとよさそうという知見です。完璧かと言うとまだまだ改善の余地はありますが、なんとなくイイ感じには動いてるかと思います。

## Step3: 遠隔で設定値を変えられるようにする

FirebaseのRemote Configなどを使いこなせば、設定値を遠隔で書き換えるといったことは可能なものの、そもそもAndroid Management APIを使用したKIOSKの前提では、もっと楽な方法があります。

それがManaged Configurationという機能で、今年のDroidkaigi2023でちょうどそのあたりの機能性をネタにした発表がありました。

https://speakerdeck.com/imsaku/managed-configurations

雑に言うと、 `Bundle` 型のkey-valueペアをAndroid Management APIで指定すると、それがデバイスに配信されてくる、というシンプルな仕組みです。

Androidフレームワークとしては、デバイスに配信されたデータは、捨てるもよし、活用するもよし、というスタンスで、プッシュメッセージに近いですが、プッシュと違って「今のkey-valueの値は何？」と現在の値を参照するAPIもあります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/ae7e76f0-ef1c-1a23-067f-134fda1152a9.png)

今回は、ユーザが設定できる項目をそのままManaged Configurationでも設定できるようにしました。ただ、**管理者による設定がユーザの設定値よりも優先されてほしいけれど、ユーザ設定値を上書きしてしまうのはやりすぎ**なので、ユーザによる設定値とManaged Configurationによる設定値を分けて保持するようにしました。（ちなみにこれは要件にかかわらず分けたほうが良いです）
あとは、リポジトリのロジック側で2つの設定値を参照し、それを吸収してアプリケーション側にはあくまで従来通りのユーザ設定値っぽく見えるようにして返します。

設定画面については、基本的にはユーザによる設定値を編集するが、Managed Configurationによる設定がある場合にはそれをdisabled状態で見せる、というロジックを追加しました。（たぶんもっときれいに書く方法はありそうですが、愚直にif文です...w）

```kotlin
        val managedConfig = ManagedConfigurationReader(requireContext())

        findPreference<SeekBarPreference>("key_num_of_digits")?.let { pref ->
            managedConfig.num_of_digits?.let { num_of_digits ->
                pref.isEnabled = false
                pref.value = num_of_digits
            }
        }

        findPreference<EditTextPreference>("key_length_of_quiz")?.let { pref ->
            managedConfig.length_of_quiz?.let { length_of_quiz ->
                pref.isEnabled = false
                pref.text = length_of_quiz.toString()
            }
            pref.summaryProvider = MySummaryProvider(resources, R.string.preference_summary_length_of_quiz)
            setNumberInputWithMaximumLength(pref, 2)
        }
```

かりに悪い人がdisabledを解除して設定をいじれたとしても、編集されるのはユーザ設定値の方なので、アプリケーションロジックでは上書きされたManaged Configurationベースの値が使われるため害は与えません。


## まとめ

以上のような試行錯誤を経て、Kaigi on Rails 2023というイベントで、フラッシュ暗算アプリをネタにブースを出していました。

基本的には「意外とちょろい」なんですが、音声認識のところだけは本当に鬼門で、機種によって全然動きが違うので、もしかするとノウハウのようでノウハウじゃないかもしれません。展示会で似たようなことをする場合には、ぜひ実際に展示する端末での開発・動作確認をすることをおすすめします！