|
Professor Seleznov
|
Я соло-делаю 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 на сайте. 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-моделями — главные вещи, на которые я бы потратил день в начале:
- Логирование 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 минут голоса бесплатно, без карты.-Источник
|