Android-蓝牙耳机和手机录音播放切换

374 阅读3分钟

需求

项目中需在连接蓝牙耳机的情况下,支持手机端录音蓝牙耳机播放和蓝牙耳机录音手机端播放,并且能够动态切换。

蓝牙耳机与手机之间的录音和播放切换,主要依赖于蓝牙协议中的音频配置文件(Profiles)。常见的蓝牙音频配置文件包括:

  • HFP (Hands-Free Profile):用于电话通话时的音频传输。
  • A2DP (Advanced Audio Distribution Profile):用于高质量音乐流媒体播放。
  • AVRCP (Audio/Video Remote Control Profile):允许对播放进行控制(如暂停、跳过曲目等)。

实现录音和播放切换的核心在于设备如何根据不同的场景选择使用合适的配置文件,并在必要时进行动态切换。

实现步骤

  1. 检测使用场景
  • 检测当前是否处于通话状态(如来电、语音通话)或媒体播放状态(如音乐、视频)。
  • 根据场景决定启用哪个蓝牙配置文件(例如通话时启用 HFP,播放时启用 A2DP)。 2 蓝牙配置文件切换
  • 当从媒体播放切换到通话时,系统应主动连接并激活 HFP 配置文件,并关闭 A2DP。
  • 反之,在通话结束之后,可以重新激活 A2DP 以恢复音乐播放。 3 音频路由管理
  • 在 Android 作系统中,需要通过音频管理 API 设置音频输出路径为蓝牙设备。
  • 示例
     AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
     audioManager.setBluetoothScoOn(true);
     audioManager.startBluetoothSco();  // 启动蓝牙SCO连接用于通话

SCO(Synchronous Connection-Oriented,同步连接导向)是蓝牙协议中用于传输语音音频的一种低延迟、点对点的同步数据传输通道。 它主要用于蓝牙耳机与手机之间的通话场景,确保语音信号的实时性和稳定性。

完整示例

demo页面

20250630-153002.jpeg

sco切换工具类,由于部分机型切换sco无法一次切换成功,需要多切换几次,切换sco是异步操作,需要广播监听回调结果

BlutoothScoUtil

class BluetoothScoUtil private constructor() {
    private val TAG = "BluetoothUtil"

    private var mConnectIndex = 0

    private var mAudioManager: AudioManager? = null

    private var mContext: Context? = null

    @Volatile
    private var mReceiver: BroadcastReceiver? = null

    companion object {
        //第一次打开sco没成功的情况,持续连接的次数
        private const val SCO_CONNECT_TIME = 5
        val instance: BluetoothScoUtil by lazy {
            BluetoothScoUtil()
        }
    }


    /**
     * 打开sco
     */
    suspend fun openSco(context: Context): Result<Unit> =
        suspendCancellableCoroutine { continuation ->
            var isResume = false
            val scope = CoroutineScope(Dispatchers.Main)
            mContext = context
            if (mAudioManager == null) {
                mAudioManager = mContext?.getSystemService(Context.AUDIO_SERVICE) as AudioManager
            }
            mAudioManager?.let { audioManager ->
                if (!audioManager.isBluetoothScoAvailableOffCall) {
                    Log.e(TAG, "Your device no support bluetooth record")
                    isResume = true
                    continuation.resume(Result.failure<Unit>(Exception("Your device no support bluetooth record!")))
                    return@suspendCancellableCoroutine
                }

                mConnectIndex = 0

                mReceiver = object : BroadcastReceiver() {
                    override fun onReceive(context: Context, intent: Intent) {
                        val state = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1)
                        val bluetoothScoOn = audioManager.isBluetoothScoOn
                        Log.i(TAG, "onReceive state=$state,bluetoothScoOn=$bluetoothScoOn")

                        if (state == AudioManager.SCO_AUDIO_STATE_CONNECTED) {
                            Log.e(TAG, "onReceive success!")
                            audioManager.isBluetoothScoOn = true // 打开SCO
                            unregisterIfRegistered()
                            if (!isResume) {
                                isResume = true
                                continuation.resume(Result.success(Unit))
                            }

                        } else {
                            Log.e(TAG, "onReceive failed index=$mConnectIndex")
                            if (mConnectIndex < SCO_CONNECT_TIME) {
                                scope.launch {
                                    delay(500)
                                    audioManager.startBluetoothSco()
                                }
                            } else {
                                unregisterIfRegistered()
                                if (!isResume) {
                                    isResume = true
                                    continuation.resume(Result.failure<Unit>(Exception("open sco failed!")))
                                }
                            }
                            mConnectIndex++
                        }
                    }
                }

                mContext?.registerReceiver(
                    mReceiver,
                    IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)
                )

                // 启动蓝牙 SCO 连接
                audioManager.stopBluetoothSco()
                audioManager.startBluetoothSco()

                // 设置取消监听器,如果协程被取消,则注销广播接收器
                continuation.invokeOnCancellation {
                    unregisterIfRegistered()
                }
            } ?: run {
                isResume = true
                continuation.resume(Result.failure<Unit>(Exception("AudioManager is null")))
            }
        }

    private fun unregisterIfRegistered() {
        mReceiver?.let {
            try {
                mContext?.unregisterReceiver(mReceiver)
            } catch (e: Exception) {
                Log.e(TAG, "Unregister receiver error", e)
            }
        }
        mReceiver = null
    }

    /**
     * 关闭sco
     */
    fun closeSco() {
        val bluetoothScoOn = mAudioManager?.isBluetoothScoOn
        Log.i(TAG, "bluetoothScoOn=$bluetoothScoOn")
        mAudioManager?.isBluetoothScoOn = false
        mAudioManager?.stopBluetoothSco()
    }
}

注意:startBluetoothSco() 是异步操作,系统会通过 onAudioFocusChange 或广播监听 ACTION_SCO_AUDIO_STATE_CHANGED 来通知应用当前状态。

MainActivity

class MainActivity : ComponentActivity() {

   companion object{
       const val TAG = "MainActivity__"

       const val REQUEST_CODE_RECORD_AUDIO = 0x11
       const val REQUEST_CODE_WRITE_EXTERNAL_STORAGE = 0x22
   }


    private lateinit var mStartBtn: Button
    private lateinit var mEndBtn: Button
    private lateinit var mPlayBtn: Button
    private lateinit var mTvStatus: TextView
    private lateinit var switchUseHeadsetMic: Switch

    private var mUseHeadsetMic = false

    private var mMediaRecorder: MediaRecorder? = null

    var mFilePath :String?=null


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        mFilePath=getExternalFilesDir(null)?.absolutePath + "/recording_test.3gp"

        mStartBtn = findViewById(R.id.btn_start)
        mEndBtn = findViewById(R.id.btn_end)
        mPlayBtn = findViewById(R.id.btn_play)
        mTvStatus = findViewById(R.id.tv_status)


        //开始录音
        mStartBtn.setOnClickListener {
            if (mUseHeadsetMic) {//使用耳机录音
                MainScope().launch(Dispatchers.Main) {
                    val result = BluetoothScoUtil.instance.openSco(this@MainActivity)
                    Log.d(TAG, "蓝牙SCO连接结果:$result")
                    startRecord(mFilePath)
                }

            } else {//使用手机扬声器录音
                startRecord(mFilePath)
            }
        }

        //停止录音
        mEndBtn.setOnClickListener {
            mTvStatus.text = "已停止收音"
            if (mUseHeadsetMic) {
                BluetoothScoUtil.instance.closeSco()
            }
            stopRecording()
        }

        //播放音频
        mPlayBtn.setOnClickListener {
            mTvStatus.text = "播放中"
            Log.d(TAG, "Playing audio file: $mFilePath  file is exit${File(mFilePath).exists()}")
            //判断文件是否存在

            changeAudioPlaySpeaker(!mUseHeadsetMic)

            val mediaPlayer = MediaPlayer().apply {
                setDataSource(mFilePath)
                prepare()
                start()
            }
            mediaPlayer.setOnCompletionListener {
                mTvStatus.text = "播放完成"
                it.release()
            }
        }

        switchUseHeadsetMic = findViewById(R.id.switchUseHeadsetMic)
        switchUseHeadsetMic.setOnCheckedChangeListener { _, isChecked ->
            mUseHeadsetMic = isChecked
        }
 registerReceiver(mReceiver, IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED))
      requestPermissions()
    }

    private fun requestPermissions() {
        //申请录音权限
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
            != PackageManager.PERMISSION_GRANTED
        ) {

            ActivityCompat.requestPermissions(
                this,
                arrayOf(Manifest.permission.RECORD_AUDIO),
                REQUEST_CODE_RECORD_AUDIO
            )
        }

        //申请蓝牙权限
        if (ContextCompat.checkSelfPermission(
                this,
                Manifest.permission.BLUETOOTH
            ) != PackageManager.PERMISSION_GRANTED
        ) {

            ActivityCompat.requestPermissions(
                this,
                arrayOf(Manifest.permission.BLUETOOTH),
                REQUEST_CODE_WRITE_EXTERNAL_STORAGE
            )
        }
       
    }


    /**
     * 开始录音
     */
    private fun startRecord(filePath: String?) {
        mTvStatus.text = "收音中..."
        mMediaRecorder = MediaRecorder().apply {
            setAudioSource(
                MediaRecorder.AudioSource.MIC
            )
            setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP)
            setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB)
            setOutputFile(filePath)
            try {
                prepare()
                start()
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        mMediaRecorder?.release()
        mMediaRecorder = null
    }

    private fun stopRecording() {
        mMediaRecorder?.apply {
            try {
                stop()
                release()
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
        mMediaRecorder = null
    }

    /** 切换播放的喇叭
     * @param twsPlay true: 使用蓝牙耳机播放音乐, false: 使用手机的麦克风播放音乐
     */
    private fun changeAudioPlaySpeaker(twsPlay: Boolean) {
        val audioManager = getSystemService(AudioManager::class.java)

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            // Android 12(API 级别 31)及以上版本
            val communicationDevices = audioManager.availableCommunicationDevices
            audioManager.mode = AudioManager.MODE_IN_COMMUNICATION

            // 查找蓝牙耳机和手机扬声器
            val bluetoothDevice =
                communicationDevices.find { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
            val speakerDevice =
                communicationDevices.find { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }

            if (twsPlay) {
                // 切换到蓝牙耳机
                bluetoothDevice?.let {
                    println("切换到蓝牙耳机 1")
                    audioManager.setCommunicationDevice(it)
                }
                Log.d(TAG, "切换到蓝牙耳机 1")
            } else {
                // 切换到手机扬声器
                speakerDevice?.let {
                    println("切换到手机扬声器 1")
                    audioManager.setCommunicationDevice(it)
                }
                Log.d(TAG, "切换到手机扬声器 1")
            }

        } else {
            // Android 7.0 到 Android 11 版本(SDK 24 至 SDK 30)
            if (twsPlay) {
                // 使用蓝牙耳机播放音乐
                println("切换到蓝牙耳机 2")
                audioManager.isBluetoothA2dpOn = true
                audioManager.mode = AudioManager.MODE_NORMAL
                audioManager.setSpeakerphoneOn(false) // 关闭扬声器
                if (Build.VERSION.SDK_INT!=Build.VERSION_CODES.R){
                    audioManager.startBluetoothSco()
                }
            } else {
                // 使用手机扬声器播放音乐
                println("切换到手机扬声器 2")
                audioManager.isBluetoothA2dpOn = false
                audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
                audioManager.setSpeakerphoneOn(true) // 打开扬声器
                if (Build.VERSION.SDK_INT!=Build.VERSION_CODES.R){
                    audioManager.stopBluetoothSco()
                }
            }
        }
    }


    /**
     * 监听蓝牙耳机连接状态
     */
    private val mReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            val state = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1)
            val mAudioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
            val bluetoothScoOn = mAudioManager.isBluetoothScoOn
            Log.d(TAG, "onReceive state=$state,bluetoothScoOn=$bluetoothScoOn")

            when (state) {
                AudioManager.SCO_AUDIO_STATE_CONNECTING -> {
                    Log.d(TAG, "onReceive connecting...")
                }

                AudioManager.SCO_AUDIO_STATE_CONNECTED -> {
                    Log.d(TAG, "onReceive success!")
                }

                AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> {
                    Log.d(TAG, "onReceive disconnected")
                }

                AudioManager.SCO_AUDIO_STATE_ERROR -> {
                    Log.d(TAG, "onReceive failed")
                }
            }
        }
    }
}