You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

如何用JavaScript实现设备到Node.js服务器的实时音频流广播

嘿,你已经在实时音频流的搭建上迈出了一大步,不过确实会遇到不少细节瓶颈,咱们一步步来解决你的问题~

实时音频流从设备到Node.js再广播到前端的解决方案

一、音频上传至服务器的方式优化

你当前用getUserMedia + ScriptProcessorNode + Socket.io的思路是对的,但还有不少优化空间,也有其他更合适的方案可以选:

当前方案的问题与改进

首先要敲个重点:ScriptProcessorNode已经被浏览器标准弃用,它在主线程运行,容易因主线程拥堵导致音频卡顿。推荐用AudioWorklet替代,它在独立的音频线程处理数据,性能更稳定。

你可以把initializeRecorder改成AudioWorklet版本:

initializeRecorder = async (stream) => {
  const audioContext = new window.AudioContext();
  // 加载单独的工作线程脚本(需创建recorder-worklet.js文件)
  await audioContext.audioWorklet.addModule('recorder-worklet.js');
  
  const audioInput = audioContext.createMediaStreamSource(stream);
  const recorderNode = new AudioWorkletNode(audioContext, 'recorder-processor');
  
  // 接收工作线程发来的音频数据并发送给服务器
  recorderNode.port.onmessage = (event) => {
    const audioData = event.data;
    this.socket.emit('stream', this.convertFloat32ToInt16(audioData));
  };
  
  audioInput.connect(recorderNode);
  recorderNode.connect(audioContext.destination);
};

对应的recorder-worklet.js内容:

class RecorderProcessor extends AudioWorkletProcessor {
  process(inputs) {
    const input = inputs[0];
    if (input.length > 0) {
      // 把Float32格式的音频数据发送给主线程
      this.port.postMessage(input[0]);
    }
    return true;
  }
}

registerProcessor('recorder-processor', RecorderProcessor);

其他可选方案

  • MediaRecorder API:如果对延迟要求不高(比如允许1-2秒延迟),可以用MediaRecorder把音频录制成Blob片段再发送。优点是代码简洁,自带opus等压缩编码,能减少传输体积;缺点是分段录制会带来一定延迟。
  • WebRTC直接广播:如果不需要Node.js中转,WebRTC可以直接在客户端间建立低延迟音频流;如果需要大规模广播,建议用WebRTC SFU(如mediasoup、Janus),它能做选择性转发和编码适配,比Socket.io更适合高并发场景。

关于fs.createReadStream + Axios的疑问

你说得完全没错!这种方式不适合实时直播流fs.createReadStream是针对本地文件的流式读取,Axios基于HTTP请求-响应模型,无法维持持久的实时数据流。实时流必须用WebSocket(Socket.io)或WebRTC这类双向持久连接。

二、服务器端的扩展性改进

你当前的服务器代码只是把流发回给同一个客户端,这显然不是广播需求。正确的广播逻辑应该是:

io.on('connection', (client) => {
  // 接收发送端的音频流,广播给所有其他客户端
  client.on('stream', (audioBuffer) => {
    // 广播给除发送者外的所有客户端
    client.broadcast.emit('stream', audioBuffer);
    // 如果需要让发送者自己也听到,改用 io.emit('stream', audioBuffer)
  });
});

扩展性建议

  • Socket.io集群:如果用户量较大,单台服务器扛不住,可以用Socket.io的Redis适配器,让多台Socket.io服务器共享连接状态,实现跨服务器广播。
  • 切换到SFU方案:如果是几十上百人的大规模广播,Socket.io转发原始音频会占用大量带宽,此时推荐用WebRTC SFU,它能优化带宽使用,支持更多并发用户。

三、客户端播放音频流的实现

收到服务器发来的Int16Array buffer后,需要用AudioContext解码并播放,这里提供两种实现方式:

方式1:使用ScriptProcessorNode(兼容旧浏览器)

retrieveAudioStream = () => {
  const audioContext = new window.AudioContext();
  const bufferSize = 2048;
  const scriptNode = audioContext.createScriptProcessor(bufferSize, 1, 1);
  
  // 用队列存储音频数据,避免网络波动导致卡顿
  const audioQueue = [];
  
  scriptNode.onaudioprocess = (e) => {
    const outputBuffer = e.outputBuffer.getChannelData(0);
    let offset = 0;
    
    // 从队列中取数据填充输出缓冲区
    while (offset < bufferSize && audioQueue.length > 0) {
      const chunk = audioQueue.shift();
      const chunkLength = chunk.length;
      
      if (offset + chunkLength <= bufferSize) {
        outputBuffer.set(chunk, offset);
        offset += chunkLength;
      } else {
        // 处理剩余部分,把没用到的放回队列
        outputBuffer.set(chunk.slice(0, bufferSize - offset), offset);
        audioQueue.unshift(chunk.slice(bufferSize - offset));
        offset = bufferSize;
      }
    }
    
    // 如果队列空了,填充静音避免爆音
    while (offset < bufferSize) {
      outputBuffer[offset] = 0;
      offset++;
    }
  };
  
  scriptNode.connect(audioContext.destination);
  
  this.socket.on('stream', (buffer) => {
    // 把Int16转成AudioContext需要的Float32格式(范围-1到1)
    const floatBuffer = new Float32Array(buffer.length);
    for (let i = 0; i < buffer.length; i++) {
      floatBuffer[i] = buffer[i] / 32768;
    }
    audioQueue.push(floatBuffer);
  });
};

方式2:使用AudioWorklet(推荐,性能更好)

同样需要创建播放用的Worklet脚本:

// 客户端初始化播放逻辑
retrieveAudioStream = async () => {
  const audioContext = new window.AudioContext();
  await audioContext.audioWorklet.addModule('player-worklet.js');
  
  const playerNode = new AudioWorkletNode(audioContext, 'player-processor');
  playerNode.connect(audioContext.destination);
  
  this.socket.on('stream', (buffer) => {
    // 转换格式后发送给工作线程
    const floatBuffer = new Float32Array(buffer.length);
    for (let i = 0; i < buffer.length; i++) {
      floatBuffer[i] = buffer[i] / 32768;
    }
    playerNode.port.postMessage(floatBuffer);
  });
};

对应的player-worklet.js

class PlayerProcessor extends AudioWorkletProcessor {
  constructor() {
    super();
    this.audioQueue = [];
    // 接收主线程发来的音频数据
    this.port.onmessage = (event) => {
      this.audioQueue.push(event.data);
    };
  }
  
  process(outputs) {
    const outputChannel = outputs[0][0];
    let offset = 0;
    
    // 从队列取数据填充输出缓冲区
    while (offset < outputChannel.length && this.audioQueue.length > 0) {
      const chunk = this.audioQueue[0];
      const chunkLength = chunk.length;
      
      if (offset + chunkLength <= outputChannel.length) {
        outputChannel.set(chunk, offset);
        offset += chunkLength;
        this.audioQueue.shift();
      } else {
        outputChannel.set(chunk.slice(0, outputChannel.length - offset), offset);
        this.audioQueue[0] = chunk.slice(outputChannel.length - offset);
        offset = outputChannel.length;
      }
    }
    
    // 填充静音
    while (offset < outputChannel.length) {
      outputChannel[offset] = 0;
      offset++;
    }
    
    return true;
  }
}

registerProcessor('player-processor', PlayerProcessor);

注意事项

  • 格式统一:确保发送端和接收端的采样率、通道数完全一致,否则会出现声音失真或播放速度异常。
  • 缓冲处理:必须用队列存储音频数据,网络传输不稳定时,队列能平滑播放节奏,避免卡顿或爆音。
  • AudioContext激活:浏览器会自动暂停闲置的AudioContext,所以需要在用户交互(比如点击按钮)时初始化,或者调用audioContext.resume()激活。

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

火山引擎 最新活动