Три месяца спустя: стемы, которые убивали вкладку, и синхрон, который меня чуть не доконал

от автора

Год назад я рассказал, как из боли «песни разбросаны по чатам» вырос сервис для команд прославления: PHP на дешёвом хостинге, React, деплой по FTP руками. В конце той статьи было три обещания одним абзацем — сделаю стемы, синхронизирую сцену, перееду с shared-хостинга. Все три оказались на порядок сложнее, чем звучали.

Эта статья — про то, что было дальше. Честно, с провалами. Про то, где я бился неделями, где всё падало и где приходилось заходить по пять-семь раз, прежде чем получилось.

Сразу оговорюсь: сами нейросети для разделения на дорожки и распознавания аккордов — не мои. Я использую API Moises. Всё остальное — доставка, движок, синхрон, продукт — моё, и об этом ниже.

Стемы: семь заходов, шесть провалов

В первой статье это была одна строка: «сделаю через Replicate». На деле я перебрал семь провайдеров разделения аудио, и шесть из них отвалились — каждый по-своему.

  • Локальный Demucs. Красиво в теории, невозможно на практике: на дешёвом хостинге нет GPU и лимит 120 секунд на запрос, а Demucs на CPU думает минутами. Не вписался ни разу.

  • Replicate. Работает, но платно за секунду GPU — и отдаёт только дорожки. Ни аккордов, ни битов. А музыканту нужен разбор песни, а не папка с WAV.

  • fal.ai. Первый, кто реально доехал до прода. Те же минусы: только разделение, платно, максимум 6 дорожек. Зато именно на нём я впервые уронил весь сервер (об этом ниже).

  • LALAL.AI. Один проход — два трека. Чтобы получить 8 дорожек, надо гонять файл по кругу и платить за каждый круг. Для бесплатного продукта экономика не сходится вообще.

  • Свой GPU-сервис. Топовое качество, десятки моделей — и отдельная инфраструктура с видеокартой, очередями и счётом за простой. Для одного разработчика — рано.

  • Music.ai. Вот где было по-настоящему обидно. Официальный платный API, который умеет всё сразу — и стемы, и аккорды, и секции. То, что нужно. Но задачи воспроизводимо падали с INTERNAL_ERROR через 65–120 минут ожидания. Полтора часа — ради ошибки. Поддержка молчала. И да, за эти попытки я платил своими деньгами. Ключ от него теперь закомментирован на всех серверах, и в правилах проекта отдельной строкой висит «перед деплоем проверь, что он закомментирован» — чтобы случайная конфигурация не увела трафик обратно на этот дорогой и нестабильный путь.

Победил седьмой — API Moises. Он за 3–8 минут отдаёт сразу весь пакет: 7–8 дорожек, аккорды с таймингом, биты, темп, тональность и секции (куплет/припев/бридж).

Одно правило, которое я вынес кровью и записал в проект отдельным пунктом: одно не отменяет другого. Пришли дорожки, а аккорды не распознались — показываем дорожки. Пришли аккорды, а разделение упало — показываем аккорды. Пользователь не должен видеть пустой экран из-за того, что одна операция из трёх не сложилась.

И маленькая победа, которая окупила всю возню: когда одну и ту же известную песню разбирает пятая команда подряд, платить за пятый разбор — глупо. Сделал дедупликацию по содержимому файла (хеш самих байтов): один и тот же трек опознаётся, кто бы и когда его ни залил, результат копируется мгновенно, кредит не списывается.

Как я уронил весь сервер шестью дорожками

Первая схема доставки казалась разумной: пусть PHP сам качает дорожки из хранилища и отдаёт их браузеру. Единая точка, никаких чужих доменов.

Это была катастрофа.

PHP-воркер занят всё время, пока играет дорожка — пять-семь минут. Шесть дорожек — шесть занятых воркеров. А пул воркеров на дешёвом хостинге — две-три штуки. Итог: 503 на весь сервис — чат, расписание, песни. Аудио убивало вообще всё.

Ту неделю я до сих пор помню. Сначала все шесть дорожек падали мгновенной ошибкой — оказалось, кэш пытался скачать 50 МБ в темп до первого отданного байта, и nginx рвал соединение. Потом 503 на всё. Потом Chrome начал блокировать часть дорожек, потому что я создавал шесть <audio> разом. Каждый вечер — новый корень.

Вывод, который я записал заглавными буквами: PHP отдаёт JSON со ссылками один раз, дальше браузер общается с хранилищем сам. Ни один воркер не участвует в передаче аудио.

А дальше — контринтуитивная деталь, из-за которой я сделал, замерил и откатил. Логика говорит: убери посредника, будет быстрее. Я убрал прокси — телефон стал качать напрямую из хранилища. Стало в разы медленнее: ~98 секунд против ~10–15 через мой сервер. Оказалось, мой VPS и хранилище — в одной сети провайдера, и он работает не лишним хопом, а ускорителем. Коммит отката так и назвал: «прокси быстрее, чем напрямую».

И вишенка: уже потом я на проде написал в конфиге nginx один адрес литералом вместо переменной. nginx резолвит такие адреса при старте, резолв ушёл не туда — и не поднялся весь сайт, а не «раздел со стемами». Час паники. Мораль: конфиг сервера — тоже код, и ломается так же тихо.

Движок: три поколения и один упрямый iPhone

Задача звучит просто: восемь дорожек, играть синхронно, с mute/solo/громкостью на каждую, с перемоткой и сдвигом тональности. В Safari на iPhone. Она оказалась самой долгой историей проекта.

V1 — в лоб. Скачал, декодировал, играю. Работает — на трёх дорожках. Дальше арифметика убивает: несжатый звук — это ~10 МБ на минуту на дорожку. Пятиминутная песня × 8 дорожек ≈ 400 МБ в памяти. iOS убивает вкладку.

V2 — умнее. Декод в фоновом потоке, звук «перемещается» (не копируется) в аудио-поток, память живёт там. 8 дорожек заиграли на iPhone. Я выдохнул. Зря.

Через неделю пришла та самая песня — медли на 704 секунды. Play → вкладка умирает. V2 всё ещё декодировал каждую дорожку целиком, а на 12 минутах это снова ~2 ГБ. Я честно исключал по одному: код тот же? — да, контрольные суммы совпадают. Доставка виновата? — перенёс файлы, краш не изменился. Прод виноват? — склонировал на тест, тоже краш. Значит — сама песня. Длина упиралась в память.

Прежде чем изобретать, я час просидел над минифицированным бандлом чужого зрелого плеера (6.4 МБ) — просто чтобы понять, как они держат длинные треки. Ответ был в одной функции: плеер не хранит песню целиком. Буфер стартует с пяти секунд, растёт по требованию, старое выселяется. В памяти — только окно вокруг того места, что играет сейчас.

V3 — окно. Я взял принцип, а реализацию сделал свою, на нативном WebCodecs: декодирую только кусок вокруг текущей позиции, старое выбрасываю. Память — ~100 МБ вместо двух гигабайт, и не зависит от длины песни.

И вот тут началось самое муторное — старт. Первая версия честно качала файл целиком: играет, но стартует две минуты. Вторая тянула только нужные кусочки по сети — старт за секунду, но звук рвался: восемь дорожек в очереди за сетью не успевали. Вокал играет ровно, остальное захлёбывается. Победил только третий, гибридный вариант: быстрый старт по кусочкам плюс тихая фоновая докачка всего файла в память. Как только дорожка целиком в памяти — сеть больше не нужна, играет ровно.

Итог на телефоне: 12-минутный медли, 8 дорожек — играет без краша, старт ~4 секунды вместо двух минут. На это ушли недели.

Перемотка добила отдельно: жмёшь — и дорожки гаснут одна за другой до полной тишины. Три независимых бага в одной кнопке, три вечера. А самая обидная история — сдвиг тональности, который тихо исчез. Настоящий сдвиг высоты звука был написан только в V1. В V2 и V3 метод остался — но пустой, ничего не делал. А подпись аккордов менялась всегда (это другой модуль). Снаружи выглядело идеально: тональность на экране едет, звук — нет. И никто не замечал, потому что глазами всё работало. Урок дорогого стоит: когда новая реализация вытесняет старую, сверять надо не то, что методы на месте, а то, что они реально делают.

Почему пришлось уехать с дешёвого хостинга

Дешёвый хостинг был идеален для медиатеки: пришёл короткий запрос — ушёл короткий ответ. Обе новые фичи сломали ровно эту модель, потому что обеим нужно держать соединение открытым.

Убийца — синхронизация Live Pro. Телефон музыканта висит на соединении с сервером до 8 секунд, ожидая, куда двинулся лидер. Команда из шести человек — шесть таких соединений по кругу. На пуле в две-три воркера этого хватает, чтобы сайт целиком отдавал 503 — ни чат, ни расписание. Долгое соединение на shared-хостинге не «медленное», оно несовместимо по построению.

Так я переехал на свой VPS. Он тут же принёс собственные проблемы — хостер режет исходящую почту (пришлось слать через HTTP API), тот самый nginx-литерал, грабли деплоя. Но это проблемы, которые можно починить, потому что сервер твой. На дешёвом хостинге стена была «нельзя», а не «сложно».

Live Pro: три дня, за которые я почти всё возненавидел

Вот главная битва. Лидер играет разбор песни с телефона в общий звук; у каждого музыканта свой телефон, и на нём должно быть то же самое в то же время — тот же аккорд, та же доля, та же секция.

Первое, что я понял, — и это сэкономило мне недели: синхронно играть звук у всех в наушниках через веб нереально. Bluetooth добавляет 100–400 мс плавающей задержки ниже уровня кода, кварц в телефонах дрейфует, железо не калибруется — а музыканты слышат рассинхрон уже на 20–30 мс. Поэтому звук — из одной точки (телефон лидера в микшер), а синхронизирую я картинку: аккорд, долю, секцию, положение в песне.

Дальше — три дня охоты, двенадцать багов, и почти каждый со своим ложным объяснением. Расскажу те, где было по-настоящему больно.

Часы, которые врали на 25 секунд. Телефоны не знают точного времени относительно сервера — у одного из тестовых iPhone расхождение было −25 секунд. Пришлось мерить это расхождение отдельно, как маленький NTP.

«Клиент разъезжается» — а виноват был сервер. Стартуем вместе, через минуту фолловер откатывается назад, разрыв растёт: 21 секунда, 50 секунд. Я неделю искал баг у клиента. Оказалось — защита на сервере: он не принимал «момент» старше 60 секунд и тихо переписывал его на «сейчас», ломая мою пару чисел. Симптом кричал «клиент», виноват был сервер.

Я чинил не тот звук. Пользователь пишет: «аккорды спешат на полторы-две секунды». Я меряю экраны — совпадают до 25 мс. Меряю ещё раз — совпадают. Разгадка пришла с вопросом «а куда выведен звук?»: оба телефона играли в свои динамики, и человек стоял у того, чей звук отставал by design. Формально всё работало как задумано — а пользователю было плохо. С тех пор у фолловера звук принудительно на паузе.

LTE травит часы. На видео с двух телефонов было видно: к концу песни один убежал на 4 секунды. Телеметрия показала, что расхождение часов прыгало +0.8 → +3.7 секунды. На LTE отправка встаёт в очередь на секунды, все замеры приходят кривыми — и я их безоговорочно применял. Пришлось добавить фильтр: замер, пришедший по плохой сети, выбрасываем целиком и держим старый — устаревший в сотни раз точнее отравленного.

Итог, которым горжусь: экраны лидера и музыкантов совпадают в пределах ±15 мс — заметно меньше, чем разброс между живыми людьми, играющими вместе.

Но главный вывод не про числа. Половину этих багов я сначала объяснил неправильно — и каждое неправильное объяснение стоило бы дня работы не туда. Runaway списал на один параметр — оказалось, другое. «Спешат аккорды» списал на данные — замер показал, что данные ровные, а слушали старую версию. Поэтому я перестал верить ушам и глазам на репетиции и построил себе приборы: стенд, который прогоняет весь алгоритм без телефонов; тесты против живого сервера; телеметрию, которая пишет, какая версия кода реально исполняется. Скучно было писать — окупилось на второй же жалобе.

Отдельная боль: тесты, которые врали, и код, который возвращался

Четыре раза подряд я деплоил фикс, просил проверить — и получал «не работает». Каждый раз это было правдой ровно наполовину: открытая вкладка на сцене не подхватывает деплой. Музыкант не перезагружает страницу, браузер исполняет старый код сколько угодно долго, а я делаю ложные выводы и «чиню» уже починенное. Апофеоз — когда арифметика симптома идеально совпала со старым багом, а телеметрия потом показала: лидер просто крутил сборку без фикса. Пришлось научить вкладку саму замечать, что вышло обновление, и сверять версию перед каждым тестом синхрона.

И совсем показательная история — аккорды, которые «улетают». Песни хранятся как две строки: аккорды над словами, выровненные пробелами. При смене тональности аккорд меняет длину (CA#), и если сохранять число пробелов, а не позицию, — каждый следующий аккорд уезжает вправо, и вся строка расползается. Я это починил. А потом оно вернулось — рефакторинг тихо вернул старую логику, потому что фикс нечем было сторожить. Полетели те же жалобы от всех команд разом: «опять слетело», «все устали». Чинил второй раз — уже с тестом, который падает, если кто-нибудь снова так сделает. Фикс без теста — это временный фикс. Разница между «починил» и «починил, чтобы осталось» — ровно один тест.

(Кстати, метроном на длинной песне тоже норовит уплыть — если гнать его по обычному таймеру. Спасает только привязка к реальному времени звука и постоянная сверка с ним; но это уже детали, разверну в комментариях, если интересно.)

ссылка на оригинал статьи https://habr.com/ru/articles/1069052/