
Телефон лежит на столе или в другой руке. Рядом в воздухе ладонь. Двигаешь её — корабль на экране поднимается и опускается. Экрана не касаешься. Камера не нужна: телефон пищит ультразвуком из своего динамика и слушает эхо своим микрофоном.
Играть: sonaroids.app (веб-приложение на iPhone; для Android есть .apk — ссылка на главном экране игры). Код: github.com/neokrasav4ik/sonaroids, MIT.
UPD. Заголовок устарел, и это хорошая новость: на Mi 9 Lite, середнячке 2019 года, играется так же, как на iPhone 16. Помогли широкий зонд (управление примерно вдвое чище) и приложение для Android со своим звуком, правильной громкостью и выбором микрофона. Новые разделы статьи — «Широкий зонд» и «Android: приложение вместо браузера».
С чего всё началось
Мне попался проект sonar.cool: Мак листает страницы жестами руки, а «видит» их через собственные динамики и микрофон. Мака у меня нет, зато есть iPhone. Отсюда вопрос: получится ли то же самое в браузере на телефоне? Причём не отдельный жест «листай», а непрерывное управление: высота ладони = высота корабля.
Сразу признаюсь: это вайбкодинг
Сам я не написал ни строчки. Обработку сигнала, игру, сервер рекордов, приложение для Android, тесты и даже гифку написал Claude Opus 5.5. От меня были идея, решения «так / не так» и проверка каждой версии своей ладонью. Для разбора я записывал с телефона сырые WAV: ладонь по линейке на 5, 10, 15 см. Модель гоняла по этим записям свои варианты обработки, пока цифры не сходились с линейкой. Ещё она сама собрала себе стенды: синтетический микрофон, повтор записанных партий, игру в Chromium без окна.
Как это работает
Зонд. Периодический сигнал из 23 тонов от 18,4 до 20,4 кГц, период 512 отсчётов при 48 кГц. AudioWorklet отдаёт поток микрофона без пропусков, так что картина эха считается каждые 10,7 мс.
Движение ладони видно по сдвигу фазы эха. Это точно, до долей миллиметра, но со временем уплывает.
Положение видно по тому, как эхо отличается от пустой комнаты. Это шумно, зато не уплывает. Обе оценки сливаются: фаза ведёт корабль, положение не даёт ему уползти.
Калибровки как таковой нет. Убираешь руку на секунду (игра запоминает пустую комнату), машешь ладонью на 5–10 см над столом или на 5–15 см от торца телефона, если он в руке, и экран подгоняется под твой размах.
Что не сработало: пик дальности (прыгал на 6–22 см), амплитуда эха (сплошной шум от пятен интерференции), стандартный AnalyserNode (окна слишком грубые) и две руки (об этом ниже).
UPD: Второй способ нашёлся случайно: телефон в руке
Во время тестов я взял телефон в руку, а другой ладонью, повёрнутой ребром вниз, стал водить у торца с разъёмом. Ближе — корабль ниже, дальше — выше. Оказалось, так управлять не хуже, чем над столом, а стол вообще не нужен. Переключателя в игре нет и не понадобился: сонар в обоих случаях просто меряет расстояние до ладони.
Но в руке всплыла новая беда. Иногда вся картина эха разом «переезжала» во времени — на 256 отсчётов на iPhone, на ~144 на OnePlus — хотя вход не прерывался. Игра принимала это за закрытый ладонью динамик, переставала сверять положение, и корабль уплывал к краю. Теперь, когда прямой сигнал пропадает, игра ищет, не сдвинулся ли весь отклик. Ищет по отклику, накопленному за треть секунды: эхо движущейся ладони при этом гасится, а прямой сигнал остаётся на месте. Если сдвиг нашёлся, игра просто переходит туда.
Широкий зонд
Полоса 18,4–20,4 кГц выбрана, чтобы писк никто не слышал. Но от ширины полосы зависит, насколько тонко сонар различает расстояние: 2 кГц — это примерно 8 см, 4,5 кГц — примерно 4 см. Я попробовал зонд 16–20,5 кГц и записал ладонь по линейке тем же сценарием, что и с обычным:
зонд | совпадение с линейкой по форме | разброс вокруг неё |
|---|---|---|
обычный, 18,4–20,4 кГц | 0,981 | 5,3 мм |
широкий, 16–20,5 кГц | 0,997 | 2,2 мм |
Дрожь, когда держишь ладонь неподвижно, одинаковая, около 3,7 мм: это дрожит уже сама рука. По ощущениям корабль на широком заметно спокойнее.
Минус в том, что 16 кГц могут слышать дети и животные (я не слышу ни тот, ни другой). Поэтому «по умолчанию» нет: перед каждой игрой игрок сам выбирает — «широкий: управление чище, могут слышать дети и животные» или «обычный: не слышен, управление чуть грубее».
Почему «нужен флагман»
Ирония в том, что игра весит около 50 КБ в сжатом виде: графика, звуки и пиксельный шрифт нарисованы кодом. А запускается нормально только на хорошем железе. Всё решает полоса 18–20 кГц: динамик и микрофон телефона вовсе не обязаны её пропускать.
телефон | спад к 20,4 кГц | эхо ладони над пустой комнатой |
|---|---|---|
iPhone 16 | около −8 дБ | около 40 дБ |
OnePlus 13 | −33 дБ | 10–15 дБ |
Redmi Note 10 | около −20 дБ | 10–15 дБ |
Когда я публиковал статью, было даже так: на столе нужен не просто флагман, а iPhone. OnePlus 13 тоже флагман, но над столом играть на нём тяжело. Зато с телефоном в руке он неожиданно почти догнал iPhone. У OnePlus ещё проваливается вход: телефон теряет куски сигнала. По справкам, которые игра отправляет на сервер, ладонь слышна и на Galaxy S25, Pixel 8 Pro, Nothing Phone (3a), realme GT Neo 5, Honor 600 Lite. На OnePlus 9 Pro — нет.
Часть проблем удалось обойти. Полоса выравнивается по уровню каждого тона: на записи с OnePlus ошибка против линейки упала с 81 до 13 мм. После провала входа прямой сигнал ищется заново. Игра сама выясняет, у какого конца телефона держать руку, и подсвечивает на экране, где махать. Но 25 дБ разницы в эхе не вытянешь, поэтому Android пока «как повезёт с телефоном».
Три дня спустя. Mi 9 Lite, середнячок 2019 года, в приложении на широком зонде играет так же, как iPhone, — и в руке, и на столе. Из всех партий, что я разбирал, у него самый спокойный корабль. То есть дело было не во флагманстве, а в том, как игра обращалась с Android: какой микрофон ей достаётся, как он пишет и насколько громко играет динамик. Об этом следующий раздел.
Android: приложение вместо браузера
В браузере на Android страница многое не контролирует. Поэтому появилось приложение — тонкая оболочка над тем же сайтом. Игра в нём та же и обновляется с сайта сама; новый APK нужен, только когда меняется сама оболочка, и приложение само предлагает обновиться. Три находки, ради которых оно было нужно:
Громкость. Первая версия приложения перед каждой игрой поднимала громкость до 60%: самая частая ошибка у игроков — «слишком тихо». На Redmi Note 10S это оказалось вредно: динамик «звенел» в собственный микрофон на 20 с лишним дБ громче, чем у iPhone и Mi 9 Lite, и эхо ладони в этом звоне тонуло. Теперь игра советует 20–30%, приложение само ставит около 25% и подстраивает по замеру, а браузер просит убавить, если телефон играет слишком громко.
Какой микрофон пишет. Страница в браузере получает тот микрофон, который выберет Android. На Redmi это то микрофон у разъёма, то у фронтальной камеры, от запуска к запуску; отсюда и «Redmi слышит ладонь только со стороны камеры» из первой версии статьи. Приложение теперь пишет звук само, в обход браузера, и выбирает микрофон явно.
Как пишет. Для записи без обработки в Android есть источник UNPROCESSED, и логично было взять его. На Mi 9 Lite он оказался хуже, чем VOICE_RECOGNITION (тот по правилам Android тоже без шумодава и автоусиления): пустая комната с ним вычиталась на −13 дБ против −36, и ладонь время от времени терялась. А задний микрофон слышит зонд примерно в 25 раз тише нижнего и расстояние не различает вовсе.
Поэтому приложение, пока ты перед игрой убираешь руку, за пару секунд пробует каждый встроенный микрофон с разными источниками. Оно меряет, насколько ровно и чисто слышен зонд, выбирает лучший вариант (при прочих равных — нижний микрофон и VOICE_RECOGNITION) и запоминает его для этого телефона. Игрок ничего не выбирает.
Вся обработка сигнала при этом осталась в коде страницы: приложение только пишет микрофон и играет зонд, а кадры передаёт странице. Один код для браузера и для Android.
Чего не получилось: вторая рука
Хотелось второй оси — управлять двумя руками или отличать лево от права. Я перепробовал всё, что пришло в голову: две ладони, кулак и ладонь (у них разное «звучание» эха), «ближняя и дальняя рука». С одним микрофоном ничего из этого не разделяется: он слышит расстояния и скорости всего сразу, но не направление. Надёжно видно только одно — что обе руки движутся синхронно. Второй микрофон через Safari не достать: браузер отдаёт два канала, но второй пустой. Остаётся стерео в приложении на Android — это следующая проверка.
Рекорды без читеров
На сервер уходит не счёт, а вся партия: номер раскладки и высота ладони на каждом шаге. Это несколько килобайт на пару минут игры. Полёт детерминирован: постоянный шаг 60 Гц, генератор случайных чисел от номера раскладки, никаких sin и pow, которые могут по-разному округляться в разных движках. Поэтому сервер переигрывает партию тем же самым файлом правил, что и браузер, и записывает только счёт, который повторился. «Прислать 999999» не получится. Бот, который честно водит ладонью, пройдёт, но пусть сначала его кто-нибудь напишет.
Сервер — Node 22 без единого npm-пакета: node:http и встроенный node:sqlite. Ник — только латиница, цифры и _: ради защиты от желающих вписать в ник SQL-инъекцию.
Что дальше
Игра работает, рекорды пишутся: сегодня, неделя, всё время. Если у вас Android, попробуйте оба способа — на столе и в руке, лучше в приложении, — и напишите в комментариях, как вышло: модель телефона, какой зонд, слышит ли он ладонь и с какого конца. Если игра не слышит ладонь, долгое нажатие на номер версии в углу экрана показывает ссылку «Журналы»; их можно приложить к issue на GitHub.
Ссылки:
игра: sonaroids.app
проект, с которого всё началось: sonar.cool

