You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

AudioWorklet录制音频在iPhone及Safari播放缺失过半问题求助

问题:AudioWorklet录制音频在Safari/iPhone上出现中间内容缺失

我基于AudioWorkletAPI实现了一款简易音频录制工具,该工具在Chrome浏览器中播放录制的音频完全正常,但在Safari浏览器及iPhone设备(含iPhone端Chrome)中,录制的音频播放时存在严重缺失——仅能听到音频的前20%与后30%部分。


问题根源分析

  1. 未主动flush剩余缓冲区数据:停止录制时,AudioWorklet缓冲区中可能还有未填满的音频片段,未被发送到主线程,导致中间部分数据丢失。
  2. 手动指定低采样率的兼容性问题:强制设置3000Hz采样率,Safari/iPhone可能会强制使用硬件支持的采样率,导致编码时的采样率与实际录制的采样率不匹配,引发音频时长错位。
  3. 数据复制逻辑效率低且有溢出风险:使用循环赋值处理音频数据,在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('采样率将使用浏览器硬件支持的默认值');
}

修复核心要点

  1. 主动Flush剩余数据:停止录制时向AudioWorklet发送flush指令,确保缓冲区中未发送的音频片段被传递到主线程。
  2. 使用默认采样率:不再强制设置低采样率,而是获取AudioContext实际使用的采样率,避免Safari的采样率转换导致的音频错位。
  3. 优化数据复制:用TypedArray.set替代循环赋值,提升性能同时避免数据溢出。
  4. 缓冲区预留空间:调整缓冲区满的判断条件,预留少量空间防止输入数据长度刚好填满缓冲区时的溢出问题。

内容的提问来源于stack exchange,提问作者slaninero

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.22 15:06:16