用于直播的网络音频 API?

Web Audio API for live streaming?

我们需要将 实时 音频(来自医疗设备)流式传输到 Web 浏览器,端到端延迟不超过 3-5 秒(假设网络延迟为 200 毫秒或更短)潜伏)。今天我们使用浏览器插件 (NPAPI) 进行解码, 过滤 (high , low, band),以及 playback 音频流(通过 Web Sockets 传送)。

我们要更换插件

我在看各种 Web Audio API demos and the most of our required functionality (playback, gain control, filtering) appears to be available in Web Audio API。但是,我不清楚 Web Audio API 是否可以用于流媒体源,因为大多数 Web Audio API 使用短声音 and/or 音频剪辑。

Web Audio API 可以用来播放直播音频吗?

更新(2015 年 2 月 11 日):

经过更多的研究和本地原型制作,我不确定 使用网络音频 API 进行实时音频流传输是否可行。由于 Web Audio API 的 decodeAudioData 并非真正设计用于处理随机的音频数据块(在我们的例子中是通过 WebSockets 传送的)。它似乎需要整个 'file' 才能正确处理它。

查看计算器:

现在可以使用 createMediaElementSource<audio> 元素连接到网络音频 API,但根据我的经验,<audio> 元素会导致大量的端到端延迟(15-30 秒),而且似乎没有任何方法可以将延迟减少到 3-5 秒以下。

认为 唯一的解决方案是将 WebRTC 与网络音频一起使用 API。我希望避免使用 WebRTC,因为它需要对我们的服务器端实现进行重大更改。

更新(2015 年 2 月 12 日)第 I 部分

我还没有完全消除 <audio> 标签(需要完成我的原型)。一旦我排除了它,我怀疑 createScriptProcessor(已弃用但仍受支持)对于我们的环境将是一个不错的选择,因为我可以 'stream'(通过 WebSockets)我们的 ADPCM 数据到浏览器,然后(在 JavaScript) 将其转换为 PCM。类似于 Scott 的库(见下文)使用 createScriptProcessor 所做的事情。此方法不需要像 decodeAudioData 方法那样的数据大小 'chunks' 和关键时序。

更新(2015 年 2 月 12 日)第二部分

经过更多测试,我取消了 <audio> 到网络音频 API 接口,因为根据源类型、压缩和浏览器,端到端延迟可能为 3-30 秒。剩下的就是 createScriptProcessor 方法(参见下面 Scott 的 post)或 WebRTC。在与我们的决策者讨论后,我们决定采用 WebRTC 方法。我假设它会起作用。但它需要更改我们的服务器端代码。

我要标记第一个答案,这样 'question' 就关闭了。

感谢收听。欢迎根据需要添加评论。

是的,网络音频 API(以及 AJAX 或 Websockets)可用于流式传输。

基本上,您拉下(或发送,在 Websocket 的情况下)一些 n 长度的块。然后你用网络音频解码它们 API 并将它们排队播放,一个接一个地播放。

因为 Web Audio API 具有高精度定时,如果您正确安排,您将不会在每个缓冲区的播放之间听到任何 "seams"。

我编写了一个流式 Web 音频 API 系统,我在其中使用 Web Worker 进行所有 Web 套接字管理以与 node.js 进行通信,这样浏览器线程就可以简单地呈现音频 ... 工作正常在笔记本电脑上很好,因为移动设备在网络工作者内部实施网络套接字方面落后,你需要不少于棒棒糖才能将其编码为运行 ...我发布了full source code here

详细说明如何通过每次移出最新的一个来播放存储在数组中的一堆单独缓冲区的评论:

如果您通过 createBufferSource() 创建缓冲区,那么它有一个 onended 事件,您可以将回调附加到该事件,该事件将在缓冲区到达其末尾时触发。您可以像这样依次播放数组中的各个块:

function play() {
  //end of stream has been reached
  if (audiobuffer.length === 0) { return; }
  let source = context.createBufferSource();

  //get the latest buffer that should play next
  source.buffer = audiobuffer.shift();
  source.connect(context.destination);

  //add this function as a callback to play next buffer
  //when current buffer has reached its end 
  source.onended = play;
  source.start();
}

希望对您有所帮助。我仍在尝试如何让这一切顺利和解决,但这是一个很好的开始,很多在线帖子都没有。

您必须创建一个新的 AudioBuffer and AudioBufferSourceNode both (or at least the latter) for every piece of data that you want to buffer... I tried looping the same AudioBuffer, but once you set .audioBuffer on the AudioContext,您对 AudioBuffer 所做的任何修改都变得无关紧要。

(注意:这些 类 有 base/parent 类 你也应该看看(在文档中引用)。)


这是我开始工作的初步解决方案(请原谅我不想评论所有内容,花了几个小时才开始工作),而且效果很好:

class MasterOutput {
  constructor(computeSamplesCallback) {
    this.computeSamplesCallback = computeSamplesCallback.bind(this);
    this.onComputeTimeoutBound = this.onComputeTimeout.bind(this);

    this.audioContext = new AudioContext();
    this.sampleRate = this.audioContext.sampleRate;
    this.channelCount = 2;

    this.totalBufferDuration = 5;
    this.computeDuration = 1;
    this.bufferDelayDuration = 0.1;

    this.totalSamplesCount = this.totalBufferDuration * this.sampleRate;
    this.computeDurationMS = this.computeDuration * 1000.0;
    this.computeSamplesCount = this.computeDuration * this.sampleRate;
    this.buffersToKeep = Math.ceil((this.totalBufferDuration + 2.0 * this.bufferDelayDuration) /
      this.computeDuration);

    this.audioBufferSources = [];
    this.computeSamplesTimeout = null;
  }

  startPlaying() {
    if (this.audioBufferSources.length > 0) {
      this.stopPlaying();
    }

    //Start computing indefinitely, from the beginning.
    let audioContextTimestamp = this.audioContext.getOutputTimestamp();
    this.audioContextStartOffset = audioContextTimestamp.contextTime;
    this.lastTimeoutTime = audioContextTimestamp.performanceTime;
    for (this.currentBufferTime = 0.0; this.currentBufferTime < this.totalBufferDuration;
      this.currentBufferTime += this.computeDuration) {
      this.bufferNext();
    }
    this.onComputeTimeoutBound();
  }

  onComputeTimeout() {
    this.bufferNext();
    this.currentBufferTime += this.computeDuration;

    //Readjust the next timeout to have a consistent interval, regardless of computation time.
    let nextTimeoutDuration = 2.0 * this.computeDurationMS - (performance.now() - this.lastTimeoutTime) - 1;
    this.lastTimeoutTime = performance.now();
    this.computeSamplesTimeout = setTimeout(this.onComputeTimeoutBound, nextTimeoutDuration);
  }

  bufferNext() {
    this.currentSamplesOffset = this.currentBufferTime * this.sampleRate;

    //Create an audio buffer, which will contain the audio data.
    this.audioBuffer = this.audioContext.createBuffer(this.channelCount, this.computeSamplesCount,
      this.sampleRate);

    //Get the audio channels, which are float arrays representing each individual channel for the buffer.
    this.channels = [];
    for (let channelIndex = 0; channelIndex < this.channelCount; ++channelIndex) {
      this.channels.push(this.audioBuffer.getChannelData(channelIndex));
    }

    //Compute the samples.
    this.computeSamplesCallback();

    //Creates a lightweight audio buffer source which can be used to play the audio data. Note: This can only be
    //started once...
    let audioBufferSource = this.audioContext.createBufferSource();
    //Set the audio buffer.
    audioBufferSource.buffer = this.audioBuffer;
    //Connect it to the output.
    audioBufferSource.connect(this.audioContext.destination);
    //Start playing when the audio buffer is due.
    audioBufferSource.start(this.audioContextStartOffset + this.currentBufferTime + this.bufferDelayDuration);
    while (this.audioBufferSources.length >= this.buffersToKeep) {
      this.audioBufferSources.shift();
    }
    this.audioBufferSources.push(audioBufferSource);
  }

  stopPlaying() {
    if (this.audioBufferSources.length > 0) {
      for (let audioBufferSource of this.audioBufferSources) {
        audioBufferSource.stop();
      }
      this.audioBufferSources = [];
      clearInterval(this.computeSamplesTimeout);
      this.computeSamplesTimeout = null;
    }
  }
}

window.onload = function() {
  let masterOutput = new MasterOutput(function() {
    //Populate the audio buffer with audio data.
    let currentSeconds;
    let frequency = 220.0;
    for (let sampleIndex = 0; sampleIndex <= this.computeSamplesCount; ++sampleIndex) {
      currentSeconds = (sampleIndex + this.currentSamplesOffset) / this.sampleRate;

      //For a sine wave.
      this.channels[0][sampleIndex] = 0.005 * Math.sin(currentSeconds * 2.0 * Math.PI * frequency);

      //Copy the right channel from the left channel.
      this.channels[1][sampleIndex] = this.channels[0][sampleIndex];
    }
  });
  masterOutput.startPlaying();
};

一些细节:

  • 您可以创建多个 MasterOutput 并以这种方式同时播放多个内容;不过,您可能希望从那里提取 AudioContext 并在所有代码中共享 1。
  • 此代码设置 2 个通道 (L + R),默认采样率为 AudioContext(我为 48000)。
  • 这段代码总共提前缓冲了5秒,每次计算1秒的音频数据,同时延迟播放和停止音频0.1秒。它还会跟踪所有音频缓冲区源,以防在输出要暂停时需要停止它们;这些被放入一个列表中,当它们应该过期时(也就是说,它们不再需要 stop()ped),它们就会被 shift() 从列表中删除。
  • 请注意我如何使用 audioContextTimestamp,这很重要。 contextTime 属性 让我知道音频开始的确切时间(每次),然后我可以在稍后调用 audioBufferSource.start() 时使用那个时间 (this.audioContextStartOffset),为了将每个音频缓冲区计时到正确的时间,应该播放它。

编辑:是的,我是对的(在评论中)!如果需要,您可以重复使用过期的 AudioBuffer。在许多情况下,这将是更“正确”的做事方式。

以下是必须为此更改的代码部分:

...
        this.audioBufferDatas = [];
        this.expiredAudioBuffers = [];
...
    }

    startPlaying() {
        if (this.audioBufferDatas.length > 0) {

...

    bufferNext() {
...
        //Create/Reuse an audio buffer, which will contain the audio data.
        if (this.expiredAudioBuffers.length > 0) {
            //console.log('Reuse');
            this.audioBuffer = this.expiredAudioBuffers.shift();
        } else {
            //console.log('Create');
            this.audioBuffer = this.audioContext.createBuffer(this.channelCount, this.computeSamplesCount,
                this.sampleRate);
        }

...

        while (this.audioBufferDatas.length >= this.buffersToKeep) {
            this.expiredAudioBuffers.push(this.audioBufferDatas.shift().buffer);
        }
        this.audioBufferDatas.push({
            source: audioBufferSource,
            buffer: this.audioBuffer
        });
    }

    stopPlaying() {
        if (this.audioBufferDatas.length > 0) {
            for (let audioBufferData of this.audioBufferDatas) {
                audioBufferData.source.stop();
                this.expiredAudioBuffers.push(audioBufferData.buffer);
            }
            this.audioBufferDatas = [];
...

这是我的起始代码,如果您想要更简单的东西,并且不需要 live 音频流:

window.onload = function() {
  const audioContext = new AudioContext();
  const channelCount = 2;
  const bufferDurationS = 5;

  //Create an audio buffer, which will contain the audio data.
  let audioBuffer = audioContext.createBuffer(channelCount, bufferDurationS * audioContext.sampleRate,
    audioContext.sampleRate);

  //Get the audio channels, which are float arrays representing each individual channel for the buffer.
  let channels = [];
  for (let channelIndex = 0; channelIndex < channelCount; ++channelIndex) {
    channels.push(audioBuffer.getChannelData(channelIndex));
  }

  //Populate the audio buffer with audio data.
  for (let sampleIndex = 0; sampleIndex < audioBuffer.length; ++sampleIndex) {
    channels[0][sampleIndex] = Math.sin(sampleIndex * 0.01);
    channels[1][sampleIndex] = channels[0][sampleIndex];
  }

  //Creates a lightweight audio buffer source which can be used to play the audio data.
  let audioBufferSource = audioContext.createBufferSource();
  audioBufferSource.buffer = audioBuffer;
  audioBufferSource.connect(audioContext.destination);
  audioBufferSource.start();
};

不幸的是,这个 ^ 特定代码不适合现场音频,因为它只使用 1 AudioBufferAudioBufferSourceNode,而且就像我说的,打开循环不允许您修改它。 .. 但是,如果您只想播放正弦波 5 秒然后停止(或 loop 它(设置为 true 并完成)),这就可以了。