AudioWorklet录制音频在iPhone及Safari播放缺失过半问题求助
问题:AudioWorklet录制音频在Safari/iPhone上出现中间内容缺失
我基于AudioWorkletAPI实现了一款简易音频录制工具,该工具在Chrome浏览器中播放录制的音频完全正常,但在Safari浏览器及iPhone设备(含iPhone端Chrome)中,录制的音频播放时存在严重缺失——仅能听到音频的前20%与后30%部分。
问题根源分析
- 未主动flush剩余缓冲区数据:停止录制时,AudioWorklet缓冲区中可能还有未填满的音频片段,未被发送到主线程,导致中间部分数据丢失。
- 手动指定低采样率的兼容性问题:强制设置3000Hz采样率,Safari/iPhone可能会强制使用硬件支持的采样率,导致编码时的采样率与实际录制的采样率不匹配,引发音频时长错位。
- 数据复制逻辑效率低且有溢出风险:使用循环赋值处理音频数据,在Safari的高实时性要求下可能出现丢帧,且未处理输入数据超过缓冲区剩余空间的情况。
修复方案及代码
修复后的AudioWorklet处理器代码
class RecorderProcessor extends AudioWorkletProcessor { bufferSize = 4096; _bytesWritten = 0; _buffer = new Float32Array(this.bufferSize); constructor() { super(); this.initBuffer(); // 监听主线程的flush指令 this.port.onmessage = (e) => { if (e.data === 'flush') { this.flush(); } }; } initBuffer() { this._bytesWritten = 0; } isBufferFull() { // 预留少量空间,避免输入数据长度刚好填满缓冲区时溢出 return this._bytesWritten + 128 > this.bufferSize; } process(inputs) { const inputChannel = inputs[0][0]; if (inputChannel) { this.append(inputChannel); } return true; } append(channelData) { if (this.isBufferFull()) { this.flush(); } // 使用TypedArray.set高效复制数据,替代循环赋值 const availableSpace = this.bufferSize - this._bytesWritten; const copyLength = Math.min(channelData.length, availableSpace); this._buffer.set(channelData.subarray(0, copyLength), this._bytesWritten); this._bytesWritten += copyLength; } flush() { if (this._bytesWritten === 0) return; this.port.postMessage(this._buffer.slice(0, this._bytesWritten)); this.initBuffer(); } } registerProcessor('recorder.worklet', RecorderProcessor);
修复后的录制启停与WAV编码代码
let sampleRate; // 使用浏览器默认采样率 let microphone; let context; let data = []; let audioSource = null; let recorderNode = null; // 保存recorder节点,用于发送flush指令 const start = async () => { document.getElementById('startBtn').disabled = true; document.getElementById('stopBtn').disabled = false; window.URL.revokeObjectURL(audioSource); data = []; // 重置录制数据 try { context = new AudioContext(); sampleRate = context.sampleRate; // 获取浏览器实际使用的采样率 microphone = await navigator.mediaDevices.getUserMedia({ audio: true }); const source = context.createMediaStreamSource(microphone); await context.audioWorklet.addModule('recorder.worklet.js'); recorderNode = new AudioWorkletNode(context, 'recorder.worklet'); source.connect(recorderNode).connect(context.destination); recorderNode.port.onmessage = (e) => { data.push(e.data); }; } catch (error) { console.error(error); alert('捕获音频出错'); document.getElementById('startBtn').disabled = false; document.getElementById('stopBtn').disabled = true; } }; const stop = async () => { document.getElementById('stopBtn').disabled = true; try { // 主动发送flush指令,确保剩余缓冲区数据被发送 if (recorderNode) { recorderNode.port.postMessage('flush'); // 短暂等待数据接收完成(可根据实际情况调整时长) await new Promise(resolve => setTimeout(resolve, 100)); } await context.close(); const flattenedData = float32Flatten(data); const blob = new Blob([encodeWAV(flattenedData, sampleRate)], { type: 'audio/wav' }); audioSource = window.URL.createObjectURL(blob); document.getElementById('audioPlayback').src = audioSource; microphone.getTracks().forEach(track => track.stop()); } catch (error) { console.error(error); } finally { document.getElementById('startBtn').disabled = false; recorderNode = null; } }; const float32Flatten = (chunks) => { const nFrames = chunks.reduce((acc, elem) => acc + elem.length, 0); const result = new Float32Array(nFrames); let currentFrame = 0; chunks.forEach(chunk => { result.set(chunk, currentFrame); currentFrame += chunk.length; }); return result; }; function encodeWAV(samples, sampleRate, format = 1, numChannels = 1, bitDepth = 16) { const bytesPerSample = bitDepth / 8; const blockAlign = numChannels * bytesPerSample; const buffer = new ArrayBuffer(44 + samples.length * bytesPerSample); const view = new DataView(buffer); writeString(view, 0, 'RIFF'); view.setUint32(4, 36 + samples.length * bytesPerSample, true); writeString(view, 8, 'WAVE'); writeString(view, 12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, format, true); view.setUint16(22, numChannels, true); view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * blockAlign, true); view.setUint16(32, blockAlign, true); view.setUint16(34, bitDepth, true); writeString(view, 36, 'data'); view.setUint32(40, samples.length * bytesPerSample, true); if (format === 1) { floatTo16BitPCM(view, 44, samples); } else { writeFloat32(view, 44, samples); } return buffer; } function floatTo16BitPCM(output, offset, input) { for (let i = 0; i < input.length; i++, offset += 2) { const s = Math.max(-1, Math.min(1, input[i])); output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); } } function writeString(view, offset, string) { for (let i = 0; i < string.length; i++) { view.setUint8(offset + i, string.charCodeAt(i)); } } function handleSampleRateChange(select) { // 移除手动设置采样率,使用浏览器默认值 alert('采样率将使用浏览器硬件支持的默认值'); }
修复核心要点
- 主动Flush剩余数据:停止录制时向AudioWorklet发送
flush指令,确保缓冲区中未发送的音频片段被传递到主线程。 - 使用默认采样率:不再强制设置低采样率,而是获取AudioContext实际使用的采样率,避免Safari的采样率转换导致的音频错位。
- 优化数据复制:用
TypedArray.set替代循环赋值,提升性能同时避免数据溢出。 - 缓冲区预留空间:调整缓冲区满的判断条件,预留少量空间防止输入数据长度刚好填满缓冲区时的溢出问题。
内容的提问来源于stack exchange,提问作者slaninero
相关产品推荐
相关产品推荐

