Как я подружил блок‑головоломку с Шопеном на WebAudio

Я делаю Blocturne, головоломку с блоками для Telegram. Механика знакомая по Block Blast: поле 8×8, три фигуры в лотке, собираешь ряды и столбцы. Сверху прикручен подсчёт как в Balatro: фишки умножаются на множитель, между раундами лавка с предметами. Но рассказать хочу про звук, потому что с ним я провозился дольше, чем со всем остальным.
Хотелось, чтобы звуки ходов попадали в музыку, а не играли поверх неё. В итоге получился маленький музыкальный движок, отдельный режим для слабых Android и пара экспериментов, которые пришлось выкинуть.

Музыка лежит нотами, а не в mp3
Фоном играют ночные пьесы: Шопен, Филд, Сати, Дебюсси, Чайковский, «Лунная соната», всего 19 штук. Сами произведения давно в общественном достоянии, а ноты я брал из машинных наборов Mutopia Project и одной аранжировки в MuseScore. У части наборов лицензия CC BY‑SA, их авторы указаны в игре.
Записи не подходили по двум причинам. Первая — вес: стартовая загрузка у игры около 250 КБ, и ноты всех девятнадцати отрывков занимают в ней примерно 8 КБ. Вторая важнее: движку нужно знать, какой аккорд звучит в каждую долю. Из нот это берётся бесплатно, а вытаскивать гармонию из аудио долго и ненадёжно.
Скрипт режет MIDI на отрывки по 45–100 секунд, обязательно целыми разделами до каденции, иначе обрыв слышно. Аккомпанемент прореживается: не больше четырёх нот в одном ударе и ничего выше мелодии. На выходе TypeScript‑файл, где у каждой пьесы мелодия, левая рука, такты и темп.
Играет всё это записанное пианино, набор FreePats Upright Piano KW (Kawai, лицензия CC0). Я взял по одной ноте через малую терцию, промежуточные получаются сдвигом playbackRate. Набор весит около 440 КБ в моно AAC и грузится в фоне. Пока он не загрузился, играет простой синтезатор, и переключение почти не слышно.
Почему ля = 444
У каждого часа ночи своя тональность и свои пьесы. Настроено всё не на привычные 440 Гц, а на ля = 444: при таком строе до второй октавы получается ровно 528 Гц.
Откуда это взялось, скажу честно. Есть популярная история про «частоты сольфеджио», и 528 Гц там самая известная. Я в чудесные свойства частот не верю, но в музыкальных играх это ищут, а мне понравилось ограничение, которое из этого вышло. Все тональности часов подобраны так, чтобы в них была нота до, и тогда тихий слой с бинауральными биениями (например, 528 Гц в левом ухе и 534 в правом, разница 6 Гц) никогда не спорит с музыкой. По умолчанию он включён совсем тихо, и его можно убрать в настройках.
Звук хода
Когда игрок ставит фигуру, звучит щипок. Нота у него — следующий тон аккорда, который сейчас звучит в пьесе. А сам звук ставится не сразу, а на ближайшую тридцать вторую долю сетки пьесы:
// Ближайшая 1/32 после «сейчас» по сетке звучащей пьесы
private q(div: 16 | 32 = 32): number {
const now = this.now;
const g = this.stepLen() * (16 / div);
const ref = this.files ? this.files.gridRef : this.nextTime;
const k = Math.floor((ref - now) / g);
return Math.max(now, ref - k * g);
}
В медленной пьесе это может быть задержка до десятой доли секунды. Я ожидал, что она будет мешать, но на деле работает наоборот: щипок попадает в музыку, и кажется, что так и было задумано.
Гармонию считаю по левой руке: на каждую долю беру ноты аккомпанемента, которые в этот момент звучат. Для щипка этого хватает, а когда очищаются линии, по тем же тонам аккорда играет короткое арпеджио на колокольчиках.
Один эксперимент пришлось откатить. Я пробовал делать ход «вторым голосом»: нотой на том же записанном пианино, на терцию‑сексту ниже мелодии. Теоретически красиво, на практике это звучало как пианист, который мажет мимо клавиш. Тембр один и тот же, и ухо воспринимает ход не как ответ игрока, а как ошибку в самой пьесе. Вернул щипок: у него свой тембр, и он слышен как отдельный голос.
Пианист, который подыгрывает
Ноты пьес я не меняю, зато меняю исполнение. Если игрок задумался дольше шести секунд, музыка становится тише примерно на 2,5 дБ и темнее (полка −7 дБ на высоких). Серия очисток подряд её раскачивает: на второй больше педали и ярче звук, на третьей челеста тихо удваивает мелодию октавой выше, на четвёртой вступает струнный фон по гармонии левой руки. Когда комбо большое, пианист как бы делает вдох: темп на мгновение растягивается до ×1,18 и за пару секунд возвращается.

Челеста и струнные почти ничего не стоят. При запуске по одной ноте каждого инструмента рендерится в OfflineAudioContext, а дальше это один буфер, который проигрывается с нужным playbackRate.
Android, который заикается
На iPhone и компьютере всё звучало нормально. А на части Android‑телефонов, например на POCO M4 Pro, музыка хрипела и заикалась.
Сначала я грешил на реверберацию. Свёртка через ConvolverNode действительно тяжёлая, я заменил её на лёгкую: восемь гребенчатых фильтров на линиях задержки с демпфированием. Стало лучше, но не вылечилось. Потом выяснилось, что проблема в количестве короткоживущих узлов. Каждая нота — это осцилляторы, огибающие и панорамы, и WebAudio на таких телефонах захлёбывается не от вычислений, а от того, как часто узлы создаются и умирают. Постоянный тон при той же нагрузке звучит чисто, это я проверил отдельной тестовой страницей.
Попробовал два решения. Первое — «рассчитанная» музыка: тот же композитор каждые четыре такта собирает кусок в OfflineAudioContext с хвостом в шесть секунд, а живой контекст только проигрывает готовые буферы и складывает хвосты. Помогло, но на совсем слабых телефонах рендер иногда не успевал, и приходилось откатываться на живой звук.
Второе в итоге осталось по умолчанию. На Android все пьесы заранее отрендерены тем же движком в файлы, AAC 80 кбит/с, от полумегабайта до мегабайта на пьесу. Рядом лежит манифест с тактами и темпом. Телефон качает только то, что звучит, а гармонию для щипков я восстанавливаю из тех же нот синхронно с файлом. Звуки ходов в этом режиме берутся из заранее собранного набора и подстраиваются по высоте не больше чем на два с половиной полутона.
60 кадров, а если не выходит, то 30
Поле и интерфейс рисуются на Canvas 2D, поверх лежит световой слой на WebGL: частицы, вспышки, «реки света» из очищенных линий. На слабых телефонах всё это отбирало процессор у звука, поэтому у игры одна шкала из пятнадцати уровней качества. Сначала гаснут эффекты, потом чуть падает разрешение, потом пропадает размытие под карточками меню, и только после этого кадров становится 30.
Уровень подбирается, пока висит заставка: игра рисует с первой миллисекунды загрузки, меряет кадры и ищет лучший уровень, который держит 60. Мерю среднее без самой медленной четверти кадров. Медиана тут обманывает: если телефон пропускает каждый второй кадр, интервалы чередуются 17 и 33 мс, и медиана вполне может показать 17. Заставку пришлось сделать прозрачной на 4%: под полностью непрозрачной браузер не компонует то, что под ней, и нагрузку на видеочип замер просто не видит.
Ещё одна находка: shadowBlur на канвасе оказался одной из самых дорогих операций для слабых видеочипов. Теперь все свечения рисуются один раз в кэш и дальше копируются, на вид разницы нет.
Немного про Telegram
Игра живёт внутри Telegram как мини‑приложение, и у платформы свои особенности. CloudStorage хранит строки до 4096 символов, а сохранение забега длиннее, так что я режу его на части и пишу пачкой раз в четыре секунды и при сворачивании. Награду за репост даю, только если Telegram подтвердил отправку: бот готовит карточку через savePreparedInlineMessage, игра открывает её через shareMessage, и только после ответа «отправлено» игрок получает «второе дыхание».
Последнее, что я сделал, — QR‑код с реферальной ссылкой на картинках для историй и репостов. Бот у меня на Node без зависимостей, тащить туда canvas ради одной картинки не хотелось, поэтому кодировщик QR (байтовый режим, уровень коррекции M) и PNG пришлось написать руками. Потом выяснилось, что фото в подготовленном сообщении Telegram принимает только в JPEG. Пришлось дописать и baseline‑JPEG: DCT, квантование, стандартные таблицы Хаффмана, без прореживания цвета, чтобы края QR оставались чёткими. Карточка весит около 190 КБ и собирается за 0,2 секунды. Проверял системным распознавателем QR в macOS, читает.

Что в итоге
Сейчас в игре ночь из шести часов по три раунда, 40 предметов‑«диковин», гости со своими правилами, восемь фаз луны вместо ставок из Balatro, ежедневная «Ночь дня» с таблицей и бесконечный режим. Звуковых пакетов три: фортепиано, лоуфай и синтвейв, причём синтвейв сочиняется на лету в тональности часа.

Поиграть можно в Telegram, бесплатно и без установки: https://t.me/BlocturneBot?startapp
Больше всего мне сейчас нужны отзывы про звук на Android. Если где‑то хрипит, заикается или ходы не попадают в такт, напишите модель телефона. И если вы решали проблему с короткоживущими узлами WebAudio как‑то без пререндера, очень хочу узнать как.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.