如何将Socket.io接收的ArrayBuffer作为音频播放?
我现在遇到一个问题:通过Socket.io接收ArrayBuffer格式的音频流,想把它实时播放出来。目前接收缓冲区的代码是这样的:
retrieveAudioStream = () => { this.socket.on('stream', (arrayBuffer) => { console.log('arrayBuffer', arrayBuffer) }) }
能不能直接把<audio/>元素的src设置成这个缓冲区?如果不行的话,该怎么播放传入的缓冲区流?
另外补充下发送端的代码,方便理解场景:
获取音频输入并初始化录制的代码:
window.navigator.getUserMedia(constraints, this.initializeRecorder, this.handleError); initializeRecorder = (stream) => { const audioContext = window.AudioContext; const context = new audioContext(); const audioInput = context.createMediaStreamSource(stream); const bufferSize = 2048; // 创建JavaScript处理节点 const recorder = context.createScriptProcessor(bufferSize, 1, 1); // 指定处理函数 recorder.onaudioprocess = this.recorderProcess; // 连接流到录制节点 audioInput.connect(recorder); // 连接录制节点到输出 recorder.connect(context.destination); }
接收inputBuffer并通过Socket.io发送的代码:
recorderProcess = (e) => { const left = e.inputBuffer.getChannelData(0); this.socket.emit('stream', this.convertFloat32ToInt16(left)) }
我之前尝试用AudioContext处理,但遇到了错误,代码和错误信息如下:
retrieveAudioStream = () => { const audioContext = new window.AudioContext(); this.socket.on('stream', (buffer) => { const b = audioContext.createBuffer(1, buffer.length, audioContext.sampleRate); b.copyToChannel(buffer, 0, 0) const s = audioContext.createBufferSource(); s.buffer = b }) }
错误:
NotSupportedError: Failed to execute 'createBuffer' on 'BaseAudioContext': The number of frames provided (0) is less than or equal to the minimum bound (0).
首先明确说:不能直接把ArrayBuffer设为audio元素的src。因为audio的src需要的是一个可访问的音频资源URL(比如Blob URL、远程音频文件地址),而你接收的是流式的原始音频数据片段,没有音频文件格式的头信息,直接设置src根本识别不了。针对你的流式音频场景,用Web Audio API(AudioContext)来实时处理播放是正确的方向,下面给你修正后的方案:
1. 先修正数据格式转换
你的发送端把Float32格式的音频数据转成了Int16来传输,所以接收端必须先把Int16Array转回Float32Array——因为Web Audio API只认Float32格式的音频数据:
// 工具函数:把Int16Array转回Float32Array function convertInt16ToFloat32(int16Array) { const float32Array = new Float32Array(int16Array.length); for (let i = 0; i < int16Array.length; i++) { // Int16的范围是-32768到32767,转成Float32的-1到1范围 float32Array[i] = int16Array[i] / 32768; } return float32Array; }
2. 用ScriptProcessorNode处理流式音频(避免报错+实时播放)
你之前的错误是因为流式音频是片段化的,每次接收的buffer是一小段,而且可能存在空数据的情况;另外,每次创建新的BufferSource会导致音频断断续续,正确的做法是用ScriptProcessorNode(或者更现代的AudioWorklet)来维护一个音频队列,实时填充音频数据:
retrieveAudioStream = () => { const audioContext = new window.AudioContext(); // 创建ScriptProcessorNode,缓冲区大小和发送端保持一致(2048) const scriptProcessor = audioContext.createScriptProcessor(2048, 1, 1); // 用来存储接收到的音频数据片段队列 const audioQueue = []; // 实时处理音频输出 scriptProcessor.onaudioprocess = (e) => { const outputBuffer = e.outputBuffer; const outputData = outputBuffer.getChannelData(0); // 从队列里取数据填充输出缓冲区 let offset = 0; while (offset < outputData.length && audioQueue.length > 0) { const chunk = audioQueue.shift(); const chunkLength = chunk.length; // 取当前能填充的最大长度 const copyLength = Math.min(outputData.length - offset, chunkLength); // 把数据复制到输出缓冲区 outputData.set(chunk.subarray(0, copyLength), offset); offset += copyLength; // 如果当前片段还有剩余,放回队列开头 if (chunkLength > copyLength) { audioQueue.unshift(chunk.subarray(copyLength)); } } // 如果队列里没数据了,填充静音(避免爆音) while (offset < outputData.length) { outputData[offset] = 0; offset++; } }; // 把处理节点连接到音频输出 scriptProcessor.connect(audioContext.destination); // 接收Socket.io的流数据 this.socket.on('stream', (arrayBuffer) => { // 先把ArrayBuffer转成Int16Array,再转回Float32Array const int16Array = new Int16Array(arrayBuffer); const float32Array = convertInt16ToFloat32(int16Array); // 把转换好的数据加入队列 audioQueue.push(float32Array); }); }
3. 为什么之前的createBuffer会报错?
你遇到的NotSupportedError大概率是这两个原因:
- 接收的
arrayBuffer是空的,导致转成TypedArray后长度为0,createBuffer要求帧数必须大于0 - 你没有把接收的ArrayBuffer转成正确的TypedArray(应该是Int16Array,因为发送端转的是Int16),直接用原始的ArrayBuffer去调用
copyToChannel,导致数据格式不匹配
4. 如果非要用audio元素怎么办?
如果你的场景不是实时流式播放,而是接收完完整音频再播放,可以把所有ArrayBuffer片段拼接成一个完整的TypedArray,然后封装成带格式头的Blob(比如WAV格式),再生成Blob URL给audio元素的src:
// 示例:拼接所有片段后生成WAV Blob播放(非实时场景) let allAudioData = []; this.socket.on('stream', (arrayBuffer) => { allAudioData.push(new Int16Array(arrayBuffer)); }); // 假设接收完后调用这个函数播放 function playCompleteAudio() { // 拼接所有Int16数据 const totalLength = allAudioData.reduce((sum, chunk) => sum + chunk.length, 0); const combinedInt16 = new Int16Array(totalLength); let offset = 0; allAudioData.forEach(chunk => { combinedInt16.set(chunk, offset); offset += chunk.length; }); // 生成WAV格式的Blob(需要添加WAV文件头) const wavBlob = createWavBlob(combinedInt16, 44100); // 采样率和发送端一致 const audioUrl = URL.createObjectURL(wavBlob); const audio = document.createElement('audio'); audio.src = audioUrl; audio.play(); } // 工具函数:把Int16Array转成WAV格式的Blob function createWavBlob(int16Array, sampleRate) { const numChannels = 1; const bytesPerSample = 2; const buffer = new ArrayBuffer(44 + int16Array.length * bytesPerSample); const view = new DataView(buffer); // 写入WAV文件头 view.setUint8(0, 0x52); // R view.setUint8(1, 0x49); // I view.setUint8(2, 0x46); // F view.setUint8(3, 0x46); // F view.setUint32(4, 36 + int16Array.length * bytesPerSample, true); // 文件大小 view.setUint8(8, 0x57); // W view.setUint8(9, 0x41); // A view.setUint8(10, 0x56); // V view.setUint8(11, 0x45); // E view.setUint8(12, 0x66); // f view.setUint8(13, 0x6D); // m view.setUint8(14, 0x74); // t view.setUint8(15, 0x20); // 空格 view.setUint32(16, 16, true); // 子块大小 view.setUint16(20, 1, true); // 音频格式(PCM) view.setUint16(22, numChannels, true); // 声道数 view.setUint32(24, sampleRate, true); // 采样率 view.setUint32(28, sampleRate * numChannels * bytesPerSample, true); // 字节率 view.setUint16(32, numChannels * bytesPerSample, true); // 块对齐 view.setUint16(34, 8 * bytesPerSample, true); // 位深度 view.setUint8(36, 0x64); // d view.setUint8(37, 0x61); // a view.setUint8(38, 0x74); // t view.setUint8(39, 0x61); // a view.setUint32(40, int16Array.length * bytesPerSample, true); // 数据大小 // 写入音频数据 for (let i = 0; i < int16Array.length; i++) { view.setInt16(44 + i * 2, int16Array[i], true); } return new Blob([buffer], { type: 'audio/wav' }); }
但注意,这个方法只适合非实时的完整音频,如果你需要实时通话那种流式播放,还是用前面的Web Audio API方案更合适。
内容的提问来源于stack exchange,提问作者Stretch0

