javascript

Запустил AI-репетитор английского месяц назад: технические грабли соло-дева

  • среда, 13 мая 2026 г. в 00:00:16
https://habr.com/ru/articles/1033992/

Я соло-делаю Speakwithai — AI-репетитор английского для русскоязычной аудитории. Месяц назад выкатил публично, за этот месяц получил 50 регистраций, 3 платящих и набор технических граблей, которые честнее разобрать, пока они свежие, а не через год по сглаженной памяти.

Это не история успеха — продукт ещё ничего не доказал. Это разбор конкретных инженерных решений, которые я бы хотел увидеть в чужом посте перед стартом.

Контекст

Что построил: web + Android-приложение, в котором пользователь голосом общается с AI-репетитором («Эмма»). Под капотом — real-time голосовая AI-модель для диалога и отдельная multimodal-модель для оценки произношения. Стек: NestJS на бэке, React на фронте, TypeORM + Postgres.

Цифры на сегодня (1 месяц в проде):

  • Зарегано: 50

  • Платящих: 3

  • MRR: ~3 тыс руб (тарифы 899 и 1799)

  • Дистрибуция: Pikabu (1185/4/0), TG-канал @speakwithai (175 подписчиков), TG mini-app демо

То есть классическая стадия, когда продукт работает, а маркетинг — нет. Дальше — про техническую часть, она интереснее.

Что построил: web + Android-приложение, в котором пользователь голосом общается с AI-репетитором («Эмма»). Под капотом — real-time голосовая AI-модель для диалога и отдельная multimodal-модель для оценки произношения. Стек: NestJS на бэке, React на фронте, TypeORM + Postgres.

Архитектура голоса: один пайплайн не подошёл

Real-time voice-модель стримит PCM-аудио по WebSocket в обе стороны: ты гонишь микрофон, тебе обратно приходит ответ модели. На десктопе это решается стандартно — Web Audio API, AudioWorklet, MediaStream. Я это собрал за пару дней, всё работало.

Потом открыл сайт на iPhone и обнаружил, что половина Web Audio там либо отсутствует, либо ведёт себя по-другому. iOS Safari не даёт AudioWorklet нормально работать в фоне, требует user gesture для unlock, AudioContext часто залипает в suspended.

Поразмыслив, я не стал переписывать рабочий PCM-путь под iOS, а сделал параллельный пайплайн через Media Source Extensions:

  • На iPhone сервер ре-инкейпсулирует PCM-стрим в fragmented MP4 на лету (ffmpeg)

  • Фронт скармливает фрагменты в ManagedMediaSource (iOS-вариант MSE)

  • Атрибут disableRemotePlayback обязателен, иначе iOS пытается прокинуть стрим на AirPlay

  • Web Speech API на iOS — no-op, поэтому транскрипт получаю серверный (модель отдаёт outputAudioTranscript параллельно с аудио)

Главный урок: для iOS-quirks делай параллельную ветку, а не пытайся унифицировать. У меня PCM-путь работает с десктопного дня один, и я к нему не возвращаюсь. iOS-ветка — отдельная история со своими граблями, но они не лезут в общий код.

Деплой получился даже интересный: основной бэк живёт на Railway (с Postgres), а параллельный сервис только для iPhone-ffmpeg-пути работает на Render — там удобнее с системным ffmpeg. Один и тот же Dockerfile из корня репозитория, разные команды старта.

Pronunciation pipeline: 503 на пустом месте

Кроме голоса в реальном времени есть отдельная фича — оценка произношения. Пользователь записывает фразу → отправляет на /api/pronunciation/assess → бэк зовёт multimodal-модель с аудио + текст эталона, парсит JSON-ответ с оценкой и подсветкой проблемных слогов.

Sentry начал регулярно показывать HeadersTimeoutError и TypeError: fetch failed из этого endpoint. Я добавил fallback-цепочку из трёх моделей одного провайдера (от более тяжёлой к более лёгкой), думая, что 503 в первой → fallback во вторую. На деле — нет.

Две причины:

  1. SDK провайдера под капотом использует undici fetch, у которого headersTimeout по умолчанию 5 минут. Если модель ушла в туман и не отвечает, один запрос блокирует поток на 5 минут до того, как мы вообще попробуем fallback.

  2. Catch-блок проверял регулярку только на UNAVAILABLE|503|overload|RESOURCE_EXHAUSTED|429HeadersTimeoutError и fetch failed сюда не попадали — exception разворачивался в 503 для юзера, fallback не срабатывал.

Починка — watchdog поверх каждого вызова:

const TIMEOUT_MS = 45_000;
const request = this.ai.generateContent({...});
const response = await Promise.race([
  request,
  new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error(`AI ${model} timeout`)), TIMEOUT_MS),
  ),
]);

И расширил регулярку транзиентности:

const transient =
  /UNAVAILABLE|503|overload|RESOURCE_EXHAUSTED|429|timeout|fetch failed|ECONN|socket hang up/i.test(msg) ||
  /UND_ERR|ETIMEDOUT|ECONN|EAI_AGAIN/i.test(code);

Worst case теперь — 135 секунд (3 модели × 45 сек) вместо «висит 5 минут и юзер видит 503». Урок: дефолтный undici headersTimeout — это 5 минут, и его нужно перебивать самому, потому что в production-fallback-цепочках это не работает.

Cost-телеметрия: счёт прилетел, источника не видно

Параллельно Sentry показал странные ивенты «модель отдала text-part вместо audio» в голосовом сервисе. С облака AI-провайдера в этот же период списали неприятную сумму. Подозрение было: модель иногда отдаёт текстовый парт вместо аудио, мы это игнорируем, но в токены провайдер это всё ещё считает. Без логирования usage metadata подтвердить было нельзя.

Прикрутил аккумуляторы:

interface UsageMetadata {
  promptTokenCount?: number;
  responseTokenCount?: number;
  thoughtsTokenCount?: number;
  promptTokensDetails?: { modality: string; tokenCount: number }[];
  responseTokensDetails?: { modality: string; tokenCount: number }[];
}

Накапливаю по сессии: usagePrompt, usageResponse, usageThoughts, разбивку по модальностям, а также text-part count и chars total. На close пишу одной строкой в лог: ID юзера, длительность сессии, токены по модальностям, был ли text-вместо-audio.

Через 2 недели данных можно будет сказать, есть ли реальная корреляция «text-part anomaly» с overcharge, или причина в другом. Урок: для платных AI-API всегда логируй usage metadata с первого дня, иначе будешь дебажить стоимость вслепую.

RuStore: отказ → миграция с TWA на Capacitor

Изначально мобильное Android-приложение было обычным TWA (Trusted Web Activity) — фактически нативный wrapper над PWA. Это самый простой способ выкатить web-продукт в Play Store.

В RuStore такой подход не прошёл модерацию. Их требование: приложение должно быть полноценным нативным, с собственными permissions, а не просто браузерным wrapper'ом. Не буду спорить, разумна ли их позиция — факт в том, что TWA пришлось убрать.

Перенёс на Capacitor. Компромисс: всё ещё React-фронт внутри WebView, но обёрнут так, что выглядит как настоящее нативное приложение, со своим AndroidManifest, permissions, intent-filters. Не самый чистый стек, но позволил сохранить кодовую базу фронта 1-в-1.

Грабли по дороге:

  • Bearer-token auth: на сайте session — httpOnly cookie, в Capacitor WebView таким не пользуешься (домен другой). Пришлось добавить отдельный header-based auth flow, чтобы Capacitor хранил token в native storage.

  • Deep links: пользователь жмёт в email на «сброс пароля» — должен вернуться в приложение, а не в браузер. Это решается App Links: autoVerify intent-filter в AndroidManifest на пути типа /reset-password и /payment-return, плюс .well-known/assetlinks.json на сайте. Capacitor appUrlOpen listener ловит URL и роутит внутрь SPA.

  • YooKassa payment-return: оплата открывается в Custom Tab, после успеха Custom Tab кидает на /payment-return → App Links перехватывает → Browser.close() → юзер уже в приложении и видит активную подписку.

Каждая из этих трёх вещей в TWA «просто работает», в Capacitor — отдельный кусок кода и тест.

Биллинг: календарный месяц — это анти-фича

Сначала я сделал лимиты по календарному месяцу: 40 минут голоса в месяц, сброс первого числа. Это казалось простым и привычным («как у всех»).

Через пару недель один из платящих сделал скриншот: 30/600 минут потрачено, «next reset 1-го числа». То есть он купил подписку 11-го, и через 20 дней ему «дадут полные» минуты, как будто только что заплатил.

Очевидно, что это бред. Надо привязывать к anniversary — заплатил 11 мая, следующий цикл начинается 11 июня, не 1 июня.

Рефакторинг получился чуть сложнее, чем казалось:

// было
const usage = await getOrCreateUsage(userId, startOfMonth(now));

// стало
const usage = await getOrCreateUsage(userId, sub.currentPeriodStart);

Плюс утилита addOneMonth() для апгрейда плана (вместо date-fns'овой endOfMonth, которая делала ровно то, что не нужно).

Урок: «по календарю» — это удобство для разработчика, не для пользователя. Если у тебя подписка с периодом — period start у пользователя должен быть датой его оплаты, не первым числом.

Маркетинг: 1185 показов, 4 клика, 0 регистраций

Технические грабли я могу решать неделями. Маркетинговые — провалил быстрее.

Первая попытка холодного трафика — статья на Pikabu в стиле «3-minute test для самопроверки английского». Сделал без хайпа, с UTM-меткой на блог.

Результат:

  • 1185 показов в ленте

  • 4 клика по UTM

  • 0 регистраций

Распределение работает (показы есть), а вот связка article → click и landing → signup — мёртвая. Параллельно в Telegram-боте есть бесплатное демо Эммы (mini-app с auto-demo-аккаунтами) — за неделю 3 человека его открыли.

Главный урок не в том, что Pikabu плохой канал. А в том, что продукт про голос, а статьи про голос — это разные продукты. Текст не передаёт магию того, как AI отвечает голосом и слышит твой акцент. Возможно, надо переходить на видео-форматы, где модальность канала совпадает с модальностью продукта. Это следующий эксперимент.

Чем закончу

Если ты делаешь что-то с голосовыми AI-моделями — главные вещи, на которые я бы потратил день в начале:

  1. Логирование usage-metadata из первого вызова (потом будет поздно)

  2. Watchdog поверх SDK-вызова провайдера (5 минут undici timeout — не шутка)

  3. Параллельные ветки для iOS вместо «универсального» web-кода

Если делаешь продукт для RU-рынка в 2026 — учитывай, что все западные Merchant-of-Record (Stripe, Paddle, LemonSqueezy и т.д.) блокируют residents РФ. Принимать оплаты от российских пользователей сейчас можно только через российский же шлюз (я использую YooKassa) и российское юрлицо или самозанятость.

Открытый вопрос для комментов: где брать холодный трафик в RU-нише языковых продуктов в 2026 при небольшом бюджете? Pikabu и Дзен дают показы без конверсии. Что реально работает на 1-3к подписчиков в месяц для соло-дева?


Speakwithai — приложение в RuStoreдемо в TG. 7 сессий и 40 минут голоса бесплатно, без карты.