Я пытаюсь воспроизвести звук браузера, который передается с моего серверного интерфейса, чтобы имитировать голосовой вызов в реальном времени таким образом, чтобы это было совместимо со всеми соответствующими браузерами, устройствами и ОС.
Аудиоформат — 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;
}
Оба ваших подхода (а и б) не сработают.
Недостающее звено — это разница в тактовой частоте между вашим сервером и вашим клиентом. Это фундаментальная проблема для любого сценария прямой трансляции.
Предположим, что часы вашего клиента идут немного быстрее: Клиент воспроизводит звук слишком быстро, заканчиваются семплы => вы слышите пробел.
Предположим, что часы вашего клиента идут немного медленнее: Клиент воспроизводит звук слишком медленно => в конечном итоге вам придется удалить весь буфер и услышать пробел.
Чтобы решить эту проблему, вам необходимо создать входной буфер на стороне клиента. Первоначально ваш клиент должен дождаться, пока буфер заполнится на 50%, а затем начать воспроизведение. Если кажется, что буфер почти пуст ==> вам придется замедлить скорость воспроизведения. Если похоже, что буфер достигает 100%, вам придется увеличить скорость воспроизведения. Обычно скорость воспроизведения меняется очень незначительно, поскольку вы просто компенсируете разницу в тактовой частоте.
Вы можете реализовать все это самостоятельно. Или вы можете использовать фреймворк, который сделает это за вас. Например WebRTC: https://webrtc.org/getting-started/peer-connections
Пока текущий фрагмент воспроизводится, я бы хотел продолжать добавлять в конец буфера последующие фрагменты по мере их получения. Клиент должен буквально воспроизвести их, когда он туда доберется, и он может предполагать, что следующий фрагмент, который необходимо воспроизвести, всегда присутствует, вплоть до последнего фрагмента. Представьте себе, что вы уже получили 10 фрагментов, в то время как первый фрагмент все еще воспроизводится, и, тем не менее, все, что происходит после первого фрагмента, звучит плохо (пробелы/зашифрованное изображение). Насколько я вижу, этому сайту delphi.ai удалось реализовать это с помощью сокет-сервера и AudioWorklet.
Если оставить в стороне упрощенный подход к очереди, есть ли у вас пример реализации в браузере WebRTC и Socket io?
Каждый кадр 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? т. е. распознавание границ, выяснение перекрытий, их устранение.
Спасибо. Я дал вам награду, потому что осталось всего несколько минут, надеюсь, вы не против ответить еще на несколько вопросов по этому поводу. Не могли бы вы рассказать, где именно в их коде они делают именно то, что вы имели в виду? Как вы думаете, можно ли использовать эту библиотеку для работы с фрагментами буфера вместо URL-адресов? Последнее обновление было 6 лет назад, оно еще актуально?
Спасибо, я добавил пример кода, который, возможно, ломается интересными способами, но, надеюсь, даст вам представление о том, как это можно сделать. Я тоже не уверен насчет использования фонографа. Он не поддерживается, но, с другой стороны, MP3 все еще такие же, как и 6 лет назад. :-)
Если я чего-то не понимаю, то в данном конкретном случае я не думаю, что разница между моим сервером и моим клиентом имеет значение. Например, если я поставлю в очередь все фрагменты с сервера на клиенте и начну воспроизводить последовательность только после получения последнего фрагмента, у меня все равно возникнут упомянутые проблемы. Я хотел бы начать воспроизведение звука, как только будет получен первый фрагмент (он относительно длиннее, чем последующие фрагменты). Последующие фрагменты принимаются задолго до завершения воспроизведения текущего фрагмента, поэтому нет необходимости замедлять скорость воспроизведения.