智能眼镜开发:眼镜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_SCO 或 TYPE_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根据项目代码总结生成。

参考资料

相关推荐
千里马学框架3 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台3 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone3 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc3 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo3 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077003 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼3 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone3 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen3 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone3 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui