Привет, Хабр. У меня дома уже много лет лежит iRobot Roomba — та самая, из середины нулевых. Выкидывать жалко, работать как пылесос она уже толком не хочет, а тут я вспомнил про старый проект HackingRoomba Тода Курта: он ещё в 2006-м показал, что Roomba можно заставить играть мелодии через открытый протокол Roomba Open Interface. Идея была классная, но утилиты RoombaMidi и RoombaMidi2 давно перестали запускаться на современных ОС — драйверов нет, порты другие, Java несовместимая. В общем, всё умерло, кроме самих роботов.

Вот я и решил: а почему бы не написать своё приложение, которое будет работать на нормальном современном ПК? Так появился RoombaMusic. О том, как я до этого дошёл, что сломалось по дороге и почему в итоге всё-таки заиграло — ниже.

Как Roomba вообще разговаривает

Roomba общается по последовательному порту через USB-TTL кабель, и протокол у неё довольно дружелюбный. Сначала отправляешь команду 128 (START) — робот просыпается. Потом 131 (SAFE MODE) или 132 (FULL MODE) — и он готов слушать команды. Дальше можно слать что угодно: музыку, движение, запросы к датчикам.

С музыкой всё интереснее. Есть команда 140 — запись песни в слот памяти, и команда 141 — воспроизведение этого слота. Казалось бы, записал мелодию, нажал play, радуйся. Но есть нюанс: в один слот влезает не больше 16 нот. Для короткого риффа хватит, для нормальной мелодии — нет. Именно на этом ограничении спотыкались все, кто пытался играть что-то серьёзнее «Ля-Ми-Ля-Соль-Фа».

Что такое слот памяти и почему в нём только 16 нот

Слот — это небольшой кусок памяти внутри контроллера Roomba, куда команда 140 записывает последовательность пар «нота — длительность». Формат простой: сначала идёт номер слота (их несколько), потом количество нот, а дальше пары байтов. Каждая нота — это число от 31 до 127 (MIDI-нотация), каждая длительность — число от 1 до 255 в единицах 1/64 секунды. То есть максимальная длительность одной ноты — примерно 4 секунды, а минимальная — около 15 миллисекунд.

Ограничение в 16 нот — это ограничение размера буфера под одну песню. При попытке записать больше Roomba просто обрежет мелодию или вернёт ошибку, в зависимости от прошивки. И вот тут начинается самое интересное: стандартный путь «записал — играй» для реальной музыки не работает. MIDI-файлы, которые я хотел играть, содержат сотни и тысячи нот. Даже простенькая мелодия на 30 секунд — это далеко за пределами 16.

Я рассматривал несколько обходных путей. Можно было бы использовать несколько слотов одновременно — но их количество тоже ограничено, и переключение между ними на ходу даёт слышимые щелчки. Можно было бы вообще не использовать слоты, а слать команды «проиграть ноту» напрямую — но такой команды в протоколе нет, есть только запись в слот и воспроизведение слота. Так что остался единственный вариант, который в итоге и заработал: постоянная перезапись слота на ходу.

Главная идея: перезапись на ходу

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

Вот этот механизм в коде — по сути, весь фокус проекта:

private void sendNoteToRoomba(int note) {
    try {
        // Записываем одну ноту в слот 0
        byte[] songData = { (byte) 140, 0, 1, (byte) note, 8 };
        serialPort.writeBytes(songData);
        
        // Сразу запускаем воспроизведение
        byte[] playCmd = new byte[]{(byte) 141, 0};
        serialPort.writeBytes(playCmd);
    } catch (SerialPortException e) {
        logText("Ошибка отправки ROI: " + e.getMessage());
    }
}

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

Я экспериментировал с задержками между записью и play, смотрел на логи и слушал результат. В какой-то момент понял, что идеальная задержка зависит от длительности самой ноты: короткие ноты можно отправлять почти без паузы, а длинные требуют, чтобы play успел сработать до перезаписи. В итоге остановился на том, что приложение просто шлёт команды так быстро, как позволяет порт, а Roomba сама разбирается — её буфер успевает принять следующую пару байтов, пока играет предыдущая нота. На слух это работает, хотя в логах видно, что иногда команды уходят буквально друг за другом без зазора.

Почему Java и jssc

Я выбрал Java, потому что хотелось кроссплатформенности: у меня самого Linux, но мало ли, кто ещё захочет запустить. А для работы с последовательным портом взял библиотеку jssc — она живая, обновляется и нормально дружит с современными ОС, в отличие от старых Java-обёрток, на которых висели оригинальные RoombaMidi. Собственно, одна из причин, почему старый софт умер — как раз в том, что библиотеки для портов перестали собираться и работать.

С портами, кстати, отдельная история. На Windows они называются COM3, COM4 и так далее, на Linux — /dev/ttyUSB0. Чтобы не заставлять пользователя вбивать это руками, я сделал выпадающий список через SerialPortList.getPortNames() — приложение само показывает, что нашло. Подключение и инициализация выглядят так:

private void connect() {
    try {
        serialPort = new SerialPort((String) portList.getSelectedItem());
        serialPort.openPort();
        serialPort.setParams(115200, 8, 1, 0);
        
        // START (128) + CONTROL MODE (130)
        byte[] initCmd = new byte[]{(byte) 128, (byte) 130};
        serialPort.writeBytes(initCmd);
        
        btnConnect.setEnabled(false);
        btnOpenFile.setEnabled(true);
        statusLabel.setText("Roomba Ready!");
    } catch (Exception ex) {
        logText("ERROR: " + ex.getMessage());
    }
}

Скорость 115200, 8 бит данных, 1 стоп-бит, без чётности — стандартные параметры для ROI. После инициализации кнопка «Подключить» блокируется, а кнопка выбора файла разблокируется, чтобы нельзя было случайно переподключиться посреди игры.

Как оно играет MIDI

Самая приятная часть — приложение умеет читать обычные .midi файлы. Внутри Java есть javax.sound.midi, который умеет разбирать MIDI и проигрывать его через Sequencer. Я подписался на сообщения секвенсора и при каждом NOTE_ON отправляю ноту в Roomba. То есть робот становится таким странным синтезатором, который играет всё, что вы ему подсунете, — были бы ноты в диапазоне, который он умеет.

Тут есть тонкость с длительностями. MIDI-файл содержит информацию о том, когда нота началась и когда закончилась, а команда 140 требует явную длительность. Я решил не заморачиваться с точным соответствием и всегда шлю длительность 8 (то есть примерно 125 миллисекунд) — на слух этого достаточно, чтобы нота прозвучала, а следующая команда успела её перезаписать. Если нота в MIDI длиннее, она всё равно обрывается перезаписью, если короче — Roomba доигрывает свои 125 мс. Для большинства мелодий это звучит приемлемо, хотя идеального ритма таким способом, конечно, не добиться.

Ещё один момент — диапазон нот. Roomba понимает не все MIDI-ноты, а только от 31 до 127. Если в файле встречаются ноты ниже, приложение их просто пропускает, иначе робот может зависнуть или издать странный звук. Это ограничение протокола, с ним ничего не сделать.

Архитектура приложения

Внутри всё устроено довольно просто, но с одним важным нюансом. Основной поток — это Swing-интерфейс. Когда пользователь выбирает файл, запускается отдельный поток, который открывает Sequencer, подписывается на сообщения и стартует воспроизведение. Receiver секвенсора вызывает sendNoteToRoomba напрямую — то есть команды в порт уходят из потока MIDI-секвенсора, а не из UI-потока. Это важно: если бы я слал команды из UI-потока, интерфейс бы подвисал на время отправки, а так всё работает гладко.

Логи выводятся в текстовое поле через SwingUtilities.invokeLater — это стандартный способ обновлять Swing-компоненты из другого потока. В логе видно каждую отправленную команду в DEC и HEX, так что если что-то звучит не так, можно посмотреть, что именно улетело в робота.

Чего не получилось и что можно улучшить

Честно говоря, идеального звука добиться не удалось. Главная проблема — длинные ноты. Если в MIDI нота тянется секунду, а приложение шлёт её с длительностью 125 мс, она обрывается раньше времени. Можно было бы вычислять длительность из MIDI-событий, но тогда пришлось бы ждать окончания ноты перед отправкой следующей, а это убивает всю идею перезаписи. Так что я остановился на компромиссе: короткие ноты звучат нормально, длинные — обрываются. Для быстрых мелодий это почти незаметно, для медленных — слышно.

Ещё одна проблема — дрейф темпа. Поскольку я не синхронизирую отправку команд с реальным временем MIDI, а просто шлю их так быстро, как получается, мелодия может слегка «плыть» относительно оригинала. На коротких отрывках это незаметно, на длинных — накапливается. В будущем можно было бы прикрутить таймер, который отправляет команды строго по timestamp'ам из MIDI, но пока руки не дошли.

Из идей на будущее: добавить поддержку движения, чтобы робот не только играл, но и «танцевал»; сделать сохранение настроек (порт, задержки); может быть, прикрутить веб-интерфейс, чтобы можно было управлять роботом с телефона. Но это уже как пойдёт.

Что в итоге

Код лежит на GitHub: github.com/TimoNiki. Видео, как это звучит: youtu.be/AulgS5baU0M.

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