Skip to content

Commit af9ab9f

Browse files
authored
fix(android): use Bluetooth microphones for voice capture (openclaw#99259)
* fix(android): route voice capture through Bluetooth microphones * chore(i18n): refresh Android voice source inventory
1 parent d47308f commit af9ab9f

6 files changed

Lines changed: 405 additions & 80 deletions

File tree

apps/.i18n/native-source.json

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4891,87 +4891,87 @@
48914891
},
48924892
{
48934893
"kind": "conditional-branch",
4894-
"line": 224,
4894+
"line": 221,
48954895
"path": "apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt",
48964896
"source": "Speaking · waiting for reply",
48974897
"surface": "android",
48984898
"id": "native.android.c5e56f0e8af094fb"
48994899
},
49004900
{
49014901
"kind": "conditional-branch",
4902-
"line": 224,
4902+
"line": 221,
49034903
"path": "apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt",
49044904
"source": "Speaking…",
49054905
"surface": "android",
49064906
"id": "native.android.7228c229ce9f97c6"
49074907
},
49084908
{
49094909
"kind": "conditional-branch",
4910-
"line": 418,
4910+
"line": 415,
49114911
"path": "apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt",
49124912
"source": "Mic off",
49134913
"surface": "android",
49144914
"id": "native.android.d84c6c14e42763ff"
49154915
},
49164916
{
49174917
"kind": "conditional-branch",
4918-
"line": 418,
4918+
"line": 415,
49194919
"path": "apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt",
49204920
"source": "Mic off · sending…",
49214921
"surface": "android",
49224922
"id": "native.android.9dda2b87d9668a4b"
49234923
},
49244924
{
49254925
"kind": "conditional-branch",
4926-
"line": 484,
4926+
"line": 481,
49274927
"path": "apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt",
49284928
"source": "Listening · sending queued voice",
49294929
"surface": "android",
49304930
"id": "native.android.c802b757f76226d9"
49314931
},
49324932
{
49334933
"kind": "conditional-branch",
4934-
"line": 484,
4934+
"line": 481,
49354935
"path": "apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt",
49364936
"source": "Sending queued voice",
49374937
"surface": "android",
49384938
"id": "native.android.01ac388cd8ae0732"
49394939
},
49404940
{
49414941
"kind": "ui-state-text",
4942-
"line": 132,
4942+
"line": 130,
49434943
"path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt",
49444944
"source": "Off",
49454945
"surface": "android",
49464946
"id": "native.android.ae8e946c1bb1f3bc"
49474947
},
49484948
{
49494949
"kind": "conditional-branch",
4950-
"line": 342,
4950+
"line": 340,
49514951
"path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt",
49524952
"source": "Listening",
49534953
"surface": "android",
49544954
"id": "native.android.b4f67e96603515bd"
49554955
},
49564956
{
49574957
"kind": "conditional-branch",
4958-
"line": 342,
4958+
"line": 340,
49594959
"path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt",
49604960
"source": "Ready",
49614961
"surface": "android",
49624962
"id": "native.android.b88e4278ead724ba"
49634963
},
49644964
{
49654965
"kind": "conditional-branch",
4966-
"line": 1629,
4966+
"line": 1600,
49674967
"path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt",
49684968
"source": "Aborted",
49694969
"surface": "android",
49704970
"id": "native.android.13531fe081a45711"
49714971
},
49724972
{
49734973
"kind": "conditional-branch",
4974-
"line": 1629,
4974+
"line": 1600,
49754975
"path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt",
49764976
"source": "Chat error",
49774977
"surface": "android",
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
package ai.openclaw.app.voice
2+
3+
import android.annotation.SuppressLint
4+
import android.content.Context
5+
import android.media.AudioDeviceCallback
6+
import android.media.AudioDeviceInfo
7+
import android.media.AudioFormat
8+
import android.media.AudioManager
9+
import android.media.AudioRecord
10+
import android.media.MediaRecorder
11+
import android.os.Build
12+
import android.os.Handler
13+
import android.os.Looper
14+
import android.util.Log
15+
16+
/** Owns one recorder and its Bluetooth route for the full capture lifecycle. */
17+
internal class AndroidAudioInputSession private constructor(
18+
private val audioManager: AudioManager,
19+
private val audioRecord: AudioRecord,
20+
) : AutoCloseable {
21+
companion object {
22+
private const val tag = "AudioInput"
23+
24+
@SuppressLint("MissingPermission")
25+
fun open(
26+
context: Context,
27+
sampleRateHz: Int,
28+
frameBytes: Int,
29+
): AndroidAudioInputSession {
30+
val minBuffer =
31+
AudioRecord.getMinBufferSize(
32+
sampleRateHz,
33+
AudioFormat.CHANNEL_IN_MONO,
34+
AudioFormat.ENCODING_PCM_16BIT,
35+
)
36+
if (minBuffer <= 0) {
37+
throw IllegalStateException("AudioRecord buffer unavailable")
38+
}
39+
val audioRecord =
40+
AudioRecord
41+
.Builder()
42+
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
43+
.setAudioFormat(
44+
AudioFormat
45+
.Builder()
46+
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
47+
.setSampleRate(sampleRateHz)
48+
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
49+
.build(),
50+
).setBufferSizeInBytes(maxOf(minBuffer, frameBytes * 4))
51+
.build()
52+
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
53+
return AndroidAudioInputSession(audioManager, audioRecord).also { session ->
54+
try {
55+
session.openRoute()
56+
} catch (err: RuntimeException) {
57+
session.close()
58+
throw err
59+
}
60+
}
61+
}
62+
}
63+
64+
private val lock = Any()
65+
private val communicationRouteOwner = bluetoothCommunicationRoute.newOwner()
66+
private val callbackHandler = Handler(Looper.getMainLooper())
67+
private var closed = false
68+
private var callbackRegistered = false
69+
private var requestedInput: AudioDeviceInfo? = null
70+
private var requestedCommunicationDevice: AudioDeviceInfo? = null
71+
private var selectedInput: AudioDeviceInfo? = null
72+
73+
private val deviceCallback =
74+
object : AudioDeviceCallback() {
75+
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
76+
refreshRouteSafely()
77+
}
78+
79+
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
80+
refreshRouteSafely()
81+
}
82+
}
83+
internal val preferredInputType: Int?
84+
get() = synchronized(lock) { selectedInput?.type }
85+
86+
internal val requestedInputType: Int?
87+
get() = synchronized(lock) { requestedInput?.type }
88+
89+
fun startRecording() {
90+
audioRecord.startRecording()
91+
Log.d(tag, "capture started preferred=${preferredInputType ?: "default"} routed=${audioRecord.routedDevice?.type ?: "pending"}")
92+
}
93+
94+
fun read(
95+
buffer: ByteArray,
96+
offset: Int,
97+
size: Int,
98+
): Int = audioRecord.read(buffer, offset, size)
99+
100+
private fun openRoute() {
101+
audioManager.registerAudioDeviceCallback(deviceCallback, callbackHandler)
102+
synchronized(lock) { callbackRegistered = true }
103+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
104+
bluetoothCommunicationRoute.begin(communicationRouteOwner)
105+
}
106+
refreshRouteSafely()
107+
}
108+
109+
private fun refreshRouteSafely() {
110+
try {
111+
refreshRoute()
112+
} catch (err: RuntimeException) {
113+
// Routing is a preference; default capture remains better than losing the voice session.
114+
Log.w(tag, "Bluetooth route update failed: ${err.message ?: err::class.simpleName}")
115+
}
116+
}
117+
118+
private fun refreshRoute() {
119+
synchronized(lock) {
120+
if (closed) return
121+
val inputs = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS).toList()
122+
val communicationDevice =
123+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
124+
selectBluetoothDevice(audioManager.availableCommunicationDevices, requestedCommunicationDevice)
125+
} else {
126+
null
127+
}
128+
val communicationSelected =
129+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
130+
bluetoothCommunicationRoute.update(audioManager, communicationRouteOwner, communicationDevice)
131+
} else {
132+
false
133+
}
134+
requestedCommunicationDevice = communicationDevice.takeIf { communicationSelected }
135+
val input = selectBluetoothInput(inputs, requestedInput, requestedCommunicationDevice)
136+
if (!sameDevice(requestedInput, input) || !sameDevice(selectedInput, input)) {
137+
requestedInput = input
138+
if (audioRecord.setPreferredDevice(input)) {
139+
selectedInput = input
140+
Log.d(tag, "preferred input changed type=${input?.type ?: "default"}")
141+
} else {
142+
selectedInput = null
143+
Log.w(tag, "preferred input rejected type=${input?.type ?: "default"}")
144+
}
145+
}
146+
}
147+
}
148+
149+
override fun close() {
150+
synchronized(lock) {
151+
if (closed) return
152+
closed = true
153+
if (callbackRegistered) {
154+
runCatching { audioManager.unregisterAudioDeviceCallback(deviceCallback) }
155+
callbackRegistered = false
156+
}
157+
runCatching { audioRecord.setPreferredDevice(null) }
158+
requestedInput = null
159+
selectedInput = null
160+
if (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
161+
runCatching { audioRecord.stop() }
162+
}
163+
runCatching { audioRecord.release() }
164+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
165+
bluetoothCommunicationRoute.close(audioManager, communicationRouteOwner)
166+
}
167+
requestedCommunicationDevice = null
168+
}
169+
}
170+
}
171+
172+
/** Serializes Android's process-wide communication route across overlapping capture cleanup. */
173+
private class BluetoothCommunicationRoute {
174+
private var nextOwner = 0L
175+
private var latestOwner = 0L
176+
private var activeOwner: Long? = null
177+
178+
@Synchronized
179+
fun newOwner(): Long = ++nextOwner
180+
181+
@Synchronized
182+
fun begin(owner: Long) {
183+
if (owner > latestOwner) latestOwner = owner
184+
}
185+
186+
@Synchronized
187+
fun update(
188+
audioManager: AudioManager,
189+
owner: Long,
190+
device: AudioDeviceInfo?,
191+
): Boolean {
192+
if (owner < latestOwner) return false
193+
latestOwner = owner
194+
if (device == null) {
195+
if (activeOwner != null) audioManager.clearCommunicationDevice()
196+
activeOwner = null
197+
return false
198+
}
199+
if (!audioManager.setCommunicationDevice(device)) {
200+
if (activeOwner != null) audioManager.clearCommunicationDevice()
201+
activeOwner = null
202+
return false
203+
}
204+
activeOwner = owner
205+
return true
206+
}
207+
208+
@Synchronized
209+
fun close(
210+
audioManager: AudioManager,
211+
owner: Long,
212+
) {
213+
if (activeOwner != owner || owner < latestOwner) return
214+
audioManager.clearCommunicationDevice()
215+
activeOwner = null
216+
}
217+
}
218+
219+
private val bluetoothCommunicationRoute = BluetoothCommunicationRoute()
220+
221+
private fun selectBluetoothDevice(
222+
devices: List<AudioDeviceInfo>,
223+
current: AudioDeviceInfo? = null,
224+
): AudioDeviceInfo? {
225+
current
226+
?.takeIf { candidate ->
227+
bluetoothPriority(candidate.type) != null && devices.any { sameDevice(it, candidate) }
228+
}?.let { return it }
229+
return devices
230+
.asSequence()
231+
.mapNotNull { device -> bluetoothPriority(device.type)?.let { priority -> priority to device } }
232+
.minWithOrNull(compareBy<Pair<Int, AudioDeviceInfo>> { it.first }.thenBy { it.second.id })
233+
?.second
234+
}
235+
236+
private fun selectBluetoothInput(
237+
devices: List<AudioDeviceInfo>,
238+
current: AudioDeviceInfo?,
239+
communicationDevice: AudioDeviceInfo?,
240+
): AudioDeviceInfo? {
241+
if (communicationDevice == null) return selectBluetoothDevice(devices, current)
242+
val candidates = devices.filter { it.type == communicationDevice.type }
243+
current?.takeIf { candidate -> candidates.any { sameDevice(it, candidate) } }?.let { return it }
244+
val address = communicationDevice.address.trim()
245+
if (address.isNotEmpty()) {
246+
candidates.firstOrNull { it.address == address }?.let { return it }
247+
}
248+
// setCommunicationDevice chooses the matching source; only override it when unambiguous.
249+
return candidates.singleOrNull()
250+
}
251+
252+
private fun bluetoothPriority(type: Int): Int? =
253+
when (type) {
254+
AudioDeviceInfo.TYPE_BLE_HEADSET -> 0
255+
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> 1
256+
else -> null
257+
}
258+
259+
private fun sameDevice(
260+
left: AudioDeviceInfo?,
261+
right: AudioDeviceInfo?,
262+
): Boolean = left?.id == right?.id && left?.type == right?.type

0 commit comments

Comments
 (0)