Я соло-делаю 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.
Цифры на сегодня (1 месяц в проде):
-
Зарегано: 50
-
Платящих: 3
-
MRR: ~3 тыс руб (тарифы 899 и 1799)
-
Дистрибуция: Pikabu (1185/4/0), TG-канал @speakwithai (175 подписчиков), TG mini-app демо
То есть классическая стадия, когда продукт работает, а маркетинг — нет. Дальше — про техническую часть, она интереснее.
Архитектура голоса: один пайплайн не подошёл
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 во вторую. На деле — нет.
Две причины:
-
SDK провайдера под капотом использует undici fetch, у которого
headersTimeoutпо умолчанию 5 минут. Если модель ушла в туман и не отвечает, один запрос блокирует поток на 5 минут до того, как мы вообще попробуем fallback. -
Catch-блок проверял регулярку только на
UNAVAILABLE|503|overload|RESOURCE_EXHAUSTED|429.HeadersTimeoutErrorи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на сайте. CapacitorappUrlOpenlistener ловит 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-моделями — главные вещи, на которые я бы потратил день в начале:
-
Логирование usage-metadata из первого вызова (потом будет поздно)
-
Watchdog поверх SDK-вызова провайдера (5 минут undici timeout — не шутка)
-
Параллельные ветки для iOS вместо «универсального» web-кода
Если делаешь продукт для RU-рынка в 2026 — учитывай, что все западные Merchant-of-Record (Stripe, Paddle, LemonSqueezy и т.д.) блокируют residents РФ. Принимать оплаты от российских пользователей сейчас можно только через российский же шлюз (я использую YooKassa) и российское юрлицо или самозанятость.
Открытый вопрос для комментов: где брать холодный трафик в RU-нише языковых продуктов в 2026 при небольшом бюджете? Pikabu и Дзен дают показы без конверсии. Что реально работает на 1-3к подписчиков в месяц для соло-дева?
Speakwithai — приложение в RuStore, демо в TG. 7 сессий и 40 минут голоса бесплатно, без карты.
ссылка на оригинал статьи https://habr.com/ru/articles/1033992/