Web Audio API: эффективно воспроизводить поток PCM - PullRequest
1 голос
/ 30 марта 2020

Вот проблема:

  • Мое JS приложение получает необработанные данные PCM (через канал данных WebRT C),
  • Частота дискретизации 88200 (могу легко измените его на 44100 на другом конце),
  • Данные уже правильно закодированы в 4-байтовых сэмплах [-1, 1] с прямым порядком байтов,
  • Данные поступают кусками по 512 сэмплы (512 * 4 байта),
  • Данные могут начать поступать в любой момент, они могут длиться в любое время, могут прекратиться, могут возобновиться.
  • Цель - воспроизвести звук.

То, что я сделал, это:

var samples = []; // each element of this array stores a chunk of 512 samples
var audioCtx = new AudioContext();
var source = audioCtx.createBufferSource();

source.buffer = audioCtx.createBuffer(1, 512, 88200);

// bufferSize is 512 because it is the size of chunks
var scriptNode = audioCtx.createScriptProcessor(512, 1, 1);

scriptNode.onaudioprocess = function(audioProcessingEvent) {
  // play a chunk if there is at least one.
  if (samples.length > 0) {
    audioProcessingEvent.outputBuffer.copyToChannel(samples.shift(), 0, 0);
  }
};

source.connect(scriptNode);
scriptNode.connect(audioCtx.destination);
source.start();

peerConnection.addEventListener("datachannel", function(e) {
  e.channel.onmessage = function(m) {
    var values = new Float32Array(m.data);
    samples.push(values);
  };
);

Есть несколько проблем:

  • audioProcessingEvent.outputBuffer.sampleRate всегда 48000. Очевидно, это не зависит от битрейта source, и я не смог найти способ установить его на 88200, 44100 или любое другое значение. Звук воспроизводится с задержкой, которая постоянно увеличивается.
  • ScriptProcessorNode устарела.
  • Это очень дорогой метод с точки зрения процессора.

Спасибо за заранее за любое предложение!

1 Ответ

1 голос
/ 30 марта 2020

Требуется AudioBuffer .

. Вы можете копировать необработанные данные PCM в его каналы непосредственно из TypedArray.
Вы можете указать его sampleRate, и AudioContext примет заботиться о повторной выборке, чтобы соответствовать настройкам звуковой карты.

Однако будьте осторожны, 2048 байт на чанк означает, что каждый чанк будет представлять только 5 мс аудиоданных. * Вы, вероятно, захотите увеличить это и реализовать некоторую стратегию буферизации.

Вот небольшая демонстрация в качестве доказательства концепции хранения данных чанков в буфер Float32Array.

const min_sample_duration = 2; // sec
const sample_rate = 88200; // Hz
// how much data is needed to play for at least min_sample_duration
const min_sample_size = min_sample_duration * sample_rate;

const fetching_interval = 100; // ms

// you'll probably want this much bigger
let chunk_size = 2048; // bytes

const log = document.getElementById( 'log' );
const btn = document.getElementById( 'btn' );

btn.onclick = e => {

  let stopped = false;
  let is_reading = false;
  
  const ctx = new AudioContext();
  // to control output volume
  const gain = ctx.createGain();
  gain.gain.value = 0.01;
  gain.connect( ctx.destination );
  // this will get updated at every new fetch
  let fetched_data  = new Float32Array( 0 );
  // keep it accessible so we can stop() it
  let active_node;

  // let's begin
  periodicFetch();

  // UI update
  btn.textContent = "stop";
  btn.onclick = e => {
    stopped = true;
    if( active_node ) { active_node.stop(0); }
  };
  oninput = handleUIEvents;

  // our fake fetcher, calls itself every 50ms
  function periodicFetch() {

    // data from server (here just some noise)
    const noise = Float32Array.from( { length: chunk_size / 4 }, _ => (Math.random() * 1) - 0.5 );
    // we concatenate the data just fetched with what we have already buffered
    fetched_data = concatFloat32Arrays( fetched_data, noise );
    // for demo only
    log.textContent = "buffering: " +  fetched_data.length + '/ ' + min_sample_size;

    if( !stopped ) {
      // do it again
      setTimeout( periodicFetch , fetching_interval );
    }
    // if we are not actively reading and have fetched enough
    if( !is_reading && fetched_data.length > min_sample_size ) {
      readingLoop(); // start reading
    }
  
  }
  function readingLoop() {
  
    if( stopped  || fetched_data.length < min_sample_size ) {
      is_reading = false;
      return;
    }
    // let the world know we are actively reading
    is_reading = true;
    // create a new AudioBuffer
    const aud_buf = ctx.createBuffer( 1, fetched_data.length, sample_rate );
    // copy our fetched data to its first channel
    aud_buf.copyToChannel( fetched_data, 0 );

    // clear the buffered data
    fetched_data = new Float32Array( 0 );
    
    // the actual player
    active_node = ctx.createBufferSource();
    active_node.buffer = aud_buf;
    active_node.onended = readingLoop; // in case we buffered enough while playing
    active_node.connect( gain );
    active_node.start( 0 );

  }

  function handleUIEvents( evt ) {

    const type = evt.target.name;
    const value = evt.target.value;
    switch( type ) {
      case "chunk-size":
        chunk_size = +value;
        break;
      case "volume":
        gain.gain.value = +value;
        break;
    }

  }

};

// helpers
function concatFloat32Arrays( arr1, arr2 ) {
  if( !arr1 || !arr1.length ) {
    return arr2 && arr2.slice();
  }
  if( !arr2 || !arr2.length ) {
    return arr1 && arr1.slice();
  }
  const out = new Float32Array( arr1.length + arr2.length );
  out.set( arr1 );
  out.set( arr2, arr1.length );
  return out;
}
label { display: block }

Размер каждого полученного фрагмента:
...