智能眼镜开发:眼镜Touch后收音与触发播放系统音乐的矛盾处理

眼镜 Touch 触发收音时意外播放系统音乐:Android 音频焦点解决方案

在智能眼镜、蓝牙耳机等设备上,一个 Touch 手势有时会同时产生两条事件链:

  • 设备 SDK 将 Touch 上报给 Android App,App 准备从眼镜麦克风收音;
  • 设备又以蓝牙媒体控制事件的形式发送"播放"指令,系统音乐 App 被唤起并开始播放。

结果是:用户本来想触发语音采集,手机却同时播放音乐。音乐不仅干扰用户,还可能通过扬声器回采进入麦克风,污染语音识别、实时翻译或语音助手的输入。

这个问题可以用 Audio Focus(音频焦点) 建立一个临时的"收音窗口":收到 Touch 后,App 先申请短时独占焦点,让系统音乐暂停或保持静音,再开始录音;收音结束后释放焦点。

本文不讨论某个具体眼镜型号或厂商 SDK,而是给出一套通用的 Android 技术方案和关键 Kotlin 代码。

一、问题是怎样产生的

一次眼镜 Touch 可能同时触发业务事件和系统媒体事件:

text 复制代码
用户触摸眼镜
     │
     ├── 设备 SDK 回调 Touch
     │        ↓
     │   App 准备切换麦克风路由并开始收音
     │
     └── 蓝牙 AVRCP / 媒体按键 PLAY
              ↓
         系统音乐 App 开始播放
              ↓
         音乐干扰本次语音采集

这里有三个不同层次的问题:

  1. 媒体控制 :Touch 是否额外产生了 KEYCODE_MEDIA_PLAY 等事件;
  2. 音频焦点:系统音乐和当前 App 谁应当在这一时刻输出声音;
  3. 录音路由AudioRecord 最终使用手机麦克风还是眼镜麦克风。

音频焦点解决的是第二个问题。它不能选择眼镜麦克风,也不能真正删除那次媒体按键事件,但可以在收音期间阻止系统音乐持续发声。

二、解决思路:用焦点包住完整收音周期

正确的执行顺序是:

text 复制代码
收到眼镜 Touch 开始事件
           ↓
申请 AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
           ↓
焦点申请是否成功?
    ├── 否:不开始收音,执行失败或降级策略
    └── 是:系统音乐暂停或被压制
                     ↓
             切换眼镜麦克风路由
                     ↓
                启动 AudioRecord
                     ↓
          Touch 结束 / 超时 / 识别完成 / 异常
                     ↓
                停止并释放录音
                     ↓
                释放音频焦点

核心原则有三条:

  1. 先申请焦点,再开始录音,尽量缩短系统音乐能够发声的时间;
  2. 焦点覆盖完整收音周期 ,不能在 AudioRecord.startRecording() 后立即释放;
  3. 所有结束分支都释放焦点,包括正常完成、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 不能替代蓝牙麦克风路由

抢到焦点并不代表已经从眼镜麦克风收音。完整方案仍应包含:

  1. 检查 RECORD_AUDIO 和相应蓝牙权限;
  2. 在 Android 12 以上使用 AudioManager.setCommunicationDevice() 选择 TYPE_BLUETOOTH_SCOTYPE_BLE_HEADSET
  3. 在旧系统使用 SCO 兼容流程;
  4. 等待系统确认通信设备切换完成;
  5. 创建并启动 AudioRecord
  6. 收音结束后清理 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 只触发收音,系统音乐绝对不能被唤起或改变播放状态",优先级更高的根治方案是:

  1. 在眼镜固件中取消该 Touch 对标准媒体 Play 的映射;
  2. 由设备 SDK 区分业务 Touch 与系统媒体控制事件;
  3. 在符合 Android 媒体会话规则的前提下,由当前媒体会话处理对应媒体按键;
  4. 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 同时触发"开始收音"和"系统音乐播放"时,可以用短时独占音频焦点为录音建立保护窗口:

  1. Touch Start 到达后立即请求 AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
  2. 只有焦点申请成功后才切换眼镜麦克风路由并启动录音;
  3. 收音期间遇到任何失焦都通知录音状态机处理;
  4. 正常结束、失败、取消、超时和页面销毁都必须释放焦点;
  5. 若收音后紧接 TTS,可让焦点覆盖完整语音交互;
  6. Audio Focus 负责压制音乐,通信设备 API 和 AudioRecord 负责眼镜收音;
  7. 如果要彻底消除系统音乐的 Play 指令,还需要从固件、SDK 或媒体按键链路根治。

这套方案的关键不是"永久抢走系统声音",而是在用户说话的短暂窗口里建立明确、可回收的音频所有权,从而避免系统音乐破坏本次语音采集。

注:部分内容由AI根据项目代码总结生成。

参考资料

相关推荐
TimeFine1 小时前
智能眼镜开发:眼镜侧收集音频
android
又见情义2 小时前
Android 系统设置从平板版迁移至TV版实践
android
杉氧5 小时前
打破边界(一):实战编写 Android/iOS 原生模块 (Native Modules)
android·react native·前端框架
baidu_247438615 小时前
Android 35适配
android
mmsx6 小时前
osmdroid 踩坑清单:大级别崩溃/路径消失/低内存卡顿/范围缩放不准
android·源码·地图·osmdroid
gf13211116 小时前
【python_回复邮件】
android·java·python
段一凡-华北理工大学7 小时前
高炉智能布料技术与炉料分布优化~专栏简介与目录
android·人工智能·高炉智能化·高炉布料·高炉布料分布·高炉布料参数优化·高炉布料矩阵
hunterandroid7 小时前
Android 多渠道打包与 Gradle 构建优化实战
android·前端·kotlin