feat: stabilize voice activity and audio routing

This commit is contained in:
Edison Jwa
2026-05-25 01:19:09 +09:00
parent eb9014cd81
commit 5515ff6643
34 changed files with 3054 additions and 1751 deletions
@@ -50,7 +50,7 @@ internal class AndroidAudioLifecycleController(
private val mainHandler = Handler(Looper.getMainLooper())
private var callbackRegistered = false
private var currentRouteType: String? = null
private var currentRouteFingerprint: String? = null
private val audioDeviceCallback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
@@ -121,17 +121,38 @@ internal class AndroidAudioLifecycleController(
device: AudioDeviceInfo?,
) {
val routeType = classifyCurrentRoute(device)
if (routeType == currentRouteType) {
val routeFingerprint = buildRouteFingerprint(routeType)
if (routeFingerprint == currentRouteFingerprint) {
return
}
currentRouteType = routeType
Log.i(TAG, "route changed to: $routeType")
currentRouteFingerprint = routeFingerprint
Log.i(TAG, "route changed to: $routeType fingerprint=$routeFingerprint")
channel?.invokeMethod(
"handleRouteChange",
mapOf("routeType" to routeType),
)
}
private fun buildRouteFingerprint(routeType: String): String {
val selectedCommunicationId =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.communicationDevice?.id?.toString().orEmpty()
} else {
""
}
val inputs = audioManager
.getDevices(AudioManager.GET_DEVICES_INPUTS)
.map { "${it.id}:${it.type}:${it.productName?.toString()?.trim().orEmpty()}" }
.sorted()
.joinToString("|")
val outputs = audioManager
.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
.map { "${it.id}:${it.type}:${it.productName?.toString()?.trim().orEmpty()}" }
.sorted()
.joinToString("|")
return "$routeType#$selectedCommunicationId#$inputs#$outputs"
}
/**
* Classify the current audio output route into a stable string
* matching the iOS route-classification schema so the Dart-side
@@ -1,6 +1,8 @@
package app.chanora.chanora_flutter
import android.content.Context
import android.Manifest
import android.content.pm.PackageManager
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
@@ -8,6 +10,7 @@ import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
@@ -121,6 +124,18 @@ internal class AndroidAudioOutputController(context: Context) :
)
return false
}
if (target.requiresBluetoothConnectPermission() &&
ContextCompat.checkSelfPermission(
appContext,
Manifest.permission.BLUETOOTH_CONNECT,
) != PackageManager.PERMISSION_GRANTED
) {
Log.w(
TAG,
"setCommunicationDevice blocked for bluetooth target=${target.logLabel()}: BLUETOOTH_CONNECT not granted",
)
return false
}
val changed = audioManager.setCommunicationDevice(target)
Log.i(TAG, "setCommunicationDevice device=${target.logLabel()} changed=$changed selected=${audioManager.communicationDevice?.logLabel()}")
return changed
@@ -230,6 +245,11 @@ internal class AndroidAudioOutputController(context: Context) :
type == AudioDeviceInfo.TYPE_BLE_BROADCAST)
}
private fun AudioDeviceInfo.requiresBluetoothConnectPermission(): Boolean =
normalizedType() == "bluetoothA2dp" ||
normalizedType() == "bluetoothSco" ||
normalizedType() == "bluetoothLe"
private fun AudioDeviceInfo.logLabel(): String =
"id=$id type=${normalizedType()} product=${productName?.toString()?.trim().orEmpty()}"
@@ -2,44 +2,50 @@ package app.chanora.chanora_flutter
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothProfile
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
/**
* Manages Android Bluetooth SCO (Synchronous Connection-Oriented) audio
* for the Chanora voice session.
* Manages Android Bluetooth voice routing for the Chanora voice session.
*
* Trace: SDD-110 (Android Bluetooth SCO)
*
* Bluetooth SCO is the low-latency, monaural audio path used by Bluetooth
* headsets for phone calls. Without SCO, voice audio may route through
* A2DP which is stereo, high-latency, and lacks the codec support for
* two-way communication. On Android we must explicitly start/stop SCO
* when a Bluetooth headset is present; the platform does not auto-manage
* this for VoIP apps.
* On Android 13 / API 33 and newer, VoIP apps are expected to select a
* Bluetooth communication route with `AudioManager.setCommunicationDevice()`
* so BLE audio headsets are supported. On older Android releases we fall
* back to legacy SCO start / stop management.
*
* ## Lifecycle
*
* 1. [start] — called by the Rust audio engine (via JNI) after the Oboe
* voice streams are opened. Calls `AudioManager.startBluetoothSco()`
* voice streams are opened. On API 33+ this prefers
* `AudioManager.setCommunicationDevice()` for Bluetooth communication
* devices. On older releases it calls `AudioManager.startBluetoothSco()`
* if a Bluetooth SCO-capable device is connected and registers a
* `BroadcastReceiver` for `ACTION_SCO_AUDIO_STATE_UPDATED`.
* 2. SCO state changes are forwarded to the Rust engine via
* 2. Legacy SCO state changes, or synthetic connected/disconnected state
* changes for the API 33+ path, are forwarded to the Rust engine via
* `publishScoStateChange(int)`, a JNI function declared in
* `crates/chanora_audio/src/android_voice_unit.rs`.
* 3. [stop] — called by the Rust engine on voice stop. Calls
* `AudioManager.stopBluetoothSco()` and unregisters the receiver.
* 3. [stop] — called by the Rust engine on voice stop. Clears the selected
* communication device on API 33+ when it is one we selected, otherwise
* falls back to `AudioManager.stopBluetoothSco()` and receiver teardown.
*
* ## Thread model
*
* `start` / `stop` are called from a tokio worker thread (via JNI).
* `AudioManager.startBluetoothSco` is asynchronous — the platform
* responds with `ACTION_SCO_AUDIO_STATE_UPDATED` which arrives on the
* main thread via the `BroadcastReceiver`.
* `AudioManager.startBluetoothSco` is asynchronous on legacy devices —
* the platform responds with `ACTION_SCO_AUDIO_STATE_UPDATED` which arrives
* on the main thread via the `BroadcastReceiver`.
*/
internal class AndroidBluetoothScoController {
@@ -72,6 +78,16 @@ internal class AndroidBluetoothScoController {
return
}
scoStarted = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (trySelectBluetoothCommunicationDevice(appContext)) {
return
}
Log.i(
TAG,
"No selectable Bluetooth communication device on API 33+; legacy SCO fallback is skipped",
)
return
}
registerScoReceiver(appContext)
tryStartSco(appContext)
}
@@ -86,12 +102,17 @@ internal class AndroidBluetoothScoController {
fun stop(context: Context) {
val appContext = context.applicationContext
scoStarted = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
clearSelectedBluetoothCommunicationDevice(appContext)
return
}
unregisterScoReceiver(appContext)
tryStopSco(appContext)
}
private var scoStarted: Boolean = false
private var receiverRegistered: Boolean = false
private var selectedCommunicationDeviceId: Int? = null
private val scoReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@@ -173,6 +194,15 @@ internal class AndroidBluetoothScoController {
Log.e(TAG, "AudioManager unavailable; cannot start SCO")
return
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S &&
ContextCompat.checkSelfPermission(
context,
Manifest.permission.BLUETOOTH_CONNECT,
) != PackageManager.PERMISSION_GRANTED
) {
Log.i(TAG, "BLUETOOTH_CONNECT not granted; skipping SCO start")
return
}
if (isBluetoothScoOn(am)) {
Log.i(TAG, "SCO already on; no-op")
@@ -212,6 +242,69 @@ internal class AndroidBluetoothScoController {
}
}
private fun trySelectBluetoothCommunicationDevice(context: Context): Boolean {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
if (am == null) {
Log.e(TAG, "AudioManager unavailable; cannot select communication device")
return false
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return false
}
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.BLUETOOTH_CONNECT,
) != PackageManager.PERMISSION_GRANTED
) {
Log.i(TAG, "BLUETOOTH_CONNECT not granted; skipping bluetooth communication device selection")
return false
}
val target = am.availableCommunicationDevices.firstOrNull { it.isBluetoothCommunicationDevice() }
if (target == null) {
Log.i(TAG, "No Bluetooth communication device available on API 33+")
return false
}
val current = am.communicationDevice
if (current?.id == target.id) {
selectedCommunicationDeviceId = target.id
publishSyntheticScoState(AudioManager.SCO_AUDIO_STATE_CONNECTED)
Log.i(TAG, "Bluetooth communication device already selected: ${target.logLabel()}")
return true
}
val changed = am.setCommunicationDevice(target)
Log.i(
TAG,
"setCommunicationDevice bluetooth target=${target.logLabel()} changed=$changed selected=${am.communicationDevice?.logLabel()}",
)
if (changed) {
selectedCommunicationDeviceId = target.id
publishSyntheticScoState(AudioManager.SCO_AUDIO_STATE_CONNECTED)
}
return changed
}
private fun clearSelectedBluetoothCommunicationDevice(context: Context) {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
if (am == null) {
Log.e(TAG, "AudioManager unavailable; cannot clear communication device")
return
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return
}
val selectedId = selectedCommunicationDeviceId
val current = am.communicationDevice
if (selectedId == null || current?.id != selectedId) {
selectedCommunicationDeviceId = null
Log.d(TAG, "No app-selected Bluetooth communication device to clear")
return
}
am.clearCommunicationDevice()
selectedCommunicationDeviceId = null
publishSyntheticScoState(AudioManager.SCO_AUDIO_STATE_DISCONNECTED)
Log.i(TAG, "clearCommunicationDevice() dispatched for bluetooth route")
}
private fun tryStopSco(context: Context) {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
if (am == null) {
@@ -230,6 +323,25 @@ internal class AndroidBluetoothScoController {
}
}
private fun publishSyntheticScoState(state: Int) {
try {
publishScoStateChange(state)
} catch (t: Throwable) {
Log.w(TAG, "publishScoStateChange JNI failed: ${t.message}", t)
}
}
private fun AudioDeviceInfo.isBluetoothCommunicationDevice(): Boolean =
type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
(type == AudioDeviceInfo.TYPE_BLE_HEADSET ||
type == AudioDeviceInfo.TYPE_BLE_SPEAKER ||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
type == AudioDeviceInfo.TYPE_BLE_BROADCAST)))
private fun AudioDeviceInfo.logLabel(): String =
"id=$id type=$type product=${productName?.toString()?.trim().orEmpty()}"
private fun scoStateName(state: Int): String = when (state) {
AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> "DISCONNECTED"
AudioManager.SCO_AUDIO_STATE_CONNECTED -> "CONNECTED"