惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Engineering at Meta
Engineering at Meta
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
C
Cyber Attacks, Cyber Crime and Cyber Security
A
Arctic Wolf
Help Net Security
Help Net Security
T
Threatpost
K
Kaspersky official blog
T
Threat Research - Cisco Blogs
C
CERT Recently Published Vulnerability Notes
T
The Exploit Database - CXSecurity.com
Stack Overflow Blog
Stack Overflow Blog
大猫的无限游戏
大猫的无限游戏
J
Java Code Geeks
B
Blog
Latest news
Latest news
爱范儿
爱范儿
G
Google Developers Blog
P
Privacy International News Feed
C
CXSECURITY Database RSS Feed - CXSecurity.com
S
Schneier on Security
H
Help Net Security
aimingoo的专栏
aimingoo的专栏
T
Tenable Blog
S
Securelist
博客园 - 【当耐特】
MongoDB | Blog
MongoDB | Blog
Last Week in AI
Last Week in AI
美团技术团队
P
Proofpoint News Feed
Cisco Talos Blog
Cisco Talos Blog
Know Your Adversary
Know Your Adversary
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Cyberwarzone
Cyberwarzone
C
Cisco Blogs
F
Fortinet All Blogs
L
Lohrmann on Cybersecurity
AWS News Blog
AWS News Blog
P
Privacy & Cybersecurity Law Blog
M
MIT News - Artificial intelligence
G
GRAHAM CLULEY
Simon Willison's Weblog
Simon Willison's Weblog
The Cloudflare Blog
The Register - Security
The Register - Security
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
GbyAI
GbyAI
V
Vulnerabilities – Threatpost
L
LINUX DO - 热门话题
V
Visual Studio Blog
I
InfoQ
阮一峰的网络日志
阮一峰的网络日志

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как я завёл нормальный голос в детское приложение, не разорившись и не заставив никого лезть в настройки
Egor Shar · 2026-06-18 · via Все публикации подряд на Хабре

Как я завёл нормальный голос в детское приложение, не разорившись и не заставив никого лезть в настройки

Средний

8 мин

9

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