眼镜 Touch 触发收音时意外播放系统音乐:Android 音频焦点解决方案
在智能眼镜、蓝牙耳机等设备上,一个 Touch 手势有时会同时产生两条事件链:
- 设备 SDK 将 Touch 上报给 Android App,App 准备从眼镜麦克风收音;
- 设备又以蓝牙媒体控制事件的形式发送"播放"指令,系统音乐 App 被唤起并开始播放。
结果是:用户本来想触发语音采集,手机却同时播放音乐。音乐不仅干扰用户,还可能通过扬声器回采进入麦克风,污染语音识别、实时翻译或语音助手的输入。
这个问题可以用 Audio Focus(音频焦点) 建立一个临时的"收音窗口":收到 Touch 后,App 先申请短时独占焦点,让系统音乐暂停或保持静音,再开始录音;收音结束后释放焦点。
本文不讨论某个具体眼镜型号或厂商 SDK,而是给出一套通用的 Android 技术方案和关键 Kotlin 代码。
一、问题是怎样产生的
一次眼镜 Touch 可能同时触发业务事件和系统媒体事件:
text
用户触摸眼镜
│
├── 设备 SDK 回调 Touch
│ ↓
│ App 准备切换麦克风路由并开始收音
│
└── 蓝牙 AVRCP / 媒体按键 PLAY
↓
系统音乐 App 开始播放
↓
音乐干扰本次语音采集
这里有三个不同层次的问题:
- 媒体控制 :Touch 是否额外产生了
KEYCODE_MEDIA_PLAY等事件; - 音频焦点:系统音乐和当前 App 谁应当在这一时刻输出声音;
- 录音路由 :
AudioRecord最终使用手机麦克风还是眼镜麦克风。
音频焦点解决的是第二个问题。它不能选择眼镜麦克风,也不能真正删除那次媒体按键事件,但可以在收音期间阻止系统音乐持续发声。
二、解决思路:用焦点包住完整收音周期
正确的执行顺序是:
text
收到眼镜 Touch 开始事件
↓
申请 AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
↓
焦点申请是否成功?
├── 否:不开始收音,执行失败或降级策略
└── 是:系统音乐暂停或被压制
↓
切换眼镜麦克风路由
↓
启动 AudioRecord
↓
Touch 结束 / 超时 / 识别完成 / 异常
↓
停止并释放录音
↓
释放音频焦点
核心原则有三条:
- 先申请焦点,再开始录音,尽量缩短系统音乐能够发声的时间;
- 焦点覆盖完整收音周期 ,不能在
AudioRecord.startRecording()后立即释放; - 所有结束分支都释放焦点,包括正常完成、Touch 取消、超时、路由失败和页面销毁。
三、为什么选择 AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
Android 提供多种焦点类型:
| 焦点类型 | 含义 | 是否适合本场景 |
|---|---|---|
AUDIOFOCUS_GAIN |
长时间持有,面向音乐或视频播放 | 不适合短时收音 |
AUDIOFOCUS_GAIN_TRANSIENT |
临时持有,其他媒体通常暂停 | 可以使用 |
AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK |
临时持有,允许其他媒体降低音量继续播放 | 不适合,音乐仍可能污染录音 |
AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE |
短时独占,面向录音、语音识别等场景 | 推荐 |
眼镜收音通常持续几秒到几十秒,而且希望这段时间内其他媒体完全停止,因此优先使用:
kotlin
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
如果设备兼容性验证发现 TRANSIENT_EXCLUSIVE 行为不稳定,可以降级为 AUDIOFOCUS_GAIN_TRANSIENT。不要使用 MAY_DUCK,因为"把音乐降低到 20%"并不能保证录音干净。
需要注意:Audio Focus 本质上仍是一套播放协调机制,不是不可绕过的全局静音锁。Android 12 以后系统加强了焦点执行,但异常播放器、厂商定制系统或焦点请求时序仍可能影响最终效果。
四、Android 8.0 以上的焦点请求
Android 8.0(API 26)开始,使用 AudioFocusRequest 申请焦点:
kotlin
private val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
private val focusRequest = AudioFocusRequest.Builder(
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
)
.setAudioAttributes(audioAttributes)
.setAcceptsDelayedFocusGain(false)
.setOnAudioFocusChangeListener(
focusChangeListener,
Handler(Looper.getMainLooper())
)
.build()
这里有两个值得说明的选择。
1. 使用 USAGE_ASSISTANT + CONTENT_TYPE_SPEECH
它表明这次焦点请求服务于一次语音交互。若场景更接近实时通话,也可以根据实际用途选择 USAGE_VOICE_COMMUNICATION。
AudioAttributes 描述的是音频用途,不负责把输入切到蓝牙麦克风。眼镜麦克风路由仍需通过 setCommunicationDevice()、旧版 SCO 和 AudioRecord 单独处理。
2. 不接受延迟焦点
Touch 收音强调即时反馈。如果系统当前无法交出焦点,延迟数秒后突然开始录音通常已经错过用户讲话,因此这里使用:
kotlin
.setAcceptsDelayedFocusGain(false)
焦点申请失败时,应立即终止本次启动或执行明确的降级策略,而不是让录音在未知时刻自动开始。
五、可复用的收音焦点守卫
下面的 RecordingAudioFocusGuard 只负责音频焦点,不依赖具体眼镜 SDK、录音器或页面。
kotlin
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.annotation.MainThread
/**
* 在一次短时语音采集期间持有音频焦点,避免系统媒体持续播放。
*
* @param onFocusLost 收音期间焦点被其他音源夺走时触发。调用方应停止收音
* 或将本次音频标记为可能受污染,不能无条件忽略。
*/
class RecordingAudioFocusGuard(
context: Context,
private val onFocusLost: () -> Unit
) {
private val audioManager =
context.applicationContext.getSystemService(Context.AUDIO_SERVICE)
as AudioManager
private val mainHandler = Handler(Looper.getMainLooper())
// 已经向系统提交且尚未 abandon 的焦点请求。
private var focusRequestActive = false
// 当前是否真正拥有焦点;临时失焦时可能为 false,但请求仍需显式释放。
private var hasFocus = false
private val focusChangeListener = AudioManager.OnAudioFocusChangeListener { change ->
mainHandler.post { handleFocusChange(change) }
}
private val focusRequest: AudioFocusRequest? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val attributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
AudioFocusRequest.Builder(
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
)
.setAudioAttributes(attributes)
.setAcceptsDelayedFocusGain(false)
.setOnAudioFocusChangeListener(focusChangeListener, mainHandler)
.build()
} else {
null
}
/**
* 在启动麦克风路由和 AudioRecord 之前调用。
*
* @return true 表示已经获得焦点,可以继续启动收音。
*/
@MainThread
fun acquire(): Boolean {
if (hasFocus) return true
// 清理仍处于挂起状态的旧请求,再开始一次新的收音窗口。
if (focusRequestActive) {
release()
}
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioManager.requestAudioFocus(checkNotNull(focusRequest))
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(
focusChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
)
}
hasFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
focusRequestActive = hasFocus
return hasFocus
}
/**
* 在停止 AudioRecord 并恢复音频路由后调用。
*/
@MainThread
fun release() {
if (!focusRequestActive) return
focusRequestActive = false
hasFocus = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioManager.abandonAudioFocusRequest(checkNotNull(focusRequest))
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(focusChangeListener)
}
}
private fun handleFocusChange(change: Int) {
when (change) {
AudioManager.AUDIOFOCUS_GAIN -> {
if (focusRequestActive) {
hasFocus = true
}
}
AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
if (!focusRequestActive) return
hasFocus = false
onFocusLost()
}
}
}
}
为什么 LOSS_TRANSIENT_CAN_DUCK 也视为失焦?因为这个类保护的是录音,而不是播放。当前 App 没有需要降低音量的播放器;一旦其他音源获准发声,本次语音采集就可能受到污染,所以应让收音层感知并做出处理。
六、把焦点与录音生命周期串起来
下面展示一条通用调用链。glassesRecorder 可以是任意眼镜 SDK 或 AudioRecord 封装:
kotlin
private val focusGuard = RecordingAudioFocusGuard(context) {
// 收音期间被其他音源夺走焦点,立即结束或标记本次采集无效。
stopVoiceCapture(reason = "audio_focus_lost")
}
@MainThread
fun onGlassesTouchStart() {
// 必须在切换路由和启动录音前抢占焦点。
if (!focusGuard.acquire()) {
onVoiceCaptureFailed(reason = "audio_focus_request_failed")
return
}
try {
// 路由切换通常是异步的,真实实现应等待路由确认后再开始读 PCM。
glassesRecorder.start()
} catch (error: Exception) {
// 任意启动异常都必须释放已经获得的焦点。
focusGuard.release()
onVoiceCaptureFailed(reason = "recorder_start_failed")
}
}
@MainThread
fun onGlassesTouchEnd() {
stopVoiceCapture(reason = "touch_finished")
}
@MainThread
private fun stopVoiceCapture(reason: String) {
try {
glassesRecorder.stop()
} finally {
// 先停止录音和恢复路由,再结束本次焦点保护窗口。
focusGuard.release()
}
}
生产实现还应保证 stopVoiceCapture() 幂等,因为 Touch End、超时、页面销毁和焦点丢失可能几乎同时到达。设备 SDK 的 Touch 回调线程通常没有统一保证;示例约定焦点和录音状态都在主线程串行修改,如果回调来自工作线程,应先切换到主线程。
如果语音识别结束后还要立即播放 TTS 回复,不要机械地在录音停止瞬间释放焦点。可以让完整语音会话继续持有 transient focus,等 TTS 播放完成再释放,避免系统音乐在"停止收音"和"开始播报"之间短暂恢复。
七、焦点申请的时机决定效果
这个问题通常同时存在 Touch SDK 回调和蓝牙媒体按键,两条链路的先后顺序不一定稳定。
情况一:系统音乐先开始播放
App 随后申请 TRANSIENT_EXCLUSIVE,系统音乐会收到失焦并暂停。用户可能听到极短的音乐起音,但收音窗口可以继续保持安静。
情况二:App 先获得焦点
理想情况下,系统音乐收到媒体播放指令后不会持续播放。但 Audio Focus 不是"持有后永远不会被抢走"的锁;如果另一个 App 随后又成功请求焦点,当前 App 仍会收到 LOSS。
因此应:
- 在最早、最可靠的 Touch Start 回调中申请焦点;
- 不要等蓝牙麦克风路由完成后才申请;
- 记录 Touch、焦点申请、媒体播放、路由完成和录音启动的相对时间;
- 收到任何失焦事件时通知录音状态机,不要静默忽略。
如果设备 SDK 只能在 Touch Release 时通知 App,而系统媒体事件在 Touch Down 时就已发出,那么 Audio Focus 只能缩短音乐播放时间,无法彻底消除起音。根治需要设备固件或 SDK 提供更早的事件,或者不再发送系统媒体播放指令。
八、Audio Focus 不能替代蓝牙麦克风路由
抢到焦点并不代表已经从眼镜麦克风收音。完整方案仍应包含:
- 检查
RECORD_AUDIO和相应蓝牙权限; - 在 Android 12 以上使用
AudioManager.setCommunicationDevice()选择TYPE_BLUETOOTH_SCO或TYPE_BLE_HEADSET; - 在旧系统使用 SCO 兼容流程;
- 等待系统确认通信设备切换完成;
- 创建并启动
AudioRecord; - 收音结束后清理
AudioRecord、恢复通信路由并释放焦点。
可以把三类职责理解为:
text
Audio Focus 控制"其他声音是否应该让路"
Communication Device 控制"从哪个音频设备输入"
AudioRecord 控制"怎样读取 PCM 数据"
三者互相配合,但任何一个都不能替代另外两个。
九、Android 版本与后台限制
Android 8.0
Android 8.0 开始使用 AudioFocusRequest,系统还引入了自动 duck。由于本场景不允许系统音乐以低音量继续播放,应使用 transient 或 transient exclusive,而不是 MAY_DUCK。
Android 12
Android 12 开始,系统会在满足条件时自动淡出失去焦点的媒体播放器,并在通话等场景中加强静音行为。相比完全依赖播放器自觉处理回调,焦点切换更可控,但应用仍需处理自己的失焦状态。
Android 15
当应用以 Android 15(API 35)或更高版本为目标时,只有顶部应用或正在运行前台服务的应用才能成功请求音频焦点。
如果眼镜 Touch 可以在页面不可见时触发收音,需要使用符合系统限制的麦克风前台服务,并由用户在允许的时机启动:
xml
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<service
android:name=".VoiceCaptureService"
android:exported="false"
android:foregroundServiceType="microphone" />
这还会受到麦克风"使用中权限"和后台启动前台服务限制,不能仅靠收到一个后台广播就无限制启动录音。
十、权限边界
申请音频焦点本身不需要 RECORD_AUDIO 权限;真正启动麦克风采集时才需要录音权限。
常见 Manifest 配置如下:
xml
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Android 11 及以下 -->
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<!-- Android 12 及以上 -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
如果只是为前台页面的一次收音申请焦点,不要额外请求无关权限。Audio Focus、麦克风权限和蓝牙连接权限应分别理解和处理。
十一、方案边界:它不会吃掉媒体播放按键
这是整个方案最重要的边界。
requestAudioFocus() 的作用是协调音频输出,不是拦截以下事件:
KEYCODE_MEDIA_PLAY;KEYCODE_MEDIA_PLAY_PAUSE;- AVRCP Play;
- 厂商蓝牙协议中的媒体控制指令。
因此,音频焦点适合解决"收音期间不要让系统音乐发声",但不能保证系统音乐 App 从未收到 Play 指令。
如果产品要求是"Touch 只触发收音,系统音乐绝对不能被唤起或改变播放状态",优先级更高的根治方案是:
- 在眼镜固件中取消该 Touch 对标准媒体 Play 的映射;
- 由设备 SDK 区分业务 Touch 与系统媒体控制事件;
- 在符合 Android 媒体会话规则的前提下,由当前媒体会话处理对应媒体按键;
- Audio Focus 作为录音期间的兜底隔离层。
不要通过持续占用焦点、循环重新抢焦点或修改系统媒体音量来"对抗"系统音乐。这会造成焦点震荡、破坏其他 App 的正常体验,也无法从根本上修复错误的媒体按键映射。
十二、常见错误
1. 开始录音后才申请焦点
音乐已经播放并可能进入录音数据。应在 Touch Start 的第一时间申请。
2. 使用 MAY_DUCK
音乐降低音量后仍然存在,仍可能通过扬声器或环境声进入眼镜麦克风。
3. 路由失败时忘记释放焦点
系统音乐会持续处于暂停或静音状态。录音启动链路必须使用 try/finally 或统一清理入口。
4. Touch End、超时和失焦分别清理,且没有幂等保护
多个结束事件可能同时发生,造成重复停止、异常或焦点状态错乱。停止方法应只执行一次有效状态转换。
5. 把抢占焦点误认为已经选中眼镜麦克风
Audio Focus 管输出竞争,通信路由和 AudioRecord 管输入采集,两者不是同一件事。
6. 为了阻止音乐直接修改系统音量
setStreamVolume() 会改变用户的全局设置。应用退出或异常后,用户音量可能无法恢复,不能用它替代 Audio Focus。
7. 收音结束后音乐不应恢复,却只依赖 abandon focus
之前因 transient loss 暂停的音乐播放器可能在焦点释放后自动恢复。如果产品要求音乐保持暂停,需要从媒体按键或媒体会话层解决,不能只依赖焦点协议。
总结
当眼镜 Touch 同时触发"开始收音"和"系统音乐播放"时,可以用短时独占音频焦点为录音建立保护窗口:
- Touch Start 到达后立即请求
AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE; - 只有焦点申请成功后才切换眼镜麦克风路由并启动录音;
- 收音期间遇到任何失焦都通知录音状态机处理;
- 正常结束、失败、取消、超时和页面销毁都必须释放焦点;
- 若收音后紧接 TTS,可让焦点覆盖完整语音交互;
- Audio Focus 负责压制音乐,通信设备 API 和
AudioRecord负责眼镜收音; - 如果要彻底消除系统音乐的 Play 指令,还需要从固件、SDK 或媒体按键链路根治。
这套方案的关键不是"永久抢走系统声音",而是在用户说话的短暂窗口里建立明确、可回收的音频所有权,从而避免系统音乐破坏本次语音采集。