React Native中Android实现蓝牙SCO录音播放+有线耳机录音播放

业务需要开发基于RN的IM客户端,已经实现了播放、录制功能,现在需要新增对蓝牙耳机、有线耳机的支持,两者都需要用到原生APi的能力。当前RN采用的模块没有合适的方法去处理,本文从当前实现逻辑分析,到Android原生原理,最后封装原生API实现RN功能。

当前RN实现播放录音方式

RN播放:react-native-sound —— 基于AudioManager,播放时通过setSpeackPhoneOn(true),去setMode(MODE_IN_COMMUNICATION),开启MODE_IN_COMMUNICATION,然后开启扬声器,避免回声消除。
AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
  
  //turn speaker on
  @ReactMethod
  public void setSpeakerphoneOn(final Double key, final Boolean speaker) {
    MediaPlayer player = this.playerPool.get(key);
    if (player != null) {
      AudioManager audioManager = (AudioManager)this.context.getSystemService(this.context.AUDIO_SERVICE);
      if(speaker){
        audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
      }else{
        audioManager.setMode(AudioManager.MODE_NORMAL);
      }
      audioManager.setSpeakerphoneOn(speaker);
    }
  }

通过sound模块封装顺序播放控制PlayRecord.ts进行播放

import Sound from 'react-native-sound';

type ISetCurrentFilepath = {(filepath: string): void};

Sound.setCategory('Voice')

export class PlayRecord {
  BaseUrl: string;
  sound = {} as Sound;
  playList = [] as string[];
  isPlay = false as boolean;
  setCurrentFilepath: ISetCurrentFilepath;

  constructor(setCurrentFilepath: ISetCurrentFilepath, baseUrl: string) {
    this.setCurrentFilepath = setCurrentFilepath;
    this.BaseUrl = baseUrl;
  }

  push(filepath: string) {
    console.log('this.isPlay', this.isPlay);
    if (this.isPlay) {
      this.playList.push(filepath);
    } else {
      this.play(filepath);
    }
  }
  closePlay() {
    console.log('closePlay', this.isPlay);
    if (this.isPlay) {
      this.isPlay = false;
      this.setCurrentFilepath('');
      this.sound.release();
    }
  }
  clickPlay(filepath: string) {
    console.log('clickPlay,this.isPlay:', this.isPlay);
    if (this.isPlay) {
      this.sound.release();
      this.isPlay = false;
      this.setCurrentFilepath('');
      this.playList = [];
    }
    this.play(filepath);
  }
  play(filepath: string,sourceType: 'url'|'local' = 'url') {

    let url = this.BaseUrl + filepath;
    if(sourceType === 'local'){
      url = filepath
    }
    this.sound = new Sound(url, Sound.CACHES, async e => {
      console.log('callback duration :>> ', this.sound.getDuration());
      if (e) {
        console.log(`${url} play fail, error:${e}`);
      }

      // await this.sound.setSpeakerphoneOn(true);    通过播放采用切换到通话模式(默认听筒),并打开扬声器
      this.isPlay = true;
      this.setCurrentFilepath(filepath);
      this.sound.play(() => {
        console.log(`${url}play success,speed:${this.sound.getSpeed()}`);

        this.setCurrentFilepath('');
        this.isPlay = false;
        this.sound.release();

        if (this.playList.length) {
          return new Promise((resolve, reject) => {
            this.play(this.playList.shift() as string);
          });
        }
      });
    });
  }
}

RN录制:react-native-audio-record —— 基于AudioRecord实现,通过audioSource控制录音类型,当前使用的VOICE_COMMUNICATION

new AudioRecord(
        audioSource,  
        sampleRateInHz, 
        channelConfig, 
        audioFormat, 
        recordingBufferSize
);

使用方式:

const options = {
  sampleRate: 16000, // default 44100
  channels: 1, // 1 or 2, default 1->AudioFormat.CHANNEL_IN_MONO   2->AudioFormat.CHANNEL_IN_STEREO
  bitsPerSample: 16, // 8 or 16, default 16
  //android only (see below) https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#VOICE_COMMUNICATION
  //7 -> AudioSource#VOICE_COMMUNICATION
  //1 -> MIC
  audioSource: 7,
  wavFile: 'test.wav', // default 'audio.wav'
};

const ar = useRef(AudioRecord);
ar.current.init(options);
ar.current.start()

当前实现方式主要通过以下2点实现:

1播放是AudioManager类采用VOICE_COMMUNICATION模式,从听筒进行播放,再打开扬声器进行播放,实现回声消除。

2.录音采用VOICE_COMMUNICATION模式

当前正常进行录音播放没有问题,但是如果遇到耳机、蓝牙等收听播放播放时,播放录音都有问题。蓝牙(无法播放录音)、有线耳机(无法播放录音)

Android实现蓝牙播放录音方式

直接使用蓝牙耳机不管通过安卓原生MediaRecord还是AudioRecord录制都无法实现录音,因为蓝牙的默认链路为单向播放传输。
蓝牙耳机有两种链路,A2DP及SCO。android的api表明:A2DP是一种单向的高品质音频数据传输链路,通常用于播放立体声音乐;而SCO则是一种双向的音频数据的传输链路,该链路只支持8K及16K单声道的音频数据,只能用于普通语音的传输,若用于播放音乐那就只能呵呵了。两者的主要区别是:A2DP只能播放,默认是打开的,而SCO既能录音也能播放,默认是关闭的。
既然要录音肯定要打开sco啦,因此识别前调用上面的代码就可以通过蓝牙耳机录音了,录完记得要关闭。

原生打开蓝牙Sco方式:

private AudioManager mAudioManager = null;
mAudioManager = (AudioManager) reactContext.getSystemService(Context.AUDIO_SERVICE);

mAudioManager.setBluetoothScoOn(true);
# https://developer.android.com/reference/android/media/AudioManager#startBluetoothSco()
mAudioManager.startBluetoothSco();

原生实现蓝牙播放录音

至此我们知道通过SCO模式,可以使蓝牙进入录音状态,至于无法播放的原因主要有2个

1.因为react-native-sound模块播放的时候调用了打开扬声器,导致音频从扬声器上进行播放。

2.录音时Audiorecord采用VOICE_COMMUNICATION,蓝牙耳麦离嘴巴远录音效果不好,在使用蓝牙时采用MIC(1)或者Default模式

原生实现主要控制2点,1.判断当前是否有蓝牙连接 2.蓝牙插入与断开检测

1:通过BluetoothAdapter类实现蓝牙状态信息获取

    /**
     * 通过这个来获取是否有耳机连接
     * @return
     * -1 无蓝牙耳机连接
     * -2 蓝牙没开
     * >0  已连接
     */
    public int getHeadSetStatus() {

      BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); // 蓝牙耳机
      if (bluetoothAdapter == null) { // 若蓝牙耳机无连接
        return -1;
      } else if (bluetoothAdapter.isEnabled()) {
        int a2dp = bluetoothAdapter.getProfileConnectionState(BluetoothProfile.A2DP); // 可操控蓝牙设备,如带播放暂停功能的蓝牙耳机
        int headset = bluetoothAdapter.getProfileConnectionState(BluetoothProfile.HEADSET); // 蓝牙头戴式耳机,支持语音输入输出
        int health = bluetoothAdapter.getProfileConnectionState(BluetoothProfile.HEALTH); // 蓝牙穿戴式设备

        // 查看是否蓝牙是否连接到三种设备的一种,以此来判断是否处于连接状态还是打开并没有连接的状态
        int flag = -1;
        if (a2dp == BluetoothProfile.STATE_CONNECTED) {
          flag = a2dp;
        } else if (headset == BluetoothProfile.STATE_CONNECTED) {
          flag = headset;
        } else if (health == BluetoothProfile.STATE_CONNECTED) {
          flag = health;
        }
        // 说明连接上了三种设备的一种
        if (flag != -1) {
          return flag;
        }
      }
      return -2;
    }


    /**
     * 停止SCO
     * @param promise
     */
    @ReactMethod
    public void stopBluetoothSco(Promise promise) {
        mAudioManager.setBluetoothScoOn(false);
        mAudioManager.stopBluetoothSco();
        mAudioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
        mAudioManager.setSpeakerphoneOn(true);
        promise.resolve(true);
    }
 
    /**
     * 开启SCO
     *
     * @param promise
     */
    @ReactMethod
    public void startBluetoothSco(Promise promise) {
        mAudioManager.setBluetoothScoOn(true);
        mAudioManager.startBluetoothSco();
        mAudioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
        mAudioManager.setSpeakerphoneOn(false);
        promise.resolve(true);
    }
 

 

2:判断耳机插入需要通过原生注册广播,进行获取插入拔出状态

    // 注册广播
    private void registerBluetoothDeviceReceiver() {
        IntentFilter intentFilter = new IntentFilter();
        intentFilter.addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED);
        intentFilter.setPriority(Integer.MAX_VALUE);//设置优先级
        blueToothtReceiver = new BlueToothtReceiver();//注册
        mReactContext.registerReceiver(blueToothtReceiver, intentFilter);
    }

    public class  BlueToothtReceiver extends BroadcastReceiver{
        @Override
        public void onReceive(Context context, Intent intent) {
            String action = intent.getAction();if(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED.equals(action)){//蓝牙连接成功
                int currentState= intent.getIntExtra(BluetoothA2dp.EXTRA_STATE, -1);//当前状态
                if(currentState == BluetoothA2dp.STATE_CONNECTED) {//连接成功
                    // connectPromise.resolve("连接成功");
                    sendEvent("connectSucceeded", "");
                }
                if(currentState == BluetoothA2dp.STATE_DISCONNECTED){//断开连接
                    sendEvent("connectDisconnect", "");
                    // connectPromise.reject("连接失败");
                }
            }
        }
    }

    // 发送事件到js
    public static void sendEvent(String eventName, @Nullable WritableMap params) {
        A2dpModule.reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, params);
    }

原生实现耳机播放录音

这一个可以参考RN Incall的原生代码实现,与蓝牙类似、获取是否存在耳机、已经监听耳机插入拔出,进行对应的切换控制

这里我尝试后,发现ACTION_HEADSET_PLUG事件一直注册不上,注册了一获取action,app打开后就会闪退,希望有Android大咖能指出错误,谢谢

public class HeadsetModule extends ReactContextBaseJavaModule {
  private BroadcastReceiver wiredHeadsetReceiver;
  private boolean hasWiredHeadset = false;
  private static final String ACTION_HEADSET_PLUG = (android.os.Build.VERSION.SDK_INT >= 21)
      ? AudioManager.ACTION_HEADSET_PLUG
      : Intent.ACTION_HEADSET_PLUG;

  public static ReactApplicationContext reactContext;

  public HeadsetModule(ReactApplicationContext context) {
    super(context);
    reactContext = context;
    // registerBluetoothDeviceReceiver();
  }

  @Override
  public String getName() {
    return "Headset";
  }

  // 注册广播
  private void registerBluetoothDeviceReceiver() {
    IntentFilter filter = new IntentFilter(ACTION_HEADSET_PLUG);
    wiredHeadsetReceiver = new WiredHeadsetReceiver();// 注册
    reactContext.registerReceiver(wiredHeadsetReceiver, filter);
  }

  public class WiredHeadsetReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
      if (ACTION_HEADSET_PLUG.equals(intent.getAction())) {
        hasWiredHeadset = intent.getIntExtra("state", 0) == 1;
        String deviceName = intent.getStringExtra("name");
        if (deviceName == null) {
          deviceName = "";
        }
        WritableMap data = Arguments.createMap();
        data.putBoolean("isPlugged", (intent.getIntExtra("state", 0) == 1) ? true : false);
        data.putBoolean("hasMic", (intent.getIntExtra("microphone", 0) == 1) ? true : false);
        data.putString("deviceName", deviceName);
        sendEvent("WiredHeadset", data);
      } else {
        hasWiredHeadset = false;
      }
    }
  }
public static void sendEvent(String eventName, String params) {
    reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, params);
  }
}

RN通过原生代码实现对应设备播放录音

通过原生实现了基础能力,接下来只要在RN中调用进行切换状态即可,下面是RN切换要点(暂不考虑耳机、蓝牙同时使用情况)

 
蓝牙断开连接触发
蓝牙连接成功
录音
ar,AudioResource值使用VOICE_COMMUNICATION模式录音,Mic模式录音范围太广,杂音多
MIC方式录音
 
播放
 
使用VOICE_COMMUNICATION同时开启speakPhoneOn使用听筒播放
 
Voice方式播放,使用VOICE_COMMUNICATION同时关闭speakPhoneOn使用听筒播放
 

 

  useEffect(() => {
    // 初始播放录音设备
    // 初始化蓝牙监听器、自动切换播放录音模式
    (async () => {
      let bluetoothStatus = await A2dp.getBluetoothStatus();
      if(bluetoothStatus > 0){
        dispatch(playRecordDeviceChange('bluetooth'))
      }else{
        dispatch(playRecordDeviceChange('local_machine'))
      }
      A2dp.on('connectSucceeded', async () => {
        console.log('蓝牙连接成功');
        dispatch(playRecordDeviceChange('bluetooth'))

      });
      A2dp.on('connectDisconnect', async () => {
        console.log('蓝牙断开连接');
        dispatch(playRecordDeviceChange('local_machine'))
      });

    })();

    return () => {
      if(playRecordDevice == 'bluetooth'){
         A2dp.stopBluetoothSco();
        console.log("关闭蓝牙SCO(app关闭)");
      }
    };
  }, [])


  useEffect(() => {
    // 初始化权限
    // 初始化录音模块 AudioRecord
    const options = {
      sampleRate: 16000, // default 44100
      channels: 1, // 1 or 2, default 1
      bitsPerSample: 16, // 8 or 16, default 16
      //7 -> AudioSource#VOICE_COMMUNICATION
      //1 -> MIC
      audioSource: 7, // android only (see below) https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#VOICE_COMMUNICATION
      wavFile: 'test.wav', // default 'audio.wav'
    };
    (async () => {
      if(!hasPermission.current) {
        console.log("检查权限",hasPermission.current)
        hasPermission.current = await checkPermission();
      }
      if(hasPermission.current){
        console.log("权限获取成功",hasPermission.current)
        if(playRecordDevice){
            if(playRecordDevice == 'local_machine'){
              await A2dp.stopBluetoothSco();
              console.log("关闭蓝牙SCO(playRecordDevice变化)");
            }else if(playRecordDevice == 'bluetooth'){
              options.audioSource = 1
              await A2dp.startBluetoothSco();
              console.log("打开蓝牙SCO(playRecordDevice变化)");
            }
            dispatch(initAudioRecord(options));
            console.log("重新初始化录音模块 AudioRecord",options);
            let mode = await A2dp.getAudioManagerMode()
            console.log('AudioManager mode :>> ', mode);

        }else{
          dispatch(initAudioRecord(options));
        }
      }else{
        console.log("权限获取失败");
        Alert.alert("权限错误","软件无法使用,获取权限失败")
      }

    })();
    return () => {}
  }, [playRecordDevice])

 

 

参考资料/代码:

https://dandelioncloud.cn/article/details/1518425101983899650

microCloudCode/react-native-a2dp: reactNative连接a2dp和开启sco (github.com)

react-native-webrtc/react-native-incall-manager: Handling media-routes/sensors/events during a audio/video chat on React Native (github.com)

posted @ 2022-10-31 15:31  进击的嘎嘣脆  阅读(1280)  评论(0编辑  收藏  举报