
Я створив потік аудіо в реальному часі від браузера до мого сервера. Ось що фактично працює.
https://ift.tt/Ldiqt5y
Получення аудіо з браузера на сервер у реальному часі звучить як двохрядкове рішення. Це не так.
Я спроєктував цей конвеєр для LiveSuggest, AI-помічника, який слухає зу meetings і дає підказки під час розмови. Це означає безперервне потокове передавання аудіо з мінімальними затримками через з’єднання WebSocket, яке може перериватися будь-якої миті.
Потік
Ось повна ланцюгова збірка:
1) Захоплення аудіо за допомогою getUserMedia (мікрофон) або getDisplayMedia (звук вкладки)
2) Передача в MediaRecorder
3) Різання на фрагменти кожні N секунд
4) Кодірування кожного фрагмента у base64
5) Надсилання через WebSocket на сервер
6) Сервер декодує та надсилає до API транскрипції
Кожен крок має свої нюанси.
MediaRecorder чудовий, поки не стане поганим
MediaRecorder обробляє кодування за вас. Я використовую audio/webm;codecs=opus, бо це широко підтримується та добре стискається.
Проблема: ви не контролюєте межі фрагментів. ondataavailable вмикається тоді, коли браузер цього захоче, а не коли вам треба. Якщо ви зупиняєте mediaRecorder.stop() і запускаєте start() щоб примусово створити новий фрагмент, ви одержуєте новий заголовок WebM щоразу. Це нормально, але фрагменти не є автономними файлами, які можна прямо конкатенувати.
Я зупинився на сегментах по 10 секунд. Достатньо коротко для чутливого до контексту транскриптора, достатньо довго для мати контекст у API транскрипції.
База64 — витратна, але практична
Бінарні WebSocket-кадри були б більш ефективними. Але base64 через JSON зберігає вміст придатним для огляду, працює з Socket.io «із коробки» і полегшує налагодження.
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64 = reader.result.split(‘,’, 2)[1];
socket.emit(‘audio-chunk’, {
sessionId,
audio: base64,
format: ‘webm’,
duration,
timestamp: Date.now(),
});
};
33% надмірної адреси розміру в реальній практиці не є проблемою. 10-секційний chunk Opus є мініатюро.
Змішування двох джерел аудіо
Якщо потрібно одночасно мікрофон і системний звук (з вкладки браузера), їх потрібно змішати. Web Audio API це дозволяє, але неінтуїтивно:
const audioContext = new AudioContext();
const destination = audioContext.createMediaStreamDestination();
const micSource = audioContext.createMediaStreamSource(micStream);
const tabSource = audioContext.createMediaStreamSource(tabStream);
micSource.connect(destination);
tabSource.connect(destination);
// destination.stream — це ваш змішаний потік
Отриманий потік подається у MediaRecorder. Обидві частини розмови потрапляють у єдиний потік. Це працює краще, ніж ви очікуєте.
Що я дізнався про надійність
Потік може померти в будь-який момент. Кнопка в Chrome Stop sharing негайно знищує потоки getDisplayMedia. Слухати за подією ended на кожному треку не є опцією.
Обмеження швидкості врятувало мене від серйозної помилки. Я використовую зсувне вікно обмеження швидкості у Redis: 60 фрагментів на хвилину на сеанс. Без цього злісний клієнт може тихо забивати API транскрипції протягом годин.
Маленькі фрагменти майже завжди є шумом. Буфери менше 2 КБ відфільтровуються перед надсиланням до API. Так само і транскрипції менше ніж з 4 слів — тиша, вдих, клацання клавіатури. Модель транскрипції недешева, і дешева «інформація» призводить до «багато шуму — багато помилкової інформації».
Перепідключення нетривіальне. Випади WebSocket трапляються. Я використовую експоненціальне зростання часу повторної спроби з розкидом, і сервер відновлює стан сеансу з Redis, коли клієнт підключається з нового екземпляра.
Чи варто було будувати з нуля?
Я розглядав сторонні сервіси, які обробляють увесь конвеєр. Але володіння аудіо-шаром означає контроль затримки, вартості та того, які дані залишаються в додатку. Для продукту, де ці три речі важливі, це було варто складності.
Потік зараз обробляє тисячі аудіо-фрагментів на день. Не гарні код, але це «поганий» сантехнічний базис, від якого залежить все інше.
HI-FI News
через DEV Community https://dev.to
26 лютого 2026 о 23:52
February 26, 2026 at 11:52PM

Залишити відповідь