js与麦克风与PCM格式再加个WS连接(笔记)

1,901 阅读3分钟

1、需求:js调用麦克风做实时(应该是吧)对讲

js获取麦克风权限,需要浏览器支持 AudioContext。

AudioContext 从chrome35开始就完全支持。

在开始说话之前,需要用户允许浏览器获取麦克风权限。即:

if (window.navigator.mediaDevices) {
      window.navigator.mediaDevices
      // 获取浏览器麦克风权限
        .getUserMedia({ 'audio': true })
      // 用户同意赋予麦克风权限
        .then(this.initRecordMicro)
      // 用户拒绝麦克风权限,或者当前浏览器不支持
        .catch(e => {
          switch (e.message || e.name) {
            case 'PERMISSION_DENIED':
            case 'PermissionDeniedError':
              this.$message.error('用户拒绝提供权限')
              break
            case 'NOT_SUPPORTED_ERROR':
            case 'NotSupportedError':
              this.$message.error('浏览器不支持您当前选择的设备')
              break
            case 'MANDATORY_UNSATISFIED_ERROR':
            case 'MandatoryUnsatisfiedError':
              this.$message.error('无法发现指定的硬件设备')
              break
            default:
              this.$message.error(`无法打开麦克风,原因:${e.code || e.name}`)
          }
        })
    } else {
      this.$message.error('您当前浏览器或者协议暂不支持麦克风')
    }

如果用户允许了,那么就开始后续操作,不允许的话,那就没了。

然后就在initRecordMicro里开始获取"流",.then回调会传入麦克风返回的流数据。然后就

    this.ctxAudio = new window.AudioContext()
    this.sourceAudio = this.ctxAudio.createMediaStreamSource(this.streamAudio)

2、完整代码如下:

    <div class="intercomMicroVol">
        <div class="intercomMicroVolCtx" />
    </div>
    <div class="microPhone"
       @mousedown.prevent="microPhoneMousedown"
       @mouseup.prevent="microPhoneMouseup"
    >
       <el-icon />
    </div>

使用的是vue+TS(很多地方暂未找到合适的type,就用了any。。),长按的时候开始,松开就结束。所以使用的是@mousedown.prevent@mouseup.prevent

export default class extends Vue{
    private streamAudio: any 
    private ctxAudio:any
    private sourceAudio:any
    private maxVol=0
    private scriptProcessor:any
    private ws:any
    
    
    // 做了一个声音大小的柱子
    @Watch('maxVol')
    private getVolStyle(val:any) {
        const dom = document.querySelector('.intercomMicroVolCtx') as HTMLElement
        if (val > 0) {
            dom.style.height = `${val * 2.6 + 10}px`
        } else {
            dom.style.height = '0'
        }
    }
    
    private mounted() {
        // 加个事件,用以处理,处于对讲期间,用户误操作了F5等刷新(咱也不知道为啥会这么干,但是提了。。。就得加。。。)
        window.addEventListener('beforeunload', (e) => this.beforeunloadHandler(e))
    }
    private destroyed() {
        this.intercomMouseup()
        window.removeEventListener('beforeunload', (e) => this.beforeunloadHandler(e))
    }
    
    private beforeunloadHandler(e: any) {
        this.intercomMouseup()
    }
    
    private intercomMousedown() {
        // 用来判断是否1秒内连续点击,处理并拦截
      if (this.last && nowTime - this.last < 1000) {
      // 用来判断是否已经有 提示
          if (document.querySelectorAll('.el-message').length === 0) {
            this.$message.warning('点的太快了,请稍后再点击~')
          }
      }else{
          this.ws = new WebSocket('ws://ip:port')
          this.ws.onopen = (e:any) => {
              console.log('连接建立', e)
              // 和后端约定好要传的东西
              this.ws.send('someID')
              this.startRecord()
          }
          this.ws.onerror = (e:any) => {
            console.log(e)
          }
       }
    
        
  }

  private intercomMouseup() {
    this.stopRecord()
  }
      
  // 当用户鼠标移出了对讲按钮,停止对讲
  private intercomMouseleave() {
      if (this.ws || this.sourceAudio) {
          this.intercomMouseup()
      }
  }
      
  private startRecord() {
  // 这里没有使用上面的完整权限判断,因为这里是弹层操作。弹层之前先获取判断了。
    if (window.navigator.mediaDevices) {
      window.navigator.mediaDevices
      // 获取浏览器麦克风权限
        .getUserMedia({ 'audio': true })
      // 用户同意赋予麦克风权限
        .then(this.initRecordMicro)
      // 用户拒绝麦克风权限,或者当前浏览器不支持
        .catch(e => {
          this.$message.error(`获取麦克风权限失败,原因:${e}`)
        })
    } else {
      this.$message.error('您当前浏览器或者浏览器版本暂不支持麦克风')
    }
  }
  private stopRecord() {
      //关闭全部
    const tracks = this.streamAudio.getAudioTracks()
    for (let i = 0, len = tracks.length; i < len; i++) {
      tracks[i].stop()
    }
    //把init里建立的audio链接都关闭
    this.sourceAudio.disconnect()
    this.scriptProcessor.disconnect()
    this.sourceAudio = null
    this.scriptProcessor = null
    this.maxVol = 0
    this.ws.close()
  }
    
  private initRecordMicro(stream:any) {
    this.streamAudio = stream
    this.ctxAudio = new window.AudioContext()
    this.sourceAudio = this.ctxAudio.createMediaStreamSource(this.streamAudio)
    // 通过 AudioContext 获取麦克风中音频音量
    // 256, 512, 1024, 2048, 4096, 8192, 16384
    // 默认支持2的整数次幂的数字,数字越大越保熟,低了容易延迟
    this.scriptProcessor = this.ctxAudio.createScriptProcessor(4096, 1, 1)
    this.sourceAudio.connect(this.scriptProcessor)
    this.scriptProcessor.connect(this.ctxAudio.destination)
    this.scriptProcessor.onaudioprocess = (audioProcessingEvent:any) => {
      // buffer处理
      // 只处理了单声道
      const buffer = audioProcessingEvent.inputBuffer.getChannelData(0)

      let sum = 0
      let outputData:any = []
      for (let i = 0; i < buffer.length; i++) {
        sum += buffer[i] * buffer[i]
      }
      // 这里只是为了取数字,来展示声音的柱子  
      this.maxVol = Math.round(Math.sqrt(sum / buffer.length) * 100)

      // 浏览器麦克风采样率为 this.ctxAudio.sampleRate 一般是44100
      const inputSampleRate = this.ctxAudio.sampleRate
      // 跟流对接,他们那边需要我提供的是8000采样率的,所以需要压缩一次
      outputData = this.compress(buffer, inputSampleRate, 8000)

      this.ws.send(outputData)
    }
  }
      
      private floatTo16BitPCM(bytes:any) {
        let offset = 0
        const dataLen = bytes.length
        // 默认采样率以16计算,而不是8位
        const buffer = new ArrayBuffer(dataLen * 2)
        const data = new DataView(buffer)

        for (let i = 0; i < bytes.length; i++, offset += 2) {
          // 保证采样帧的值在-1到1之间
          let s = Math.max(-1, Math.min(1, bytes[i]))
          // 将32位浮点映射为16位整形 值
          // 16位的划分的是 2^16=65536,范围是-32768到32767
          //  获取到的数据范围是[-1,1]之间,所以要转成16位的话,需要负数*32768,正数*32767,就可以得到[-32768,32767]范围内的数据
          // 第三个参数,true 含义是  是否是小端字节序 这里设置为true
          data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true)
        }
        return data
      }
      private compress(data:any, inputSampleRate:number, outputSampleRate:number) {
        const rate = inputSampleRate / outputSampleRate
        const compression = Math.max(rate, 1)
        const length = Math.floor(data.length / rate)
        const result = new Float32Array(length)
        let index = 0
        let j = 0
        while (index < length) {
          // 取整
          let temp = Math.floor(j)
          result[index] = data[temp]
          index++
          j += compression
        }
        // 将压缩过的数据转成pcm格式数据
        return this.floatTo16BitPCM(result)
      }
}

3、补充个知识点

之前都是本地run的服务,localhost访问是没问题的,但是部署之后发现无法获取window.navigator.mediaDevices,是因为浏览器有保护机制,即,window.navigator.mediaDevices只能在localhost或者httpsfile中才可以。所以,记得https啊~~~

打完收工