П/ВИН

Голосовой бот Telegram: один голос и три пресета

·8 мин чтения

Когда у тебя есть 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-меню. Пользователь должен был выбрать голос, и только потом текст уходил на озвучку. Для внутреннего инструмента, где голос один и он уже выбран — это лишний шаг и лишняя точка отказа.

Проблема: слишком много выбора там, где выбора быть не должно

Главный запрос был простым: оставить один профессиональный голос и убрать меню выбора. Голос уже клонирован, уже проверен, уже нравится — зачем каждый раз предлагать альтернативы?

Технически это означало:

  1. Зафиксировать voice_id в конфигурации через переменную окружения.
  2. Убрать запрос списка голосов при каждом сообщении.
  3. Убрать inline-меню выбора голоса.
  4. Текст сразу отправлять на озвучку без промежуточных шагов.

Первый вариант решения — добавить обязательную переменную 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 предоставляет несколько числовых настроек:

ПараметрДиапазонОписание
stability0–1Стабильность голоса. Ниже — больше эмоций, выше — ровнее
similarity_boost0–1Сходство с оригинальным клоном
style0–1Выразительность и характерная манера
speed0.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 в бизнес.