LoginSignup
0
0

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

Posted at

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

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

image.png

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

Step0: ViewModel + LiveData の構成にする

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

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

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

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

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

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

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を監視するだけになりました。

/*
 * 数字を表示する画面
 */
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

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

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

Step2: 音声入力に対応する

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

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

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

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

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

        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)
                    }
                }
            }

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

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

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

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

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

            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)

ピン♪ ポポン♪ を消す

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

    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)
    }
speechRecognizer.startListening(intent)
muteBeepSound()

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

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

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

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

RecognitionListenerには、引数が全く同じ onPartialResultsonResults の2つのコールバックがあります。

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

            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()
                }
            }
    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でちょうどそのあたりの機能性をネタにした発表がありました。

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

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

image.png

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

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

        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というイベントで、フラッシュ暗算アプリをネタにブースを出していました。

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

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