Привет, Хабр.
Меня зовут Роман, я основатель Somazone — платформы для сохранения голосовой памяти о близких людях. Пользователь загружает аудиозаписи, мы клонируем голос и создаём AI-агента, с которым можно общаться.
Звучит просто, да?
Два месяца без выходных. Рефакторинги в три часа ночи, когда глаза уже не фокусируются, но ты точно знаешь, что если не починишь сейчас — утром будет хуже. Прод падает в субботу вечером, когда ты наконец-то решил поужинать с семьёй. Баги, которые воспроизводятся только на проде и только у одного пользователя из Владивостока (почему Владивосток? мы так и не поняли). WebSocket-соединения, которые умирают по непонятным причинам. FFmpeg, который выжирает всю память на сервере и роняет всё вокруг.
Это была весёлая поездка. И я хочу поделиться граблями, на которые мы наступили, чтобы вы могли их обойти. Или хотя бы посмеяться над нашими страданиями.
Что мы вообще строим и зачем
Прежде чем нырять в технические детали — контекст. Без него непонятно, зачем мы вообще во всё это ввязались.
У меня семь лет назад умер отец. Рак, хоспис, февраль в Питере. Остались его голосовые сообщения. Несколько минут на старом телефоне. Я их переслушивал и каждый раз думал: а что если можно не просто слушать, а поговорить? Услышать ответ?
Звучит как фантастика. Но технологии не стоят на месте.
Идея лежала в долгом ящике, пока AI не дозрел. В 2024-м он дозрел. Голосовые модели стали достаточно хорошими. Клонирование голоса из 15 секунд аудио — реальность. И мы решили: пора.
Основной флоу платформы:
Пользователь подключает Telegram → система автоматически извлекает все голосовые сообщения
Параллельно анализируем текстовые сообщения — манеру речи, любимые фразы, паттерны общения, характерные словечки
Обучаем голосовую модель (минимум 15 секунд аудио, оптимально — несколько минут)
Создаём AI-агента с личностью и манерой речи этого человека
Пользователь общается через текст, голос или видео-аватар
Казалось бы — что тут сложного? Взял API, прикрутил фронт, задеплоил. Стартап на выходные.
Ха-ха. Ха.
Реальность оказалась... другой.
Технологический стек
Начну со стека, потому что от него зависит контекст всех проблем. И потому что выбор технологий — это отдельное приключение.
Frontend
React 18.3 + TypeScript 5.6 + Vite 5.4
├── UI: shadcn/ui (Radix UI) + Tailwind CSS 3.4
├── Анимации: Framer Motion 11
├── Роутинг: Wouter 3.3
├── Состояние: TanStack Query 5.60
├── Формы: React Hook Form 7.55 + Zod 3.24
├── i18n: i18next 25.6 (5 языков)
└── PWA: Service Worker + Web Push (VAPID)
Почему Wouter вместо React Router?
О, этот холивар. React Router — индустриальный стандарт, все его знают, везде документация. Но.
Раз��ер бандла. Wouter — 2KB gzipped. React Router v6 — 20KB+. «Подумаешь, 18KB разницы» — скажете вы. А я скажу: наше приложение — PWA. Оно должно грузиться на мобильном интернете в метро. На старом телефоне бабушки, которая хочет услышать голос ушедшего мужа. Каждый килобайт на счету.
Да, у Wouter меньше фич. Нет встроенных лоадеров, нет defer, нет всякой магии. Но знаете что? Нам они и не нужны. YAGNI в чистом виде. Мы пишем стартап, не enterprise-монстра.
Backend
Node.js 20 + Express 4.21 + TypeScript
├── ORM: Drizzle 0.39 (PostgreSQL)
├── БД: PostgreSQL (Neon, serverless)
├── Сессии: express-session + connect-pg-simple
├── WebSocket: ws 8.18
├── Файлы: multer 2.0
├── Планировщик: node-cron 4.2
└── Rate Limiting: express-rate-limit 8.2
Почему Drizzle, а не Prisma?
О, это отдельная история боли и прозрения.
Мы начинали с Prisma. Как все нормальные люди в 2024-м. Типобезопасность, красивые миграции, Prisma Studio для дебага. Всё было прекрасно на локалке.
Потом мы задеплоили.
Cold start на serverless — 3-5 секунд. Пользователь открывает приложение, делает первый запрос, и... ждёт. Смотрит на спиннер. Думает, что всё сломалось. Уходит.
Почему? Prisma генерирует жирнющий клиент. Query engine написан на Rust и компилируется в отдельный бинарник. При холодном старте этот бинарник нужно загрузить и инициализировать. На serverless это происходит каждый раз, когда функция «остывает».
Мы провели выходные за профилированием и поняли: надо что-то менять.
Перешли на Drizzle. Cold start упал до 500ms. Типобезопасность та же (даже лучше — Drizzle ближе к SQL). Миграции генерируются из TypeScript-схем. Runtime overhead минимальный — это просто обёртка над SQL.
Минус один: документация местами... как бы это сказать... creative. «�� давайте напишем пример, но не объясним, почему он работает». Stack Overflow и чтение исходников спасают.
AI и ML
Вот тут начинается самое интересное.
На самом старте, когда нужно было быстро проверить гипотезу (будут ли люди вообще этим пользоваться?), мы использовали ElevenLabs. Их Instant Voice Cloning позволил за день собрать прототип и показать первым пользователям.
Реакция: «Вау, это реально работает! Это его голос!»
Гипотеза подтверждена. Люди хотят этот продукт.
Но сидеть на чужом API — это risk management nightmare:
Цены могут измениться (и они меняются — привет, OpenAI)
Latency зависит от загрузки их серверов
Сервис может лечь в самый неподходящий момент (Murphy's Law в действии)
Мы в России — санкционные риски никто не отменял
Поэтому параллельно с развитием прод��кта мы строили собственную ML-инфраструктуру.
Сейчас обучаем собственные модели на базе Coqui TTS XTTS v2:
Fine-tuning на голосовых данных каждого пользователя
Inference на собственных GPU (арендуем у Selectel)
Полный контроль над качеством, стоимостью и доступностью
Для текстовой части — собственная дообученная модель для анализа паттернов речи и генерации ответов. Это отдельная магия: нужно не просто генерировать текст, а генерировать текст в стиле конкретного человека. С его словечками, паузами, манерой строить предложения. «Ну ты это... как его... молодец!» — должно звучать именно так, как говорил папа, а не как литературный критик.
Инфраструктура
├── Хостинг: Selectel (GPU-серверы для обучения моделей)
├── БД: Neon PostgreSQL (serverless)
├── Object Storage: Selectel S3-совместимое хранилище
├── Платежи: Robokassa
├── Email: Mailganer SMTP
└── CDN: Cloudflare
Почему Selectel?
Три причины:
GPU-инстансы — нам нужны карточки для обучения и inference голосовых моделей. Не всё можно делать на CPU.
S3-совместимое хранилище — терабайты аудио надо где-то хранить. Причём хранить надёжно — это не логи, которые можно потерять. Это голоса людей, которых больше нет.
Данные в России — 152-ФЗ, персональные данные, вот это всё. Да и просто — меньше latency для российских пользователей.
Проблема #1: Race Conditions при создании агентов
Как мы это обнаружили
Утро понедельника. Кофе. Открываю админку посмотреть, как там наши пользователи. Смотрю на базу данных.
В таблице ai_agents — бардак. У одного пользов��теля три агента с одинаковым external_id. У другого — пять. У третьего — два, и оба «активные».
Что за чертовщина?!
Первая мысль: нас хакнули. Вторая: баг в коде. Третья (правильная): race condition.
Что происходило
Давайте разберёмся по шагам.
Пользователь открывает страницу чата. React-компонент монтируется. В useEffect отправляем WebSocket-запрос: «Эй, сервер, дай мне агента для этого профиля. Если нет — создай».
Всё нормально, да?
Нет.
Сценарий 1: Пользователь открывает страницу, потом быстро переключается на другую вкладку, потом возвращается. React компонент размонтируется и монтируется заново. Ещё один запрос.
Сценарий 2: Пользователь открывает чат на телефоне. Потом на компьютере. Два устройства, два запроса, одновременно.
Сценарий 3: Медленный интернет. Первый запрос висит 5 секунд. Пользователь думает «не работает», обновляет страницу. Второй запрос.
И вот они оба приходят на сервер. Оба проверяют: «А есть ли уже агент?» Нету. Оба создают.
В базе — два агента для одного профиля. Дубликат.
Классический race condition. Первая глава учебника по конкурентному программированию. И мы на неё наступили.
Первая попытка (не сработала)
«Ну это же просто! Добавим проверку!»
// Наивный подход (НЕ ДЕЛАЙТЕ ТАК)
async function getOrCreateAgent(profileId: string) {
// Проверяем, есть ли уже
const existing = await db.query.aiAgents.findFirst({
where: eq(aiAgents.profileId, profileId)
});
if (existing) {
return existing;
}
// Нету — создаём
const newAgent = await createAgentInExternalService(profileId);
await db.insert(aiAgents).values({
profileId,
externalId: newAgent.id
});
return newAgent;
}
Видите проблему?
Между findFirst и insert проходит время. Миллисекунды, но этого достаточно. Второй запрос успевает тоже выполнить findFirst, тоже не найти агента, тоже создать.
Это называется «check-then-act» антипаттерн. И он не работает в конкурентной среде. Никогда.
Решение
База данных умнее нас. Пусть она следит за уникальностью.
Шаг 1: Уникальный индекс
CREATE UNIQUE INDEX idx_ai_agents_profile_id
ON ai_agents(profile_id);
Теперь PostgreSQL физически не позволит вставить два агента с одинаковым profile_id.
Шаг 2: Upsert с обработкой конфликта
async function getOrCreateAgent(profileId: string) {
// Пытаемся вставить
const result = await db
.insert(aiAgents)
.values({
profileId,
externalId: generateTempId(), // Временный ID
status: 'creating'
})
.onConflictDoUpdate({
target: aiAgents.profileId,
set: { updatedAt: new Date() } // Прост�� обновляем timestamp
})
.returning();
const agent = result[0];
// Если агент уже существовал и готов — возвращаем
if (agent.status === 'ready') {
return agent;
}
// Если мы его только что создали — настраиваем
if (agent.status === 'creating') {
const externalAgent = await createAgentInExternalService(profileId);
await db.update(aiAgents)
.set({ externalId: externalAgent.id, status: 'ready' })
.where(eq(aiAgents.id, agent.id));
return { ...agent, externalId: externalAgent.id, status: 'ready' };
}
return agent;
}
Теперь всё атомарно. Два параллельных запроса? Один вставит запись, второй обновит timestamp. Дубликатов не будет.
Шаг 3: Очистка существующего бардака
-- Оставляем только первого агента для каждого профиля
DELETE FROM ai_agents a
WHERE a.id NOT IN (
SELECT MIN(id) FROM ai_agents
GROUP BY profile_id
);
Урок: Если что-то может произойти параллельно — оно произойдёт. Закон Мёрфи работает всегда. Никаких «check-then-act». Только атомарные операции на уровне БД.
Проблема #2: Fire-and-Forget, который убивает молча
Контекст
Обучение голосовой модели — не мгновенная операция. Нужно:
Скачать аудио из хранилища
Препроцессинг: нормализация, шумоподавление, нарезка на сегменты
Собственно обучение модели
Валидация результата
Сохранение модели в хранилище
Всё вместе — 30-60 секунд. Иногда дольше, если аудио много.
Пользователь нажимает кнопку «Создать голос». Что он видит? Спиннер. Минуту. Полторы.
Знаете, что делает нормальный человек, когда спиннер крутится больше 30 секунд?
Варианты:
Обновляет страницу («может, зависло?»)
Закрывает вкладку («потом попробую»)
Пишет в поддержку («у вас всё сломалось!»)
Уходит к конкурентам («фигня какая-то»)
Ни один из этих вариантов нам не нравится.
Антипаттерн
Вот как мы делали первые две недели (стыдно признаться, но честность важнее):
app.post('/api/profiles/:id/train', async (req, res) => {
try {
// Пользователь ждёт, пока это выполняется...
await trainVoiceModel(req.params.id); // 60+ секунд
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
Проблем тут целый вагон:
HTTP timeout. Cloudflare режет соединения через 100 секунд. Некоторые мобильные операторы — через 30. Запрос просто не успевает завершиться.
Пользовательский timeout. Даже если технически соединение живо — человек не будет ждать минуту. Он уйдёт.
Потеря контекста. Пользователь закрыл вкладку — для него запрос «отменился». А на сервере? А на сервере процесс продолжает работать. Или не продолжает — зависит от реализации. Неопределённость.
Никакого фидбека. Пользователь не знает, что происходит. 10% готово? 90%? Зависло? Работает? Тайна.
Правильный подход
Принцип простой: отвечаем НЕМЕДЛЕННО, обрабатываем АСИНХРОННО, информируем ПОСТОЯННО.
app.post('/api/profiles/:id/train', async (req, res) => {
const profileId = req.params.id;
// 1. Проверяем, что профиль существует и можно обучать
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, profileId)
});
if (!profile) {
return res.status(404).json({ error: 'Profile not found' });
}
if (profile.status === 'processing') {
return res.status(409).json({ error: 'Already training' });
}
// 2. Меняем статус
await db.update(profiles)
.set({ status: 'processing', progress: 0 })
.where(eq(profiles.id, profileId));
// 3. СРАЗУ отвечаем клиенту
res.json({
status: 'processing',
message: 'Training started',
estimatedTime: '1-2 minutes'
});
// 4. Запускаем обработку в фоне (НЕ await!)
trainVoiceModelAsync(profileId);
});
Клиент получает ответ за 50ms. Может показать красивое сообщение «Обучаем модель, это займёт пару минут» и либо ждать, либо уйти — обучение всё равно завершится.
А теперь самое важное — обработка ошибок
Вот тут многие (и мы в том числе) делают фатальную ошибку:
// НЕПРАВИЛЬНО!
async function trainVoiceModelAsync(profileId: string) {
try {
await trainVoiceModel(profileId);
await db.update(profiles)
.set({ status: 'ready' })
.where(eq(profiles.id, profileId));
} catch (error) {
console.error('Training failed:', error);
throw error; // ОШИБКА! Не делайте так!
}
}
Видите throw error? Это убийца.
Эта функция выполняется вне контекста HTTP-запроса. Мы её запустили и забыли (fire-and-forget). Кто поймает этот throw?
Правильный ответ: никто. Ошибка улетит в глобальный обработчик uncaughtException. В лучшем случае — просто залогируется. В худшем — уронит весь процесс Node.js.
А пользователь? А пользователь навечно застрянет со статусом processing. Потому что мы не обновили его на error.
Правильная версия:
async function trainVoiceModelAsync(profileId: string) {
try {
// Обучаем с прогрессом
await trainVoiceModel(profileId, {
onProgress: async (percent) => {
await db.update(profiles)
.set({ progress: percent })
.where(eq(profiles.id, profileId));
}
});
// Успех!
await db.update(profiles)
.set({ status: 'ready', progress: 100 })
.where(eq(profiles.id, profileId));
} catch (error) {
// НИКАКОГО throw! Всё обрабатываем здесь.
console.error(`Training failed for profile ${profileId}:`, error);
// Пытаемся сохранить статус ошибки
try {
await db.update(profiles)
.set({
status: 'error',
errorMessage: error.message,
progress: 0
})
.where(eq(profiles.id, profileId));
} catch (updateError) {
// Даже если не удалось обновить статус — логируем и НЕ падаем
console.error('Failed to save error status:', updateError);
}
// Опционально: алерт в мониторинг
alertOps('Voice training failed', { profileId, error: error.message });
// НЕ пробрасываем ошибку дальше!
}
}
Урок: В fire-and-forget функциях ВСЕ ошибки обрабатываются внутри. Никаких throw. Никаких rethrow. Всё ловим, всё логируем, всё сохраняем в статус.
Проблема #3: WebSocket умирает без объяснений
Симптомы
Пользователь разговаривает с AI-агентом голосом. Всё работает. Голос звучит, ответы приходят, красота.
Проходит 2-3 минуты.
Тишина. Соединение обрывается. Без ошибок. Без предупреждений. Просто — тишина.
Пользователь думает, что агент «задумался». Ждёт. Ещё ждёт. Потом понимает, что что-то не так. Обновляет страницу — работает. Через 3 минуты — опять обрывается.
Поддержка завалена тикетами: «Ваш бот зависает!», «Почему он перестаёт отвечать?!»
Расследование
Первая гипотеза: баг в коде. Проверили — нет, всё норм.
Вторая гипотеза: проблемы на клиенте. Попробовали на разных браузерах, разных устройствах — везде одинаково.
Третья гипотеза: что-то между клиентом и сервером.
Бинго.
Кто убивает WebSocket-соединения?
Угадайте с одного раза?
Ответ: ВСЕ.
Nginx (если используете как reverse proxy): дефолтный proxy_read_timeout — 60 секунд. Если за 60 секунд по соединению ничего не передаётся — Nginx его закрывает.
Cloudflare: таймаут на idle-соединения — 100 секунд.
AWS ALB: 60 секунд.
Мобильные операторы: когда захотят. Серьёзно. Некоторые режут соединения через 30 секунд, некоторые через 5 минут, некоторые рандомно.
Браузер: если решит, что вкладка неактивна — может «оптимизировать» соединение.
WebSocket-соединение, по которому ничего не передаётся, выглядит для всех этих прокси как «мёртвое» или «забытое». И они его «убирают». Для нашего же блага, разумеется.
Решение — Heartbeat
Идея простая: нужно постоянно показывать всем этим умникам в середине, что соединение живое. Пинг-понг каждые 30 секунд:
lass RobustWebSocket {
private ws: WebSocket | null = null;
private url: string;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private baseDelay = 1000;
constructor(url: string) {
this.url = url;
}
connect() {
console.log('Connecting to WebSocket...');
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.reconnectAttempts = 0; // Сбрасываем счётчик при успех��
this.onConnected?.();
};
this.ws.onmessage = (event) => {
this.onMessage?.(event.data);
};
this.ws.onclose = (event) => {
console.log(`WebSocket closed: ${event.code} ${event.reason}`);
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
// onclose вызовется автоматически после onerror
};
}
private scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnect attempts reached, giving up');
this.onMaxRetriesExceeded?.();
return;
}
// Экспоненциальный backoff: 1s, 2s, 4s, 8s, ...
const delay = this.baseDelay * Math.pow(2, this.reconnectAttempts);
const jitter = Math.random() * 1000; // Немного рандома, чтобы все клиенты не реконнектились одновременно
console.log(`Reconnecting in ${delay + jitter}ms (attempt ${this.reconnectAttempts + 1})`);
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay + jitter);
}
send(data: string) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(data);
} else {
console.warn('WebSocket not ready, message dropped');
}
}
// Callbacks
onConnected?: () => void;
onMessage?: (data: string) => void;
onMaxRetriesExceeded?: () => void;
}
На клиенте — автоматическое переподключение с экспоненциальным backoff:
class RobustWebSocket {
private ws: WebSocket | null = null;
private url: string;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private baseDelay = 1000;
constructor(url: string) {
this.url = url;
}
connect() {
console.log('Connecting to WebSocket...');
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.reconnectAttempts = 0; // Сбрасываем счётчик при успехе
this.onConnected?.();
};
this.ws.onmessage = (event) => {
this.onMessage?.(event.data);
};
this.ws.onclose = (event) => {
console.log(`WebSocket closed: ${event.code} ${event.reason}`);
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
// onclose вызовется автоматически после onerror
};
}
private scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnect attempts reached, giving up');
this.onMaxRetriesExceeded?.();
return;
}
// Экспоненциальный backoff: 1s, 2s, 4s, 8s, ...
const delay = this.baseDelay * Math.pow(2, this.reconnectAttempts);
const jitter = Math.random() * 1000; // Немного рандома, чтобы все клиенты не реконнектились одновременно
console.log(`Reconnecting in ${delay + jitter}ms (attempt ${this.reconnectAttempts + 1})`);
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay + jitter);
}
send(data: string) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(data);
} else {
console.warn('WebSocket not ready, message dropped');
}
}
// Callbacks
onConnected?: () => void;
onMessage?: (data: string) => void;
onMaxRetriesExceeded?: () => void;
}
После этих изменений — ни одного тикета про «зависающего бота».
Урок: WebSocket без heartbeat — это не production-ready решение. Никогда. Даже если «у меня на локалке работает». Особенно если у вас на локалке работает.
Проблема #4: Пользователи загружают мусор
Контекст
Наша модель учится на аудио, которое загружает пользователь. Это не просто входные данные — это фундамент всего. Качество входных данных определяет качество модели.
Garbage in — garbage out. Железное правило ML.
Что загружают пользователи?
О, это отдельная песня. Мы видели всё:
Голосовые из WhatsApp, сжатые до неузнаваемости. WhatsApp использует Opus с агрессивным битрейтом. Голос превращается в кашу.
Записи с музыкой на фоне. «Ну там немного музыка играла, но голос же слышно!» Модель: «О, отлично, научусь петь!»
Аудио из машины. Шум двигателя, шум дороги, шум радио. Голос где-то на фоне.
Тишина с редкими словами. Минута записи, из них 50 секунд — тишина. 10 секунд — «Ага... Угу... Понятно...»
Записи телефонных звонков. Половина — собеседник. Модель путается, чей голос учить.
Записи с эхо. Ванная комната — отличное место для записи голосовых, да?
На таком модель обучается генерировать... ну, что-то. Шум. Бульканье. «Голос как из бочки», как написал один пользователь в отзыве.
Решение — Smart Audio Quality Filtering
Прежде чем отправлять аудио на обучение, мы его тщательно анализируем:
interface AudioQualityMetrics {
duration: number; // Длительность в секундах
loudness: number; // Громкость в LUFS (стандарт вещания)
snr: number; // Signal-to-Noise Ratio в dB
speechRatio: number; // Доля речи vs тишина/шум/музыка
clippingRatio: number; // Доля клиппинга (перегрузки)
sampleRate: number; // Частота дискретизации
bitrate: number; // Битрейт (если сжатый формат)
}
function evaluateQuality(metrics: AudioQualityMetrics): QualityVerdict {
const dominated by problems: string[] = [];
let score = 100;
// Проверка длительности
if (metrics.duration < 5) {
problems.push('Too short (min 5 seconds)');
score -= 50;
}
// Проверка громкости
if (metrics.loudness < -35) {
problems.push('Too quiet');
score -= 20;
} else if (metrics.loudness > -5) {
problems.push('Distorted (too loud)');
score -= 30;
}
// Проверка SNR
if (metrics.snr < 10) {
problems.push('Too much background noise');
score -= 40;
} else if (metrics.snr < 15) {
problems.push('Noticeable background noise');
score -= 15;
}
// Проверка доли речи
if (metrics.speechRatio < 0.5) {
problems.push('Not enough speech (mostly silence/noise)');
score -= 30;
} else if (metrics.speechRatio < 0.7) {
problems.push('Some non-speech segments');
score -= 10;
}
// Проверка клиппинга
if (metrics.clippingRatio > 0.05) {
problems.push('Audio is clipping (distorted)');
score -= 25;
}
return {
score: Math.max(0, score),
problems,
acceptable: score >= 60,
recommendation: score < 60
? 'Please record in a quiet environment, speak clearly, hold phone 20-30cm from mouth'
: null
};
}
Анализ делаем через FFmpeg — он умеет вытащить всё что нужно:
async function analyzeAudio(filePath: string): Promise<AudioQualityMetrics> {
// Получаем базовую информацию
const probeResult = await execAsync(
`ffprobe -v error -show_format -show_streams -of json "${filePath}"`
);
const probe = JSON.parse(probeResult.stdout);
// Анализируем громкость (loudnorm фильтр)
const loudnessResult = await execAsync(
`ffmpeg -i "${filePath}" -af "loudnorm=print_format=json" -f null - 2>&1 | grep -A20 "input_"`
);
const loudness = parseLoudnessOutput(loudnessResult.stdout);
// Детектируем тишину
const silenceResult = await execAsync(
`ffmpeg -i "${filePath}" -af "silencedetect=noise=-30dB:duration=0.3" -f null - 2>&1`
);
const silenceSegments = parseSilenceOutput(silenceResult.stdout);
// Вычисляем долю речи
const totalDuration = parseFloat(probe.format.duration);
const silenceDuration = silenceSegments.reduce((sum, seg) => sum + seg.duration, 0);
const speechRatio = 1 - (silenceDuration / totalDuration);
// Детектируем клиппинг
const clipResult = await execAsync(
`ffmpeg -i "${filePath}" -af "astats=metadata=1:reset=1" -f null - 2>&1 | grep "Flat_factor"`
);
const clippingRatio = parseClippingOutput(clipResult.stdout);
return {
duration: totalDuration,
loudness: loudness.input_i,
snr: estimateSNR(loudness),
speechRatio,
clippingRatio,
sampleRate: parseInt(probe.streams[0].sample_rate),
bitrate: parseInt(probe.format.bit_rate)
};
}
Если файл не проходит проверку — показываем пользователю понятное сообщение:
Качество записи недостаточное
Обнаружены проблемы:
Слишком много фонового шума
Мало речи (в основном тишина)
Рекомендации:
Запишите в тихом помещении
Держите телефон на расстоянии 20-30 см от лица
Говорите чётко и естественно
Длительность записи: минимум 15 секунд, оптимально 1-3 минуты
Если файл проходит частично — вырезаем хорошие куски и используем только их.
Урок: Пользователи не виноваты, что загружают плохие файлы. Они не аудиоинженеры. Они хотят сохранить голос мамы, а не разбираться в децибелах и SNR. Наша задача — либо сделать так, чтобы система работала с тем, что есть, либо внятно объяснить, что нужно исправить.
Проблема #5: /tmp переполняется
Как это выглядело
Прод работает неделю. Всё стабильно. Метрики в норме. Пользователи довольны.
Потом начинаются странности. Транскрипция падает с ошибкой. Смотрим логи:
Error: ENOSPC: no space left on device, write
Нет места на диске?! У нас же 50GB!
Захожу на сервер:
$ df -h
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 50G 50G 0 100% /
Что?!
$ du -sh /tmp
47G /tmp
А, вот оно что.
Почему так получилось
Несколько наших сервисов работают с аудиофайлами. Whisper API для транскрипции. FFmpeg для анализа. Всё это требует файлов, не буферов.
И мы писали код в стиле:
// Быстро написали, работает, погнали дальше
async function transcribe(audioBuffer: Buffer): Promise<string> {
const tempPath = `/tmp/audio-${Date.now()}.webm`;
fs.writeFileSync(tempPath, audioBuffer);
const result = await whisperApi.transcribe(tempPath);
return result.text;
// ... файл остался на диске
}
При успешном выполнении — файл оставался. При ошибке — файл оставался. При таймауте — файл оставался.
Тысячи запросов в день. Средний размер файла — 500KB.
1000 запросов × 500KB = 500MB в день.
За неделю — 3.5GB мусора. За месяц... ну вы поняли.
Решение
Вариант 1: try/finally (обязательно)
async function transcribe(audioBuffer: Buffer): Promise<string> {
// Добавляем случайный суффикс для уникальности
const tempPath = path.join(
'/tmp',
`audio-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.webm`
);
fs.writeFileSync(tempPath, audioBuffer);
try {
const result = await whisperApi.transcribe(tempPath);
return result.text;
} finally {
// finally выполнится ВСЕГДА:
// - при успешном return
// - при throw
// - при таймауте (если промис реджектится)
try {
fs.unlinkSync(tempPath);
} catch (unlinkError) {
// Не смогли удалить? Логируем, но не падаем
console.warn(`Failed to delete temp file: ${tempPath}`, unlinkError);
}
}
}
Вариант 2: Cron-очистка (страховка)
Даже с try/finally что-то может пойти не так. Процесс может упасть между созданием файла и его удалением. Поэтому добавляем страховочную очистку:
import cron from 'node-cron';
// Каждый час чистим файлы старше часа
cron.schedule('0 * * * *', async () => {
const tmpDir = '/tmp';
const maxAge = 60 * 60 * 1000; // 1 час в миллисекундах
const prefixes = ['audio-', 'voice-', 'transcript-']; // Наши префиксы
let cleaned = 0;
let freed = 0;
for (const file of fs.readdirSync(tmpDir)) {
// Проверяем, наш ли это файл
if (!prefixes.some(p => file.startsWith(p))) continue;
const filePath = path.join(tmpDir, file);
try {
const stat = fs.statSync(filePath);
if (Date.now() - stat.mtimeMs > maxAge) {
fs.unlinkSync(filePath);
cleaned++;
freed += stat.size;
}
} catch (error) {
// Файл уже удалён или нет доступа — игнорируем
}
}
if (cleaned > 0) {
console.log(`Temp cleanup: removed ${cleaned} files, freed ${(freed / 1024 / 1024).toFixed(1)}MB`);
}
});
Вариант 3: Мониторинг (чтобы не проспать)
// Проверяем свободное место каждые 5 минут
cron.schedule('*/5 * * * *', async () => {
const { stdout } = await execAsync('df -h / | tail -1');
const usagePercent = parseInt(stdout.match(/(\d+)%/)?.[1] || '0');
if (usagePercent > 80) {
alertOps('Disk usage warning', { usagePercent });
}
if (usagePercent > 95) {
alertOps('CRITICAL: Disk almost full!', { usagePercent });
}
});
Урок: Временные файлы — бомба замедленного действия. Они накапливаются незаметно и взрываются в самый неподходящий момент. try/finally — обязательно. Cron-очистка — страховка. Мониторинг — чтобы не проснуться от звонка angry пользователя.
Оставшиеся проблемы (кратко)
Статья и так получилась длинной, поэтому остальные проблемы опишу покороче. Если интересны детали — спрашивайте в комментариях.
Проблема #6: Robokassa не дожидается ответа
Webhook должен ответить за 30 секунд, иначе платёж считается неуспешным. Наша логика активации занимала 10-15 секунд.
Решение: Отвечаем OK{InvId} сразу, до обработки. Всю логику — в fire-and-forget с правильной обработкой ошибок.
Проблема #7: React Query не инвалидирует кэш
После мутации данные в UI не обновлялись. Оказалось, что ключи кэша были неконсистентными: где-то ['/api/profiles', id], где-то ['/api/profiles/${id}'].
Решение: Единый формат ключей по всему проекту. Полный URL как первый (и единственный) элемент массива.
Проблема #8: Один Multer на все случаи жизни
Один multer instance для аудио, картинок и документов — каша с валидацией.
Решение: Фабрика multer'ов. Разные upload'ы для разных типов файлов.
Проблема #9: Autoplay не работает
Браузеры блокируют автовоспроизведение аудио без действия пользователя.
Решение: Либо не автовоспроизводить (кнопка Play), либо «разблокировать» аудио при первом любом взаимодействии с страницей.
Проблема #10: FFmpeg съедает сервер
500 параллельных FFmpeg-процессов → OOM → прощай, прод.
Решение: p-limit для контролируемого параллелизма. Максимум 10 одновременных процессов.
Проблема #11: Telegram банит за спам
Массовая загрузка файлов → FLOOD_WAIT_3600 → бан на час.
Решение: Bottleneck для rate limiting с экспоненциальным backoff при получении FLOOD_WAIT.
Проблема #12: Видео не успевает сгенерироваться
Генерация видео-аватара — 20-30 секунд. Пользователь не ждёт столько.
Решение: Каскадный fallback с таймаутами. Видео не готово за 30 сек? Отдаём голос. Голос не готов? Отдаём текст.
Выводы
За два месяца мы прошли путь от идеи до работающего продукта. Наступили на десятки граблей. Спали по 4 часа. Переписывали архитектуру на ходу. Чинили проды в выходные.
Но запустились. И оно работает.
Главные уроки:
Race conditions случаются всегда. Используйте атомарные операции на уровне БД.
Fire-and-forget требует дисциплины. Никаких throw — все ошибки обрабатывайте внутри.
WebSocket без heartbeat — не production. Соединения будут умирать.
Параллелизм контролируйте. Promise.all на 500 элементов — DDoS на себя.
Webhook'и отвечайте мгновенно. Вся логика — потом.
Временные файлы накапливаются. try/finally и cron-очистка.
Graceful degradation — must have. Всегда имейте fallback.
Пользователи загружают мусор. Валидируйте и объясняйте.
Два месяца без выходных — реально. Если горишь идеей и знаешь, зачем это делаешь.
Если есть вопросы по архитектуре, конкретным решениям или хотите поспорить — велком в комментарии. Постараюсь ответить на всё.
Стек:
Frontend: React, TypeScript, Vite, Tailwind, shadcn/ui
Backend: Node.js, Express, Drizzle, PostgreSQL
ML: Собственные модели на базе Coqui TTS XTTS v2
Infra: Selectel (GPU + S3), Neon, Cloudflare
ganqqwerty
Два месяца - это и есть стартап за выходные.