Я в одиночку делаю 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;                                              // офлайн + нет в кеше
}

По уровням:

  1. Bundled — в приложение зашит только маленький набор общих статичных фраз, которые не зависят от слова и у всех звучат одинаково: «Great job!», похвала, переходы между раундами. Они есть мгновенно и офлайн с первого запуска. И это вообще единственное, что реально лежит в бандле.

  2. Device cache — всё словесное (draw-prompt, подтверждения под конкретное слово) не зашито, оно докачивается после покупки пака при первом обращении в documentDirectory и дальше живёт там навсегда.

  3. Download — сама докачка идёт с Cloudflare Worker, который проксирует S3 и кешируется на эдже (caches.default). Сгенерил раз — раздаю почти бесплатно. Воркеры это вообще моя любовь, если что.

  4. 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, отдельная боль). Вопросы и критику по архитектуре закидывайте в комменты.

Комментарии (0)