javascript

Web Audio API, lamejs и 0 байт на бэкенде: пишу MP3-склейщик целиком в браузере

  • суббота, 28 февраля 2026 г. в 00:00:11
https://habr.com/ru/articles/1004356/

Привет, Хабр!

Меня зовут Виктор, и я хочу рассказать, как бытовая рабочая задача привела меня к тому, что я написал полноценный аудиоредактор, который работает целиком в браузере - без единого запроса на сервер. Под капотом - 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 vs FFmpeg.wasm

Самый первый вопрос - чем обрабатывать звук.

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

decodeAudioData() - асинхронный, удобный

CLI-подобный, неудобный

Первая загрузка страницы

Мгновенная

+25 MB скачать и инициализировать

Для задачи «склеить 3-10 MP3-файлов» FFmpeg.wasm - это пушка по воробьям. Web Audio API для декодирования + отдельный лёгкий энкодер - быстрее, проще, легче.

MP3-кодирование: lamejs

Браузер прекрасно декодирует 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.

Фреймворк: React 19 + Vite 6

Почему не 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. С другими фреймворками пришлось бы писать обёртки.

Визуализация: WaveSurfer.js

Без визуализации waveform инструмент - слепой. Ты не видишь, где начинается второй трек, где тишина, где пик громкости.

Альтернативы:

  • Peaks.js (BBC) - мощный, но API сложнее и заточен под серверный рендеринг пиков

  • Самописное решение на Canvas - полный контроль, но недели работы ради того, что WaveSurfer делает из коробки

WaveSurfer.js выиграл: интерактивность (play/pause/seek по клику), нормализация, удобный API. Самый сложный компонент в проекте - MergedWaveform - объединённый waveform всех треков с регионами, перетаскиваемым курсором и подсветкой активного трека.

Drag & Drop: @dnd-kit

Какой аудио-редактор без перетаскивания треков?

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 без прогресс-бара - хуже.

God Component на 1199 строк

App.tsx - это один файл, в котором живёт всё: стейты треков, плеера, настроек, DnD-контекст, логика импорта из расширения, сортировка, и даже рендер двух разных страниц (редактор и экран "готово").

Как это произошло? Классическая история: "добавлю ещё один useState... и ещё один... и обработчик... и ещё JSX...". Работает? Работает. Поддерживать? Больно.

Иконки-франкенштейны

Я подключил Lucide React - прекрасную библиотеку иконок. 4000+ иконок на любой случай. А потом открыл App.tsx и обнаружил, что CheckCircle2 и Music написаны руками как SVG-компоненты прямо в конце файла. Потому что в момент написания забыл, что они есть в lucide. Или был офлайн. Или просто было 2 часа ночи.

Они до сих пор там.

Safari и AudioContext

Потратил вечер на дебаг ситуации "всё работает в Chrome, в Safari - тишина". Оказалось: Safari требует user gesture (клик, тап) для запуска AudioContext. Если создать контекст без пользовательского действия - он будет в состоянии suspended и молча ничего не проиграет.

Это задокументировано. Но кто читает документацию в первой итерации?

lamejs блокирует UI

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

Буду рад обратной связи. В том числе жёсткой. Несколько конкретных вопросов:

  1. Какие форматы экспорта вам нужны? MP3, WAV и FLAC есть, нужен ли AAC/OGG?

  2. Сталкивались ли с ограничениями по памяти на больших файлах?

  3. Стоит ли вынести аудио-пайплайн в отдельный npm-пакет?

  4. Какие ещё инструменты вы бы хотели видеть целиком на клиенте, без серверной части?

Спасибо, что дочитали. Пойду удалю самописные SVG-иконки из App.tsx.