From b0dd673cb9b486e63744baad92ed88ce2f0482b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 5 Apr 2024 23:57:18 +0200 Subject: [PATCH] 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 --- common/build.gradle.kts | 1 + .../android/common/util/AudioRecorder.kt | 42 ++++++++++++++++- .../android/common/util/AudioUrlPlayer.kt | 47 ++++++++++++++++++- .../android/common/util/UtilModule.kt | 10 +++- gradle/libs.versions.toml | 2 + 5 files changed, 98 insertions(+), 4 deletions(-) diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 3f403a0fc31..6a5eb510816 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -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) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/util/AudioRecorder.kt b/common/src/main/java/io/homeassistant/companion/android/common/util/AudioRecorder.kt index c8a24686e1f..24848abf1fc 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/util/AudioRecorder.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/util/AudioRecorder.kt @@ -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!!) + } } diff --git a/common/src/main/java/io/homeassistant/companion/android/common/util/AudioUrlPlayer.kt b/common/src/main/java/io/homeassistant/companion/android/common/util/AudioUrlPlayer.kt index af43fb52449..ad2e428aaa6 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/util/AudioUrlPlayer.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/util/AudioUrlPlayer.kt @@ -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!!) } } diff --git a/common/src/main/java/io/homeassistant/companion/android/common/util/UtilModule.kt b/common/src/main/java/io/homeassistant/companion/android/common/util/UtilModule.kt index 78af8919e7c..adbf595b1d2 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/util/UtilModule.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/util/UtilModule.kt @@ -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()) @Provides @Singleton - fun provideAudioUrlPlayer(): AudioUrlPlayer = AudioUrlPlayer() + fun provideAudioUrlPlayer(@ApplicationContext appContext: Context): AudioUrlPlayer = + AudioUrlPlayer(appContext.getSystemService()) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 031d8fb886a..9afea9d17f0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }