[Android] 014. 音声認識(RecognizerIntent)

録音を試したのでAndroid SDK標準の音声認識も試してみました
※ ここでは前回の録音機能は使いません
https://developer.android.com/training/wearables/user-input/voice
https://developer.android.com/reference/kotlin/android/speech/package-summary

Android Developerで実装方法を詳しく説明してるページはなさそうです

Intent呼び出しの場合は録音などのパーミッションチェックも不要で最低限これだけで実装できます
PCにマイクつけてないので、エミュレータではなくリモートデバックして確認しています
音声認識はオフラインモードで実行してます

@Composable
fun Screen(modifier: Modifier = Modifier) {
    var text by remember { mutableStateOf("") }
    val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
        if (it.resultCode == Activity.RESULT_OK) {
            val result = it.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
            text = result?.joinToString("\n") ?: "rejected."
        }
        else if (it.resultCode != Activity.RESULT_CANCELED) {
            text = "error ${it.resultCode}"
        }
    }

    Column(modifier) {
        Button(onClick = {
            text = ""
            val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
            intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
            intent.putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, true)
            intent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 5)
            launcher.launch(intent)
        }) {
            Text("start")
        }
        Text(text)
    }
}

認識結果の候補を出すためにわざと不明瞭な発話をしてためしました
人名とかだと漢字候補を得ることもできます

UIをカスタマイズしたい場合はSpeechRecognizer経由で呼び出します
queriesの指定も必要らしいのですが、リファレンスなどでは確認できず…

<manifest
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.INTERNET" />

    <queries>
        <intent>
            <action
                android:name="android.speech.RecognitionService" />
        </intent>
    </queries>
class MainActivity : ComponentActivity() {
    var recognizer : SpeechRecognizer? = null
    val text = MutableStateFlow("")

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ...
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            setupSpeechRecognizer()
        }

        setContent {
            MaterialTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Screen(
                        onClick = { start() },
                        text,
                        Modifier.padding(innerPadding),
                    )
                }
            }
        }
    }

    @RequiresApi(Build.VERSION_CODES.S)
    fun setupSpeechRecognizer() {
        if (SpeechRecognizer.isOnDeviceRecognitionAvailable(this)) {
            recognizer = SpeechRecognizer.createSpeechRecognizer(applicationContext)
            recognizer?.setRecognitionListener(object : RecognitionListener {
                override fun onReadyForSpeech(params: Bundle?) {}
                override fun onBeginningOfSpeech() {}
                override fun onRmsChanged(rmsdB: Float) {}
                override fun onBufferReceived(buffer: ByteArray?) {}
                override fun onEndOfSpeech() {}
                override fun onError(error: Int) {
                    text.update { "error $error" }
                }
                override fun onResults(results: Bundle?) {
                    val result = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
                    text.update { result?.joinToString("\n") ?: "rejected." }
                }
                override fun onPartialResults(partialResults: Bundle?) {}
                override fun onEvent(eventType: Int, params: Bundle?) {}
            })
        }
    }

    fun start() {
        val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
        intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
        intent.putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, true)
        intent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 5)
        recognizer?.startListening(intent)
    }

    override fun onResume() {
        super.onResume()
        if (ContextCompat.checkSelfPermission(this, RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(this, arrayOf(RECORD_AUDIO), 0)
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        recognizer?.destroy()
    }
}

@Composable
fun Screen(onClick: () -> Unit, text: StateFlow<String>, modifier: Modifier = Modifier) {
    val text by text.collectAsState()
    Column(modifier) {
        Button(onClick = onClick) {
            Text("start")
        }
        Text(text)
    }
}

サポート言語などを確認したい場合は以下ですが、不具合があるようで正常でもonErrorも呼ばれます
またエミュレータでは失敗します

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    recognizer?.checkRecognitionSupport(
        intent,
        Executors.newSingleThreadExecutor(),
        object : android.speech.RecognitionSupportCallback {
            override fun onSupportResult(recognitionSupport: RecognitionSupport) {
                Log.d("checkRecognitionSupport", recognitionSupport.toString())
            }
            
            override fun onError(error: Int) {
                Log.d("checkRecognitionSupport", "onError $error")
            }
        }
    )
}

認識結果の信頼度(confidence)はオンラインモードであれば取得可能です

intent.putExtra(RecognizerIntent.EXTRA_CONFIDENCE_SCORES, true)

override fun onResults(results: Bundle?) {
    val confidence = results?.getFloatArray(SpeechRecognizer.CONFIDENCE_SCORES)
    ...
}

以下ちょっと専門的な内容の覚書き

・連続発話

以下のような指定をすると、発話検出後すぐに音声認識が自動で終了しないので連続でしばらく発話できる
※ 短い方の指定が有効になるという話もあるようです
stopListening()を呼ぶなどして、発話完了したら手動で停止する必要がありそう

指定した場合、認識中の認識結果の予測の表示のEXTRA_PARTIAL_RESULTSが強制で有効になるようです
認識結果はonPartialResultsのみで受け取れるようになるので、自分で文字列連結など表示などの対応が必要になります

intent.putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 10000)
intent.putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 5000)

・認識中の認識結果の予測の表示

定数になってない”final_result”で認識結果が確定したかどうかがわかります
このためかUPSIDE_DOWN_CAKE(API 34)で追加されたEXTRA_SEGMENTED_SESSION(onSegmentResults)が有効にならないようです
※ これ確定してたら発話区間検出できたってこと?

intent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
    // 使い方がどこにも乗ってないしソース見ても追えないがGemini曰くBooleanだそうです...
    intent.putExtra(RecognizerIntent.EXTRA_SEGMENTED_SESSION, true)
}

recognizer?.setRecognitionListener(object : RecognitionListener {
    override fun onPartialResults(partialResults: Bundle?) {
        partialResults?.keySet()?.forEach {
            Log.d("onPartialResults", it)
        }

        val final = partialResults?.getBoolean("final_result")
        Log.d("onPartialResults", "final  $final")
    }
}

・単語ごとの信頼度とタイムスタンプ

音声認識モデルの問題か設定しても結果は取得できないようです
en_USとかでもダメでした
とにかく情報もなく、RecognizerIntent自体の実装もコードが追えないので🤷

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
    intent.putExtra(RecognizerIntent.EXTRA_REQUEST_WORD_CONFIDENCE, true)
    intent.putExtra(RecognizerIntent.EXTRA_REQUEST_WORD_TIMING, true)
}

// おそらくRecognitionPartでrawTextなどで取得できるかと思うのだが、Bandleに値が入ってこない...
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
    val part = results?.getParcelableArrayList(SpeechRecognizer.RECOGNITION_PARTS, RecognitionPart::class.java)
}

・自動句読点挿入

一回だけ”、”が出たのみたけど、なかなか自動挿入されない
“。”はFORMATTING_OPTIMIZE_LATENCYにすればたぶん第1候補に100%付加される

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    intent.putExtra(RecognizerIntent.EXTRA_ENABLE_FORMATTING, RecognizerIntent.FORMATTING_OPTIMIZE_LATENCY)
    intent.putExtra(RecognizerIntent.EXTRA_HIDE_PARTIAL_TRAILING_PUNCTUATION, false)
}

・音声認識開始と停止の効果音を消す

発話開始タイミングを知らせる開始音はUX的にも音声認識的にもあった方が良い

以下を参考
https://qiita.com/rairaii/items/5310272678ada8f96b7c

fun muteBeepSound(mute: Boolean = true) {
    val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
    if (mute) audioManager.adjustStreamVolume(AudioManager.STREAM_NOTIFICATION, AudioManager.ADJUST_MUTE, 0)
    else audioManager.adjustStreamVolume(AudioManager.STREAM_NOTIFICATION, AudioManager.ADJUST_UNMUTE, 0)
}

recognizer?.setRecognitionListener(object : RecognitionListener {
    override fun onReadyForSpeech(params: Bundle?) {
        muteBeepSound()
    }
    override fun onError(error: Int) {
        muteBeepSound(false)
    }
    override fun onResults(results: Bundle?) {
        // onEndOfSpeechと思ったけど認識結果処理の後に音がなっているようなのでここで元に戻すようにした
        val scope = CoroutineScope(Job() + Dispatchers.Default)
        scope.launch {
            delay(500)
            muteBeepSound(false)
        }
    }
}

・外部から音声を渡す

Geminiさんはできないって言ってたけど、それっぽいフラグはある
ただ使い方が不明

EXTRA_AUDIO_SOURCE
EXTRA_AUDIO_SOURCE_ENCODING
EXTRA_AUDIO_SOURCE_CHANNEL_COUNT
EXTRA_AUDIO_SOURCE_SAMPLING_RATE

・キーワード認識

「OK Google」みたいな特定キーワード検出を自分のアプリでも実装する場合
オフラインモードで音声認識止まったら自動で再開するようにすれば実装できそうだが
オフラインモードでの信頼度が0.0なのと、特定ワードを出やすくするEXTRA_BIASING_STRINGSの使い方が不明
以下のようにしても効果がなさそうです

intent.putExtra(RecognizerIntent.EXTRA_BIASING_STRINGS, arrayOf("にわとり", "ニワトリ"))

Android Studio Koala 2024.1.1 Patch 1 built on July 11, 2024