TL;DR: взял Яндекс.Станцию Мини 3, получил root, снёс весь яндексовский софт и собрал на ней полностью автономного голосового ассистента на базе Claude. Будишь словом «Клод», задаёшь вопрос — колонка сама слушает (Vosk), думает (Claude API) и отвечает голосом (ElevenLabs). По пути пришлось чинить звук, который играл втрое медленнее, прямо в регистрах тактового генератора, и впихивать 157-мегабайтную модель распознавания в 240 МБ RAM без swap. Весь код — на GitHub.
Отдельная линия этой истории: я делал всё это в паре с Claude Code. И где-то к середине проекта поймал себя на забавной мысли — Claude Code, по сути, собирал своего же младшего брата: маленького Claude, который теперь живёт внутри колонки и откликается на «Клод». Старший пишет код, отлаживает, диагностирует — чтобы младший поселился в железке и заговорил. Дальше эта мысль меня уже не отпускала.
Зачем
У меня стояла Яндекс.Станция Мини 3. Отличное железо: 4 ядра, микрофоны, динамик, кольцо из диодов — и всё это заперто в Алисе. Захотелось, чтобы вместо «Алиса» я говорил «Клод» и получал ответы от модели, которую сам выбираю. Причём чтобы вся обвязка крутилась на самой колонке.
Отдельное условие к себе: никакого паяльника и UART. Только программные методы и обязательно с путём отката — убивать железку не хотелось.
Спойлер: получилось. Но дорога оказалась длиннее, чем я думал.
Что за железо
Немного матчасти о Мини 3 (кодовое имя bergamot):
SoC | Amlogic AXG, 4× Cortex-A53 |
Ядро | aarch64, Linux 4.9.68 (вендорное) |
Userspace | 32-bit armhf, glibc 2.30, Buildroot 2020.02 |
RAM | 240 МБ, 0 swap |
Флеш | 512 МБ NAND |
Звук | TDM → класс-D усилитель |
Любопытная деталь, которая потом аукнется: ядро 64-битное, а весь userspace — 32-битный armhf. Это значит, что на колонке спокойно запускаются и armhf-бинари (через системный glibc), и статические aarch64-бинари (им libc не нужен, а ядро 64-битное). Этим я потом и пользовался.
Root, SSH и зачистка Алисы
Root на этих колонках добывается через отладочные интерфейсы (ADB на порту 5555 и т.п.) — про это на Хабре и 4PDA написано достаточно, повторяться не буду. Важно, что rootfs смонтирован как обычный UBIFS и доступен на запись (никакого dm-verity на system-разделе), а значит его можно переделывать на живой системе.
Первым делом поставил dropbear (SSH), чтобы не зависеть от ADB. Дальше — снёс весь яндексовский userspace: голосовой стек, демоны, всё лишнее. Оставил голый Buildroot: «часы, звук и колечко сами прикрутим».
Тут вылезла первая весёлая особенность, которая потом сэкономила бы мне часы, если бы я понял её сразу: у dropbear нет sftp-сервера, поэтому scp не работает. Файлы туда-сюда гонял через ssh root@host 'cat > file' и обратно ssh root@host 'cat file' > local. Запомните, если будете ковырять такое же.
Архитектура
Конвейер получился такой:
микрофоны ─▶ вейк-ворд «Клод» ─▶ Vosk STT ─▶ Claude API ─▶ ElevenLabs TTS ─▶ динамик (arecord) (на устройстве) (на устройстве) (облако) (облако) (aplay) кольцо: оранжевый спин = слушаю · пульс = думаю · ровно = говорю
Всё, кроме самих вызовов к Claude и ElevenLabs, работает на колонке.
Кросс-компилировал всё на маке. В роли кросс-компилятора — Zig (zig cc -target arm-linux-gnueabihf.2.30 для armhf под нужный glibc, -target aarch64-linux-musl -static для статики), для Rust — cargo-zigbuild. Zig здесь прекрасен: один бинарник умеет собирать под кучу таргетов и сам тащит нужные версии glibc-заголовков.
Собственно, бо́льшую часть этого кода писал не я вручную, а Claude Code: я формулировал, куда двигаемся, он предлагал реализацию, собирал, заливал на колонку и смотрел логи. Получилось эдакое совместное воспитание: старший Claude кодит инфраструктуру, младший готовится в неё въехать.
Распознавание речи: Vosk против whisper
Первый вопрос — на чём делать STT на устройстве. Проверили два варианта прямо на колонке.
whisper.cpp (tiny, q5_1) собрался статиком под aarch64. Распознаёт русский, по памяти влезает (пик ~138 МБ). Но скорость — 11.9 секунды на 4 секунды аудио. whisper всегда молотит 30-секундное окно, поэтому даже короткая команда — это 10+ секунд ожидания. Для живого ассистента неприемлемо.
Vosk (small-ru-0.22) на armv7l-сборке — распознаёт потоково, near-real-time, пик по памяти ~157 МБ. Живую фразу с микрофона колонки распознал дословно, включая слово «клод». Он же работает и как детектор вейк-ворда: ловит «клод» в частичных (partial) результатах мгновенно, отдельный движок не нужен.
Выбор очевиден — Vosk. whisper оставил в запасе для случаев, где нужна точность и не важна скорость.
Грабли по дороге:
libvosk.soидёт без SONAME, поэтому линкер зашивает полный путь какNEEDED. Лечитсяpatchelf --replace-needed.Клиент надо собирать под glibc 2.30 (
-target arm-linux-gnueabihf.2.30), иначе он тянет более новую версию и не запускается.Микрофоны отдают 4 канала (
arecord -c 4), живые — каналы 0–2, четвёртый — feedback. Беру нулевой.
Мозг: Claude API
Мозг — это прямой HTTPS-запрос к Claude Messages API. Никакого «агента», просто POST /v1/messages с вопросом, модель claude-haiku-5-5 (быстрая и дешёвая, для голосового Q&A в самый раз). Написали крошечный клиент на Rust (ureq + rustls), собрали статиком под aarch64 — TLS в Rust делается в десять строк, в отличие от боли с libcurl/mbedtls в C. Ответ haiku приходит примерно за секунду.
Один сюрприз всплыл позже, уже в бою: API начал возвращать ответы с блоком extended thinking — content[0] оказался типа thinking (с подписью), а не text. Наивный парсер брал content[0].text, не находил его и вываливал в ответ весь сырой JSON. А дальше этот мегабайт JSON уходил в синтез речи, и колонку убивал OOM. Лечится правильно: идём по массиву content, собираем только блоки типа text, остальное игнорим. Заодно добавили системный промпт «отвечай кратко, 1–3 предложения, без markdown» — голосовому ассистенту длинные простыни ни к чему, да и звёздочки из markdown он бы зачитывал вслух.
Отдельно доставила ирония момента: Claude Code отлаживал ответ, который возвращал его собственный мозг. Старший чинил парсер для реплик младшего — буквально разбирал, как тот думает, чтобы тот не захлёбывался собственными мыслями.
Детектив: почему звук играл втрое медленнее
Вот это была моя любимая часть. Сгенерили тестовую фразу голосом ElevenLabs, скинули на колонку, проиграли — и услышали замедленного демона. Голос тянется, тон занижен, как заезженная плёнка. На маке та же запись играет идеально.
Шаг 1: данные не виноваты
Первым делом исключили данные. PCM корректный: 24 кГц, нужная длительность, на маке играет чисто. Значит проблема в воспроизведении на устройстве.
Чтобы не мучить уши (и нервы — один раз eSpeak так гаркнул из колонки, что я подпрыгнул), мерили беззвучно: amixer sset Master 0, затем засекали, сколько реально играет aplay. Фокус в том, что aplay блокируется, пока буфер не доиграет на аппаратных часах — то есть по времени воспроизведения можно вычислить реальную частоту дискретизации, не слыша ни звука.
Цифры:
запрошенная частота | реальная длительность | должно быть |
|---|---|---|
24000 | 9.70 с | 3.20 с |
48000 | 4.90 с | 3.20 с |
Ровно ×3 замедление.
Шаг 2: SoC отдаёт правильный поток
Полезли в dmesg и ALSA. Выяснилось, что SoC-сторона идеальна:
aml_dai_set_tdm_sysclk freq(12288000)acm8625 hw_params: srate:48000, chn:2, chn_size:16, bclk:1536000 bclk(3072000) bclk_lrclk_ratio(64) <- 3072000/64 = 48000, всё верно
acm8625 hw_params: srate:48000, chn:2, chn_size:16, bclk:1536000
bclk(3072000) bclk_lrclk_ratio(64) <- 3072000/64 = 48000, всё верно
hw_params подтверждает: регистры запрограммированы под 48 кГц. А по факту — 16 кГц. Железо само себе противоречит.
Шаг 3: клок защёлкивается вживую
Тут помогла находка: делители TDM (MST_B_SCLK0/1) пишутся драйвером напрямую в регистры EE_AUDIO (база 0xFF642000), а не через common-clock framework — значит их видно через /dev/mem и devmem, и их можно менять на лету. Проверили гипотезу: поменяли делитель SCLK прямо во время (беззвучного) воспроизведения — длительность изменилась. Запись в регистр защёлкивается live. Отлично, можно подбирать.
Шаг 4: виноват не тот MPLL
Дальше арифметика сошлась. Для 48 кГц нужно деление mclk → lrclk = 256. Регистр MST_B_SCLK0 даёт ровно это (/4 × /64 = /256). А выходит 16 кГц — то есть деление по факту /768, втрое больше. Значит… сам mclk_b втрое ниже, чем думает система: 4.096 МГц вместо 12.288 МГц. При этом clk_summary врёт — показывает 12.288 МГц, потому что реального регистра он не видит.
Смотрим MCLK_B_CTRL (0xFF642008) = 0x81000001: мультиплексор выбирает источник №1. По таблице MPLL в clk_summary: mpll1 = 8.192 МГц, а mpll3 = 24.576 МГц. Драйвер выбрал mpll1 (8.192) и поделил на 2 → 4.096 МГц. А надо было mpll3 (24.576) /2 → 12.288 МГц. То есть драйвер просто взял не тот PLL, ровно втрое ниже.
Фикс — одно значение в один регистр:
devmem 0xFF642008 32 0x83000001 # mux=3 (mpll3) /2 = 12.288 МГц
Проверили беззвучным замером: слив 480000 кадров ускорился с 30 до 10 секунд — ровно 48 кГц. Победа!
Шаг 5: усилитель мстит
Радовались рано. Проиграли реальный звук — тишина. Оказалось, усилитель acm8625 при смене частоты посреди потока ловит скачок LRCLK и сам себя мьютит (защита от клок-глича). На беззвучных замерах этого не было видно — там и так тишина.
А драйвер, зараза, переписывает MCLK_B при каждом открытии потока обратно на свой кривой mux. Классический тупик: правим регистр — драйвер его перетирает, правим посреди потока — усилитель глохнет.
Решение: маленький демон-«холдер», который через mmap /dev/mem держит правильное значение регистра в тугом цикле (~30 мкс). Он возвращает верный клок за микросекунды после того, как драйвер его перетёр — раньше, чем усилитель успевает размьютиться. Усилитель с самого начала видит стабильные 48 кГц и не глохнет.
Итог: полноценный чистый звук 48 кГц, полоса 12 кГц. Правка живёт только в RAM — перезагрузка всё возвращает, флеш не трогаем. Идеально ложится в моё «не убить колонку».
Почему так вообще вышло? Судя по всему, правильную настройку аудио-клока ставил родной init Яндекса, который я вычистил. Без него драйвер падает в дефолт с кривым mux.
Как впихнуть 157 МБ в 240 МБ без swap
Собрали всё вместе — и ассистент стал падать на загрузке модели. Killed. Классический OOM: модель Vosk на пике загрузки хочет ~157 МБ, а свободно было ~130. Без swap ядро просто прибивает процесс.
Ещё отдельно выяснилось, что fork() из такого большого процесса тоже отваливается по памяти (ENOMEM) — а ассистент форкает хелперы. Симптом был коварный: хелпер «не запускался» вообще без следов, потому что падал сам fork.
Оба лечатся двумя строчками:
echo 1 > /proc/sys/vm/overcommit_memory # fork проходит, дети сразу делают exec# + zram-swap: сжатый swap прямо в RAM, без износа флешаecho 160M > /sys/block/zram0/disksize; mkswap /dev/zram0; swapon /dev/zram0
# + zram-swap: сжатый swap прямо в RAM, без износа флеша
echo 160M > /sys/block/zram0/disksize; mkswap /dev/zram0; swapon /dev/zram0
Файловый swap на UBIFS не работает (нет поддержки swap-файлов), а вот zram в ядре был (CONFIG_ZRAM=y). Он даёт сжатый swap-блок прямо в оперативке — модель влезла, 15 МБ сжались до 6, пик загрузки разгладился. Износа флеша ноль.
Диалоговый режим и мелочи UX
Дальше пошла доводка под живое общение:
Мгновенная реакция. Вейк «Клод» ловится в partial-результатах Vosk — кольцо вспыхивает сразу, ещё пока договариваешь, а не после паузы.
Сессия. После ответа колонка ещё 5 секунд слушает продолжение без повторного «Клод». Задал вопрос → услышал ответ → сразу спросил ещё. Промолчал 5 секунд — сессия закрывается. Тут был тонкий баг: фоновый шум давал Vosk «пустые финалы», на которых случайно сбрасывался таймер, и сессия не закрывалась никогда. Лечится: не продлевать таймаут на пустых/коротких результатах.
Кнопки громкости. Родного обработчика больше нет, поэтому написали свой: читаем события тач-контроллера (
/dev/input/event1, коды 114/115 — minus/plus), крутим ALSA Master. Заодно кольцо показывает уровень шкалой зелёный → красный. Код голосовой кнопки (582) оставил на будущее.
Единый бинарь
Сначала всё жило модульно: отдельный оркестратор, холдер клока, демон громкости, плюс пара shell-скриптов-клея. Работало, но хотелось «один умный бинарь без сухих скриптов».
Свернули всё в один процесс klod (на C, armhf, линкует libvosk). Внутри — потоки:
главный:
arecord(поднимает сам) → Vosk → машина состояний (IDLE → SESSION);поток кольца (LED-анимации + оверлей громкости);
поток холдера клока (держит клок во время воспроизведения);
поток кнопок громкости.
Мозг и TTS остались крошечными helper-бинарями (из-за TLS в Rust), но klod вызывает их напрямую через fork/exec, без шелла. Запуск — одна команда.
Пара нюансов threading+fork, на которых можно обжечься: setenv не async-signal-safe, поэтому переменные окружения ставлю один раз в родителе до создания потоков (дети наследуют), а в форкнутых детях — только dup2/execv. И да, pkill на busybox работал ненадёжно — процессы убивал перебором /proc/*/comm.
Прод: автозапуск и живучесть
Финал — чтобы всё поднималось само. Init-скрипты в /etc/init.d/:
S02zram— zram-swap + overcommit;S99klod— сам ассистент.
Проверили честным ребутом: uptime маленький (значит реально перезагрузилась), и через ~35 секунд после включения колонка сама подняла ассистента → микрофон → громкость → zram. Полностью автономная железка.
Единственное честное ограничение: мозг и голос — облачные, поэтому без интернета колонка молчит (она даже «нет связи» произнести не может — TTS тоже в облаке). Как-то ночью у меня пропадал интернет, и наутро «Клод не отвечал» — ровно по этой причине. Вернулся интернет — всё заработало само. На очереди — локальный офлайн-клип «нет связи», чтобы вместо тишины было внятно.
Итог
Что в сухом остатке:
Яндекс.Станция Мини 3 превращена в автономного голосового ассистента на Claude: вейк-ворд, распознавание, мозг, синтез речи, кольцо, кнопки — всё на самой колонке.
По пути: зачистка стокового софта, оффлайн-STT в 240 МБ RAM через zram, фикс звука на уровне регистров тактового генератора ЦАПа, диалоговый режим, единый бинарь, автозапуск с живучестью.
Всё — программно, с откатом через перезагрузку, без паяльника.
Самый сочный баг — конечно, тройное замедление звука: когда clk_summary врёт, а истину знает только голый регистр по /dev/mem. Отдельный кайф — что всё это отлаживалось «вслепую», по длительности воспроизведения, чтобы лишний раз не пугать себя демоническим голосом из колонки.
А ещё — вся эта работа сделана в паре с Claude Code. И это, пожалуй, главный сюжет: Claude Code шаг за шагом собрал своего младшего — маленького Claude, который теперь живёт в колонке, просыпается на «Клод» и отвечает голосом. Старший написал ему тело из C и Rust, починил ему слух и голос, впихнул мозг в 240 мегабайт и научил не глохнуть. Сын поселился в железке. По-моему, это лучший вид наследования.
Код, сборочные рецепты и init-скрипты (без ключей) выложил на GitHub: https://github.com/alexrosette/Yandex-Claude-Assistant
Совсем забыл сказать огромное спасибо сообществу Yandex Station Extended в телеграмме: @hackalice
А так же не забывайте про сайт: hackalice.me

