Голосовой бот Telegram: один голос и три пресета
Когда у тебя есть Telegram-бот, который умеет озвучивать текст голосом — это уже круто. Но когда этот бот каждый раз спрашивает пользователя «какой голос выбрать?» из списка в двадцать позиций — это уже не продукт, а прототип. Именно с этой проблемы началась основная итерация разработки golosovoy-bot-v1.
Контекст проекта
Бот написан на TypeScript с использованием grammY — современного фреймворка для Telegram-ботов на Node.js. Для синтеза речи используется ElevenLabs API — один из лучших сервисов клонирования и генерации голоса на рынке. Аудио конвертируется из MP3 в OGG/Opus через ffmpeg, чтобы Telegram корректно воспроизводил голосовые сообщения.
Архитектура простая: src/elevenlabs.ts отвечает за работу с API, src/bot.ts — за логику бота, src/presets.ts — за настройки голоса. Деплой через Docker Compose на VPS, образ публикуется в GitHub Container Registry.
На старте бот загружал список всех доступных голосов из аккаунта ElevenLabs и строил из них inline-меню. Пользователь должен был выбрать голос, и только потом текст уходил на озвучку. Для внутреннего инструмента, где голос один и он уже выбран — это лишний шаг и лишняя точка отказа.
Проблема: слишком много выбора там, где выбора быть не должно
Главный запрос был простым: оставить один профессиональный голос и убрать меню выбора. Голос уже клонирован, уже проверен, уже нравится — зачем каждый раз предлагать альтернативы?
Технически это означало:
- Зафиксировать
voice_idв конфигурации через переменную окружения. - Убрать запрос списка голосов при каждом сообщении.
- Убрать inline-меню выбора голоса.
- Текст сразу отправлять на озвучку без промежуточных шагов.
Первый вариант решения — добавить обязательную переменную ELEVENLABS_VOICE_ID в .env и использовать её напрямую. Никакого динамического запроса списка голосов, никакого меню.
// До: загружаем все голоса и строим меню
const voices = await elevenlabs.getVoices();
const professionalVoices = voices.filter(v => v.category === 'professional');
// ... строим inline keyboard
// После: берём голос из конфига и сразу озвучиваем
const voiceId = env.ELEVENLABS_VOICE_ID; // фиксированный ID
await synthesize(text, voiceId, preset);Это сразу убрало целый класс проблем: больше не нужно кешировать список голосов, не нужно обрабатывать callback от кнопок выбора, не нужно думать о том, что будет если список голосов изменится в аккаунте ElevenLabs.
Настройки голоса и переход на Eleven v3
Как только убрали меню выбора, сразу встал следующий вопрос: а какие параметры голоса использовать? ElevenLabs предоставляет несколько числовых настроек:
| Параметр | Диапазон | Описание |
|---|---|---|
stability | 0–1 | Стабильность голоса. Ниже — больше эмоций, выше — ровнее |
similarity_boost | 0–1 | Сходство с оригинальным клоном |
style | 0–1 | Выразительность и характерная манера |
speed | 0.7–1.2 | Скорость речи |
Изначально бот работал на модели eleven_multilingual_v2 с базовыми настройками:
{
"model_id": "eleven_multilingual_v2",
"voice_settings": {
"stability": 0.5,
"similarity_boost": 0.75,
"style": 0,
"speed": 1.0
}
}Переход на Eleven v3 был продиктован желанием получить более живую интонацию и доступ к Audio Tags — специальным тегам, которые позволяют управлять эмоциями прямо в тексте:
[calmly] Добрый день.
[excited] Мы запустили проект!
[curious] Подскажи, получается?
[emphasizes] Смотри, это важно.Переключение модели делается одной строкой в конфиге:
elevenLabsModel: env.ELEVENLABS_MODEL?.trim() || "eleven_v3"Однако здесь возникла неожиданная проблема: в .env на сервере осталась старая переменная ELEVENLABS_MODEL=eleven_multilingual_v2, которая переопределяла дефолтное значение. В результате Audio Tags отправлялись в модель v2, которая не умеет их интерпретировать и просто произносила [curious] вслух как обычный текст. Решение — сделать модель принудительной в коде, не позволяя переменной окружения откатить её на старую версию.
Система пресетов: четыре стиля подачи
Когда базовая озвучка заработала, появился следующий запрос: дать пользователю выбор стиля подачи, но не через список голосов, а через готовые пресеты. Это принципиально другой UX — вместо «какой голос?» спрашиваем «как подать этот текст?».
Был создан отдельный модуль src/presets.ts с четырьмя пресетами:
export const PRESETS = {
business: {
label: '💼 Деловой',
stability: 0.5,
similarity_boost: 0.85,
style: 0.1,
speed: 1.05,
},
natural: {
label: '🗣 Естественный',
stability: 0.5,
similarity_boost: 0.8,
style: 0.15,
speed: 1.0,
},
calm: {
label: '😌 Спокойный',
stability: 0.5,
similarity_boost: 0.9,
style: 0,
speed: 0.82,
},
emotional: {
label: '🔥 Эмоциональный',
stability: 0.5,
similarity_boost: 0.75,
style: 0.35,
speed: 1.05,
},
};Первая версия пресетов «Деловой» и «Спокойный» оказалась слишком похожей — разница в звучании была почти незаметна. Пришлось развести их сильнее: деловой получил скорость 1.05 и стиль 0.1 (чётко и энергично), спокойный — скорость 0.82 и стиль 0 (мягко и расслабленно). Разница в скорости на 0.23 единицы на практике очень хорошо слышна.
Автоматические Audio Tags и анализ текста
Одна из интересных фич — автоматическое добавление эмоциональных тегов на основе анализа текста. Идея простая: если абзац заканчивается вопросом, добавить [curious]; если в тексте есть акцентные слова вроде «смотри», «важно», «необходимо» — добавить [emphasizes].
function addAudioTags(text: string): string {
return text
.split('\n')
.map(line => {
const trimmed = line.trim();
if (!trimmed) return line;
if (trimmed.endsWith('?')) {
return `[curious] ${trimmed}`;
}
const accentWords = ['смотри', 'важно', 'необходимо', 'нужно', 'успеть', 'обязательно'];
if (accentWords.some(word => trimmed.toLowerCase().includes(word))) {
return `[emphasizes] ${trimmed}`;
}
return line;
})
.join('\n');
}Эта функция включается только для пресетов, где audio_tags: true. Для пресетов с максимальным сходством голоса теги отключены — они могут менять тембр и манеру подачи, что нежелательно когда важна точность клонирования.
Пример трансформации текста перед отправкой в API:
// Входящий текст:
Смотри: чтобы забрать бонус, нужно успеть сегодня.
Подскажи, получается?
// После обработки:
[emphasizes] Смотри: чтобы забрать бонус, нужно успеть сегодня.
[curious] Подскажи, получается?Финальная конфигурация пресетов
После нескольких итераций тестирования пришли к финальному набору из трёх пресетов, основанных на настройках «максимального сходства»:
🎯 Похожий — базовый пресет, максимальное сходство с оригинальным клоном:
{
"stability": 0.75,
"similarity_boost": 0.95,
"style": 0,
"speed": 1.0,
"audio_tags": false
}⚡ Быстрее — тот же голос, скорость +10%:
{
"stability": 0.75,
"similarity_boost": 0.95,
"style": 0,
"speed": 1.1,
"audio_tags": false
}🐢 Медленнее — тот же голос, скорость -10%:
{
"stability": 0.75,
"similarity_boost": 0.95,
"style": 0,
"speed": 0.9,
"audio_tags": false
}Ключевое открытие: поднятие stability с 0.5 до 0.75 и similarity_boost до 0.95 при нулевом style даёт звучание, максимально близкое к оригинальному клону даже на модели v3. Это позволило получить лучшее из двух миров — современную модель с поддержкой Audio Tags и при этом высокую точность воспроизведения голоса.
Дополнительно была добавлена кнопка с настройками старой модели eleven_multilingual_v2 для сравнения — чтобы всегда можно было вернуться к референсному звучанию и понять, в какую сторону двигаться дальше.
Деплой и инфраструктура
Бот деплоится через Docker Compose на VPS. Dockerfile включает ffmpeg для конвертации аудио:
FROM node:20-alpine
RUN apk add --no-cache ffmpeg
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY dist/ ./dist/
CMD ["node", "dist/index.js"]Для CI/CD настроен GitHub Actions с публикацией образа в GitHub Container Registry. Команда деплоя на сервере:
docker compose up -d --buildВесь цикл от коммита до работающего бота занимает около 3-4 минут.
Результат
Что получили в итоге:
- Убрали меню выбора голоса — пользователь больше не тратит время на выбор из списка, которого он не понимает.
- Три чётких пресета с понятными названиями и эмодзи — выбор стиля подачи занимает одно нажатие.
- Автоматические Audio Tags для улучшения интонации вопросов и акцентных фраз.
- Переход на Eleven v3 с сохранением высокого сходства голоса через правильные настройки
stabilityиsimilarity_boost. - 22 теста покрывают логику пресетов и трансформацию текста.
Выводы
Главный урок этой итерации — меньше выбора там, где выбор не нужен. Когда у тебя один профессиональный голос и конкретная задача, предлагать пользователю выбирать из двадцати голосов — это антипаттерн. Правильное решение: зафиксировать голос в конфиге, а пользователю дать выбор только того, что действительно влияет на результат — стиля подачи.
Второй урок — переменные окружения могут ломать дефолты. Баг с ELEVENLABS_MODEL=eleven_multilingual_v2 в .env, который переопределял новое дефолтное значение eleven_v3, — классический пример того, как старая конфигурация на сервере живёт своей жизнью. Решение: для критичных параметров делать значение принудительным в коде, а не полагаться только на дефолт.
Третий урок — пресеты должны звучать по-разному. Первая версия «Делового» и «Спокойного» была почти идентичной, потому что разница в параметрах была слишком маленькой. На практике пользователь слышит разницу только когда она действительно заметна — разница в скорости 0.1 единицы почти не ощущается, 0.2+ — уже хорошо слышна.
Четвёртый урок — Audio Tags в ElevenLabs v3 работают только с правильной моделью. Это звучит очевидно, но именно такая ошибка привела к тому, что бот несколько минут произносил [curious] вслух как обычный текст. Всегда проверяй, что модель в запросе соответствует фичам, которые ты используешь. И всегда проверяй реальный .env на сервере, а не только локальный.
Проект продолжает развиваться — следующий шаг, скорее всего, более умный анализ текста для Audio Tags и возможно интеграция с GPT для предобработки скриптов перед озвучкой.
Полезные ссылки

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.