Я в одиночку делаю Kalyaki — приложение, где дети учат английский через рисование: рисуют слова (cat, sun, house), оно распознаёт рисунок прямо на устройстве через ONNX-модель и отвечает голосом. Хотя «в одиночку» — это уже не совсем честно: с Claude Code, конечно, но заранее — нет, это не очередная статья как я стал 10х-инженером благодаря ИИ.
Сначала был робо-воис
В MVP голос был сделан максимально дёшево — системный TTS (expo-speech поверх голосов iOS/Android). Ноль инфраструктуры, работает офлайн. Для проверки гипотезы — самое то, и я ни разу не жалею, что начал так.
А потом пошли тесты, и почти все отзывы были про одно: «голос неприятный», «звучит как робот». И это правда — пока пользователь сам не зайдёт в системные настройки и не докачает «улучшенный» голос, TTS звучит как робот из нулевых. Для приложения, где ребёнок не читает, а слушает, это критично.
Я сделал честный костыль — модалку с инструкцией, как зайти и докачать нормальный голос. Угадайте, сколько людей это сделали. Короче, примерно никто. И это абсолютно ожидаемо: любой шаг, где надо выйти из приложения, полезть в настройки системы и что-то там скачать — это шаг, который не сделают. Тем более родитель, который включил приложение ребёнку на пять минут.
То есть вывод простой: хороший голос должен достаться всем сразу, без единого телодвижения со стороны юзера. И при этом — дёшево.
Дёшево — это не только про деньги
Под «дёшево» я держал в голове сразу четыре штуки:
Деньги. Нормальные TTS (я взял ElevenLabs) тарифицируются по символам. Дёргать API на каждое произнесение в проде — это счёт, который растёт вместе с тем, как дети залипают в приложении.
Задержка. Нарисовал кошку — ответ должен прилететь сразу, а не «подождите, я схожу в облако».
Офлайн. Должно говорить в самолёте и в метро.
Поддержка. Никаких «зайдите в настройки», мы это уже проходили.
И вот про деньги есть неприятный момент, который доходит не сразу: с рантайм-озвучкой самые залипающие дети становятся самыми дорогими. Чем больше ребёнок любит приложение и чем дольше в нём сидит — тем больше я плачу. Для детской аппки, где залипание это вообще-то цель, платить за залипание — так себе схема.
Озвучка — это ассеты, а не сервис
И тут до меня дошла простая вещь: всё, что приложение когда-либо скажет, известно заранее и лежит в коде. Это же обучалка, все реплики собираются из шаблонов:
// кусок enumerator'а (Node, на этапе сборки) export const RAW_VARIATIONS = { drawPrompt: ['Can you draw {ARTICLE} {WORD}?', "Let's draw {ARTICLE} {WORD}!", 'Draw {ARTICLE} {WORD} for me!'], success: ['Great job!', 'Amazing!', "You're a star!", 'Wonderful!'], voiceConfirmAsk: ['Can you say {WORD}?', 'Say {WORD} out loud!', 'Now you say {WORD}!'], badgeUnlocked: ['You earned the {WORD} badge!', 'Look — you got the {WORD} badge!'], // ...всего ~18 видов реплик };
И самая главная мысль: набор реплик конечный, значит это вообще не сервис, который надо гонять в рантайме — это данные, которые можно сгенерить один раз и закешировать навсегда. Не TTS в рантайме, а TTS на сборке. И вся экономика переворачивается: ElevenLabs я плачу один раз за весь словарь, а в проде раздаю готовые mp3 как обычную статику.
Сразу оговорка, к которой вернусь в конце: конечный набор — это и плюс, и обязательство. Добавил слово в каталог — молча расширил множество фраз, и вот уже есть реплика, у которой нет звука. То есть от конечности есть толк, только если ты можешь доказать, что покрыл её целиком. Но об этом ниже.
Даже артикли пришлось считать на генерации: a cat, an apple, исключения вроде an hour и a unicorn, плюс the для некоторых слов. Мелочь, пока таких фраз не тысячи.
Имя файла = хеш от всего, что влияет на звук
Каждую фразу превращаю в ключ. И ключ — это не просто текст, а отпечаток всего, что вообще влияет на звук:
// sha256(text | lang | voiceId | modelId | settings | audioVersion)[:12] export function hashFor(normalizedText, cfg) { const payload = [ normalizedText, cfg.lang, cfg.voiceId, cfg.modelId, JSON.stringify(cfg.settings), String(cfg.audioVersion), ].join('|'); return createHash('sha256').update(payload, 'utf8').digest('hex').slice(0, 12); }
Тот самый cfg, по которому считается хеш, — это просто конфиг голоса, и хеш собирается из текста фразы плюс вот этих полей:
export const TTS_CONFIG = { lang: 'en-US', voiceId: '21m00Tcm4TlvDq8ikWAM', // ElevenLabs "Rachel" modelId: 'eleven_multilingual_v2', settings: { stability: 0.5, similarity: 0.75, style: 0, speed: 0.9 }, audioVersion: 1, } as const;
То есть в хеш заложено всё, от чего зависит звучание: текст, язык, конкретный голос, модель, её настройки и audioVersion — мой ручной рубильник «перегенери всё», если захочу сменить что-то, чего в конфиге нет.
Эта мелкая чистая функция тащит сразу три роли: она и ключ кеша, и фикстура для тестов, и точка, в которой две реализации обязаны сойтись (про это дальше). Мне нравятся такие примитивы — когда одна штука закрывает несколько забот. И раз один хеш = один неизменный звук, можно раздавать с CDN как immutable навсегда, а смена голоса или бамп audioVersion сами прокручивают все хеши — старый кеш инвалидируется, руками чистить ничего не надо.
Три уровня, и офлайн почти бесплатно
Резолв — чистая функция, весь IO прокинут снаружи. И тут классный побочный эффект: офлайн — это не отдельная фича, а просто порядок фолбэков. Его не надо «прикручивать», он сам выпадает из того, в каком порядке ты перебираешь источники. Проверить офлайн = передать online: () => false одним аргументом, без сети и без телефона:
/** bundled → device-cache → download → null (дальше caller → expo-speech) */ export async function resolveAudio(text, d): Promise<Resolved> { const entry = d.lookup(text); if (!entry) return null; // не наша фраза if (entry.bundled) { const src = d.bundledUri(entry.hash); if (src != null) return { kind: 'bundled', source: src }; } if (await d.isCached(entry.hash)) return { kind: 'cache', source: d.cachedPath(entry.hash) }; if (await d.online()) { try { const uri = await d.download(d.cdnUrl(entry.hash), entry.hash); return { kind: 'download', source: uri }; } catch { return null; } } return null; // офлайн + нет в кеше }
По уровням:
Bundled — в приложение зашит только маленький набор общих статичных фраз, которые не зависят от слова и у всех звучат одинаково: «Great job!», похвала, переходы между раундами. Они есть мгновенно и офлайн с первого запуска. И это вообще единственное, что реально лежит в бандле.
Device cache — всё словесное (draw-prompt, подтверждения под конкретное слово) не зашито, оно докачивается после покупки пака при первом обращении в
documentDirectoryи дальше живёт там навсегда.Download — сама докачка идёт с Cloudflare Worker, который проксирует S3 и кешируется на эдже (
caches.default). Сгенерил раз — раздаю почти бесплатно. Воркеры это вообще моя любовь, если что.expo-speechкак предохранитель от тишины. Если не срослось вообще ничего (купленное слово ещё не докачалось + холодный офлайн) — приложение не молчит, а проговаривает старым системным голосом. Да, робот, но это редкий край, и лучше робот, чем тишина.
И префетч: как только пак куплен и ребёнок открывает слово, я в фоне подтягиваю сразу весь набор его фраз. Пока он дорисовывает — всё уже на устройстве, задержки нет. В вес приложения это ничего не добавляет, апка не пухнет от каждого нового пака.
Зачем все эти проверки, если я один
А вот теперь обещанная часть. И она не про «смотрите как быстро AI всё накодил». Скорость сама по себе — не достижение: скорость без рельсов это просто долг, который копится быстрее. Интереснее, что эту скорость делает устойчивой, когда за проектом один человек.
У соло-дева нет команды. Нет ревьюера, нет QA, нет коллеги, который через полгода вспомнит «слушай, ты же слово добавил — звук-то сгенерил?». А мой основной соавтор — агент, который не помнит мои инварианты между сессиями и не чувствует, когда что-то незаметно разъехалось. Поэтому главная опасность тут — не сломанный код, его компилятор поймает. Опасность в двух вещах: тихо разъехались две штуки, которые обязаны совпадать, и я что-то забыл (добавил слово — не озвучил).
Решение тупое: вынести каждый такой инвариант из головы в проверку, которая падает сама. То есть проверки в пайплайне работают за ту команду, которой у меня нет. Их получилось несколько разных, и полезно назвать их по именам:
Coverage (
generate-tts.mjs --audit): у каждой произносимой фразы есть звук, нет «сирот». Это тот самый ревьюер, который помнит «добавил слово — регенерь». Добавил в каталогoctopus, забыл сгенерить — сборка падает и прямо тычет носом:
$ node scripts/generate-tts.mjs --audit tts audit OK — 2285 phrases covered # а вот если забыл озвучить новое слово: $ node scripts/generate-tts.mjs --audit tts audit FAILED { "ok": false, "missing": ["drawPrompt-octopus-0", "voiceConfirmAsk-octopus-0", "rawWord-octopus"], "orphans": [], "missingFiles": [], "stale": [] }
Parity (тест с зашитым вектором): хеш считается дважды — в Node при генерации и в Worker (Web Crypto) при раздаче. Тест следит, чтобы обе реализации давали один и тот же хеш, иначе приложение бы просило файлы, которых нет. Туда же — тест, что шаблоны в Node-скрипте совпадают с шаблонами в самом приложении:
it('templates match voicePrompts.VARIATIONS exactly', () => { expect(RAW_VARIATIONS).toEqual(VARIATIONS); });
Staleness: манифест не должен отставать от кода. Для каждой фразы пересчитываю хеш заново и сверяю с тем, что записано в манифесте — подкрутил настройки голоса, а звук не перегенерил, значения разъехались, и сборка красная.
Determinism: проверяю, что хеш реально реагирует на каждый вход, который влияет на звук. Если я случайно выкину из хеша одну из осей, скажем
audioVersion, то бамп версии перестанет прокручивать кеш — и под старым именем поедет старый звук. Тест по очереди меняет голос и версию и следит, что хеш каждый раз другой.
it('changes when any config axis changes', () => { const base = hashFor('great job!', CFG); expect(hashFor('great job!', { ...CFG, voiceId: 'v2' })).not.toBe(base); expect(hashFor('great job!', { ...CFG, audioVersion: 2 })).not.toBe(base); });
Тут есть приятная асимметрия: audit бесплатный, не требует никаких секретов и крутится на каждом PR, а генерация стоит денег, требует ключей ElevenLabs и S3 и запускается руками изредка. То есть дешёвый и быстрый чек стоит на входе у дорогой и необратимой операции. Эту схему я теперь тащу вообще везде.
И та же логика, кстати, делает код удобным для агента. Я не могу дать ему настоящий айфон и сеть — значит цепочку резолва и офлайн надо написать так, чтобы её можно было прогнать целиком на чистых функциях. Ограничение «агент не запускает реальное приложение» само вытолкнуло меня к нормальной декомпозиции, что забавно.
И честно про разделение труда, чтобы не было ощущения рекламы: архитектура — контент-адресация, что бандлить, что докачивать — моя, и думал я над ней долго. А агент незаменим ровно там, где работа тупая, но ошибкоёмкая: перечислить тысячи комбинаций фраз, разгрести артикли с исключениями, побайтово повторить один и тот же хеш на двух языках (JS crypto и Web Crypto), обвязать всё тестами. Это та работа, на которой человек ошибается от усталости, а агент не устаёт.
Что в итоге
Поменял «бесплатный, но робот, да ещё и требующий действий от юзера» голос на «нормальный, мгновенный, офлайновый и почти бесплатный в проде». И сработало это из-за одной вещи: набор реплик конечный. Если выход системы конечен — кешируй его целиком и адресуй по контент-хешу. Качество облачного голоса не обязано тащить за собой облачную стоимость.
Но главный результат для меня — не сам голос, а то, что я заложил не фичу, а машину, которая даёт мне (одному, с агентом) расширять озвучку быстро и без страха:
Новые фразы без боязни не озвучить. Это и есть audit: страх убирается не силой воли, а красной сборкой. Забыл звук — CI не пустит, а не ребёнок услышит тишину.
Другие голоса — одной строкой.
voiceId/audioVersionв хеше: меняешь голос — все хеши прокручиваются, старый кеш инвалидируется сам, ничего не протухает.Возможность проверить каждое слово. Та же перечислимость, что дала предвычисление, означает, что я могу взять и просмотреть вообще все слова, которые приложение когда-либо скажет ребёнку. Для соло-родителя-разработчика в Kids-категории это не абстракция, а спокойный сон.
Если интересно, я веду дневник разработки Kalyaki в Telegram: @opg_dev. Там и про ML на устройстве, и про такие вот развязки, и про продуктовые грабли (и про эпопею с ревью в App Store, отдельная боль). Вопросы и критику по архитектуре закидывайте в комменты.