H5音频处理——踩坑之旅

7,136 阅读12分钟

随着公司产品的业务扩展,今年算是和浏览器的录音功能硬磕上了。遇到了不少奇葩的问题以及一些更多的扩展吧~这里记录一下分享给同样遇到问题后脑壳疼的各位。

解析base64的pcm数据进行播放

这个场景还是存在的。在websocket和server的交互上可能不存在问题。但是如果是原生应用间的交互,为了保证数据的一致性,只传string的情况下就需要用到了。

  1. 解析base64变为arrayBuffer.

    function base642ArrayBuffer() {
    			const binary_string = window.atob(base64); // 解析base64
          const len = binary_string.length;
          const bytes = new Uint8Array(len);
          for (let i = 0; i < len; i++) {
            bytes[i] = binary_string.charCodeAt(i);
          }
      		// 如果不`.buffer`则返回的是Unit8Array、各有各的用处吧
      		// Unit8Array可以用来做fill(0)静音操作,而buffer不行
          return bytes.buffer;
    }
    
  2. 由于浏览器不能支持播放pcm数据,所以如果后端server”不方便“给你加上wav请求头.那我们需要自己造一个wav的头(也就是那44个字节)

      function buildWaveHeader(opts) {
        const numFrames = opts.numFrames;
        const numChannels = opts.numChannels || 1;
        const sampleRate = opts.sampleRate || 16000; // 采样率16000
        const bytesPerSample = opts.bytesPerSample || 2; // 位深2个字节
        const blockAlign = numChannels * bytesPerSample;
        const byteRate = sampleRate * blockAlign;
        const dataSize = numFrames * blockAlign;
    
        const buffer = new ArrayBuffer(44);
        const dv = new DataView(buffer);
    
        let p = 0;
    
        p = this.writeString('RIFF', dv, p); // ChunkID
        p = this.writeUint32(dataSize + 36, dv, p); // ChunkSize
        p = this.writeString('WAVE', dv, p); // Format
        p = this.writeString('fmt ', dv, p); // Subchunk1ID
        p = this.writeUint32(16, dv, p); // Subchunk1Size
        p = this.writeUint16(1, dv, p); // AudioFormat
        p = this.writeUint16(numChannels, dv, p); // NumChannels
        p = this.writeUint32(sampleRate, dv, p); // SampleRate
        p = this.writeUint32(byteRate, dv, p); // ByteRate
        p = this.writeUint16(blockAlign, dv, p); // BlockAlign
        p = this.writeUint16(bytesPerSample * 8, dv, p); // BitsPerSample
        p = this.writeString('data', dv, p); // Subchunk2ID
        p = this.writeUint32(dataSize, dv, p); // Subchunk2Size
    
        return buffer;
      }
      function writeString(s, dv, p) {
        for (let i = 0; i < s.length; i++) {
          dv.setUint8(p + i, s.charCodeAt(i));
        }
        p += s.length;
        return p;
      }
      function writeUint32(d, dv, p) {
        dv.setUint32(p, d, true);
        p += 4;
        return p;
      }
      function writeUint16(d, dv, p) {
        dv.setUint16(p, d, true);
        p += 2;
        return p;
      }
    
  3. 把头和pcm进行一次拼装

    concatenate(header, pcmTTS);
    function concatenate(buffer1, buffer2) {
        const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength);
        tmp.set(new Uint8Array(buffer1), 0);
        tmp.set(new Uint8Array(buffer2), buffer1.byteLength);
        return tmp.buffer;
      }
    
  4. 转成可播放buffer流,可以用来获取时间,如果是多段pcm数据流还可以进行组装拼接

    audioCtx.decodeAudioData(TTS, (buffer) => { 存储起来准备播放 });
    // buffer.duration 可以用来判断播放时长
    // buffer
    
  5. 播放

    const source = audioCtx.createBufferSource();
    const gainNode = audioCtx.createGain();
    source.buffer = buffer;
    gainNode.gain.setTargetAtTime(0.1, audioCtx.currentTime + 2, 5);
    source.connect(gainNode);
    gainNode.connect(context.destination);
    source.start('需要播音的时长 简单的可以用buffer.duration或者自己计算拼接后的长度逻辑' + this.context.currentTime);// 这里必须要加上currentTime
    

录音后发现手机端公放情况下噪音、回声严重

在业务需求中,有一个比较坑的需求。我们产品的场景是模拟一个机器人和用户的通讯对话过程。中间涉及机器人音频播放和用户的说话录音(因为功能上的需求,要求机器人播音时仍然录音来保证抢话逻辑的存在)。本来这个方案在佩戴耳机的场景下仍然能够做到表现还不错,但是在一次定制化需求下要求实现以上功能的情况下手机音频公放,不许佩戴耳机。然后我们就崩溃了,花了很多时间去调研实现(其实这块没前端啥事,但是可以整理一下我的认知)。

  1. **VAD做降噪逻辑。**vad是声音活动检测,检测有没有声音。和降噪其实是两回事。但是通过加入Vad的算法模块可以起到一定的降噪作用,其做法是粗暴地默认人声比环境声音要大,去除声音小的音源。但是并不算是标准的降噪处理。
  2. 降噪一般来怎么实现。因为噪音在声学层面和人声没有显著差别,纯软件算法实现降噪是很难的。所以一般都是硬件过滤一次然后再到算法层面.噪音采集进来之后很难过滤。综上所述降噪主要还是靠硬件设备(麦克风阵列),但是经过检验不同手机公放下硬件设备都不一致,而且公放的逻辑底下,华为mate20pro/iphoneX等其实都还是会有明显的回声,于是后面我们为了体验的问题被迫砍了需求(只在轮到用户说法的时候才录音)——后续可能还会继续调研吧,联系到的第三方方案暂时没能成功引入验证,所以也不能肯定这个方向完全不可行。
  3. 有没有专门的降噪算法。专门的降噪算法肯定是有的,如果可以场景是安卓和IOS原生设备下,可以通过调用底层的API在本地直接实现降噪甚至是回声的消除,相当于把算法模块以sdk的形式直接安装到应用上。但是如果是在web端那就没办法必须传到云上进行算法降噪。
  4. 最好的做法。在算法能力局限的情况下还是得去引导客户去佩戴麦克风。因为就场景而言,这种场景下要实现比较好的对话效果提取信息对ASR的质量是有非常高要求的。通过物理设备降噪,能够很大程度地减轻算法端的压力。毕竟物理单元还可以实现主动降噪(市面上的那些主动降噪耳机)。

使用webview

因为各种原因吧,我们开始调试怎么原生和网页交互(原生应用负责采集音频,音频的pcm流通过方法回调提供给到网页中进行后续的处理)。这算是我第一次对接原生应用,再加上我司目前还不需要这方面开发人员,所以可能踩了一些在大家看来常识性的问题。也稍作整理:

原生应用和webview的交互

以前一直以为交互形式是带有回调函数等花里胡哨的操作的。对接上后才知道,两端的调用都只能用简单的方法调用传参。这就导致一个问题,我们和原生应用的交互需要把方法绑定在window下,而绑定在window下的方法没有拥有vue的this上下文,所以为了打通原生应用的pcm数据流能正常下发到vue实例中进行逻辑处理,我写了一个简单的事件订阅者模式,通过订阅、通知的形式来实现了。

如何看控制台的日志

在嵌入webview之后最大的问题大概就是我们要怎么看chrome的日志了。可能现在采用的方式还是一个比较蛋疼的实现方式,我分别下载了IOS的开发工具和安卓的开发工具,然后让他们帮忙把环境给我搭起来,之后调整就是我自己的事情了。这样的方法有个好处,就是假如我遇到一些小问题(涉及原生的改动),我可以直接自己查一下上手改一些小逻辑,不需要依赖别人,提高一定的效率。

这里还有个坑,但是就是安卓开启了chrome的调试模式后,打开控制台会出现404报错。其实这需要你用魔法上网之后才能正常访问。不然不管你咋捣鼓都不会成功滴。

权限相关的注意点

webview嵌入原生应用后有很多权限上的问题,例如是否允许localstorage、是否允许非法的安全证书(本地开发会伪造证书来模拟https)、是否允许开启录音权限、https是否允许加载http资源、甚至细致到播音等等。遇到这个问题我的办法是,尽可能和搭档描述清楚我的页面会做什么操作,然后由他们去判断给你开什么权限

IOS的爱恨纠缠

IOS的坑实在太多了,希望能给大家踩完这些坑。

wkbview下无法支持web端录音

这个其实是个比较蛋疼的点。一开始我发了一段用来检验浏览器兼容性的代码,让合作伙伴(他们负责写原生app嵌入我们的webview)帮忙先简单地试一下兼容性是否有问题以敲定我们方案。结果估计是没沟通好,在临近项目上线前,尝试把我们的页面嵌入时才发现原来丫的不支持这个功能。这算是狠狠踩了坑,后面没办法只能选择临时更换方案,在嵌入IOS的webview使用原生的录音,其他环境逻辑继续走网页录音。

**总结一下,IOS12版本(现阶段最新版本)safari能够支持网页端录音,但是使用wkwebview(原生app嵌入webview)的场景下不支持这个功能。**有看到在github上有人在IOS11时说预估IOS12会支持这个功能。对于我们而言,这样兼容性比较差的方案肯定是毫不留情给它废弃掉。

safari下多次调用audioCtx.xxx后报错null is not an object

在safari下我们针对每一次录音和播放机器人声音的操作都会生成一个audioContext的实例,在chrome下不管进行多少次操作都没有问题。但是切换到safari后,发现页面最多不能操作5次,只要操作第5次就必然报错。按理说每次的关系应该都是独立的,在确保现象后,找到这篇文章audiocontext Samplerate returning null after being read 8 times。大概意思是,调用失败的原因是因为audioCtx不能被创建超过6个,否则则会返回null。结合我们的5次(这个数值可能有一定偏差),可以很直观地判断到问题应该就出在这里——我们的audio示例并没有被正常销毁。也就是代码中的audioCtx = null;并没有进入到垃圾回收。同样借助MDN文档,发现这个方法.

AudioContext.close();

关闭一个音频环境, 释放任何正在使用系统资源的音频.

于是过断把audioContext = null修改成audioContext.close()完美解决。

safari下audio标签无法获取duration,显示为Infinity

在safari下,从远端拉回的音频文件放到audio标签后,获取总时长显示为Infinity.但是在chrome下没有这个问题,于是开始定位问题。首先,看这篇文章audio.duration returns Infinity on Safari when mp3 is served from PHP,从文章中的关键信息中提取得到这个问题很大概率是由于请求头设置的问题导致的。所以我尝试把远端的录音文件拉过来放到了egg提供的静态文件目录,通过静态文件的形式进行访问(打算看看请求头应该怎么修改),结果惊喜的发现egg提供的处理静态文件的中间件在safari下能完美运行。这基本就能确定锅是远端服务没有处理好请求头了。同时看到MDN的文档介绍对dutaion的介绍.于是能判断到,在chrome下浏览器帮你做了处理(获取到了预设的长度),而safari下需要你自己操作。

A double. If the media data is available but the length is unknown, this value is NaN. If the media is streamed and has no predefined length, the value is Inf.

当然看到length的时候我一度以为是contentLength,结果发现最下面的答案中还有一句:

The reason behind why safari returns duration as infinity is quite interesting: It appears that Safari requests the server twice for playing files. First it sends a range request to the server with a range header like this:(bytes:0-1).If the server doesnt’ return the response as a partial content and if it returns the entire stream then the safari browser will not set audio.duration tag and which result in playing the file only once and it can’t be played again.

大概的意思就是在safari下获取音频资源会发送至少两次的请求,第一次请求会形如(bytes: 0-1),如果服务端没有根据这个请求返回相应的字节内容,那么safari就不会帮你解析下一个请求拿回来的全量音频数据,失去一系列audio标签的功能特性。于是对于请求,我们可以这么粗糙的解决:

    const { ctx } = this;
    const file = fs.readFileSync('./record.mp3');
    ctx.set('Content-Type', 'audio/mpeg');

    if (ctx.headers.range === 'bytes=0-1') {
      ctx.set('Content-Range', `bytes 0-1/${file.length}`);
      ctx.body = file.slice(0, 1);
    } else {
      ctx.body = file;
    }

当然这个处理是很粗糙的处理方式,我反观看了一下koa中间件实现的static-cache它能在safari下正常运行,但是却没有上面的代码。所以我觉得,这上面的代码则是一段偏hack形式的实现。当然现在还没有找到正确的解题思路。

不支持/deep/选择器

这个问题暂时没有响应的解决方案。只能是把需要修改到子组件的样式提取到不带scope的style标签上来做到。暂时没有找到比较平滑的兼容方式。

ios调用停止原生录音,导致wkwebview进入假死状态(无法使用路由跳转及发送请求等)

这个其实是属于原生录音的问题,但是因为一开始以为是前端的问题所以花了很多时间才把问题定位了出来。记录在这里以防别的小伙伴也踩坑。

在项目中的代码,结束一次的会话会进行各种保存操作和路由跳转操作。但是在接入ios的录音功能后就发现页面的请求虽然是显示已发出,但是后台却迟迟没有收到。——终终于定位到是由于调用了ios的录音停止而导致的这个问题,大概是页面进行一些任务队列相关的操作时就会卡死(如果只是console.log并不会)

这里也稍微贴一下ios的解决方法

// 停止录音队列和移除缓冲区,以及关闭session,这里无需考虑成功与否
AudioQueueStop(_audioQueue, false);
// 移除缓冲区,true代表立即结束录制,false代表将缓冲区处理完再结束
AudioQueueDispose(_audioQueue, false);

调用context.createBufferSouce.stop()报错

在嵌入webview后,页面中断的时机,需要将当前正在播放的音频都中断掉。而在ios下执行这个方法会报错(有一些原因导致需要重复执行)。对于这种报错,选择采用了最简单的try {} catch{}住,因为在其他情况下都没有,测试了好几种情况应该都没出其他问题


后记,其实吧这段时间还做了很多事情。像什么web-rtc这些,但是一直没时间整理,如果大家有兴趣的话~后面可以整理一下