Request audio focus for Assist voice input + output (#4308)

* Request audio focus for Assist voice input + output

 - Request exclusive focus for Assist voice input (no other applications allowed to playback)
 - Request focus with ducking allowed for Assist voice output

* Remove useless @Inject
This commit is contained in:
Joris Pelgröm 2024-04-05 23:57:18 +02:00 committed by GitHub
parent 314a60bd2e
commit b0dd673cb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 98 additions and 4 deletions

View file

@ -62,6 +62,7 @@ dependencies {
implementation(libs.appcompat)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.media)
api(libs.androidx.room.runtime)
api(libs.androidx.room.ktx)

View file

@ -2,8 +2,13 @@ package io.homeassistant.companion.android.common.util
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioManager.OnAudioFocusChangeListener
import android.media.AudioRecord
import android.media.MediaRecorder.AudioSource
import androidx.media.AudioAttributesCompat
import androidx.media.AudioFocusRequestCompat
import androidx.media.AudioManagerCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -16,7 +21,7 @@ import kotlinx.coroutines.launch
/**
* Wrapper around [AudioRecord] providing pre-configured audio recording functionality.
*/
class AudioRecorder {
class AudioRecorder(private val audioManager: AudioManager?) {
companion object {
// Docs: 'currently the only rate that is guaranteed to work on all devices'
@ -42,6 +47,9 @@ class AudioRecorder {
/** Flow emitting audio recording bytes as they come in */
val audioBytes = _audioBytes.asSharedFlow()
private var focusRequest: AudioFocusRequestCompat? = null
private val focusListener = OnAudioFocusChangeListener { /* Not used */ }
/**
* Start the recorder. After calling this function, data will be available via [audioBytes].
* @throws SecurityException when missing permission to record audio
@ -55,6 +63,7 @@ class AudioRecorder {
if (!ready) return false
if (recorderJob == null || recorderJob?.isActive == false) {
requestFocus()
recorder?.startRecording()
recorderJob = ioScope.launch {
val dataSize = minBufferSize()
@ -84,6 +93,7 @@ class AudioRecorder {
recorder?.stop()
recorderJob?.cancel()
recorderJob = null
abandonFocus()
releaseRecorder()
}
@ -101,4 +111,34 @@ class AudioRecorder {
}
private fun minBufferSize() = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
private fun requestFocus() {
if (audioManager == null) return
if (focusRequest == null) {
focusRequest = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE).run {
setAudioAttributes(
AudioAttributesCompat.Builder().run {
setUsage(AudioAttributesCompat.USAGE_ASSISTANT)
setContentType(AudioAttributesCompat.CONTENT_TYPE_SPEECH)
build()
}
)
setOnAudioFocusChangeListener(focusListener)
build()
}
}
focusRequest?.let {
try {
AudioManagerCompat.requestAudioFocus(audioManager, it)
} catch (e: Exception) {
// We don't use the result / focus if available but if not still continue
}
}
}
private fun abandonFocus() {
if (audioManager == null || focusRequest == null) return
AudioManagerCompat.abandonAudioFocusRequest(audioManager, focusRequest!!)
}
}

View file

@ -1,9 +1,14 @@
package io.homeassistant.companion.android.common.util
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.AudioManager.OnAudioFocusChangeListener
import android.media.MediaPlayer
import android.os.Build
import android.util.Log
import androidx.media.AudioAttributesCompat
import androidx.media.AudioFocusRequestCompat
import androidx.media.AudioManagerCompat
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.Dispatchers
@ -13,7 +18,7 @@ import kotlinx.coroutines.withContext
/**
* Simple interface for playing short streaming audio (from URLs).
*/
class AudioUrlPlayer {
class AudioUrlPlayer(private val audioManager: AudioManager?) {
companion object {
private const val TAG = "AudioUrlPlayer"
@ -21,6 +26,9 @@ class AudioUrlPlayer {
private var player: MediaPlayer? = null
private var focusRequest: AudioFocusRequestCompat? = null
private val focusListener = OnAudioFocusChangeListener { /* Not used */ }
/**
* Stream and play audio from the provided [url]. Any currently playing audio will be stopped.
* This function will suspend until playback has started.
@ -50,6 +58,7 @@ class AudioUrlPlayer {
)
setOnPreparedListener {
if (isActive) {
requestFocus(isAssistant)
it.start()
cont.resume(true)
} else {
@ -89,5 +98,41 @@ class AudioUrlPlayer {
private fun releasePlayer() {
player?.release()
player = null
abandonFocus()
}
private fun requestFocus(isAssistant: Boolean) {
if (audioManager == null) return
if (focusRequest == null) {
focusRequest = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK).run {
setAudioAttributes(
AudioAttributesCompat.Builder().run {
if (isAssistant) {
setUsage(AudioAttributesCompat.USAGE_ASSISTANT)
setContentType(AudioAttributesCompat.CONTENT_TYPE_SPEECH)
} else {
setUsage(AudioAttributesCompat.USAGE_MEDIA)
setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC)
}
build()
}
)
setOnAudioFocusChangeListener(focusListener)
build()
}
}
focusRequest?.let {
try {
AudioManagerCompat.requestAudioFocus(audioManager, it)
} catch (e: Exception) {
// We don't use the result / focus if available but if not still continue
}
}
}
private fun abandonFocus() {
if (audioManager == null || focusRequest == null) return
AudioManagerCompat.abandonAudioFocusRequest(audioManager, focusRequest!!)
}
}

View file

@ -1,8 +1,12 @@
package io.homeassistant.companion.android.common.util
import android.content.Context
import android.media.AudioManager
import androidx.core.content.getSystemService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@ -12,9 +16,11 @@ object UtilModule {
@Provides
@Singleton
fun provideAudioRecorder(): AudioRecorder = AudioRecorder()
fun provideAudioRecorder(@ApplicationContext appContext: Context): AudioRecorder =
AudioRecorder(appContext.getSystemService<AudioManager>())
@Provides
@Singleton
fun provideAudioUrlPlayer(): AudioUrlPlayer = AudioUrlPlayer()
fun provideAudioUrlPlayer(@ApplicationContext appContext: Context): AudioUrlPlayer =
AudioUrlPlayer(appContext.getSystemService<AudioManager>())
}

View file

@ -39,6 +39,7 @@ ksp = "1.9.23-1.0.19"
ktlint = "12.1.0"
lifecycle = "2.7.0"
material = "1.11.0"
media = "1.7.0"
media3 = "1.3.0"
navigation-compose = "2.7.7"
okhttp = "4.12.0"
@ -86,6 +87,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref
activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activity-compose" }
android-beacon-library = { module = "org.altbeacon:android-beacon-library", version.ref = "androidBeaconLibrary" }
androidx-health-services-client = { module = "androidx.health:health-services-client", version.ref = "healthServicesClient" }
androidx-media = { module = "androidx.media:media", version.ref = "media" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "room" }