Bluetooth mic routing fix#6436
Conversation
…outing, replacing line 33 with dynamic audio source selection.
Updated AudioRecorder to dynamically select audio source based on connected Bluetooth devices, enhancing audio recording functionality.
Refactor audio source selection to prioritize VOICE_COMMUNICATION for Bluetooth audio. Enable and disable Bluetooth SCO as needed based on API level.
|
Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍 |
There was a problem hiding this comment.
Pull request overview
This PR aims to fix voice-assistant speech recognition when invoked from a Bluetooth headset button by routing input through Bluetooth SCO and selecting an appropriate AudioRecord source in the shared :common audio recording utility.
Changes:
- Update
AudioRecorderto selectAudioSource.VOICE_COMMUNICATIONwhen Bluetooth SCO is available - Start/stop Bluetooth SCO around audio focus acquisition/release
- (Accidental) Adds a repository-root
AudioRecorder.ktfile containing non-compilable pseudo-code
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| common/src/main/kotlin/io/homeassistant/companion/android/common/util/AudioRecorder.kt | Adds Bluetooth SCO routing and dynamic audio source selection for voice recording |
| AudioRecorder.kt | New file at repo root that appears to be Copilot-generated pseudo-code and will break compilation |
| /** | ||
| * Determine the appropriate audio source based on connected devices. | ||
| * Prefers Bluetooth SCO if available, falls back to VOICE_COMMUNICATION, | ||
| * and finally defaults to MIC if neither is available. | ||
| */ | ||
| private fun getAudioSource(): Int { | ||
| if (audioManager == null) { | ||
| return AudioSource.MIC | ||
| } | ||
|
|
||
| // Check if Bluetooth SCO is available | ||
| return if (audioManager.isBluetoothScoAvailableOffCall()) { | ||
| // Use VOICE_COMMUNICATION which is the standard for Bluetooth audio | ||
| AudioSource.VOICE_COMMUNICATION | ||
| } else { | ||
| AudioSource.MIC | ||
| } | ||
| } |
There was a problem hiding this comment.
The KDoc for getAudioSource() doesn't match the actual behavior: the function returns VOICE_COMMUNICATION only when Bluetooth SCO is available, otherwise MIC (there is no separate “fallback to VOICE_COMMUNICATION” path). Please update the documentation (or adjust the logic) so it accurately reflects what the code does.
| // Enable Bluetooth SCO if available (requires API 11+) | ||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { | ||
| if (audioManager.isBluetoothScoAvailableOffCall()) { | ||
| audioManager.startBluetoothSco() | ||
| } | ||
| } |
There was a problem hiding this comment.
startBluetoothSco() is guarded by an API 11 check, but this project’s minSdk is 23, so the version guard is redundant. Also, consider tracking whether this instance actually started SCO so you can stop it deterministically (rather than relying on a global stop later).
|
|
||
| // Disable Bluetooth SCO (requires API 11+) | ||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { | ||
| audioManager.stopBluetoothSco() | ||
| } | ||
|
|
There was a problem hiding this comment.
abandonFocus() calls stopBluetoothSco() unconditionally (only gated by the redundant API-level check), even if this AudioRecorder never started SCO. Since SCO is a global AudioManager state, this can interfere with other audio usage. Please gate the stop call on a local “SCO started” flag and/or the same condition used to start SCO.
| // Disable Bluetooth SCO (requires API 11+) | |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { | |
| audioManager.stopBluetoothSco() | |
| } | |
| // Disable Bluetooth SCO (requires API 11+) | |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB && | |
| audioManager.isBluetoothScoAvailableOffCall() | |
| ) { | |
| audioManager.stopBluetoothSco() | |
| } |
| import android.media.AudioManager; | ||
| import android.telecom.Call; | ||
| import android.bluetooth.BluetoothAdapter; | ||
| import android.bluetooth.BluetoothDevice; | ||
|
|
||
| // Function to detect connected Bluetooth devices | ||
| private AudioDevice getAudioSource() { | ||
| BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); | ||
| if (bluetoothAdapter != null && bluetoothAdapter.isEnabled()) { | ||
| for (BluetoothDevice device : bluetoothAdapter.getBondedDevices()) { | ||
| if (device.getType() == BluetoothDevice.DEVICE_TYPE_AUDIO) { | ||
| // return audio source associated with the Bluetooth device | ||
| return AudioDevice.BLUETOOTH; | ||
| } | ||
| } | ||
| } | ||
| // Fallback to default audio source if no Bluetooth devices are connected | ||
| return AudioDevice.DEFAULT; |
There was a problem hiding this comment.
This AudioRecorder.kt at the repository root is Java-like pseudo-code (semicolon imports, non-Kotlin function syntax, unknown types like AudioDevice) and will not compile in this Kotlin/Gradle project. It looks like an accidentally committed Copilot-generated snippet—please remove it from the PR (or convert it into valid Kotlin in the correct module/package if it’s actually needed).
| import android.media.AudioManager; | |
| import android.telecom.Call; | |
| import android.bluetooth.BluetoothAdapter; | |
| import android.bluetooth.BluetoothDevice; | |
| // Function to detect connected Bluetooth devices | |
| private AudioDevice getAudioSource() { | |
| BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); | |
| if (bluetoothAdapter != null && bluetoothAdapter.isEnabled()) { | |
| for (BluetoothDevice device : bluetoothAdapter.getBondedDevices()) { | |
| if (device.getType() == BluetoothDevice.DEVICE_TYPE_AUDIO) { | |
| // return audio source associated with the Bluetooth device | |
| return AudioDevice.BLUETOOTH; | |
| } | |
| } | |
| } | |
| // Fallback to default audio source if no Bluetooth devices are connected | |
| return AudioDevice.DEFAULT; | |
| import android.media.AudioManager | |
| import android.telecom.Call | |
| import android.bluetooth.BluetoothAdapter | |
| import android.bluetooth.BluetoothDevice | |
| private enum class AudioDevice { | |
| BLUETOOTH, | |
| DEFAULT, | |
| } | |
| // Function to detect connected Bluetooth devices | |
| private fun getAudioSource(): AudioDevice { | |
| val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() | |
| if (bluetoothAdapter?.isEnabled == true) { | |
| val bondedDevices: Set<BluetoothDevice>? = bluetoothAdapter.bondedDevices | |
| if (!bondedDevices.isNullOrEmpty()) { | |
| // Return audio source associated with any bonded Bluetooth device | |
| return AudioDevice.BLUETOOTH | |
| } | |
| } | |
| // Fallback to default audio source if no Bluetooth devices are connected | |
| return AudioDevice.DEFAULT |
| @@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow | |||
| import kotlinx.coroutines.flow.asSharedFlow | |||
| import kotlinx.coroutines.isActive | |||
| import kotlinx.coroutines.launch | |||
| import android.os.Build | |||
There was a problem hiding this comment.
AudioDeviceInfo is imported but never used, and Build is imported after non-Android imports. This will fail ktlint/unused-import checks—please remove the unused import and keep imports grouped/ordered consistently.
Co-authored-by: kikura <37535682+kikura@users.noreply.github.com>
Fix Bluetooth SCO state management in AudioRecorder
TimoPtr
left a comment
There was a problem hiding this comment.
Thanks for proposing this change, but I'm not sure it actually works. The API documentation is mentioning that when using startBluetoothSco that you should wait for a given state
As the SCO connection establishment can take several seconds, applications should not rely on the connection to be available when the method returns but instead register to receive the intent ACTION_SCO_AUDIO_STATE_UPDATED and wait for the state to be SCO_AUDIO_STATE_CONNECTED.
And I don't see that in your code.
For the next steps you should do the modification yourself and test, and only use AI to help you on the issues you have.
| } | ||
|
|
||
| // Check if Bluetooth SCO is available | ||
| return if (audioManager.isBluetoothScoAvailableOffCall()) { |
There was a problem hiding this comment.
Use the property instead of the function
| return if (audioManager.isBluetoothScoAvailableOffCall()) { | |
| return if (audioManager.isBluetoothScoAvailableOffCall) { |
| /** | ||
| * Wrapper around [AudioRecord] providing pre-configured audio recording functionality. | ||
| */ | ||
| class AudioRecorder(private val audioManager: AudioManager?) { |
There was a problem hiding this comment.
I would like a small update of the documentation that explains the behavior of the recorder.
| // Check if Bluetooth SCO is available | ||
| return if (audioManager.isBluetoothScoAvailableOffCall()) { | ||
| // Use VOICE_COMMUNICATION which is the standard for Bluetooth audio | ||
| AudioSource.VOICE_COMMUNICATION |
There was a problem hiding this comment.
Any reason to not use that all the time, it might be beneficial to other scenario than only Bluetooth.
Microphone audio source tuned for voice communications such as VoIP. It will for instance take advantage of echo cancellation or automatic gain control if available.
|
|
||
| companion object { | ||
| // Docs: 'currently the only rate that is guaranteed to work on all devices' | ||
| const val SAMPLE_RATE = 44100 |
There was a problem hiding this comment.
From the documentation
Even if a SCO connection is established, the following restrictions apply on audio output streams so that they can be routed to SCO headset:
the stream type must be STREAM_VOICE_CALL
the format must be mono
the sampling must be 16kHz or 8kHz
The following restrictions apply on input streams:
the format must be mono
the sampling must be 8kHz
It means we should not use 44100 when we are going to use Bluetooth but 8000 otherwise you might get weird behavior.
|
|
||
| // Enable Bluetooth SCO if available | ||
| if (audioManager.isBluetoothScoAvailableOffCall()) { | ||
| audioManager.startBluetoothSco() |
There was a problem hiding this comment.
This method is deprecated and the documentation suggest to use setCommunicationDevice instead can you adjust and try again?
| @@ -0,0 +1,20 @@ | |||
| // Other existing imports | |||
Refactor audio recording logic to support dynamic sample rates based on Bluetooth SCO availability. Update methods to use currentSampleRate and improve Bluetooth handling.
…o make changes as this is beyond my capabilities.
|
Take the time to review my comments, but also the code generated before asking for review. |
Description
Fixes an issue where the Bluetooth microphone was not being used when launching the voice assistant from a Bluetooth headset button. The app would fail to recognize voice commands unless the phone's microphone was within earshot.
Problem
When triggering the voice assistant from a Bluetooth headset (e.g., pressing the button on AirPods), the app would display "no text recognised" even though the Bluetooth headset was functioning correctly with other voice assistants like Google Gemini.
Solution
Enabled Bluetooth SCO (Synchronous Connection Oriented) audio routing by:
VOICE_COMMUNICATIONwhen Bluetooth SCO is availablestartBluetoothSco()call inrequestFocus()to enable SCO audio mode before recordingstopBluetoothSco()call inabandonFocus()to properly clean up SCO connectionTesting
Files Changed
*Please not that the neccessary code has been generated by Co-Pilot, but has been tested and works.