AudioWorkletProcessor, воспроизводящий потоковое аудио, зашифрованное звучание

Я пытаюсь воспроизвести звук браузера, который передается с моего серверного интерфейса, чтобы имитировать голосовой вызов в реальном времени таким образом, чтобы это было совместимо со всеми соответствующими браузерами, устройствами и ОС.

Аудиоформат — mp3 с частотой дискретизации 44,1 кГц и скоростью 192 кбит/с.

приложение.js

await audioContext.audioWorklet.addModule('call-processor.js');

const audioContext = new AudioContext({ sampleRate: 44100 });
const audioWorkletNode = new AudioWorkletNode(audioContext, 'call-processor');

// add streamed audio chunk to the audioworklet
const addAudioChunk = async (base64) => {
  const buffer = Buffer.from(base64, 'base64');

  try {
    const audioBuffer = await audioContext.decodeAudioData(buffer.buffer);
    const channelData = audioBuffer.getChannelData(0); // Assuming mono audio

    audioWorkletNode.port.postMessage(channelData);
  } catch (e) {
    console.error(e);
  }
};

вызов-processor.js

class CallProcessor extends AudioWorkletProcessor {
  buffer = new Float32Array(0);

  constructor() {
    super();
    
    this.port.onmessage = this.handleMessage.bind(this);
  }

  handleMessage(event) {
    const chunk = event.data;

    const newBuffer = new Float32Array(this.buffer.length + chunk.length);

    newBuffer.set(this.buffer);
    newBuffer.set(chunk, this.buffer.length);

    this.buffer = newBuffer;
  }

  process(inputs, outputs) {
    const output = outputs[0];
    const channel = output[0];
    const requiredSize = channel.length;

    if (this.buffer.length < requiredSize) {
      // Not enough data, zero-fill the output
      channel.fill(0);
    } else {
      // Process the audio
      channel.set(this.buffer.subarray(0, requiredSize));
      // Remove processed data from the buffer
      this.buffer = this.buffer.subarray(requiredSize);
    }

    return true;
  }
}

registerProcessor('call-processor', CallProcessor);

Я тестирую в браузере Chrome.

На ПК первый фрагмент ответа каждого потока звучит идеально, а на iPhone — странно и роботизированно.

В обоих случаях последующие фрагменты звучат немного зашифрованно.

Не могли бы вы помочь мне понять, что я делаю неправильно?

Отмечу, что я не настроен на AudioWorklet, цель состоит в том, чтобы иметь цельный аудиопоток, совместимый со всеми соответствующими браузерами, устройствами и ОС.

Я также попробовал 2 подхода к источнику звука, используя стандартизированный аудиоконтекст (https://github.com/chrisguttandin/standardized-audio-context) для расширенной совместимости:

а. Рекурсивное ожидание завершения текущего источника аудиобуфера перед воспроизведением следующего источника в последовательности.

б. Запуск каждого источника аудиобуфера сразу после получения его фрагмента, при этом начальной точкой является сумма длительностей предыдущих аудиобуферов.

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

приложение.js

import { Buffer } from 'buffer';
import { AudioContext } from 'standardized-audio-context';

const audioContext = new AudioContext();

const addAudioChunk = async (base64) => {
  const uint8array = Buffer.from(base64, 'base64');
  const audioBuffer = await audioContext.decodeAudioData(uint8array.buffer);
  const source = audioContext.createBufferSource();

  source.buffer = audioBuffer;

  source.start(nextPlayTime);

  nextPlayTime += audioBuffer.duration;
}
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Улучшение производительности загрузки с помощью Google Tag Manager и атрибута Defer
Улучшение производительности загрузки с помощью Google Tag Manager и атрибута Defer
В настоящее время производительность загрузки веб-сайта имеет решающее значение не только для удобства пользователей, но и для ранжирования в...
Безумие обратных вызовов в javascript [JS]
Безумие обратных вызовов в javascript [JS]
Здравствуйте! Юный падаван 🚀. Присоединяйся ко мне, чтобы разобраться в одной из самых запутанных концепций, когда вы начинаете изучать мир...
Система управления парковками с использованием HTML, CSS и JavaScript
Система управления парковками с использованием HTML, CSS и JavaScript
Веб-сайт по управлению парковками был создан с использованием HTML, CSS и JavaScript. Это простой сайт, ничего вычурного. Основная цель -...
JavaScript Вопросы с множественным выбором и ответы
JavaScript Вопросы с множественным выбором и ответы
Если вы ищете платформу, которая предоставляет вам бесплатный тест JavaScript MCQ (Multiple Choice Questions With Answers) для оценки ваших знаний,...
0
0
206
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Оба ваших подхода (а и б) не сработают.

Недостающее звено — это разница в тактовой частоте между вашим сервером и вашим клиентом. Это фундаментальная проблема для любого сценария прямой трансляции.

Предположим, что часы вашего клиента идут немного быстрее: Клиент воспроизводит звук слишком быстро, заканчиваются семплы => вы слышите пробел.

Предположим, что часы вашего клиента идут немного медленнее: Клиент воспроизводит звук слишком медленно => в конечном итоге вам придется удалить весь буфер и услышать пробел.

Чтобы решить эту проблему, вам необходимо создать входной буфер на стороне клиента. Первоначально ваш клиент должен дождаться, пока буфер заполнится на 50%, а затем начать воспроизведение. Если кажется, что буфер почти пуст ==> вам придется замедлить скорость воспроизведения. Если похоже, что буфер достигает 100%, вам придется увеличить скорость воспроизведения. Обычно скорость воспроизведения меняется очень незначительно, поскольку вы просто компенсируете разницу в тактовой частоте.

Вы можете реализовать все это самостоятельно. Или вы можете использовать фреймворк, который сделает это за вас. Например WebRTC: https://webrtc.org/getting-started/peer-connections

Если я чего-то не понимаю, то в данном конкретном случае я не думаю, что разница между моим сервером и моим клиентом имеет значение. Например, если я поставлю в очередь все фрагменты с сервера на клиенте и начну воспроизводить последовательность только после получения последнего фрагмента, у меня все равно возникнут упомянутые проблемы. Я хотел бы начать воспроизведение звука, как только будет получен первый фрагмент (он относительно длиннее, чем последующие фрагменты). Последующие фрагменты принимаются задолго до завершения воспроизведения текущего фрагмента, поэтому нет необходимости замедлять скорость воспроизведения.

Royi Bernthal 05.07.2024 23:46

Пока текущий фрагмент воспроизводится, я бы хотел продолжать добавлять в конец буфера последующие фрагменты по мере их получения. Клиент должен буквально воспроизвести их, когда он туда доберется, и он может предполагать, что следующий фрагмент, который необходимо воспроизвести, всегда присутствует, вплоть до последнего фрагмента. Представьте себе, что вы уже получили 10 фрагментов, в то время как первый фрагмент все еще воспроизводится, и, тем не менее, все, что происходит после первого фрагмента, звучит плохо (пробелы/зашифрованное изображение). Насколько я вижу, этому сайту delphi.ai удалось реализовать это с помощью сокет-сервера и AudioWorklet.

Royi Bernthal 05.07.2024 23:46

Если оставить в стороне упрощенный подход к очереди, есть ли у вас пример реализации в браузере WebRTC и Socket io?

Royi Bernthal 09.07.2024 21:49
Ответ принят как подходящий

Каждый кадр MP3 начинается с 12 бит, равных единице. Если вы обрежете MP3 именно по этим границам, можно будет декодировать части с помощью decodeAudioData(), поскольку они сами по себе представляют собой действительный файл.

Проблема в том, что любой из этих кадров может содержать данные, которые уже принадлежат следующему кадру. При обрезке MP3 по границе кадра эта информация теряется, поскольку decodeAudioData() не отслеживает предыдущие вызовы. Вам нужно будет сохранить небольшое перекрытие при декодировании частей, чтобы дважды декодировать кадры на границе. Аудиоданные, созданные этими дубликатами кадров, затем необходимо удалить из AudioBuffer, созданных decodeAudioData(), при их сшивании.

Представьте, что у вас есть переменная с именем arrayBuffer, которая содержит содержимое MP3. Затем вы можете собрать все его кадры следующим образом:

const uint8Array = new Uint8Array(arrayBuffer);
const frames = [];

for (let i = 0; i < uint8Array.length - 1; i += 1) {
  if (uint8Array[i] === 0xff && (uint8Array[i + 1] & 0xf0) === 0xf0) {
    frames.push(i);
  }
}

Затем вы можете перебирать кадры, чтобы декодировать их в группах с перекрытием.

const offlineAudioContext = new OfflineAudioContext({
  length: 1,
  // This should be the sampleRate of the MP3.
  sampleRate: 44100
});

const cache = [0, 0];
const channelDatas = [];
const framesPerInterval = 50;

let begin = 0;
let end = 0;

for (let i = 0; i < frames.length - framesPerInterval; i += framesPerInterval) {
  const audioBuffer = await offlineAudioContext.decodeAudioData(
    arrayBuffer.slice(
      frames[Math.max(0, i - framesPerInterval)],
      frames[i + framesPerInterval]
    )
  );

  const intervalLength = audioBuffer.length - cache[1];

  begin = end - cache[0];
  end = cache[1] + Math.round(intervalLength / 2);

  // This needs to be done for every channel.
  channelDatas.push(audioBuffer.getChannelData(0).slice(begin, end));

  cache[0] = cache[1];
  cache[1] = intervalLength;
}

В итоге channelDatas должно содержать образцы без каких-либо пробелов.

Но вы также можете использовать AudioDecoder для декодирования звука. Это API с отслеживанием состояния для декодирования аудиофайлов по частям. На данный момент он доступен только в Chrome, но над его реализацией в Firefox и Safari уже работают.

Интересно, имеет смысл. Как бы вы реализовали это не только в Chrome? т. е. распознавание границ, выяснение перекрытий, их устранение.

Royi Bernthal 10.07.2024 12:08
github.com/Rich-Harris/phonograph — пример реализации этой идеи.
chrisguttandin 10.07.2024 13:42

Спасибо. Я дал вам награду, потому что осталось всего несколько минут, надеюсь, вы не против ответить еще на несколько вопросов по этому поводу. Не могли бы вы рассказать, где именно в их коде они делают именно то, что вы имели в виду? Как вы думаете, можно ли использовать эту библиотеку для работы с фрагментами буфера вместо URL-адресов? Последнее обновление было 6 лет назад, оно еще актуально?

Royi Bernthal 10.07.2024 14:08

Спасибо, я добавил пример кода, который, возможно, ломается интересными способами, но, надеюсь, даст вам представление о том, как это можно сделать. Я тоже не уверен насчет использования фонографа. Он не поддерживается, но, с другой стороны, MP3 все еще такие же, как и 6 лет назад. :-)

chrisguttandin 10.07.2024 16:42

Другие вопросы по теме