The Daily Newsstand · Free, Always
Saturday, October 17, 2026

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

Translate

Я делаю 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 как‑то без пререндера, очень хочу узнать как.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.