mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 02:32:47 +00:00
fix(android): release communication route after dictation (#4069)
Stopping capture abandoned audio focus but left Android's communication mode and selected device owned by Paseo, which could keep Bluetooth audio on its narrow-band call route without an active microphone. Release and reacquire the complete route with the audio session, and prevent idle device callbacks from restoring it.
This commit is contained in:
@@ -259,7 +259,9 @@ background music stays paused. On iOS this is not just a "while recording" probl
|
||||
`.playAndRecord`/`.voiceChat` category is non-mixing and survives backgrounding, and iOS re-asserts
|
||||
it every time the app returns to the foreground — so one dictation turn kills music for the life of
|
||||
the process, on every open, until the app is force-quit. Android holds
|
||||
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect.
|
||||
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect. It must also reset
|
||||
`MODE_IN_COMMUNICATION` and clear the selected communication device; abandoning focus alone can
|
||||
leave Bluetooth earbuds on their narrow-band call route without an active microphone indicator.
|
||||
|
||||
`createAudioEngine` (`packages/app/src/voice/audio-engine.native.ts`) calls
|
||||
`releaseAudioSession()` whenever capture stops and the playback queue drains, and the iOS module
|
||||
@@ -267,9 +269,9 @@ also releases on `OnAppEntersBackground`. The native side re-guards on `isRecord
|
||||
`speechPlayer.isPlaying`, because the native engine is a singleton shared by multiple JS engine
|
||||
wrappers (voice provider + dictation) and only it knows the true state.
|
||||
|
||||
This cannot be validated by a JS test — verify on a device: play music, open Paseo, use dictation
|
||||
once, stop, and confirm the music resumes; then background/foreground the app and confirm it keeps
|
||||
playing.
|
||||
This cannot be validated by a JS test — verify on a device: play music through Bluetooth earbuds,
|
||||
open Paseo, use dictation once, stop, and confirm the music resumes at full quality; then
|
||||
background/foreground the app and confirm it keeps playing at full quality.
|
||||
|
||||
## Unistyles + Reanimated
|
||||
|
||||
|
||||
+37
-13
@@ -35,6 +35,7 @@ class AudioEngine (context: Context) {
|
||||
private val executorServiceMicrophone = Executors.newFixedThreadPool(1)
|
||||
private val executorServicePlayback = Executors.newFixedThreadPool(1)
|
||||
private var speakerDevice: AudioDeviceInfo? = null
|
||||
private var communicationRouteActive = false
|
||||
private var bridgeWindowStartedAtMs = System.currentTimeMillis()
|
||||
private var micEvents = 0
|
||||
private var micBytes = 0L
|
||||
@@ -82,25 +83,26 @@ class AudioEngine (context: Context) {
|
||||
@SuppressLint("NewApi")
|
||||
private fun initializeAudio(context:Context) {
|
||||
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
activateCommunicationRoute()
|
||||
if (!requestAudioFocus()) {
|
||||
handleAudioFocusBlocked()
|
||||
}
|
||||
|
||||
// Route audio to external device if connected, otherwise route to speaker
|
||||
updateAudioRouting()
|
||||
|
||||
// Listen for changes in audio routing
|
||||
audioManager.registerAudioDeviceCallback(object:android.media.AudioDeviceCallback(){
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
|
||||
Log.d("AudioEngine", "onAudioDevicesAdded")
|
||||
super.onAudioDevicesAdded(addedDevices)
|
||||
updateAudioRouting()
|
||||
if (communicationRouteActive) {
|
||||
updateAudioRouting()
|
||||
}
|
||||
}
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
|
||||
Log.d("AudioEngine", "onAudioDevicesRemoved")
|
||||
super.onAudioDevicesRemoved(removedDevices)
|
||||
updateAudioRouting()
|
||||
if (communicationRouteActive) {
|
||||
updateAudioRouting()
|
||||
}
|
||||
}
|
||||
}, null)
|
||||
|
||||
@@ -169,10 +171,29 @@ class AudioEngine (context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun activateCommunicationRoute() {
|
||||
communicationRouteActive = true
|
||||
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
updateAudioRouting()
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun releaseCommunicationRoute() {
|
||||
communicationRouteActive = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
audioManager.clearCommunicationDevice()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.isSpeakerphoneOn = false
|
||||
}
|
||||
audioManager.mode = AudioManager.MODE_NORMAL
|
||||
}
|
||||
|
||||
/**
|
||||
* Give audio focus back once we are neither recording nor playing. The focus request is
|
||||
* AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, so holding it after a dictation turn leaves the
|
||||
* user's music paused indefinitely.
|
||||
* Give the complete communication session back once we are neither recording nor playing.
|
||||
* Focus pauses the user's music, while MODE_IN_COMMUNICATION and the selected communication
|
||||
* device can leave Bluetooth earbuds on their narrow-band call route after capture stops.
|
||||
*/
|
||||
@SuppressLint("NewApi")
|
||||
fun releaseAudioSession() {
|
||||
@@ -186,6 +207,7 @@ class AudioEngine (context: Context) {
|
||||
if (::audioTrack.isInitialized) {
|
||||
audioTrack.pause()
|
||||
}
|
||||
releaseCommunicationRoute()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,6 +231,7 @@ class AudioEngine (context: Context) {
|
||||
if (audioFocusRequest != null) {
|
||||
return
|
||||
}
|
||||
activateCommunicationRoute()
|
||||
requestAudioFocus()
|
||||
if (::audioTrack.isInitialized) {
|
||||
audioTrack.play()
|
||||
@@ -273,6 +296,7 @@ class AudioEngine (context: Context) {
|
||||
if (::audioTrack.isInitialized) {
|
||||
audioTrack.pause()
|
||||
}
|
||||
releaseCommunicationRoute()
|
||||
audioSampleQueue.clear()
|
||||
isPlaying = false
|
||||
isRecordingBeforePause = false
|
||||
@@ -283,6 +307,7 @@ class AudioEngine (context: Context) {
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startRecording(): Boolean {
|
||||
activateCommunicationRoute()
|
||||
if (!requestAudioFocus()) {
|
||||
handleAudioFocusBlocked()
|
||||
return false
|
||||
@@ -453,6 +478,7 @@ class AudioEngine (context: Context) {
|
||||
if (!wasRecordingBeforePause && !isPlaying) {
|
||||
return
|
||||
}
|
||||
activateCommunicationRoute()
|
||||
if (!requestAudioFocus()) {
|
||||
handleAudioFocusBlocked()
|
||||
return
|
||||
@@ -489,13 +515,11 @@ class AudioEngine (context: Context) {
|
||||
fun tearDown() {
|
||||
stopRecording()
|
||||
audioTrack.stop()
|
||||
audioManager.mode = AudioManager.MODE_NORMAL
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
audioManager.clearCommunicationDevice()
|
||||
}
|
||||
releaseCommunicationRoute()
|
||||
audioFocusRequest?.let { request ->
|
||||
audioManager.abandonAudioFocusRequest(request)
|
||||
}
|
||||
audioFocusRequest = null
|
||||
executorServiceMicrophone.shutdownNow()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user