Web Audio API, lamejs и 0 байт на бэкенде: пишу MP3-склейщик целиком в браузере
- суббота, 28 февраля 2026 г. в 00:00:11
Привет, Хабр!
Меня зовут Виктор, и я хочу рассказать, как бытовая рабочая задача привела меня к тому, что я написал полноценный аудиоредактор, который работает целиком в браузере - без единого запроса на сервер. Под капотом - Web Audio API, OfflineAudioContext, порт LAME-энкодера на JavaScript и немного стыдных архитектурных решений, о которых тоже расскажу.
У нас на работе, как и у многих, регулярные совещания в Zoom. И у Zoom есть прекрасная особенность: если во время звонка хоть на секунду пропадает интернет - запись разбивается на несколько файлов. Один час митинга - и вот у тебя 3-4 MP3-фрагмента, которые нужно склеить, чтобы переслать коллегам или переслушать.
Казалось бы, задача на 30 секунд. Но на практике каждый раз это превращалось в квест.
Десктопные программы - Audacity, Adobe Audition - это прекрасные инструменты, но для задачи "склеить три файла" они ощущаются как вызов экскаватора, чтобы посадить цветок. Установка, импорт каждого файла, выставление порядка, экспорт с правильными настройками. А ещё попробуй объяснить PM-у, который просит "быстро склеить запись", как пользоваться Audacity.
Онлайн-сервисы вроде audio-joiner.com - внешне идеальный вариант. Заходишь, загружаешь, склеиваешь, скачиваешь. Но есть один нюанс: это запись рабочего совещания. С обсуждением внутренних проектов, цифрами и иногда - фамилиями клиентов. Заливать такое на сервер неизвестного сайта - вопрос, на который служба безопасности ответит однозначно. Да и я сам не хочу.
ffmpeg в терминале - вариант для гиков, но и у него свои проблемы. Во-первых, каждый раз гуглить синтаксис: ffmpeg -i "concat:file1.mp3|file2.mp3" -acodec copy output.mp3 - или нет, подождите, это же не работает с MP3 напрямую, нужен concat demuxer, то есть сначала создать текстовый файл со списком, потом... Во-вторых, если файлы в разных bitrate или sample rate - молча получишь артефакты. В-третьих - нет никакой визуализации. Ты не слышишь и не видишь, что получилось, пока не откроешь результат в плеере. И в-четвёртых - попробуй объяснить коллегам, что такое терминал.
В какой-то момент я подумал: браузер умеет декодировать аудио. JavaScript умеет кодировать в MP3. Значит, технически можно сделать инструмент, где файлы никуда не уходят, обработка целиком на клиенте, а интерфейс - понятный даже тому, кто не знает слова "ffmpeg".
Так появился mergemp3.io.
Первый вопрос, который встаёт при проектировании такого инструмента - а нужен ли вообще сервер?
Классическая схема: пользователь загружает файлы → сервер обрабатывает → отдаёт результат. Привычно, надёжно, предсказуемо. Но у нас другая ситуация:
┌──────────────────────────────────────────────┐ │ Браузер │ │ ┌──────────┐ ┌─────────────┐ ┌──────────┐ │ │ │ File API │→ │Web Audio API│→ │ lamejs / │ │ │ │(загрузка)│ │ (декодинг, │ │ libflac │ │ │ │ │ │ обработка) │ │(энкодинг)│ │ │ └──────────┘ └─────────────┘ └──────────┘ │ │ ↓ │ │ Blob → download │ └──────────────────────────────────────────────┘ Сервер: ¯\_(ツ)_/¯
Клиент | Сервер | |
|---|---|---|
Приватность | Файлы не покидают машину | Нужно доверять стороннему серверу |
Скорость | Нет upload/download | Зависит от канала и очереди |
Стоимость | $0 за вычисления | CPU/RAM на сервере стоят денег |
Масштабируемость | 1000 пользователей = 0 нагрузки | Линейный рост затрат |
Работа офлайн | Работает после первой загрузки | Без интернета - никак |
Выбор был очевиден. Единственная цена - ограничения браузера по памяти и скорости. Об этом - позже.
Самый первый вопрос - чем обрабатывать звук.
Web Audio API - это нативный браузерный интерфейс, созданный для работы с аудио. Был стандартизирован и доступен во всех основных браузерах с апреля 2021 года. AudioContext.decodeAudioData() декодирует MP3, WAV, OGG, FLAC - всё, что умеет браузер. OfflineAudioContext рендерит аудио быстрее реального времени. По сути - виртуальная звуковая карта, которая вместо колонок отдаёт готовый AudioBuffer.
FFmpeg.wasm - это порт ffmpeg в WebAssembly. Мощная штука, умеет вообще всё. Но:
Критерий | Web Audio API | FFmpeg.wasm |
|---|---|---|
Размер | 0 KB (встроен в браузер) | ~25 MB wasm-бинарник |
Скорость декодирования | Нативная | Медленнее (wasm overhead) |
Кодирование | Нужна отдельная библиотека | Всё встроено |
API |
| CLI-подобный, неудобный |
Первая загрузка страницы | Мгновенная | +25 MB скачать и инициализировать |
Для задачи «склеить 3-10 MP3-файлов» FFmpeg.wasm - это пушка по воробьям. Web Audio API для декодирования + отдельный лёгкий энкодер - быстрее, проще, легче.
Браузер прекрасно декодирует MP3, но кодировать обратно не умеет и не хочет. MediaRecorder даёт OGG Opus или WebM - но не MP3. А мне нужен именно MP3: универсальный формат, который откроется везде.
lamejs - это порт легендарного LAME MP3 encoder на JavaScript. Проверен временем, кодирует в 192 kbps, результат неотличим от десктопного LAME.
Альтернативы:
shine.js - проще, но ощутимо хуже качество на сложном аудио
FFmpeg.wasm - умеет кодировать, но ради одного формата тащить 25 MB - нет
Помимо MP3, я добавил экспорт в WAV (тривиально - это просто PCM-данные с заголовком) и FLAC через libflacjs для тех, кому нужен lossless.
Почему не Next.js? Приложение на 100% клиентское. SSR, серверные компоненты, API routes - ничего из этого не нужно. Next.js добавил бы сложности без пользы.
Почему Vite, а не Webpack? HMR за миллисекунды вместо секунд. Нативный ESM в dev-режиме. TypeScript, JSX, code splitting - из коробки. В 2025 году выбирать Webpack для нового проекта - нужна веская причина. У меня её не было.
Почему React, а не Vue/Svelte? Честный ответ - экосистема. Мне нужны были:
Radix UI - headless UI-компоненты (тултипы, переключатели)
@dnd-kit - Drag & Drop для сортировки треков
WaveSurfer.js - хорошо интегрируется с React через refs
Всё это plug-and-play с React. С другими фреймворками пришлось бы писать обёртки.
Без визуализации waveform инструмент - слепой. Ты не видишь, где начинается второй трек, где тишина, где пик громкости.
Альтернативы:
Peaks.js (BBC) - мощный, но API сложнее и заточен под серверный рендеринг пиков
Самописное решение на Canvas - полный контроль, но недели работы ради того, что WaveSurfer делает из коробки
WaveSurfer.js выиграл: интерактивность (play/pause/seek по клику), нормализация, удобный API. Самый сложный компонент в проекте - MergedWaveform - объединённый waveform всех треков с регионами, перетаскиваемым курсором и подсветкой активного трека.
Какой аудио-редактор без перетаскивания треков?
react-beautiful-dnd - deprecated, не поддерживает React 18+. Не вариант.
@dnd-kit - модульный, лёгкий, активно поддерживается. Из коробки даёт горизонтальную и вертикальную сортировку, ограничение по осям, работу с клавиатурой.
Отдельная история - мобильные устройства. DnD на тачскринах - UX-ад. Жест перетаскивания конфликтует со скроллом, пользователь не понимает, что нужно зажать. Решение: на экранах < 768px DnD полностью отключён, вместо него - кнопки ↑↓ для перемещения треков.
Без кода - только пайплайн и логика:
File (MP3/WAV/FLAC/OGG) │ ▼ AudioContext.decodeAudioData() ← нативный декодер браузера │ ▼ AudioBuffer (Float32, от -1.0 до 1.0) ← сырые PCM-данные в RAM │ ├─→ Нормализация громкости ← находим максимальный пик │ (масштабируем все сэмплы по всем каналам, │ до целевого уровня 0.95) масштабируем один раз │ ├─→ Удаление тишины ← всё, что ниже -40 dB │ (вырезаем участки дольше 0.5 секунды, │ оставляя 50 мс «подушки» по краям) │ ▼ OfflineAudioContext ← виртуальный микшер │ ├─→ BufferSourceNode для каждого трека ├─→ GainNode для fade in/out ├─→ Кроссфейд: перекрытие торцов треков с линейным │ изменением громкости │ ▼ renderedBuffer = await offlineCtx.startRendering() │ ├─→ lamejs → MP3 (192 kbps) ├─→ WAV header + PCM → WAV ├─→ libflacjs → FLAC │ ▼ Blob → URL.createObjectURL() → автоматический download
Ключевой момент - OfflineAudioContext. Это "виртуальная звуковая карта", которая рендерит аудио быстрее реального времени, без колонок. Она берёт все подключённые к ней ноды (источники, усилители, эффекты), просчитывает результат и отдаёт готовый AudioBuffer. 30 минут аудио превращаются в результат за секунды.
Для каждого трека создаётся BufferSourceNode (источник) и GainNode (управление громкостью). Кроссфейд реализован просто: конец n-го трека и начало (n+1)-го перекрываются по времени, а GainNode плавно уводит громкость первого в ноль и поднимает громкость второго из нуля. Длительность кроссфейда настраивается пользователем от 0 до 10 секунд.
Я обещал быть честным. Вот моя доска позора.
Когда пользователь нажимает "Merge", появляется красивый экран с процентами. "Analyzing tracks... 23%". "Mixing audio... 57%". "Encoding to MP3... 84%".
Так вот, эти проценты - чистая имитация. Прогресс увеличивается через setInterval на случайную величину Math.random() * 8 + 2. Дело в том, что OfflineAudioContext.startRendering() возвращает один Promise. Без промежуточных колбэков. Ты либо ждёшь, либо получаешь результат. Узнать, сколько процентов отрендерено - невозможно.
Поэтому пользователь видит "60%", а реально может быть 10% или 95%. Но выглядит красиво, и ощущение прогресса - есть.
Стыдно ли? Немного. Но UX без прогресс-бара - хуже.
App.tsx - это один файл, в котором живёт всё: стейты треков, плеера, настроек, DnD-контекст, логика импорта из расширения, сортировка, и даже рендер двух разных страниц (редактор и экран "готово").
Как это произошло? Классическая история: "добавлю ещё один useState... и ещё один... и обработчик... и ещё JSX...". Работает? Работает. Поддерживать? Больно.
Я подключил Lucide React - прекрасную библиотеку иконок. 4000+ иконок на любой случай. А потом открыл App.tsx и обнаружил, что CheckCircle2 и Music написаны руками как SVG-компоненты прямо в конце файла. Потому что в момент написания забыл, что они есть в lucide. Или был офлайн. Или просто было 2 часа ночи.
Они до сих пор там.
Потратил вечер на дебаг ситуации "всё работает в Chrome, в Safari - тишина". Оказалось: Safari требует user gesture (клик, тап) для запуска AudioContext. Если создать контекст без пользовательского действия - он будет в состоянии suspended и молча ничего не проиграет.
Это задокументировано. Но кто читает документацию в первой итерации?
lamejs кодирует MP3 в main thread. Для файла на 3-5 минут это незаметно. Для 15+ минут - интерфейс замирает на несколько секунд. Кнопки не нажимаются, анимации останавливаются. Классический случай, когда нужны Web Workers, но пока руки не дошли.
AudioBuffer хранится целиком в RAM. Посчитаем: один трек, 5 минут, стерео, 44100 Hz, Float32 = 5×60×44100×2×4≈106 MB. Десять таких треков - больше гигабайта. На смартфоне с 3 GB RAM - вкладка просто крашится.
Решение - стриминговая обработка через AudioWorklet. Но это фактически переписывание всего аудио-пайплайна. Пока живём с ограничением.
Метрика | Значение |
|---|---|
Production-бандл | ~2 MB (code splitting на 4 чанка) |
Время склейки 10 MP3 × 3 мин | ~90 секунд |
Основной код | ~3100 строк |
Самый большой компонент | MergedWaveform.tsx - 849 строк |
Production-зависимости | 14 библиотек |

В планах:
Обрезка аудио - вырезать ненужные фрагменты прямо перед склейкой
Импорт из облаков - Google Drive, Dropbox, OneDrive. Файлы скачиваются в браузер, на сервер по-прежнему ничего не уходит
Индивидуальные настройки для каждого трека - громкость, fade in/out, кроссфейд. Сейчас только глобальные
Настройки качества экспорта - битрейт, частота дискретизации
Автоматическое шумоподавление
Сохранение проекта - закрыть вкладку и вернуться позже
Исходники планирую открыть. Если интересно - дайте знать в комментариях.
Проект живёт тут: mergemp3.io
Буду рад обратной связи. В том числе жёсткой. Несколько конкретных вопросов:
Какие форматы экспорта вам нужны? MP3, WAV и FLAC есть, нужен ли AAC/OGG?
Сталкивались ли с ограничениями по памяти на больших файлах?
Стоит ли вынести аудио-пайплайн в отдельный npm-пакет?
Какие ещё инструменты вы бы хотели видеть целиком на клиенте, без серверной части?
Спасибо, что дочитали. Пойду удалю самописные SVG-иконки из App.tsx.