网页用JS通过socket播放音频帧数据

1,731 阅读3分钟

作 者

受音视频毒打的萌新,此生不会再爱了。

播放场景

如果是socket.io的话,如下:

const socket = io('yourServerAddress');
socket.on('yourEvent', audioFrameData => {
    // 播放业务逻辑代码
})

如果用的是websocket的话,如下:

const ws = new WebSocket('yourServerAddrsss');
ws.onmessage = (event) => {
    // 播放业务逻辑代码
}

不管是socket.io还是websocket,只是传输协议的不同,播放逻辑都是一样的。

下面的播放代码都有socket.io做样例,不再一一说明。

方法一

网上搜到的大多是这种,通过AudioContext解码音频帧数据,然后创建BufferSource去逐帧播放。

// 创建 AudioContext 实例
const audioContext = new (window.AudioContext || window.webkitAudioContext)();

// 待播放的chunks
let audioBufferChunks = [];

// 控制是否开始播放
let audioStarted = false;

const socket = io('yourServerAddress');
socket.on('yourEvent', audioFrameData => {
    // 播放业务逻辑代码
    
    // 转成arrayBuffer
    const arrayBufferMeta = stringToArrayBuffer(audioFrameData, 'hex');
    
    // 解码
    audioContext.decodeAudioData(arrayBufferMeta.buffer, (buffer) => {
        audioBufferChunks.push(buffer)
        // 如果还没有开始播放,触发第一次播放
        if (audioStarted === false) {
            playSound()
        }
    })
})

// 播放帧
function playSound() {
    try {
        if (audioBufferChunks.length !== 0) {
            audioStarted = true;
            // 创建当前播放音频帧要用的bufferSource
            const source = audioContext.createBufferSource();
            // 把chunks数组里的第一帧放入source里
            source.buffer = audioBufferChunks[0]
            source.connect(audioContext.destination)
            // 开始播放此帧
            source.start(0)
            // 监听结束事件
            source.addEventListener('ended', () => {
                // 去掉已播放完的帧
                audioBufferChunks.splice(0, 1);
                // 回调继续播放
                playSound();
            })
        } else {
          audioStarted = false;  
        }
    } catch (e) {
        console.log(e)
        audioStarted = false;
    }
}

// 传输的时候,服务器端可能会转成base64字符串传输,也可能转成hex字符串(十六进制)传输
function stringToArrayBuffer(string, type) {
    // 无论怎样,要先把数据转成js里的ArrayBuffer
    if (type === 'base64') {
        // 代码找不到了 =.=
    }
    if (type === 'hex') {
        // remove the leading 0x
        const hexString = string.replace(/^0x/, '');

        // ensure even number of characters
        if (hexString.length % 2 != 0) {
            console.log('WARNING: expecting an even number of characters in the hexString');
        }

        // check for some non-hex characters
        const bad = hexString.match(/[G-Z\s]/i);
        if (bad) {
            console.log('WARNING: found non-hex characters', bad);
        }

        // split the string into pairs of octets
        const pairs = hexString.match(/[\dA-F]{2}/gi);

        // convert the octets to integers
        const integers = pairs.map(function (s) {
            return parseInt(s, 16);
        });

        const array = new Uint8Array(integers);
        // console.log(array);
        return array;
    }
}

这个方法可用,但是有一个很严重的问题,帧与帧之间切换的时候,会有爆音,非常明显,根本不能用于生产环境。

当时试着在帧与帧之间加淡入淡出效果,一定程序上缓解了爆音的问题,但随之而来的是播放过程的声音间接性的糊一下糊一下的。

帧与帧之间淡入淡出效果实现如下:


/**
 * 前面省略
 */
const source = audioContext.createBufferSource();

const currBuffer = audioBufferChunks[0];
let currChannel = currBuffer.getChannelData(0);

// 开始和结尾的1.5秒做淡入淡出,以下以48000的采样率算
const sd = 48000 / 1000 * 1.5;
const ed = currChannel.lenght - sd;
for (let i=0; i < currChannel.lenght; i++) {
    let factor = 1;
    if (i < sd) {
        factor = i / sd;
    } else if (i > ed) {
        factor = (currChannel.lenght - 1) / sd
    }
    // i的值会在0~1之间
    // 乘以1还是它自己,剩以0则为0,可以想像为这个factor就是音量大小
    currChannel[i] = currChannel[i] * factor
}

// 后续的都一样,把currBuffer赋给source.buffer
source.buffer = currBuffer;
/**
 * 后面省略
 */

方法二

经过上面这两番折腾,老板还是不放过!爆音不能接受!声音忽高忽低也不能接受!直到无意间在MDN上看到这个「实验中的功能」,彻底拯救了我!

神奇的实验中的功能点这里

微信截图_20211109155441.png

用法如下:

首先,写一个audio标签,设为display:none;不显示

<audio id="audioPlay" style="display:none !important" controls autoplay></audio>

js 部分

// 你那边要用的音频格式支不支持要去MDN上查一下
const mineCodec = 'audio/aac';

// 声明 SourceBuffer
let sourceBuffer;

// 浏览器兼容性检查
if ('MediaSource' in window && MediaSource.isTypeSupported(mineCodec)) {
    // 声明MediaSource
    const mediaSource = new MediaSource();
    // 取得dom元素
    const audioPlay = document.getElementById('audioPlay');
    // 创建ObjectURL
    audioPlay.src = window.URL.createObjectURL(mediaSource)
    // 监听source打开的事件
    mediaSource.addEventListener('sourceopen', () => {
        // 给 sourceBuffer 赋值
        sourceBuffer = mediaSource.addSourceBuffer(mineCodec)
    })
} else {
    console.error('unsupported MIME type or codec: ', mineCodec)
} 

socket.on('yourEvent', (audioFrameData) => {

    // 转成arrayBuffer 
    const arrayBufferMeta = stringToArrayBuffer(audioFrameData, 'hex');
    
    // 就这样,非常简单
    sourceBuffer.appendBuffer(arrayBufferMeta);
     
})

后记

后来,公司某个项目中用了国内某摄像头厂家的解决方案,发现他们的网页播放器用的也是方法二,算是大厂背书了吧。此坑已填好,从此我与音视频是路人了,不想再碰音视频!!!