Vue 录音实时上传,分析问题,解决问题,最后展示完整方案

1,587 阅读4分钟

「这是我参与11月更文挑战的第5天,活动详情查看:2021最后一次更文挑战

因为公司事做语音翻译的,在官网上有个试用的功能,需要在网页上录音,并传到后台进行翻译并进行语音合成。

业务需求就是在网页上使用录音功能,每100ms 通信一次,将数据传输到后端进行一套链路处理,

原来的业务逻辑是使用http 进行通信,每1s 通信一次,但是想优化下准备使用websocket 通信,降低翻译延迟,将延时降低为100ms,所以进行优化。

1、项目问题

项目使用的前端框架是Vue,但是因为我是后端开发,所以我对vue不懂,前端的开发使用的插件是recorderX 。在每秒发送数据的时候还是正常的,但是在将定时器调整为100ms的时候,数据总是不能正常发送,要么就是空包,要么就是一下数据量很大,超过了后端的长度上限,导致无法处理,业务上也不能满足需求,因为前端还有其他事情需要处理,在研究了几天之后,果断放弃了,一直把这个问题闲置下来

可以看到前面的只有44个字节,后面连续几帧数据一直没变,最后突然变大。

2、解决问题的路径

因为我最近项目上不是很忙,所以有一些时间,这个录音的问题就暂时放到我这了,有问题就要解决,下面说一下我的解决问题的路径。

image.png

1.学习vue语法

前端我是不太懂,只在上古时代的时候写过jsp 和 js ,Vue 这种高级框架我哪里懂,所以得学习下。不得不说前端的东西现在进化的挺好,虽然vue 依然不怎么懂,但是大概知道vue的模块分三部分

第一部分是界面的配置

第二部分是逻辑代码

第三部分是网页样式

<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js App"/>
  </div>
</template>
 
<script>
import HelloWorld from './components/HelloWorld.vue'
 
export default {
  name: 'App',
  components: {
    HelloWorld
  }
}
</script>
 
<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

2.搭建环境

vue的环境搭建还蛮复杂的,我选择的ide 是 webstorm ,但是据说前端使用的是VScode,作为idea 的重度用户,果断选择了webstorm.

安装nodejs,

部署vue 环境

环境大概搭建完成之后,现在依然不能理解的是为什么每次都要使用命令启动,npm run serve ,不过不影响我解决问题

3.代码问题

前端遇到的问题是在定时器缩短到100ms 的时候,recorder 在每次获取数据的时候无法正常的获取数据,在定时器的时候稍微长点的时候是正常的

基于这种问题我有几个猜测

第一:vue 的刷新机制导致recorder 的数据无法技术放入buffer 中,(应该不是)

第二:没有正确的使用recorderX,在获取的时候没有清楚buffer( 在读取recorderX 的源码之后并没有发现什么异常,试着调用了recorder.clear(),然后并没有什么卵用)

第三:写法有问题 (在尝试了几种写法后果断放弃)

第四:插件有问题,换插件(在绝望之际,我放弃了,直接换了插件,最后完美解决问题)

3、代码解决

最后的解决办法是通过换插件,将原来的插件换掉,换成HZRecorder

下面是源码,我做了一些优化。文件名为HZRecorder.js

export function HZRecorder(stream, config) {
    config = config || {};
    config.sampleBits = config.sampleBits || 16;   //采样数位 8, 16
    config.sampleRate = config.sampleRate || 16000;  //采样率16khz
​
    var context = new (window.webkitAudioContext || window.AudioContext)();
    var audioInput = context.createMediaStreamSource(stream);
    var createScript = context.createScriptProcessor || context.createJavaScriptNode;
    var recorder = createScript.apply(context, [4096, 1, 1]);
​
    var audioData = {
        size: 0     //录音文件长度
        , buffer: []   //录音缓存
        , inputSampleRate: context.sampleRate  //输入采样率
        , inputSampleBits: 16    //输入采样数位 8, 16
        , outputSampleRate: config.sampleRate  //输出采样率
        , outputSampleBits: config.sampleBits    //输出采样数位 8, 16
        , input: function (data) {
            this.buffer.push(new Float32Array(data));
            this.size += data.length;
        }
        , compress: function (clearBuff) { //合并压缩
            //合并
            var data = new Float32Array(this.size);
            var offset = 0;
            for (var i = 0; i < this.buffer.length; i++) {
                data.set(this.buffer[i], offset);
                offset += this.buffer[i].length;
            }
            //压缩
            var compression = parseInt(this.inputSampleRate / this.outputSampleRate);
            var length = data.length / compression;
            var result = new Float32Array(length);
            var index = 0, j = 0;
            while (index < length) {
                result[index] = data[j];
                j += compression;
                index++;
            }
            //  TODO 每次获取清空缓冲区
            if (clearBuff){
                this.buffer = []
                this.size = 0
            }
​
            return result;
        }
        , encodeWAV: function (clearBuff) {
            var sampleRate = Math.min(this.inputSampleRate, this.outputSampleRate);
            var sampleBits = Math.min(this.inputSampleBits, this.outputSampleBits);
            var bytes = this.compress(clearBuff);
            var dataLength = bytes.length * (sampleBits / 8);
            var buffer = new ArrayBuffer(44 + dataLength);
            var data = new DataView(buffer);
​
            var channelCount = 1;//单声道
            var offset = 0;
​
            var writeString = function (str) {
                for (var i = 0; i < str.length; i++) {
                    data.setUint8(offset + i, str.charCodeAt(i));
                }
            }
​
            // 资源交换文件标识符
            writeString('RIFF'); offset += 4;
            // 下个地址开始到文件尾总字节数,即文件大小-8
            data.setUint32(offset, 36 + dataLength, true); offset += 4;
            // WAV文件标志
            writeString('WAVE'); offset += 4;
            // 波形格式标志
            writeString('fmt '); offset += 4;
            // 过滤字节,一般为 0x10 = 16
            data.setUint32(offset, 16, true); offset += 4;
            // 格式类别 (PCM形式采样数据)
            data.setUint16(offset, 1, true); offset += 2;
            // 通道数
            data.setUint16(offset, channelCount, true); offset += 2;
            // 采样率,每秒样本数,表示每个通道的播放速度
            data.setUint32(offset, sampleRate, true); offset += 4;
            // 波形数据传输率 (每秒平均字节数) 单声道×每秒数据位数×每样本数据位/8
            data.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), true); offset += 4;
            // 快数据调整数 采样一次占用字节数 单声道×每样本的数据位数/8
            data.setUint16(offset, channelCount * (sampleBits / 8), true); offset += 2;
            // 每样本数据位数
            data.setUint16(offset, sampleBits, true); offset += 2;
            // 数据标识符
            writeString('data'); offset += 4;
            // 采样数据总数,即数据总大小-44
            data.setUint32(offset, dataLength, true); offset += 4;
            // 写入采样数据
            if (sampleBits === 8) {
                for (var i = 0; i < bytes.length; i++, offset++) {
                    var s = Math.max(-1, Math.min(1, bytes[i]));
                    var val = s < 0 ? s * 0x8000 : s * 0x7FFF;
                    val = parseInt(255 / (65535 / (val + 32768)));
                    data.setInt8(offset, val, true);
                }
            } else {
                for (var i2 = 0; i2 < bytes.length; i2++, offset += 2) {
                    var s2 = Math.max(-1, Math.min(1, bytes[i2]));
                    data.setInt16(offset, s2 < 0 ? s2 * 0x8000 : s2 * 0x7FFF, true);
                }
            }
​
            return new Blob([data], { type: 'audio/wav' });
        }
    };
    //开始录音
    this.start = function () {
        audioInput.connect(recorder);
        recorder.connect(context.destination);
    }
​
    //停止
    this.stop = function () {
        recorder.disconnect();
    }
​
    //获取音频文件
    this.getBlob = function (clearBuff) {
        clearBuff = clearBuff || false;
        // this.stop();
        return audioData.encodeWAV(clearBuff);
    }
​
    //回放
    this.play = function (audio) {
        // var blob=this.getBlob();
        // saveAs(blob, "F:/3.wav");
        audio.src = window.URL.createObjectURL(this.getBlob());
    }
​
    //上传
    this.upload = function () {
        return this.getBlob()
    }
​
    //音频采集
    recorder.onaudioprocess = function (e) {
        audioData.input(e.inputBuffer.getChannelData(0));
        //record(e.inputBuffer.getChannelData(0));
    }
​
}
调用的地方:文件名为HelloWorld.vue

<template>
  <div>
    <button @click="btnClick">开始</button>
  </div>
</template>
​
<script>
import { HZRecorder} from '../HZRecorder.js';
​
export default {
  name: 'HelloWorld',
  props: {
    msg: String
  },
  data() {
    return {
      index:0,
      timeCount:0,
      recorder :HZRecorder,
      audio_context:AudioContext,
    };
  },
methods:{
  btnClick: function () {
    this.recorder.start()
    setInterval(() => {
      var blob = this.recorder.getBlob();
      const file = new File([blob], 'audio.wav');
      console.log("-----------"+ file.size)
​
      var blob2 = blob.slice(this.index,blob.size -1);
      console.log("----blob2-------" + blob2.size)
      this.index = blob.size -1;
      // this.recorder.start()
​
      this.timeCount = this.timeCount +1
      if (this.timeCount === 50){
        const blobUrl = window.URL.createObjectURL(blob)
        const a = document.createElement('a')
        a.style.display = 'none'
        a.download = new Date().getTime() +'.wav'
        a.href = blobUrl
        a.click()
      }
    }, 100)
  }
},
  mounted() {
   var that = this
    this.$nextTick(() => {
      try {
        // <!-- 检查是否能够调用麦克风 -->
        window.AudioContext = window.AudioContext || window.webkitAudioContext;
        navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia;
        window.URL = window.URL || window.webkitURL;that.audio_context = new AudioContext;
        console.log('navigator.getUserMedia ' + (navigator.getUserMedia ? 'available.' : 'not present!'));
      } catch (e) {
        alert('No web audio support in this browser!');
      }
​
      navigator.getUserMedia({audio: true}, function (stream) {
        that.recorder = new HZRecorder(stream,{
          sampleBits: 16,
          sampleRate: 8000
        })
        console.log('初始化完成');
      }, function(e) {
        console.log('No live audio input: ' + e);
      });
    })
  },
}
​
​
​
</script>
​
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
  margin: 40px 0 0;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
</style>

​ 在页面上点击开始,

image.png

然后F12 打开控制台看到输出,每帧的长度是1681,完成了需求

image.png

总结

做了后端蛮久的突然接触前端,虽然想学但是总感觉有点难,心理上有点怂,虽然最后做了各种猜测,也算解决了问题,但是还是需要对学习陌生领域的时候的心理状态做一个复盘,要清空自己,不能用原来的历史知识排斥新接触的知识,先吸收后理解