Свой сервис синтеза речи на Python: F5-TTS, RUAccent и FastAPI с нуля

Свой сервис синтеза речи на Python: F5-TTS, RUAccent и FastAPI с нуля

Собрал за один вечер микросервис синтеза речи на Python, с нуля. Это тот самый кусок, который потом будет отвечать голосом в голосовом роботе на IP-телефонии. Сначала venv, PyTorch под CUDA, Hugging Face Hub, RUAccent, F5-TTS и веб-обвязка на FastAPI, uvicorn, orjson и websockets, потом скрипт gpu_check.py, структура каталогов и настройки на pydantic-settings с чтением .env через pathlib. Чтобы было с чем сравнивать, первым подключил облачный Yandex SpeechKit: тестовый пост он озвучил за 0.96 секунды, а время меряю собственным контекстным менеджером на модуле time. Дальше локальный конвейер. RUAccent на модели turbo3.1 расставляет ударения и заодно показывает свой ляп на омографе «готов», а класс TextToSpeech поверх F5-TTS Russian от Misha24-10 генерирует речь по референсному аудио и тексту. Библиотека синхронная, поэтому заворачиваю её в асинхронный интерфейс через отдельный поток, аудио уходит байтами из потока в памяти, без записи на диск. Замеры показывают цену качества: 5.69 секунды при nfe_step=32 против 1.47 секунды при nfe_step=8. Для реалтайма нужна видеокарта и оптимизированный инференс вроде NVIDIA Triton, на нём обещают больше чем трёхкратный прирост против голого торча. В финале сервис на FastAPI: lifespan грузит модель в память видеокарты при старте и кладёт в глобальный bot_manager, роутер с префиксом v1 держит websocket-эндпоинт, который в бесконечном цикле принимает текст и отдаёт аудио в base64. Отдельно разобрал, почему websocket, а не HTTP, и почему нейронку правильнее выносить в отдельный микросервис: конфликты версий библиотек потом обходятся дороже.

Виртуальное окружение и установка библиотек

Стартуем с чистого проекта: терминал, активированное виртуальное окружение и файл README, куда Илья по порядку складывает все команды установки, чтобы зрители могли просто копировать и повторять. Дальше по списку ставятся библиотеки: Hugging Face Hub, PyTorch (сразу в двух вариантах, под видеокарту и под процессор), RUAccent для расстановки ударений, модель F5-TTS Russian и веб-обвязка сервиса. Попутно вылезает суровая реальность: часть зеркал не отвечает из-за блокировок, версии не находятся, и вместо того чтобы воевать с pip, автор просто копирует готовое виртуальное окружение из своего рабочего проекта.

Виртуальное окружение проекта

Отдельное venv-окружение под проект, которое видно прямо в терминале и которое при желании можно целиком скопировать из другого проекта.

Проект создаётся с нуля, и первым делом проверяем, активировано ли виртуальное окружение: в терминале висит метка venv. Все библиотеки ставятся именно внутрь него, а команды установки Илья заранее выписывает в файл README в корне проекта, чтобы выполнять их по порядку и потом отдать зрителям. Когда pip начинает ругаться на версии и не может достучаться до нужных адресов, автор идёт в обход: закрывает окно и просто копирует уже собранное виртуальное окружение со своего рабочего проекта поверх нового. Копирование идёт долго, но не из-за размера, а из-за количества файлов в библиотеках.

  • Метка venv в терминале говорит, что окружение активировано
  • README в корне со всеми командами по порядку, удобно повторять урок копипастом
  • Виртуальное окружение это просто папка, её можно скопировать из проекта, где всё уже работает
  • Копирование venv медленное из-за кучи мелких файлов, а не из-за диска

README проекта: команды установки библиотек, чтобы повторить урок

python -m pip install --upgrade pip
pip install huggingface-hub

# PyTorch под CUDA — версию cuXXX подставьте под своё железо
pip install torch==2.8.0+cu128 torchaudio==2.8.0+cu128 \
    --extra-index-url https://download.pytorch.org/whl/cu128

pip install ruaccent
pip install f5-tts
pip install fastapi pydantic-settings "uvicorn[standard]" orjson websockets
pip install yandex-speechkit
Я возьму просто все вот эти библиотеки, которые уже скачаны. И просто я их скопирую с своего проекта, в котором я все разрабатывал. Вот это виртуальное окружение.

PyTorch: сборка под CUDA или под CPU

PyTorch ставится либо специальной сборкой под видеокарту (CUDA), либо обычной под процессор, и от этого выбора зависит, будет ли реалтайм.

Библиотеки для работы с нейронками ставятся отдельной командой, и в ней есть приставка с версией CUDA, то есть специальная сборка, чтобы задействовалась видеокарта. Для тех, у кого видеокарты нет, Илья показывает, где взять CPU-команду: на сайте PyTorch выбираешь Windows и CPU, и сайт сам выдаёт готовую строку установки. Из выданной команды torchvision выбрасываем, он не нужен, а вот torchaudio нужен, ставится тем же способом, только без привязки к CUDA. Момент принципиальный: на процессоре реалтайма практически не будет, CPU-вариант годится только под постобработку. Закинул запись встречи, обработал, и всё.

  • На сайте PyTorch выбираешь ОС и CPU/CUDA, он выдаёт готовую команду установки
  • torchvision из команды убираем, torchaudio добавляем
  • CUDA-сборка нужна для видеокарты, обычная для процессора
  • На CPU реально только пакетная постобработка аудио, реалтайм только на видеокарте

README проекта: команды установки библиотек, чтобы повторить урок

python -m pip install --upgrade pip
pip install huggingface-hub

# PyTorch под CUDA — версию cuXXX подставьте под своё железо
pip install torch==2.8.0+cu128 torchaudio==2.8.0+cu128 \
    --extra-index-url https://download.pytorch.org/whl/cu128

pip install ruaccent
pip install f5-tts
pip install fastapi pydantic-settings "uvicorn[standard]" orjson websockets
pip install yandex-speechkit
А для реалтайма в любом случае нужна видеокарта.

Hugging Face Hub

Библиотека для работы с порталом Hugging Face, откуда модели удобно закачивать, не таская файлы руками.

Сразу после обновления пакетного менеджера ставится huggingface_hub, специальная библиотека для работы с Hugging Face. Сам портал Илья описывает просто: место, где собрана куча нейронок, они структурированы, к ним есть примеры, и оттуда их очень удобно закачивать. Библиотека как раз затем и нужна, чтобы не скачивать файлы моделей вручную, а забирать их программно. Только осторожнее: на портале попадаются и вредоносные нейронки, типа вирусов, которые похищают информацию.

  • Hugging Face это портал со структурированным каталогом нейронок и примерами
  • huggingface_hub позволяет скачивать модели программно, а не руками
  • На портале есть и вредоносные модели, бывают штуки, которые похищают информацию
Это такой портал, где собрана куча нейронок.

RUAccent — простановка ударений

Нейронка, которая расставляет в тексте ударения перед тем, как отдать его на озвучку.

RUAccent это доработанная нейронка, у которой одна-единственная задача: проставить в тексте ударения. Если текст заранее не оформить и ударения не расставить, озвучка получается очень странной. А если проставить, синтез звучит гораздо более естественно, без этого шага вылезают соответствующие ляпы. Илья честно оговаривается, что и у самой RUAccent есть определённые ляпы, и обещает их показать.

  • Текст нужно готовить до синтеза, а не кидать в TTS как есть
  • Ударения дают заметно более естественную озвучку
  • RUAccent не идеальна, свои ляпы у неё тоже есть
работает, если вы предварительно проставили ударение и после этого озвучка получается гораздо более естественной.

F5-TTS Russian (Misha24-10)

Модель F5-TTS, дообученная на русском датасете. Именно она и будет синтезировать речь в проекте.

Вторая нейронка проекта берётся с Hugging Face, это F5-TTS Russian от Misha24-10. Автор модели взял базовую F5-TTS и обучил её на русском датасете, так что русскую речь она понимает вполне хорошо. На странице модели есть и полное описание, и сам файл модельки. Руками его качать не надо, для этого и ставилась библиотека для работы с Hugging Face, она всё это дело и обрабатывает.

  • F5-TTS Russian это базовая F5-TTS, дообученная на русском датасете
  • Модель лежит на Hugging Face со своим описанием и файлом весов
  • Качается не руками, а через библиотеку Hugging Face Hub
То есть он взял нейронку F5 TTS и на русском датасете её обучил. То есть она вполне очень хорошо понимает русскую речь.

Веб-обвязка сервиса: FastAPI, pydantic-settings, uvicorn, orjson, websockets

Набор библиотек под веб-часть сервиса. Связь между сервисами тут строится на веб-сокетах, а не на HTTP.

Сервису нужен веб-сервер, поэтому ставим FastAPI, к нему pydantic-settings для удобного управления настройками и uvicorn, чтобы этот веб-сервер запускать. orjson добавляется как более быстрый сериализатор JSON, а websockets нужен, чтобы работать с веб-сокетами. Веб-сокеты выбраны намеренно, вместо HTTP: нужно практически реальное взаимодействие между сервисами. Один сервис запускается, потом второй, они подключаются, устанавливают связь, и дальше она постоянно открыта, потоки идут в обе стороны без задержек на HTTP-запросы.

  • FastAPI и uvicorn это веб-сервер сервиса
  • pydantic-settings для настроек, orjson для быстрой сериализации JSON
  • Веб-сокеты вместо HTTP: связь устанавливается один раз и держится открытой
  • Причина в потоковом двустороннем взаимодействии без задержек на каждый запрос
Почему не HTTP? Потому что нам нужно практически реальное взаимодействие.

Проверка CUDA и структура проекта

Библиотеки поставили, теперь надо убедиться, что они реально видят видеокарту, и разложить проект по папкам. Илья подключает Black, пишет крошечный скрипт gpu_check.py на torch.cuda.is_available и запускает его прямо из IDE. Дальше каркас проекта: папки под сгенерированное аудио, логи, модели, голоса и исходники. В конце появляется .env с ключами и пара тестовых текстов, один из которых нужен, чтобы прогреть модель.

Black как форматтер кода

Black ставится прямо из настроек IDE и дальше форматирует код сам.

Перед тем как писать код, Илья ставит Black, просто потому что ему так удобнее работать. Ставится он из настроек IDE: находим Black, включаем форматтер, проставляем галочки. Если галочки не активны, рядом будет кнопка Install, жмём её, и после этого галочки встают. Дальше форматирование работает само, руками ничего делать не надо.

  • Black включается в настройках IDE, с конфигами возиться не надо
  • Галочки не активны, значит жмём Install прямо там же в настройках
  • После установки код форматируется автоматически
Black форматор, включаем здесь.

Скрипт GPU Check и проверка CUDA

Маленький Python-скрипт на torch.cuda.is_available показывает, видит PyTorch видеокарту или нет.

После установки PyTorch и torchaudio хорошо бы иметь скрипт, который покажет, встали ли нужные версии библиотек. Илья создаёт новый Python-скрипт, называет его gpu_check.py: импортирует torch, пишет функцию, которая проверяет наличие CUDA, и выводит информацию по видеокарте. Если CUDA не активна, скрипт скажет, что доступен только CPU. Запуск правой кнопкой, Run GPU Check, и смотрим консоль. CUDA активна, одна видеокарта работает, значит библиотеки поставились как надо.

  • Скрипт импортирует torch и проверяет, доступна ли CUDA
  • CUDA активна, выводится информация по видеокарте, нет, скрипт сообщает про CPU
  • Запускается прямо из IDE правой кнопкой → Run
  • Так и убеждаемся, что установленные версии библиотек рабочие

gpu_check.py — проверка доступности CUDA и информации о видеокарте

import torch


def get_gpu_info():
    if torch.cuda.is_available():
        return {
            "Cuda available": torch.cuda.is_available(),
            "Number of GPUs": torch.cuda.device_count(),
            "Current GPU device": torch.cuda.current_device(),
            "GPU name": torch.cuda.get_device_name(torch.cuda.current_device()),
        }
    return {"Cuda unavailable": "Using CPU"}


print(get_gpu_info())
Здесь мы пишем небольшую функцию, импортируем Torch, пишем функцию, проверяем, есть ли у нас CUDA есть, выводим информацию по той видеокарте, которая у нас есть.

Структура каталогов проекта

Каркас проекта: папки generated_audio, logs, models, refs и source, помеченная как корень исходников.

Структуру Илья накидывает сразу, не откладывая на потом. Создаётся пустая директория generated_audio, туда пойдут сгенерированные файлы. Обязательно папка с логами: «всегда делаю папку с логами». Отдельно папка с моделями и папка refs с референсными голосами. И папка с исходниками source, которую надо пометить через Mark Directory as → Sources Root, чтобы IDE знала, где у нас лежат исходники.

  • generated_audio, сюда падают сгенерированные аудиофайлы
  • Папку с логами автор создаёт всегда, просто привычка
  • Модели и refs с референсными голосами лежат прямо в проекте
  • source помечается как Sources Root, иначе IDE не поймёт структуру
Также обязательно создаем папку с исходниками source и помечаем, что это у нас исходники. Вот здесь mark directory s source root для того, чтобы у нас IDE знала, где у нас хранятся исходники.

Предзагруженные модели и папка refs с референсными голосами

Модели на 20 гигабайт копируются в проект заранее, а в refs лежат референсные записи голосов.

Модели скачиваются с Hugging Face автоматически, но 20 гигабайт ждать долго, поэтому Илья просто копирует уже скачанные модели в проект через Проводник. Рядом появляется папка refs, в ней хранятся голоса: текст и его аудиозапись rev.wav, то есть Илья сам зачитал текст и записал его. Второй голос женский, его прислала Ольга, начитала стишок. Этот голос уже выкладывался в Telegram. Итого в проекте используются два голоса.

  • Модели с Hugging Face копируются локально, чтобы не ждать загрузку 20 ГБ
  • В refs лежат референсные голоса: текст плюс его аудиозапись
  • Референс это wav-файл с начитанным текстом
  • Голоса в проекте два: мужской (автора) и женский
20 гигабайт модели загружать будет нам очень долго ждать, поэтому я их сейчас скопирую сюда.

Файл .env и ключи API

Настройки и ключи живут в .env, который делается из шаблона .env.template.

В Python настройки принято хранить в .env. Илья раздаёт .env.template: его надо скопировать, вставить и убрать слово template, получится рабочий файл, куда вы прописываете свои ключи. Внутри лежит ключ Яндекса: идёте в облако Яндекса, заводите личный кабинет (при первой регистрации дают баланс на несколько дней), генерируете API-ключ и даёте ему разрешение, например на генерацию голоса. С Hugging Face Token так же: регистрация, профиль, раздел с API-ключами, генерируете, выдаёте права, вставляете. Остальное это адреса локального запуска сервиса с префиксами, ключи run, которые разделяют настройки сервисов, и сами настройки: хост, IP, порт.

  • .env.template копируем, слово template убираем, получается .env
  • Ключ Яндекс.Облака берётся в личном кабинете, при первой регистрации дают стартовый баланс
  • Hugging Face Token генерируется в профиле, ключу выдаются права
  • Там же лежат адреса локального запуска, ключи run по сервисам, хост и порт приложения
  • Свой рабочий .env автор показывает не скрывая, ключи он потом всё равно пересоздаёт
Смотрите, в Python всегда принято какие-то настройки хранить в .env. То есть я вам скидываю .env template, который надо будет просто взять, скопировать, вставить, template убрать.

Тестовые тексты и прогрев модели

Первый незначащий текст скармливается модели для прогрева, иначе она генерит медленнее.

В проект заранее кладутся два текста. Первый это готовый пост, на нём будут тестироваться разные модели и настройки, чтобы смотреть, с какой скоростью генерируется аудио. Второй это стих Пушкина в файле warm.txt. Он нужен для прогрева: модель загрузилась первый раз, ей подают какой-то незначащий текст, чтобы прогреть. После этого нормальный текст генерируется уже с максимальной скоростью. Непрогретая модель работает медленнее, поэтому первый прогон всегда делается на небольшом тексте.

  • Тестовый пост нужен, чтобы замерять скорость разных моделей и настроек
  • Стих Пушкина в отдельном файле идёт как прогревочный текст
  • Непрогретая модель генерирует медленнее
  • Первый запрос к модели всегда короткий незначащий текст
Надо ее разогреть, то есть мы ей подаем просто какой-то незначащий текст первый раз для того, чтобы ее прогреть немножко. И после этого, когда мы уже подаем нормальный текст, этот текст генерируется с максимальной скоростью.

Настройки на pydantic-settings и загрузчик моделей

Инфраструктура готова, дальше автор берётся собственно за проект. Первым делом config.py, где на pydantic-settings собирается считывание настроек из .env: классы на BaseModel под каждую группу параметров, пути через pathlib, единый класс Settings на BaseSettings и один экземпляр, доступный всему приложению. Отдельно задаются переменные окружения для Hugging Face, чтобы модели не улетали гигабайтами на диск C. Заканчивается секция маленьким скриптом download_models.py, который тянет RUAccent и F5-TTS в папку проекта.

config.py на pydantic-settings

Отдельный файл config.py, где pydantic-settings читает .env и превращает его в типизированные настройки проекта.

Настройки лежат в .env, и их надо нормально прочитать и использовать в проекте. Под это автор заводит файл config.py и строит в нём всю инфраструктуру для считывания и хранения настроек. Работает это на библиотеке pydantic-settings: её задача в том, чтобы единый .env файл был считан правильно. Рядом импортируются os, pathlib для универсальных путей и BaseModel как основа для классов настроек.

  • config.py, единая точка, где собирается вся инфраструктура настроек
  • pydantic-settings читает .env и отдаёт настройки проекту
  • рядом импортируются os, pathlib и BaseModel

config.py: классы настроек на BaseModel — хост, порт, префикс API

from pathlib import Path

from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict


class RunConfig(BaseModel):
    host: str = "127.0.0.1"
    port: int = 8001


class ApiPrefix(BaseModel):
    prefix: str = "/api"


class PathConfig:
    src_root = Path(__file__).parent
    prj_root = src_root.parent
    models = prj_root / "models"
    logs = prj_root / "logs"
    ref = prj_root / "ref"


class Services(BaseModel):
    hf_token: str = ""
    ya_token: str = ""
Итак, нам надо вот эти настройки из .env файла считывать.

Пути через pathlib

pathlib строит универсальные пути к каталогам проекта, чтобы не зависеть от того, в какой ОС он запущен.

Чтобы не писать руками слэши в ту или иную сторону, автор берёт pathlib: она строит пути автоматически, в зависимости от системы, где вы запускаете проект. Относительно самого файла config.py через свойство parent берётся путь до каталога source, потом ещё один parent, и поднимаемся к верхней директории, получаем PRG_ROOT. К корню проекта прибавляются подкаталоги: models, logs и остальные. Есть ещё небольшая функция, которая при желании выведет, какие полные пути pathlib насобирала до этих каталогов.

  • pathlib убирает ручные слэши и разницу между Windows и Linux
  • parent от файла конфига даёт source, ещё один parent даёт корень проекта
  • к корню прибавляются models, logs и прочие каталоги
  • есть функция, чтобы проверить собранные полные пути
Чтобы нам не писать там левый слэш, правый слэш. Эта библиотека все автоматически строит за вас. В зависимости от того, в какой системе вы запускаете ваш проект.

Классы настроек на BaseModel

На каждую группу настроек свой класс от BaseModel, где имена атрибутов должны совпадать с ключами из .env.

Автор делает по классу на каждую настройку: отдельно параметры запуска, отдельно префикс для API, отдельно класс Services с токенами Hugging Face и Yandex. Все они наследуются от BaseModel. Название самих классов роли не играет, значение имеют только имена атрибутов: они должны полностью совпадать с ключами из .env, только в .env всё в верхнем регистре, а в классе в нижнем. У атрибута указывается тип и значение по умолчанию: если .env считать не удалось, настройка просто станет дефолтом (у токенов дефолт это пустая строка).

  • по классу на каждую группу настроек, все от BaseModel
  • имя класса не важно, важны имена атрибутов, они совпадают с ключами .env
  • в .env верхний регистр, в классе нижний
  • у каждого атрибута есть тип и значение по умолчанию на случай, если .env не прочитался

config.py: классы настроек на BaseModel — хост, порт, префикс API

from pathlib import Path

from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict


class RunConfig(BaseModel):
    host: str = "127.0.0.1"
    port: int = 8001


class ApiPrefix(BaseModel):
    prefix: str = "/api"


class PathConfig:
    src_root = Path(__file__).parent
    prj_root = src_root.parent
    models = prj_root / "models"
    logs = prj_root / "logs"
    ref = prj_root / "ref"


class Services(BaseModel):
    hf_token: str = ""
    ya_token: str = ""
Название самих классов значения никак не имеют.

Класс Settings на BaseSettings и поиск .env

Итоговый класс Settings на BaseSettings задаёт разделитель и префикс, находит .env через pathlib и отдаёт единый экземпляр настроек на всё приложение.

Сначала пишется функция, которая через pathlib и parent.parent говорит, в каком каталоге искать .env файл. Указать можно несколько файлов, но автор указывает конкретный .env. Дальше на основе BaseSettings делается класс Settings: в нём задаётся разделение между настройками (двойное подчёркивание), префикс и та самая функция поиска .env. При инициализации создаются атрибуты вроде run, и всё, что в .env относится к run, то есть host и port, попадает именно туда. Имена атрибутов, опять же, должны совпадать с ключами, только в нижнем регистре. В конце создаётся экземпляр класса, доступный во всём приложении со всеми настройками из .env.

  • функция на pathlib указывает, где искать .env
  • в Settings задаются разделитель (двойное подчёркивание), префикс и путь к .env
  • префикс в .env раскладывает ключи по вложенным атрибутам: run.host, run.port
  • созданный экземпляр Settings используется во всём проекте

config.py: класс Settings, единый экземпляр настроек и переменные окружения Hugging Face

def get_env_path():
    return Path(__file__).parent.parent / ".env"


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        case_sensitive=False,
        env_nested_delimiter="__",   # FASTAPI__RUN__PORT=8001
        env_prefix="FASTAPI__",
        env_file=get_env_path(),
    )
    run: RunConfig = RunConfig()
    api: ApiPrefix = ApiPrefix()
    path: PathConfig = PathConfig()
    services: Services = Services()


settings = Settings()

# Куда huggingface_hub складывает модели и токен доступа
os.environ["HF_HOME"] = str(settings.path.models)
os.environ["HF_TOKEN"] = settings.services.hf_token
То есть отсюда иди, найди и загрузи все настройки.

Переменные окружения для Hugging Face

Через os.environ задаются путь до папки моделей и Hugging Face токен, иначе модели улетят в каталог пользователя и забьют диск C.

Hugging Face любит, чтобы ему задали определённые параметры. Не укажете, и модели поедут скачиваться в каталог пользователя, а весят они гигабайты, диск C забьётся очень быстро. Поэтому автор через os.environ устанавливает переменную окружения с путём до папки моделей конкретного проекта и прописывает Hugging Face токен: некоторые модели требуют токена и принятия правил, хотя далеко не все. Эти две переменные окружения устанавливаются при запуске проекта. Можно и по-другому, прописать их руками в переменных среды системы, что в Windows, что в Linux.

  • без указания пути модели падают в каталог пользователя и забивают диск C
  • os.environ задаёт путь до папки моделей проекта и Hugging Face токен
  • часть моделей требует токена и принятия правил, часть качается и без него
  • то же самое можно задать руками в переменных среды ОС

config.py: класс Settings, единый экземпляр настроек и переменные окружения Hugging Face

def get_env_path():
    return Path(__file__).parent.parent / ".env"


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        case_sensitive=False,
        env_nested_delimiter="__",   # FASTAPI__RUN__PORT=8001
        env_prefix="FASTAPI__",
        env_file=get_env_path(),
    )
    run: RunConfig = RunConfig()
    api: ApiPrefix = ApiPrefix()
    path: PathConfig = PathConfig()
    services: Services = Services()


settings = Settings()

# Куда huggingface_hub складывает модели и токен доступа
os.environ["HF_HOME"] = str(settings.path.models)
os.environ["HF_TOKEN"] = settings.services.hf_token
А модели весят гигабайты, и он вам туда накачает, и ваш диск C забьется очень быстро.

Скрипт download_models.py

Отдельный простой скрипт на snapshot_download из huggingface_hub, который выкачивает RUAccent и F5-TTS в папку моделей проекта.

Загрузку моделей автор вынес в отдельный файл download_models.py, потому что столкнулся с тем, что модели сегодня закачиваются, а завтра нет. Скрипт очень простой: из huggingface_hub импортируется snapshot_download, подтягиваются настройки, а сам вызов обёрнут в проверку прямого запуска, чтобы при импорте из другого места этот код не выполнялся. В snapshot_download передаётся id модели с Hugging Face и директория, куда качать: к каталогу моделей добавляются подпапки под RUAccent и F5-TTS. Запустите повторно, и уже скачанные модели заново не потянутся, скрипт просто быстро отработает. Id модели берётся копированием со страницы модели на Hugging Face.

  • загрузка вынесена в отдельный файл, потому что модели скачиваются нестабильно
  • snapshot_download из huggingface_hub плюс настройки проекта
  • код под проверкой прямого запуска, при импорте не сработает
  • передаются id модели и целевая директория, папки под RUAccent и F5-TTS создаются автоматически
  • повторный запуск ничего не докачивает, если модели уже на месте
Я его делаю отдельно, потому что, я говорю, столкнулся с тем, что модели сегодня закачиваются, завтра не закачиваются. Поэтому сделал отдельный файлик для закачивания моделей.

Облачный синтез Yandex SpeechKit и замер скорости

До локальной модели ещё дойдём, а пока Илья заводит точку отсчёта: облачный синтез Yandex SpeechKit. Пример взят прямо с сайта Яндекса и чуть подпилен под проект, поэтому кода тут кот наплакал. Подключили библиотеку, передали текст, выбрали голос, настроение и скорость, забрали аудиофайл. По дороге появляется мелкая, но полезная утилита: контекстный менеджер на модуле time, который показывает, сколько миллисекунд отработал конкретный кусок кода. В конце прогон на живом тексте поста с ударениями, Яндекс уложился в 0.96 секунды.

Облачный синтез Yandex SpeechKit

Готовый облачный TTS от Яндекса. Автор поднимает его первым, чтобы было с чем сравнивать: просто и быстро.

Автор специально начинает не с локальной модели, а с облака Яндекса. Пример там максимально простой, и на нём сразу видно, как вообще устроен синтез речи. Создаётся Python-файл с примером, который взят с сайта Яндекса и немного подправлен под проект. Библиотеку yandex-speechkit придётся поставить отдельно, и тут автор сразу предупреждает: Яндекс периодически переименовывает свои библиотеки, так что на момент просмотра название может отличаться. Дальше всё по накатанной: импортируем библиотеку, вызываем функцию синтеза.

  • Начинают с облачной генерации от Яндекса, потому что пример совсем простой
  • Пример взят с сайта Яндекса и слегка адаптирован
  • Библиотеку yandex-speechkit надо ставить отдельно
  • Яндекс любит переименовывать свои библиотеки, так что на момент просмотра имя может быть уже другим

yandex_tts.py — облачный синтез через Yandex SpeechKit с замером времени

from speechkit import model_repository, configure_credentials, creds

from src.config import settings
from src.time_utils import measure_time

# Аутентификация через API-ключ (лежит в .env, не в коде)
configure_credentials(
    yandex_credentials=creds.YandexCredentials(api_key=settings.services.ya_token)
)


def synthesize(text, export_path):
    model = model_repository.synthesis_model()
    model.voice = "jane"
    model.role = "good"
    model.speed = 1.3

    with measure_time("Синтез речи через Yandex API"):
        result = model.synthesize(text, raw_format=False)
    result.export(export_path, "wav")
давайте сначала попробуем облачную генерацию от яндекса чтобы вам было понятно потому что пример очень простой создаем python файл

Утилита замера времени (time_utils)

Отдельный Python-файл с утилитой на модуле time. Считает, сколько выполнялся нужный кусок кода.

Чтобы мерить скорость, автор заводит новый Python-файл, time_utils. Внутри импортируется модуль time: он отдаёт текущее время, значит можно зафиксировать момент до начала кода, момент после и вычесть одно из другого. В функцию передаётся лейбл, название того, что меряем, и по окончании он выводится вместе с посчитанным временем в отформатированном виде, до миллисекунд. Смысл простой: приложение реалтаймовое, и надо понимать, в каком именно месте и сколько времени съедают конкретные функции.

  • Утилита лежит в отдельном Python-файле time_utils в папке source
  • Модуль time отдаёт текущее время: замерили до кода, замерили после, разница и есть то, что нам надо
  • В функцию передаётся лейбл, то есть описание того, что меряем, и выводится вместе со временем
  • Вывод в секундах и миллисекундах: в реалтайм-приложении надо видеть, где утекает время

time_utils.py — контекстный менеджер для замера времени выполнения

import time
from contextlib import contextmanager


@contextmanager
def measure_time(label="Выполнение"):
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f"{label}: {elapsed:.4f} сек")


# Использование, в том числе вложенное:
with measure_time("Общее время"):
    with measure_time("  Первая часть"):
        ...
здесь мы импортируем библиотеку тайм для того чтобы нам надо будет замерять определенные кусочки кода как с какой скоростью они выполняются

Контекстный менеджер как таймер

Декоратор contextmanager превращает обычную функцию в конструкцию with, которая сама замеряет время блока при выходе из него.

К функции замера применяется специальный декоратор, контекстный менеджер. Внутри в переменную start кладётся текущее время, дальше идёт обработка исключительной ситуации и yield, а всё, что окажется под with с отступом, выполняется как тело блока. Как только блок закончился, автоматически отрабатывает вторая часть кода: считает, сколько прошло, и печатает в консоль. Альтернатива, если кратко, руками писать start и end вокруг каждого куска и каждый раз считать разницу. Контекстный менеджер это дублирование убирает. Замеры при этом можно вкладывать друг в друга и получать общее время блока плюс время каждой функции внутри. На демонстрации автор запускает пример с time.sleep на секунду и показывает, что вывалилось в консоль.

  • Декоратор contextmanager: на старте кладём текущее время в переменную, yield отдаёт управление телу блока
  • Пишем with, имя функции и название замера, а замеряемый код идёт ниже с отступом
  • Когда блок закончился, расчёт и вывод отрабатывают сами
  • Замеры можно вкладывать друг в друга: и общее время блока, и время каждой функции внутри

time_utils.py — контекстный менеджер для замера времени выполнения

import time
from contextlib import contextmanager


@contextmanager
def measure_time(label="Выполнение"):
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f"{label}: {elapsed:.4f} сек")


# Использование, в том числе вложенное:
with measure_time("Общее время"):
    with measure_time("  Первая часть"):
        ...
у нее здесь применяется специальный декоратор контекстный менеджер он позволяет нам сделать очень хитрую функцию

Функция синтеза Yandex SpeechKit

Принимает текст и папку для сохранения, подключается к модели, задаёт голос, настроение и скорость, выгружает аудиофайл.

Функция синтеза тоже взята прямо из примера Яндекса, из их библиотеки. На вход идут текст и путь, куда положить готовый аудиофайл. Внутри сначала подключение к модели, а дальше указываем, каким голосом, с каким настроением и с какой скоростью надо сгенерировать аудио. Сам вызов генерации автор заворачивает в свой контекстный менеджер замера с подписью про синтез речи Яндекса, а результат экспортируется в указанный файл.

  • Функция взята из примера в библиотеке Яндекса, на вход текст и папка для аудиофайла
  • Сначала подключение к модели, потом голос, настроение и скорость генерации
  • Вызов генерации обёрнут контекстным менеджером с подписью про синтез речи Яндекса
  • Результат экспортируется в указанный файл

yandex_tts.py — облачный синтез через Yandex SpeechKit с замером времени

from speechkit import model_repository, configure_credentials, creds

from src.config import settings
from src.time_utils import measure_time

# Аутентификация через API-ключ (лежит в .env, не в коде)
configure_credentials(
    yandex_credentials=creds.YandexCredentials(api_key=settings.services.ya_token)
)


def synthesize(text, export_path):
    model = model_repository.synthesis_model()
    model.voice = "jane"
    model.role = "good"
    model.speed = 1.3

    with measure_time("Синтез речи через Yandex API"):
        result = model.synthesize(text, raw_format=False)
    result.export(export_path, "wav")
подключаемся к модели получаем модель указываем каким голосом с каким настроением и с какой скоростью нам надо сгенерировать наш аудио файл

Расстановка ударений перед синтезом

Текст поста прогоняется через функцию, которая по плюсикам расставляет ударения. С ними голос звучит заметно живее.

Для теста автор берёт не случайную фразу, а свой пост: тот же самый текст, но с уже расставленными плюсиками. Отдельная функция считывает файл и везде, где стоит плюсик, ставит ударение. Честно говоря, на отдельных словах ударение всё равно встаёт неправильно, но в целом работает очень хорошо и почти не промахивается. Итог такой: после обработки поста с ударениями голос получается гораздо более естественным. Готовый текст кладётся в переменную, а путь для сохранения собирается из корневой папки проекта и имени файла.

  • Озвучивают реальный пост автора, тот же текст, но с плюсиками
  • Функция считывает файл и ставит ударение везде, где стоит плюсик
  • На отдельных словах ударение всё равно улетает не туда, но в целом почти не ошибается
  • С ударениями голос получается гораздо естественнее
делаем эту функцию которая расставляет ударение она считывает файл и везде где плюсики она растает ударение

Замер скорости Яндекса: 0.96 секунды

Длинный пост Yandex SpeechKit озвучил меньше чем за секунду, 0.96 с. Это и есть базовая планка скорости для проекта.

Запускаем функцию с обёрткой-таймером и получаем конкретную цифру: 0.96 секунды, столько Яндекс потратил на генерацию файла. Причём текст был вполне длинный, целый пост, а уложился меньше чем в секунду. Автор оговаривается, что на замер могла повлиять трансляция и трафик. В реальном диалоге фразы куда короче, одна реплика вроде вопроса про доставку, и генерация проходит ещё быстрее. Поэтому дальше в проекте языковую модель будут просить укладывать ответ максимум в одно предложение: человеку в диалоге лекция не нужна.

  • 0.96 секунды, столько Яндекс потратил на генерацию аудиофайла по тестовому посту
  • Текст был длинный, целый пост, а уложился меньше чем в секунду
  • На замер могли повлиять трансляция и трафик
  • В живом диалоге фразы короче, поэтому языковую модель попросят отвечать максимум одним предложением
ну вот 0 96 это то время за сколько яндекс сгенерировал нам файл

Ответы на вопросы: RAG, ИИ-ассистенты и сравнение 1С с Python

Модель отработала за 0.96 секунды, и Илья делает паузу на вопросы из чата. Кода тут почти нет, зато есть архитектура: как собрать RAG-систему вопрос-ответ в Docker под магистерскую диссертацию, куда дальше растёт голосовой проект (транскрибация, ответ языковой модели, генерация голоса) и зачем всё это бизнесу. Отдельно прилетает 1С: после Python автор честно говорит, что по синтаксису и работе с классами платформа сильно отстаёт, а ИИ-ассистента по коду в конфигураторе просто нет.

RAG-система вопрос-ответ в Docker

Сделать RAG под магистерскую вполне реально. Какой подход выбрать, зависит от того, что у вас лежит в базе.

Вопрос из чата: насколько реально сделать RAG-систему вопрос-ответ в Docker на сервере для магистерской диссертации. Илья отвечает, что без проблем, но подходов к RAG много, и выбор упирается в то, какая у вас база. В пример он приводит ученицу с курса: у неё уже был готовый массив пар вопрос-ответ, а хотелось, чтобы система отвечала в чате и на произвольные вопросы, а не только на заранее заготовленные. Небольшую RAG-систему под эту задачу собрали прямо на курсе, и её, по словам автора, для диссертации вполне достаточно.

  • Подход к RAG выбирается от структуры базы: готовые пары вопрос-ответ это один сценарий, произвольный корпус совсем другой
  • Задача ученицы: отвечать в чате на произвольные вопросы, опираясь на уже существующие ответы
  • Небольшой RAG уровня курса закрывает требования магистерской работы
мы вот в курсе небольшую такую рак систему сделали и ее вполне достаточно уже будет для какой-то диссертации

Конвейер голосового бота: транскрибация → LLM → синтез

Аудиопроект будет помощнее обычного RAG: входящий голос транскрибируется, языковая модель отвечает, ответ обратно превращается в голос.

Илья рассказывает, куда движется текущий аудиопроект, и он будет заметно мощнее того, что делается на вебинаре. Полный цикл: обработка входящего голоса, транскрибация, ответ языковой моделью и обратная генерация голоса. Участок автор сам называет достаточно сложным, но говорит, что дальше по курсу будет ещё интереснее, уже не в формате вебинаров. Продолжение такое: сохранять весь диалог человека с машиной, чтобы на руках была готовая транскрибация текста для дальнейшего анализа.

  • Четыре шага: входящий голос, транскрибация, ответ языковой модели, генерация голоса
  • Автор не скрывает, что участок сложный, но проект развивается дальше по курсу
  • Весь диалог человека с машиной сохраняется, и это база для последующего анализа
обработка входящего голоса транскрибация ответ с помощью языковой модели и обратно генерация голоса это конечно тоже достаточно сложный участок но дальше у нас еще будет интереснее

Анализ звонков менеджеров через транскрибацию

Тот же конвейер разбирает живые звонки: разделить голоса, оценить работу менеджера, собрать отчёт для РОПа.

Из сохранённой транскрибации Илья предлагает делать вполне прикладную для бизнеса вещь: отправлять диалог на анализ языковой модели. Проверять можно, как отработал менеджер, проговорил ли все нужные акции, предложил ли дополнительные товары. Причём разбирать можно не только машину, но и звонки живых людей: специальные нейронки умеют транскрибировать и отделять голос клиента от голоса менеджера, так что диалог раскладывается по ролям. Дальше оценка удовлетворённости клиента в баллах и отчёт для РОПа: видно, что менеджер Иванов сегодня отработал на 9 из 10, можно выписать премию.

  • Диаризация: нейронки отделяют голос клиента от голоса менеджера, диалог раскладывается по ролям
  • LLM проверяет разговор по чек-листу: акции, допродажи, как вообще отработали
  • На выходе оценка в баллах и отчёт руководителю отдела продаж, а по нему уже премия
можно поставить оценку и в итоге можно сформировать отчет для ропа

ИИ-ассистент для кода и режим прототипа

На прототипе автор сгенерированный код вообще не читает, только жмёт «принять». Разбирается уже потом, когда всё заработало.

Илья рассказывает, что поставил себе ИИ-ассистента для кода, и называет его офигенной бомбой: появись такое для 1С, помогло бы очень сильно. Основная польза в том, чтобы быстро набросать прототип. Он пишет промпт «сделай то, сделай вот это», ассистент программирует, создаёт файлы, а автор только соглашается. На стадии прототипа код он даже не проверяет, и только когда прототип заработал, садится и смотрит, как именно всё сделано, чтобы перетащить удачные куски в реальный проект. Сам он в реальном проекте так не писал, но говорит, что многие используют и там, а лимиты большие. При регистрации через VPN нужен американский номер телефона, но одну эсэмэску получить, по его словам, не проблема.

  • Прототип: код не проверяется вообще, важно только чтобы заработало
  • Когда прототип запустился, автор разбирает код и переносит удачные куски в реальный проект
  • Лимиты большие, регистрация через VPN и американский номер телефона
то есть я просто прошу пром там сделай то сделай вот это она мне все программирует пишет пишет пишет создает файл этом только соглашаюсь

Сравнение Python и 1С: классы и синтаксис

Код на Python не сильно сложнее, а вот классы с наследованием дают то, чего в 1С «короче нет».

Илья разбирает код на Python и говорит прямо: ничего сложного там нет. Та же функция с названием, те же переменные и присваивания, только всё на английском. Разница в классах: инициализация, методы, атрибуты, и это, по его словам, гораздо удобнее 1С. У него есть проект, где такого не хватает прямо до боли: он бы сделал класс «автозапчасти» с общими атрибутами и методами, а всё остальное унаследовал бы, добавляя только новые атрибуты с новыми типами. Без этого код выходит совсем не нормализованный. Когда поработаешь с Python, становится видно, насколько 1С по синтаксису отстаёт. Классы принято ругать, но их силу понимаешь, только когда сам ими попользуешься. А чтение файлов автор относит к вещам, которые надо просто запомнить: open, имя файла, режим чтения, кодировка. В 1С тоже свой синтаксис, который держишь в голове, и ничего.

  • Базовый синтаксис Python не сложнее 1С: функции, переменные, присваивания, только на английском
  • Классы, наследование, атрибуты и методы, вот чего автору реально не хватает в 1С на живом проекте
  • Без наследования код выходит ненормализованным, а в 1С этого «короче нет»
  • Чтение файла в Python это просто синтаксис на запоминание: open, имя, режим, кодировка
когда начинаешь работать с питоном после этого понимаешь насколько 1с по синтаксису по вот этому сильно прям отстает

Разметка ударений и интонаций в синтезе Яндекса

У голоса Яндекса настраиваются эмоции и интонации, но на вебинаре размечают только ударения.

Спрашивают, можно ли сделать голос Яндекса поэмоциональнее. Илья говорит, что эмоции с интонациями там вроде настраиваются, а интонация подбирается автоматически. Дополнительно можно делать разметку: интонации, паузы, ещё что-то. Но это уже более сложный подход, и на вебинаре размечают только ударения. Заодно он закрывает вопрос про сохранение файла: озвученные файлы нужны только для презентации, для реальной работы их сохранять не обязательно. В качестве примера он взял свой же пост и озвучивает его разными моделями, начал с Яндекса.

  • Эмоции и интонации у голоса Яндекса настраиваются, интонация ставится автоматически
  • Разметкой можно задавать не только ударения, но и интонации с паузами, только это подход посложнее
  • Файлы сохраняются чисто для демонстрации: один и тот же пост озвучивается разными моделями
Сейчас мы делаем разметку только ударения.

RUAccent: расстановка ударений перед озвучкой

Локальная модель есть, но прежде чем что-то озвучивать, тексту надо расставить ударения. Илья пишет отдельный модуль accent.py: класс-обёртку над RUAccent, которая грузит модель turbo3.1 и отдаёт текст с проставленными ударениями. Тут же показывает, где модель промахивается на омографах (слово «готов»), и объясняет, почему без ударений русский текст в любой генерации звучит криво.

Модуль accent.py и класс-обёртка над RUAccent

Отдельный файл accent.py с классом, который прячет внутри всю работу с библиотекой RUAccent.

Перед озвучкой локальной моделью первым делом надо расставить ударения. Илья заводит под это отдельную «библиотечку» accent.py: импортирует туда саму RUAccent, которая идёт с моделью, и настройки приложения (config). Внутри объявляет класс-обёртку и называет его прямо по имени библиотеки, RUAccent. Дальше весь остальной код проекта работает уже не с библиотекой напрямую, а с этим классом, и про её потроха ему знать не надо.

  • Ударения — первое, что делаем перед локальной моделью
  • Отдельный файл accent.py, в него импортируются RUAccent и настройки приложения
  • Класс-обёртка прячет работу с моделью от остального кода

accent.py — класс-обёртка над RUAccent, загрузка модели turbo3.1 на CUDA

from ruaccent import RUAccent

from src.config import settings


class RUAccentizer:
    def __init__(self):
        self.device = "cuda"
        self.accentizer = RUAccent()
        self.accentizer.load(
            omograph_model_size="turbo3.1",
            use_dictionary=True,
            tiny_mode=False,
            device=self.device,
            workdir=str(settings.path.models / "ruaccent"),
        )

    def process(self, input_text):
        """Расставляет ударения в тексте."""
        return self.accentizer.process_all(input_text)
Здесь мы импортируем ту библиотеку, которая идет с моделью, ru-акцент, и настройки нашего приложения сюда.

Загрузка модели: turbo3.1, use_dictionary, device, workdir

В конструкторе создаётся экземпляр RUAccent и вызывается load с параметрами модели, словарей, устройства и рабочей папки.

В конструкторе, при инициализации, создаётся экземпляр RUAccent, и у него вызывается метод load, который подгружает модель. Модель указывается turbo3.1: по словам Ильи, работает очень быстро, и качества хватает. Дальше включается use_dictionary, а параметр по моделям ставится в false. В device автор пишет CUDA, потому что считает на видеокарте, но при желании можно поставить и CPU. Последний параметр — workdir, путь до папки models/ruaccent, где уже лежат скачанные модели. Оттуда всё и подтягивается автоматически при инициализации класса.

  • turbo3.1 — быстрая, и качества достаточно
  • use_dictionary включён, models — false
  • device: у автора CUDA, считает на видеокарте; без карты ставим CPU
  • workdir смотрит в локальную папку models/ruaccent со скачанными моделями
  • При инициализации класс сам подтягивает оттуда всё нужное

accent.py — класс-обёртка над RUAccent, загрузка модели turbo3.1 на CUDA

from ruaccent import RUAccent

from src.config import settings


class RUAccentizer:
    def __init__(self):
        self.device = "cuda"
        self.accentizer = RUAccent()
        self.accentizer.load(
            omograph_model_size="turbo3.1",
            use_dictionary=True,
            tiny_mode=False,
            device=self.device,
            workdir=str(settings.path.models / "ruaccent"),
        )

    def process(self, input_text):
        """Расставляет ударения в тексте."""
        return self.accentizer.process_all(input_text)
Здесь мы указываем модель Turbo 3.1.

Метод process и вызов process_all

У класса один метод process: принимает текст, внутри дёргает process_all у модели и возвращает текст с ударениями.

У класса-обёртки одна функция, метод process, куда передаётся текст. Внутри он просто вызывает у самой модели process_all и передаёт туда этот же текст. На выходе получаем текст с расставленными ударениями. Проверяет Илья всё через секцию if __name__ == "__main__": создаёт экземпляр класса, задаёт текст, печатает результат через print. Секция срабатывает только при запуске файла напрямую, для быстрой проверки удобно.

  • process — единственный публичный метод класса, на вход текст
  • Внутри вызывается process_all у модели RUAccent
  • На выходе текст с расставленными ударениями
  • Проверка через секцию if __name__ == "__main__" и print
Дальше у нашего класса мы сделаем один метод PROCESS, одну функцию, скажем так. Куда будет передаваться текст.

Ляп на омографе: слово «готов»

На фразе «Не готов продолжить наш диалог» модель ставит ударение не туда, потому что так записано у неё в словаре.

Второй пример Илья прогнал специально, чтобы показать ляп. На фразе «Не готов продолжить наш диалог» ударение уезжает не в то место, и получается другое слово. Автор полез разбираться и выяснил, что у RUAccent есть словарики, куда часть слов зашита напрямую: приходит слово «готов» — ударение ставится по словарю, и всё. Лечится руками: идём в модель, разархивируем словарь, переставляем плюсик в другое место, сохраняем. После этого ударение встаёт правильно. Ну или можно написать автору модели, чтобы поправил у себя.

  • Пример ошибки: «Не готов продолжить наш диалог» — ударение уезжает не туда
  • Причина в словаре внутри модели, там ударение для слова зафиксировано
  • Лечится руками: разархивировать словарь, переставить плюсик, сохранить
  • Второй вариант — написать автору модели
  • Русский язык штука непростая; у автора RUAccent есть статья на Хабре про разработку модели
Не готов продолжить наш диалог.

Зачем вообще расставлять ударения

Без ударений русский текст через любую генерацию звучит неестественно.

Илья отдельно проговаривает, зачем этот шаг вообще нужен: без расстановки ударений текст, прогнанный через любую генерацию, будет звучать неестественно, а какие-то вещи прямо очень. Это не косметика, а обязательная предобработка перед синтезом. Ноги растут из самого русского языка: у автора RUAccent даже есть статья на Хабре о том, как он эту модель разрабатывал, Илья советует найти её поиском по названию RUAccent. Итог секции он формулирует просто: «проблема омографов и ударений, как я её решал».

  • Ударения — обязательный шаг перед синтезом, а не опция
  • Без них озвучка звучит неестественно
  • Про разработку RUAccent есть статья на Хабре, ищется по названию модели
  • GitHub и Hugging Face модели тоже находятся поиском по имени
Некоторые вещи будут звучать прям очень неестественно.

Тестовая функция process_post: файл на входе, файл на выходе

Небольшая утилита: читает текстовый файл, расставляет ударения и пишет результат в другой файл.

Отдельно Илья показывает функцию process_post, чисто тестовую, чтобы удобнее было экспериментировать. Она импортирует RUAccent из accent и config, считывает текстовый пост в переменную, создаёт экземпляр класса, расставляет ударения и пишет результат обратно в файл. Автор обращает внимание на то, что with open встречается дважды и выглядит одинаково, но в одном случае это чтение, а в другом запись. Запуск проходит с кодом 0, на выходе txt-файл с расставленными ударениями. В проект эту функцию тащить не надо, автор сам говорит, что она там не нужна.

  • Читает текстовый файл, расставляет ударения, пишет результат в другой txt
  • with open и на чтение, и на запись: конструкция одна, режимы разные
  • Сделана чисто под тест, чтобы прогнать свой пост
  • В основной проект её брать не нужно
Это просто такая тестовая функция.

Главный класс локального синтеза на F5-TTS

Ядро вебинара. Илья пишет главный класс локального синтеза на F5-TTS: от импортов и конструктора до метода infer и потока в памяти. Класс делается асинхронным поверх синхронной библиотеки, модель грузится на CUDA, референсное аудио и текст подтягиваются по имени голоса. А в конце живые замеры: 5.69 секунды при nfe_step=32 против 1.47 секунды при nfe_step=8, и разговор о том, где тут проходит компромисс между качеством и скоростью.

Асинхронный класс поверх синхронной библиотеки

F5-TTS синхронная, поэтому синтез уходит в отдельный поток, а наружу класс отдаёт асинхронный интерфейс.

Асинхронной поддержки у F5-TTS нет, а сервису надо тянуть много запросов сразу. Поэтому импортируется asyncio, а сам синхронный вызов синтеза запускается в отдельном потоке, чтобы не тормозить основную программу. Иначе получится так: один диалог генерируется, а все остальные стоят и ждут. Илья сразу оговаривается, что асинхронность тут всё равно половинчатая: модель одна, многопоточность она не поддерживает.

  • asyncio нужен обязательно: класс должен быть асинхронным
  • сам синтез синхронный, его уводят в отдельный поток
  • без этого остальные диалоги ждут в очереди
  • асинхронность половинчатая: модель в единственном экземпляре

local_tts_f5.py: асинхронный синтез — синхронная библиотека уходит в отдельный поток

class TextToSpeech:
    async def synthesize_to_bytes(self, text: str) -> tuple[bytes, int]:
        # Библиотека F5-TTS синхронная — уводим тяжёлый вызов в отдельный
        # поток, чтобы не блокировать event loop бота/веб-сервера
        return await asyncio.to_thread(self._synthesize_sync, text)
класс нам надо сделать асинхронный, хотя, в принципе, все библиотеки эти синхронные.

Конструктор класса TextToSpeech

В конструктор передаются имя голоса, скорость и nfe_step, там же задаётся устройство и пустой атрибут модели.

Класс называется TextToSpeech, перевод текста в голос. В конструктор принимается voice_name: по этому имени голоса референсы автоматически подтягиваются из папки, допустим «мой голос» или «Ольга». Отдельной переменной задаётся скорость, для разных голосов её удобно ставить разную: для русского текста лучше 1.2–1.3, иначе диалоги получаются растянутыми. Там же указывается устройство (Илья ставит CUDA, потому что генерирует на видеокарте, а на процессоре надо ставить CPU) и создаётся пустой атрибут модели, который заполнится позже, при загрузке.

  • voice_name решает, какой голос подтянется из папки референсов
  • speed для русского текста лучше 1.2–1.3, на 1.0 речь растянутая
  • device: CUDA для видеокарты, CPU — если карты нет
  • атрибут модели создаётся пустым и заполняется при загрузке

local_tts_f5.py: конструктор класса TextToSpeech — voice_name, speed, nfe_step и пути до модели

import asyncio
import io

import soundfile as sf
from f5_tts.api import F5TTS

from config import settings


class TextToSpeech:
    def __init__(
        self, voice_name: str = "ilya", speed: float = 1.0, nfe_step: int = 32
    ):
        self.device = "cuda"
        self.model = None
        self.ckpt_path = (
            settings.path.models
            / "f5" / "F5TTS_v1_Base_v2/model_last_inference.safetensors"
        )
        self.vocab_path = settings.path.models / "f5" / "F5TTS_v1_Base/vocab.txt"
        self._load_model()
        self.ref_audio = None
        self.ref_text = None
        self._load_refs(voice_name)
        self.speed = speed
        self.nfe_step = nfe_step
В конструкторе нашего класса у нас будет приниматься Voice Name, то есть это тот голос, который у нас... будет использоваться при генерации.

Загрузка модели: safetensors, словарь, load_model

Метод load_model создаёт экземпляр F5-TTS с путями до модели и словаря и с устройством, после чего модель уезжает в видеокарту.

На Hugging Face у модели лежит несколько вариантов, Илья берёт самый свежий: V1 Base версия 2, и внутри неё указывает файл model_last_inference.safetensors. Рядом лежит model_last.pt, это скомпилированный вариант под запуск через другой инференс. Отдельно указывается путь до словаря. В методе load_model в переменную модели кладётся экземпляр класса F5-TTS, куда передаются расположение модели, расположение словаря и устройство. При инициализации функции отрабатывают по очереди, и модель грузится в видеокарту.

  • берётся последняя модель V1 Base версия 2 с Hugging Face
  • файл модели — model_last_inference.safetensors
  • рядом лежит скомпилированный вариант под другой инференс
  • load_model получает путь модели, путь словаря и устройство

local_tts_f5.py: конструктор класса TextToSpeech — voice_name, speed, nfe_step и пути до модели

import asyncio
import io

import soundfile as sf
from f5_tts.api import F5TTS

from config import settings


class TextToSpeech:
    def __init__(
        self, voice_name: str = "ilya", speed: float = 1.0, nfe_step: int = 32
    ):
        self.device = "cuda"
        self.model = None
        self.ckpt_path = (
            settings.path.models
            / "f5" / "F5TTS_v1_Base_v2/model_last_inference.safetensors"
        )
        self.vocab_path = settings.path.models / "f5" / "F5TTS_v1_Base/vocab.txt"
        self._load_model()
        self.ref_audio = None
        self.ref_text = None
        self._load_refs(voice_name)
        self.speed = speed
        self.nfe_step = nfe_step

local_tts_f5.py: _load_refs подтягивает референсное аудио и текст, _load_model создаёт F5TTS

    def _load_refs(self, voice_name: str):
        # Эталон голоса: аудио 10-15 сек и его точная расшифровка
        self.ref_audio = settings.path.ref / voice_name / "ref.wav"
        with open(
            settings.path.ref / voice_name / "ref.txt", "r", encoding="utf-8"
        ) as f:
            self.ref_text = f.read().strip()

    def _load_model(self):
        """Загружает модель F5-TTS в память видеокарты"""
        self.model = F5TTS(
            ckpt_file=str(self.ckpt_path),
            vocab_file=str(self.vocab_path),
            device=self.device,
        )

    async def warmup(self):
        """Прогрев: первый холостой прогон, чтобы убрать cold start"""
        dummy_text = "Три девицы под окном пряли поздно вечерком."
        _ = await self.synthesize_to_bytes(dummy_text)
Здесь мы как раз переменную метод помещаем экземпляр класса вот этого f5.dts, где мы передаем в параметрах расположение нашей модели, расположение словарика и на каком устройстве у нас будет работать модель.

ref_audio и ref_text — эталон голоса

Нейронке дают образец: аудиофайл с записанным голосом и текст, который в нём произнесён.

ref_audio — это записанный аудиофайл, где Илья прочитал определённый текст. ref_text — этот самый текст. Модель обрабатывает пару и уже на её основе озвучивает то, что мы ей подаём. Служебная функция _load_refs по voice_name собирает путь: папка ref, дальше имя голоса и имя файла. Нижнее подчёркивание тут значит «только для внутреннего использования внутри класса», примерно как служебные функции в общих модулях БСП в 1С. Текстовый файл считывается в переменную, а ref_audio библиотека читает сама, нам достаточно хранить в нём путь до аудиофайла. Илья замечает, что здесь можно было бы оптимизировать и загрузить аудио сразу в оперативку, но код от этого сильно усложнится.

  • ref_audio — запись голоса, ref_text — произнесённый в ней текст
  • _load_refs собирает пути по voice_name из папки ref
  • в ref_audio хранится путь, сам файл читает библиотека
  • нижнее подчёркивание = служебный метод для внутреннего использования

local_tts_f5.py: _load_refs подтягивает референсное аудио и текст, _load_model создаёт F5TTS

    def _load_refs(self, voice_name: str):
        # Эталон голоса: аудио 10-15 сек и его точная расшифровка
        self.ref_audio = settings.path.ref / voice_name / "ref.wav"
        with open(
            settings.path.ref / voice_name / "ref.txt", "r", encoding="utf-8"
        ) as f:
            self.ref_text = f.read().strip()

    def _load_model(self):
        """Загружает модель F5-TTS в память видеокарты"""
        self.model = F5TTS(
            ckpt_file=str(self.ckpt_path),
            vocab_file=str(self.vocab_path),
            device=self.device,
        )

    async def warmup(self):
        """Прогрев: первый холостой прогон, чтобы убрать cold start"""
        dummy_text = "Три девицы под окном пряли поздно вечерком."
        _ = await self.synthesize_to_bytes(dummy_text)
То есть, мы нейронке указываем, какой текст, как был озвучен.

Прогрев модели

Перед первым боевым запуском модель один раз прогоняют на тестовом тексте, а результат выбрасывают.

Перед первым использованием модели нужен прогрев, под это делается отдельная функция warm. Текст для прогрева Илья не стал считывать из файла со стихом, просто вписал его текстовой переменной. Внутри через await вызывается асинхронная функция синтеза, а результат никуда не сохраняется. Задача одна: чтобы модель разок прогналась и разогрелась.

  • warm вызывается один раз перед боевой генерацией
  • текст прогрева зашит прямо в переменную
  • результат прогрева не сохраняется
  • внутри используется тот же асинхронный метод синтеза

local_tts_f5.py: пример запуска — прогрев, замер времени и сохранение аудио

    def _synthesize_sync(self, text: str) -> tuple[bytes, int]:
        wav, sr, _ = self.model.infer(
            ref_file=str(self.ref_audio),
            ref_text=self.ref_text,
            gen_text=text,
            speed=self.speed,
            remove_silence=False,
            nfe_step=self.nfe_step,   # рычаг качество/скорость
            seed=None,
        )

        # Аудио уходит байтами из потока в памяти, без записи на диск
        audio_buffer = io.BytesIO()
        sf.write(audio_buffer, wav, sr, format="WAV")
        audio_bytes = audio_buffer.getvalue()
        audio_buffer.close()
        return audio_bytes, sr
Она, скажем так, разогреется, когда мы вызовем эту функцию.

Метод infer и поток в памяти вместо файлов

Главная функция вызывает infer и отдаёт байты через поток в памяти, диск не трогает вообще.

У загруженной модели есть метод infer, он и генерирует данные. В него передаются ref_audio, ref_text, gen_text (тот текст, который надо озвучить), скорость, качество генерации и seed = None. Параметр удаления тишины ставится в false: он немного замедляет модель, а на низких значениях качества начинает срезать лишнее. Дальше через io создаётся поток в памяти, туда пишется аудио, из потока забираются байты, поток закрывается, наружу уходят двоичные данные. soundfile и сохранение файлов остаются только для теста: в реальном проекте всё крутится в оперативке, потому что на диск уходят драгоценные миллисекунды. Илья сравнивает это с потоками в памяти в 1С, которые как раз и завезли, чтобы не сохранять файл перед прикреплением к телу HTTP-запроса.

  • infer принимает ref_audio, ref_text, gen_text, скорость, nfe_step, seed=None
  • удаление тишины выключено: замедляет и срезает лишнее на низком качестве
  • результат уходит байтами через поток в памяти (io)
  • сохранение файлов через soundfile — только для теста
  • аналогия с потоками в памяти в 1С

local_tts_f5.py: асинхронный синтез — синхронная библиотека уходит в отдельный поток

class TextToSpeech:
    async def synthesize_to_bytes(self, text: str) -> tuple[bytes, int]:
        # Библиотека F5-TTS синхронная — уводим тяжёлый вызов в отдельный
        # поток, чтобы не блокировать event loop бота/веб-сервера
        return await asyncio.to_thread(self._synthesize_sync, text)
Поэтому сохранение файлов только для теста.

nfe_step: компромисс качества и скорости

nfe_step задаёт качество генерации: на 32 получается 5.69 секунды, на 8 — уже 1.47 секунды.

nfe_step — это качество генерации, с которым нейронка синтезирует звук. Чем выше значение, тем лучше звучит, и тем медленнее считается. На дефолтных 32 синтез поста занял 5 секунд 69 миллисекунд, а Яндекс тот же пост сгенерировал за 0.96 секунды. Илья говорит прямо: 6 секунд для real-time диалога нереальны. Пока транскрибировали, пока ответила LLM, и сверху ещё почти 6 секунд на озвучку. При nfe_step=8 модель выдала 1.47 секунды, это уже гораздо реальнее, но разница в качестве, по его словам, слышна сильно. Отсюда и вывод: значение надо подбирать под себя, возможно поставить 12 или 16.

  • nfe_step = качество: выше значение — лучше звук, но медленнее
  • 32 (дефолт): синтез 5.69 с; Яндекс тот же пост — 0.96 с
  • 8: синтез 1.47 с, но разница в качестве слышна сильно
  • почти 6 секунд нереальны для real-time диалога по телефону
  • разумный компромисс где-то в районе 12–16

local_tts_f5.py: пример запуска — прогрев, замер времени и сохранение аудио

    def _synthesize_sync(self, text: str) -> tuple[bytes, int]:
        wav, sr, _ = self.model.infer(
            ref_file=str(self.ref_audio),
            ref_text=self.ref_text,
            gen_text=text,
            speed=self.speed,
            remove_silence=False,
            nfe_step=self.nfe_step,   # рычаг качество/скорость
            seed=None,
        )

        # Аудио уходит байтами из потока в памяти, без записи на диск
        audio_buffer = io.BytesIO()
        sf.write(audio_buffer, wav, sr, format="WAV")
        audio_bytes = audio_buffer.getvalue()
        audio_buffer.close()
        return audio_bytes, sr
и вот мы видим да синтез 5 секунд 69 миллисекунд вот аудио у нас сгенерировано и сравните с яндексом

Инференс на PyTorch против Triton и пула моделей

Сейчас модель крутится на PyTorch, но разработчик обещает на NVIDIA Triton прирост больше чем в три раза.

Запуск идёт через PyTorch, библиотеку не самую оптимизированную. Есть варианты, заточенные под видеокарты плотнее, например NVIDIA Triton Inference Server: он по максимуму выжимает возможности оборудования. К уроку запустить его не получилось, поэтому вопрос отложили. Разработчик модели говорит, что на Triton скорость по сравнению с инференсом на PyTorch больше чем в три раза выше. Тогда текущие 1.47 секунды при nfe_step=8 превратились бы примерно в 0.3 секунды, то есть быстрее Яндекса. А значит, можно было бы поднять nfe_step до 16 и всё равно уложиться в 0.7–0.8 секунды. Отдельная головная боль в том, что модель не поддерживает многопоточность: в продакшене нужен либо Triton, который умеет держать несколько экземпляров и сам раскидывает нагрузку, либо свой пул моделей с ручным распределением диалогов.

  • текущий инференс — PyTorch, не самый оптимизированный
  • на Triton заявлен прирост более чем в три раза: ~0.3 с вместо 1.47 с
  • с Triton можно поднять nfe_step до 16 и остаться в пределах секунды
  • модель не поддерживает многопоточность
  • альтернатива Triton — свой пул моделей с ручным распределением нагрузки
И я ее пробовал, она не поддерживает многопоточность.

Вопросы: Triton Inference Server и зачем нужен этот сервис

Блок вопросов из зала. Спрашивают в основном про два: что за зверь NVIDIA Triton Inference Server и зачем вообще городить свой локальный сервис синтеза речи. По Triton Илья рассказывает, как тот компилирует модели под конкретную видеокарту и раздаёт инференс по HTTP и gRPC. Со вторым вопросом интереснее, там приходится объяснять конечную цель всего проекта: голосовой робот на IP-телефонии, который поднимает трубку, разговаривает голосом и переводит звонок на нужный отдел. И заодно честно говорит, что готовая модель ChatGPT эту задачу закрывает и без всей нашей возни, просто стоит приличных денег.

NVIDIA Triton Inference Server

Сервер инференса от NVIDIA: компилирует модель под твоё железо и сам раздаёт её по HTTP или gRPC.

Triton это разработка NVIDIA, и она максимально оптимизирована под их же оборудование, что логично. Ты закидываешь туда модели разных форматов, PyTorch он поддерживает, а Triton компилирует их в свой специальный формат под ту конкретную видеокарту, которая стоит в машине. Дальше он уже сам распределяет нагрузку, подгружает и масштабирует модели, то есть за тебя делает всю эту работу. Работает через HTTP или gRPC, клиенты на Python готовые: указываешь IP развёрнутого Triton и обращаешься к своей модели. За счёт того, что компиляция идёт прямо под железо, инференс получается заметно быстрее.

  • Разработка NVIDIA, заточенная под их же оборудование
  • Принимает модели разных форматов (PyTorch в том числе) и компилирует в свой формат
  • Компиляция идёт под конкретную видеокарту при запуске, отсюда и прирост скорости
  • Сам распределяет нагрузку, подгружает и масштабирует модели
  • Доступ через HTTP или gRPC, клиенты на Python готовые: указываешь IP развёрнутого сервера
штука очень крутая и очень быстро соответственно она работает быстрее она компилирует прям под твое оборудование под твою видеокарту

Голосовой робот на IP-телефонии — конечная цель проекта

Ради чего всё затевается: робот поднимает трубку, разговаривает голосом и переводит звонок на нужный отдел.

Тут Илья объясняет, какой сервис он вообще хочет получить в итоге. Человек звонит, трубку через IP-телефонию поднимает не менеджер, а робот: «Алло, здравствуйте, компания такая-то». Звонящий просит позвать дядю Васю, а в промпте уже прописано, что если просят позвать сотрудника, надо уточнить отдел и фамилию. LLM генерирует ответ текстом, текст озвучивается нужным голосом через тот самый TTS-сервис и уходит обратно по IP-телефонии. Когда абонент назвал отдел, бот через прикрученные к нему инструменты управляет IP-телефонией и переводит звонок на конкретный отдел, уже живому человеку. Заменять людей полностью цель не стоит. Нужен бот, который может ответить на любые вопросы, а на «кто убил Кеннеди» честно скажет, что не знает.

  • Трубку через IP-телефонию поднимает робот, а не живой менеджер
  • В промпте прописан сценарий: просят сотрудника, уточни отдел и фамилию
  • LLM генерирует текст ответа, сервис TTS озвучивает его нужным голосом
  • К боту прикручены инструменты, которые управляют IP-телефонией и переводят звонок
  • Цель не в том, чтобы заменить людей, а в том, чтобы довести звонок до нужного отдела
То есть мы не хотим заменить полностью людей. Мы хотим сделать бота, который может отвечать на любые вопросы.

Готовая модель ChatGPT против своих микросервисов

У ChatGPT уже есть модель, которая делает всё это без микросервисов и ухищрений. Но она стоит денег.

Илья тут говорит честно: задача решается и без того, что мы собираем на вебинаре. У ChatGPT есть уже готовая модель, которая умеет разговаривать голосом безо всех этих микросервисов и ухищрений. Проблема одна, это цена. Ролики на YouTube в духе «за 10 минут соберём аудиобота, который будет заниматься суперпродажами» он не отрицает: круто, работает, только стоит очень приличных денег. Ну и допускает, что в будущем выпустят более дешёвые облачные модели, которые смогут вести живые диалоги с клиентами. Тогда своя обвязка станет не нужна.

  • Готовая модель ChatGPT закрывает задачу без микросервисов и ухищрений
  • Минус один, но решающий: цена
  • Ютубовские «аудиоботы за 10 минут» работают, просто стоят очень приличных денег
  • В будущем могут появиться дешёвые облачные модели, и тогда своя сборка не понадобится
У ChatGPT есть уже готовая модель.

Этап генерации в общем конвейере

Текущий шаг вебинара: научиться генерировать аудио из текста, который выдаст LLM.

Дальше Илья возвращает разговор от конечной цели к тому, чем занимаемся прямо сейчас. Сейчас у нас этап генерации. LLM генерирует ответ текстом, и этот текст надо подать в генератор, то есть в тот самый TTS. На выходе получается аудиофайл, который уже отдают человеку. То есть сервис синтеза речи это один кусок конвейера, который стоит между текстовым ответом модели и звуком в трубке.

  • LLM выдаёт ответ текстом, это вход для TTS
  • Текст подаётся в генератор, на выходе аудиофайл
  • Аудиофайл уже отдаётся человеку
  • Текущий вебинар закрывает именно этот кусок общего конвейера
Так, сейчас у нас этап генерация.

Выбор голоса и образец для клонирования

Голос подойдёт почти любой, нужен короткий образец на 10-15 секунд, чтобы модель его подхватила.

Спрашивают, какие есть лучшие голоса кроме тех, что он показывал. Илья отвечает: да хоть какие. Он просто взял голосовое сообщение, которое ему передали в Telegram, текст этого сообщения использовал как reference text, а саму запись чуть-чуть обрезал. Обрезал не просто так: в этой модели надо подавать секунд 10-15, вот такие коротенькие ролики, тогда она сможет использовать этот голос дальше. Про ElevenLabs говорит, что это готовый сервис, который умеет озвучивать примерно то же самое, и под капотом у них тоже какая-то нейронка крутится.

  • Годится практически любой голос, взяли обычную голосовушку из Telegram
  • Текст сообщения пошёл как reference text, аудио чуть обрезали
  • Модели нужен образец секунд на 10-15, короткий ролик
  • ElevenLabs это готовый сервис с нейронкой под капотом, делает примерно то же самое
потому что в этой модели там секунд надо 10-15 подавать, вот такие вот коротенькие ролики, для того, чтобы она могла потом этот голос использовать.

Архитектура сервиса: FastAPI, логи, lifespan и WebSocket

Модель уже умеет говорить, теперь вокруг неё надо собрать сервис. Илья набрасывает архитектуру на FastAPI: главный файл main.py, отдельный bot.py с глобальным bot_manager, настройка логов, функция lifespan для загрузки модели при старте и роутер с WebSocket-эндпоинтом. Смысл всей этой конструкции простой: держать модель в памяти видеокарты постоянно и общаться с соседним сервисом по постоянному соединению, без накладных расходов HTTP.

Стек сервиса и запуск через uvicorn

FastAPI, uvicorn, orjson и библиотека websockets. main.py создаёт экземпляр приложения и сам же его запускает.

Ставим фреймворк FastAPI с uvicorn, сериализатором orjson и библиотекой веб-сокетов. Главный файл это main.py, рядом лежит bot.py, где живёт bot_manager. В main создаётся экземпляр приложения FastAPI, туда указывается сериализатор JSON и функция lifespan, которая должна запускаться при старте. В самом конце файла вызывается uvicorn.run, куда передаётся строка вида main:app, хост и порт из настроек. Перезагрузку отключаем, reload false, чтобы не висела проверка изменений кода. Запускается всё элементарно: правой клавишей run main.

  • main.py это главный файл приложения, bot.py отдельный файл под bot_manager
  • В экземпляр FastAPI передаются сериализатор JSON (orjson) и lifespan
  • uvicorn.run получает main:app, хост и порт из настроек
  • reload false, автоперезагрузка при изменении кода тут не нужна

main.py: lifespan грузит модель в bot_manager при старте и выгружает при остановке

@asynccontextmanager
async def lifespan(app: FastAPI):
    logger.info("TTS application starting up")
    tts = TextToSpeech(voice_name="ilya", speed=1)

    # Прогрев модели, чтобы убрать cold start на первом запросе
    logger.info("Прогрев модели TTS...")
    try:
        await tts.warmup()
        logger.info("Модель успешно прогрета")
    except Exception as e:
        logger.warning(f"Ошибка прогрева модели: {e}")

    bot_manager["tts"] = tts   # модель доступна из любого хендлера
    yield
    bot_manager["tts"] = None
    logger.info("TTS application shutting down")


main_app = FastAPI(default_response_class=ORJSONResponse, lifespan=lifespan)
main_app.include_router(api_router, prefix=settings.api.prefix)
Поэтому мы делаем файл main.py. Это у нас наш главный файл нашего приложения. Делаем еще один файл bot.py.

Настройка логирования

В Python логи настраиваются как угодно: свой файл, свой уровень детализации, ротация по размеру.

Тут Илья отдельно подчёркивает контраст с 1С: там настраивать логи нельзя, а в Python пиши хоть куда и хоть какие. В файле логера импортируется специальный класс RotatingFileHandler, который позволяет перезаписывать файл лога по достижении определённого размера, и библиотека для формирования путей. Все логи складываются в папку logs, туда создаётся project.log. Дальше создаётся корневой логер с настройками: максимальный размер одного файла, что писать в файл и что выводить в консоль. В файл идёт подробная информация, в консоль не сильно подробная. Если захочется, можно гибко разделить логи на несколько файлов: info.log, error.log и так далее.

  • Логи хранятся в папке logs, основной файл project.log
  • RotatingFileHandler ограничивает максимальный размер файла лога
  • Разные обработчики: подробно в файл, кратко в консоль
  • При желании можно разнести на info.log, error.log и другие файлы

log.py — логирование с ротацией файлов в папку logs

import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path

logs_dir = Path(__file__).parent.parent / "logs"
LOG_FILE = logs_dir / "project.log"

logger = logging.getLogger("llm_logger")

if not logger.handlers:
    # Ротация файла лога: до 5 МБ, 5 бэкапов
    file_handler = RotatingFileHandler(
        LOG_FILE, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8"
    )
    file_handler.setLevel(logging.INFO)
    fmt = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
    file_handler.setFormatter(fmt)
    logger.addHandler(file_handler)

    console_handler = logging.StreamHandler()
    console_handler.setLevel(logging.INFO)
    console_handler.setFormatter(fmt)
    logger.addHandler(console_handler)

logger.setLevel(logging.INFO)
В 1С у нас нет возможности настраивать логи.

lifespan — жизненный цикл приложения

Функция lifespan с контекстным менеджером грузит тяжёлую модель один раз при старте и выгружает её при завершении.

В FastAPI есть точка старта приложения, по аналогии с «при начале работы системы» в 1С. Пишем функцию lifespan, которая принимает app, и оборачиваем её в контекстный менеджер asynccontextmanager, синтаксическая конструкция описана в документации. Всё, что до yield, выполняется при самом старте, вот тут и подключаются, и загружаются тяжёлые модели. Text-to-Speech модель грузится долго, и если делать это на каждый вызов, она будет каждый раз подгружаться с диска в память, а после запроса удаляться. После завершающей части Илья кладёт в ключ tts значение None, чтобы модель гарантированно выгрузилась из оперативки при остановке приложения. Тяжёлых подгрузок в этой функции может быть и несколько.

  • lifespan оборачивается контекстным менеджером и принимает app
  • При старте грузим тяжёлые модели, чтобы не тянуть их на каждый запрос
  • Моделей и тяжёлых подгрузок может быть несколько
  • При завершении в ключ tts пишется None, модель удаляется из памяти

main.py: lifespan грузит модель в bot_manager при старте и выгружает при остановке

@asynccontextmanager
async def lifespan(app: FastAPI):
    logger.info("TTS application starting up")
    tts = TextToSpeech(voice_name="ilya", speed=1)

    # Прогрев модели, чтобы убрать cold start на первом запросе
    logger.info("Прогрев модели TTS...")
    try:
        await tts.warmup()
        logger.info("Модель успешно прогрета")
    except Exception as e:
        logger.warning(f"Ошибка прогрева модели: {e}")

    bot_manager["tts"] = tts   # модель доступна из любого хендлера
    yield
    bot_manager["tts"] = None
    logger.info("TTS application shutting down")


main_app = FastAPI(default_response_class=ORJSONResponse, lifespan=lifespan)
main_app.include_router(api_router, prefix=settings.api.prefix)
Вот как раз в этой функции мы подгружаем нашу модель.

bot_manager — глобальный держатель модели

Пустая структура-словарь, в которую при старте кладётся инициализированная модель, доступная из любой части приложения.

В bot.py объявляется bot_manager, по умолчанию это пустая структура, в терминах Python пустой словарь. При старте приложения в lifespan в этой глобальной переменной создаётся ключик, куда помещается экземпляр модели, уже инициализированный и загруженный в память с определёнными параметрами: голос Илья, скорость единица. Дальше bot_manager импортируется в любом месте проекта, и там уже доступна модель, лежащая в памяти видеокарты. Она будет находиться там всё время, пока приложение не завершится. Этот же bot_manager потом импортируется в роутере, чтобы вызывать синтез.

  • bot_manager это изначально пустая структура (словарь) в bot.py
  • При старте в ключ кладётся загруженная модель с параметрами (голос, скорость)
  • Импортируется в любой части приложения, модель уже в памяти видеокарты
  • Живёт до завершения приложения

main.py: lifespan грузит модель в bot_manager при старте и выгружает при остановке

@asynccontextmanager
async def lifespan(app: FastAPI):
    logger.info("TTS application starting up")
    tts = TextToSpeech(voice_name="ilya", speed=1)

    # Прогрев модели, чтобы убрать cold start на первом запросе
    logger.info("Прогрев модели TTS...")
    try:
        await tts.warmup()
        logger.info("Модель успешно прогрета")
    except Exception as e:
        logger.warning(f"Ошибка прогрева модели: {e}")

    bot_manager["tts"] = tts   # модель доступна из любого хендлера
    yield
    bot_manager["tts"] = None
    logger.info("TTS application shutting down")


main_app = FastAPI(default_response_class=ORJSONResponse, lifespan=lifespan)
main_app.include_router(api_router, prefix=settings.api.prefix)
Она будет находиться там все время, пока у нас наше приложение не завершится.

APIRouter, префикс и версионирование

FastAPI работает как HTTP-сервер, а запросы разбирает роутер, с префиксом /api и версией v1.

Фреймворк FastAPI работает как HTTP-сервер: приходят HTTP- или WebSocket-запросы, и их кто-то должен обрабатывать. Этим и занимается роутер. В main подключается APIRouter с префиксом, взятым из настроек: приложение поднимается на 127.0.0.1:8000, дальше идёт /api и уже конкретные маршруты. В router.py создаём роутер с префиксом v1, обычная нумерация версий API. Смысл в том, что если захочется поменять функционал, вы не ломаете предыдущие разработки: делаете роутер версии 2, разрабатываете там своё и в один прекрасный день переключаетесь. Илья называет это best practice при разработке таких сервисов.

  • Роутер это точка, где обрабатываются HTTP- и WebSocket-запросы
  • Префикс /api подтягивается из настроек, хост 127.0.0.1, порт 8000
  • Внутри роутера префикс v1, итоговый путь /api/v1/...
  • Версионирование позволяет ввести v2, не сломав старое
Создаем роутер с префиксом v1.

WebSocket-эндпоинт и цикл обработки

Асинхронная функция websocket_endpoint принимает соединение и в бесконечном цикле принимает текст, синтезирует речь и отдаёт base64 обратно.

Главная функция роутера это websocket_endpoint, она принимает соединение WebSocket и объявлена асинхронной, потому что сам FastAPI асинхронный. Итоговый маршрут /ws/tts: когда приложение обращается по полному пути, срабатывает подключение. Функция пытается установить соединение, пишет в консоль, что всё подключилось, и работает внутри обработки исключений. При WebSocketDisconnect пишем, что соединение закрыто клиентом, при непонятной ошибке отправляем расширенную информацию в лог, в finally пишем, что соединение завершено. Дальше идёт бесконечный цикл: постоянно мониторим канал и получаем текст от сервиса транскрибации, пишем в лог запуск генерации речи, обращаемся к bot_manager, синтезируем текст в двоичные данные, получаем аудио и sample rate, конвертируем в строку base64 и вызываем у WebSocket send_text. Отдельно обрабатывается исключительная ситуация по синтезу, тогда клиенту отправляется описание ошибки.

  • websocket_endpoint асинхронный, маршрут /ws/tts
  • Бесконечный цикл: приём текста → синтез через bot_manager → base64 → send_text
  • Отдельные обработки исключений: по WebSocket и по синтезу
  • Данные уходят на frontend, простую страничку, которая их озвучивает
  • Проверить прямо сейчас нельзя: нужен клиент, его напишут в другом сервисе

router.py: websocket-эндпоинт — приём текста, синтез, аудио в base64 клиенту

import base64

from fastapi import APIRouter, WebSocket, WebSocketDisconnect

from src.bot import bot_manager
from src.log import logger

router = APIRouter(prefix="/v1")


@router.websocket("/ws_tts")
async def websocket_endpoint(websocket: WebSocket):
    """Клиент шлёт текст, сервер возвращает синтезированное аудио в base64."""
    await websocket.accept()
    logger.info("Новое WebSocket подключение")
    try:
        while True:
            message = await websocket.receive_text()
            logger.info(f"Получен текст для синтеза: '{message}'")
            try:
                audio_bytes, sr = await bot_manager["tts"].synthesize_to_bytes(message)
                audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
            except Exception as synth_error:
                logger.error(f"Ошибка синтеза речи: {synth_error}", exc_info=True)
                await websocket.send_text(f"ERROR: {str(synth_error)}")
                continue

            await websocket.send_text(audio_base64)
            logger.info("Аудио отправлено клиенту")
    except WebSocketDisconnect:
        logger.info("WebSocket соединение закрыто клиентом")
    finally:
        logger.info("Соединение завершено")
То есть мы получили текст, сгенерировали и обратно отправляем текстовые данные, сгенерированные только в строке base64.

Зачем WebSocket, а не HTTP-запрос

WebSocket-соединение устанавливается один раз и держится постоянно, а каждый HTTP-запрос тратит время на служебное взаимодействие.

Это был вопрос из зала, и Илья отвечает прямо. В случае WebSocket соединение устанавливается при старте приложения, когда стартует второй сервис, и дальше держится постоянно, не закрывается. А у HTTP-запроса под капотом происходит определённое взаимодействие, которое затрачивает время, несколько миллисекунд на каждый запрос. Здесь же идёт борьба за то, чтобы сервис работал максимально быстро, поэтому между сервисом транскрибации и сервисом синтеза в реальном времени держится постоянная связь. Кстати, веб-сокеты не так давно добавили и в 1С, по этому поводу у одного из подписчиков Ильи есть статьи на Инфостарте.

  • WebSocket: одно соединение при старте, дальше держится и не закрывается
  • HTTP: каждый запрос стоит миллисекунд на служебное взаимодействие
  • Цель это максимальная скорость сервиса и связь в реальном времени
  • В 1С веб-сокеты появились недавно, есть статьи на Инфостарте
http запрос, когда вы отправляете, у него там происходит под капотом определенное взаимодействие, которое затрачивает время.

Почему это отдельный микросервис

Нейронки требуют конкретных версий библиотек, поэтому синтез выносится в отдельный мини-сервис, который может крутиться даже на другом компьютере.

Тут Илья объясняет, почему не собрал всё в одном проекте. Нейронки зачастую требуют определённых библиотек, вот здесь мы ставили конкретные версии, и нет никакой гарантии, что при наращивании сервиса другие библиотеки будут с ними корректно работать. Такое бывает часто, особенно когда в один проект вставляют множество разных нейронок и собирают из этого pipeline: одна нейронка, вторая, третья, пятая, а на выходе результат. Поэтому какие-то версии приходится выносить в отдельный мини-сервис. Ну и бонусом такой мини-сервис может работать вообще на отдельном компьютере, допустим на Ubuntu-сервере с видеокартой, куда ты вообще никак не лезешь. При этом сам синтез можно гонять и просто через один файлик, не заморачиваясь со всей остальной архитектурой, и даже убрать асинхронность, чтобы код стал лаконичнее.

  • Разные нейронки требуют несовместимых версий библиотек
  • Проблема обостряется, когда в один pipeline собирают несколько моделей
  • Мини-сервис может крутиться на отдельной машине с видеокартой
  • Для простой генерации архитектура не обязательна, хватит одного файла без асинхронности
Потому что вот эти вот все нейронки зачастую требуют определенных библиотек. И вот здесь мы, допустим, ставили определенные версии библиотек.

Куда это ведёт: курс по ИИ-агентам для 1С

Сервис синтеза речи из этой статьи — один кирпич большого конвейера: транскрибация → LLM → синтез, который в итоге отвечает голосом в роботе на IP-телефонии. Всю цепочку целиком я разбираю на флагманском курсе «ИИ-агент для 1С: LangChain, RAG и MCP-серверы»: окружение, теория Python, FastAPI, LangChain, RAG, агенты на LangGraph и деплой в Docker. Ниже — как устроена программа и почему в ней есть неочевидные блоки вроде тестирования LLM-приложений и поиска по номенклатуре, который по сути свой маленький «Яндекс». На вдумчивое изучение и внедрение закладывайте примерно полгода — доступ к материалам не сгорает.

Программа курса по модулям

Курс идёт по порядку: окружение, теория Python, FastAPI, LangChain и RAG, потом агенты на LangGraph и деплой в Docker.

Начинается всё с рабочего окружения: настроить компьютер, поставить нужные библиотеки. Дальше теория Python, для тех, кто языка побаивается, всё разжёвывается на пальцах. Потом базовый проект на FastAPI с разбором архитектуры проекта, получение API-ключей, основы LangChain и RAG. После практики с RAG-ботом идёт теория LangGraph и агенты, распознавание голосовых сообщений через Telegram, агент по наличию товара и составу заказов, небольшой раздел по промптингу и деплой. Курс Илья пополняет по ходу, поэтому цена к его окончанию растёт. По времени это примерно 6 месяцев.

  • Окружение, теория Python, базовый проект на FastAPI
  • API-ключи, основы LangChain, RAG и практика с RAG-ботом
  • Теория LangGraph и агенты, голосовые через Telegram, промптинг, деплой
  • Работа идёт через готовые библиотеки, а не напрямую с моделями
  • По времени примерно 6 месяцев
Дальше теория ланграфа, потому что у нас сейчас, наверное, все современные уже слышали про агентов. Как раз агентов мы будем пилить на ланграфе.

Поиск по номенклатуре как RAG-задача

На курсе строится RAG, который ищет нужную позицию среди десятков тысяч наименований номенклатуры.

Одна из практических задач курса это поиск по номенклатуре внутри RAG. Илья называет её нетривиальной: фактически пишется свой Яндекс, который среди десятков тысяч позиций пытается найти то, что интересует человека. Дальше эта же задача возвращается в виде агента, который отвечает по наличию товара и составу заказов, и вот это уже, по его словам, очень сложный RAG. Всё в связке с 1С: курс целится в то, чтобы закрыть побольше реальных задач автоматизации, которые можно решить для 1С.

  • Поиск по номенклатуре это нетривиальная задача внутри RAG
  • По сути разрабатывается собственный поисковик по десяткам тысяч позиций
  • Продолжение задачи — агент по наличию товара и составу заказов
  • Фокус курса на задачах автоматизации, которые можно решить для 1С
Это очень нетривиальная задача, фактически мы будем писать там свой Яндекс, свой Яндекс будем разрабатывать, в котором мы будем среди десятков тысяч позиций пытаться найти то, что интересует человека.

Тестирование LLM-приложений и метрики

Отдельный блок курса про то, как собрать датасет, договориться о метриках и прогнать тест через Langfuse, чтобы сдавать проект по цифрам, а не по ощущениям клиента.

Тестирование LLM-приложений Илья считает важной вещью, поэтому под него отведён отдельный блок. Датасет, на котором будет тестироваться проект, и метрики надо составлять с клиентом заранее. Причина простая: такие проекты сдаются по метрикам, а не по субъективному мнению. Иначе клиент говорит, что ваш RAG плохо работает, а как он это определил, непонятно. Схема в курсе такая: договорились, что из 100 вопросов должно быть 90 правильных ответов, прогнали тест через Langfuse по датасету, получили 90 процентов, подпись в акте, проект сдан. Параллель с 1С-проектами прямая: если ТЗ чётко не прописано, потом очень тяжело со всем этим разбираться.

  • Датасет и метрики согласуются с клиентом заранее
  • Проекты сдаются по метрикам, а не по субъективной оценке
  • Пример критерия: 90 правильных ответов из 100 вопросов
  • Прогон теста по датасету выполняется через Langfuse
  • Аналогия с 1С: без чёткого ТЗ сдача превращается в спор
Потому что эти проекты сдаются по метрикам.

Деплой: Docker, чистая VDS и инфраструктура Яндекса

Каждый проект курса упаковывается в Docker, а в блоке деплоя показано развёртывание на чистой VDS и в инфраструктуре Яндекса.

Каждый проект курса по факту упаковывается в Docker, и есть отдельный раздел про то, как этот Docker развернуть и настроить. Вариантов показано два: чистая VDS и инфраструктура Яндекса. Второй Илья хвалит отдельно, инфраструктура удобная, плюс у Яндекса есть свои модели, поднял сервер и пользуешься. Пример из практики: клиенту он развернул такой сервер за 2500 рублей в месяц с моделями Яндекса.

  • Все проекты курса упаковываются в Docker
  • Разбираются два сценария: чистая VDS и инфраструктура Яндекса
  • У Яндекса есть свои модели, доступные прямо из их инфраструктуры
  • Пример из практики: сервер клиенту за 2500 в месяц
Ну, диплой развертывания проекта, по факту мы каждый вот этот проект упаковываем в докер, а здесь я просто покажу, как мы можем этот докер развернуть и настроить на каком-то проекте.

Что дальше

Мы собрали работающий TTS-микросервис на своём железе: F5-TTS Russian для синтеза, RUAccent для ударений, FastAPI с WebSocket для потоковой отдачи аудио и lifespan для загрузки модели в память видеокарты один раз при старте. Дальше эту скорость можно догонять оптимизированным инференсом (NVIDIA Triton), а сам сервис — встраивать в голосового робота: транскрибация входящего звонка → LLM с поиском по номенклатуре 1С → синтез ответа.

Если хотите пройти весь этот путь на своих данных и довести до промышленного решения под 1С — приходите на курс «ИИ-агент для 1С: LangChain, RAG и MCP-серверы». Исходники этого сервиса с инструкцией в README можно забрать в блоге; если что-то не заведётся — задавайте вопросы, разберём.

Глоссарий

venv
Виртуальное окружение Python под конкретный проект: свой набор библиотек, не пересекающийся с системным. Видно прямо в терминале IDE, при блокировках его можно целиком скопировать из другого проекта.
CUDA
Технология NVIDIA, через которую PyTorch считает на видеокарте вместо процессора. От того, поставлена ли сборка torch с суффиксом CUDA, напрямую зависит, уложится ли синтез в реалтайм.
Hugging Face Hub
Портал с моделями и одноимённая библиотека для работы с ним. Через неё модели качаются кодом, а не руками через браузер.
F5-TTS Russian (Misha24-10)
Модель F5-TTS, дообученная на русском датасете, рабочая лошадка проекта. Синтезирует речь заданным голосом по короткому референсному аудио.
RUAccent
Нейросеть, которая расставляет ударения в русском тексте перед подачей на озвучку. Без ударений любой русский текст в синтезе звучит неестественно.
gpu_check.py
Скрипт на десяток строк с torch.cuda.is_available: проверяет, что PyTorch действительно видит видеокарту, и печатает её характеристики. Первое, что стоит запустить после установки библиотек.
.env и .env.template
Файл с ключами и настройками окружения, который не уезжает в репозиторий, и его шаблон для повторения проекта. Здесь лежат ключ Яндекса, Hugging Face Token, префикс API, хост и порт.
pydantic-settings
Библиотека, которая читает .env и превращает его в типизированные классы настроек. В проекте на ней собран config.py с единым экземпляром Settings на всё приложение.
snapshot_download
Функция huggingface_hub, скачивающая модель целиком в указанную папку. В связке с переменными окружения HF через os.environ модели ложатся в проект, а не забивают диск C.
Yandex SpeechKit
Облачный TTS Яндекса, который автор поднимает первым как эталон скорости. Тестовый пост он озвучил за 0.96 секунды, это планка, с которой сравнивается локальная генерация.
contextmanager (таймер)
Декоратор из contextlib, который превращает функцию в конструкцию with. На нём собрана утилита time_utils для замера времени блоков кода, в том числе вложенных.
Омографы
Слова, которые пишутся одинаково, а произносятся с разным ударением. Именно на них RUAccent ошибается: на вебинаре модель промахнулась в слове «готов», потому что так записано в её словаре.
turbo3.1
Модель RUAccent, которая грузится методом load с параметрами use_dictionary, device и workdir. Работает на видеокарте, ударения отдаёт плюсиками прямо в тексте.
safetensors
Формат файла весов модели, в проекте это model_last_inference.safetensors от версии v1 base v2. Путь до него и до словаря передаётся в F5TTS при загрузке.
ref_audio и ref_text
Образец голоса для клонирования: аудиофайл на 10-15 секунд и текст, который в нём произнесён. Лежат в папке refs и подтягиваются методом _load_refs по имени голоса.
nfe_step
Число шагов генерации F5-TTS, прямой рычаг качество/скорость. При 32 синтез занял 5.69 секунды, при 8 всего 1.47 секунды, а разница в звучании при этом не катастрофическая.
Прогрев модели
Один холостой прогон на тестовом тексте сразу после загрузки, результат которого выбрасывается. Без него первая реальная генерация заметно медленнее.
NVIDIA Triton Inference Server
Сервер инференса от NVIDIA: компилирует модель под конкретное железо, сам масштабирует её и раздаёт по HTTP или gRPC. Разработчик F5-TTS обещает на нём более чем трёхкратный прирост против чистого PyTorch.
lifespan
Функция FastAPI на asynccontextmanager, которая грузит тяжёлую TTS-модель в память видеокарты один раз при старте приложения и выгружает при завершении. Модель кладётся в глобальный словарь bot_manager.
WebSocket-эндпоинт и base64
Маршрут /ws/tts: соединение поднимается один раз и держится, в бесконечном цикле принимается текст и обратно уходит синтезированное аудио, закодированное в base64. HTTP тут проигрывает, потому что каждый запрос тратит время на служебное рукопожатие.

Частые вопросы