Как я за выходные собрала сервис озвучки книг на FastAPI + Edge TTS + Telegram Mini App
Привет, Хабр! Я люблю слушать книги, но не все есть на Литрес и Storytel. Особенно это касается профессиональной литературы, фанфиков, статей и документов — всего того, что вряд ли когда-нибудь озвучат профессиональные дикторы.
Я поняла, что нейросетевые голоса уже достаточно хороши для комфортного прослушивания. И подумала: а что если сделать Telegram-бота, которому можно просто скинуть файл — а через пару минут слушать аудиокнигу в удобном плеере прямо в Telegram?
Так родился VoiceBooks — open-source сервис для озвучки книг, который работает полностью бесплатно.
В этой статье я разберу архитектуру open-source проекта: как организован парсинг 6 форматов в единый пайплайн, как работает фоновая генерация аудио без Celery и RabbitMQ, и как элегантно обойти лимиты Telegram Bot API на загрузку файлов.
Стек: Python 3.12, FastAPI, aiogram 3, Edge TTS, SQLAlchemy 2.0 + PostgreSQL. Деплой — Railway.
1. Что получилось
Бот принимает файлы форматов EPUB, FB2, PDF, DOCX, TXT или MOBI. Пользователь выбирает один из 7 нейросетевых голосов (предварительно прослушав превью) и запускает процесс.
Ключевые функции плеера (Telegram Mini App):
Воспроизведение с навигацией по распознанным главам книги.
Скорость от 0.75x до 2x.
Автосохранение позиции прослушивания (синхронизация между Telegram и Web).
Таймер сна и фоновое воспроизведение.
Всё это работает в рамках бесплатного пет-проекта.
2. Архитектура — как это работает
Один процесс — всё внутри
Бот, API, TTS-воркер и фронтенд живут в одном FastAPI-процессе. Для высоконагруженного enterprise-решения это антипаттерн, но для пет-проекта на Railway с ограничением по RAM (1–2 GB) — это оптимальный выбор. Жизненный цикл управляется через lifespan FastAPI: мы поднимаем соединения с БД, запускаем фоновую таску TTS-воркера и polling Telegram-бота, а при остановке контейнера корректно их завершаем (graceful shutdown).
База данных как очередь (DBaaQ)
Я намеренно отказалась от Celery, RabbitMQ или Redis. PostgreSQL выступает одновременно хранилищем данных и очередью задач. У каждого текстового фрагмента (Chunk) есть поле status (pending, processing, done).
Фоновый воркер опрашивает базу и забирает батч pending-чанков. Это избавляет от дополнительных зависимостей в инфраструктуре. Примечание: Так как воркер работает в единственном экземпляре (один процесс), обычного SELECT достаточно. При масштабировании до нескольких контейнеров запрос необходимо усилить блокировкой FOR UPDATE SKIP LOCKED, чтобы воркеры не разбирали одни и те же чанки.
Edge TTS: дёшево и сердито
Вместо ресурсоёмких офлайн-моделей (например, Silero TTS, требующего ~800 MB RAM даже на CPU), используется библиотека edge-tts. Это обёртка над недокументированным API Microsoft Read Aloud. Риск: API не является публичным, поэтому Microsoft теоретически может ограничить к нему доступ. На данный момент это работает стабильно, предоставляет десятки языков и нулевой overhead по RAM/CPU на сервере.
3. Парсинг книг — 6 форматов, единый пайплайн
Парсер построен по паттерну Strategy — единый интерфейс extract_chapters() маршрутизирует логику в зависимости от расширения файла. Каждый экстрактор возвращает унифицированную структуру: список кортежей (chapter_title, text).
EPUB: Парсим через ebooklib + BeautifulSoup. Нюанс формата: тег <title> обычно содержит название всей книги, а не главы. Поэтому ищем заголовки строго в тегах <h1>/<h2>/<h3>. FB2: Рекурсивный обход XML-дерева по тегам <section>. Обязательно ограничение глубины рекурсии (например, depth < 3), чтобы избежать фрагментации, где каждый абзац становится самостоятельной главой. PDF: Используем PyMuPDF. Если в документе есть Table of Contents (TOC), бьём текст по нему. Если нет — применяем фолбэк: каждая страница становится логическим чанком.
Алгоритм чанкирования
Edge TTS оптимально работает с фрагментами текста до 900 символов. Алгоритм разрезания текста имеет три уровня деградации, чтобы не рвать смысл:
Поиск последней границы предложения (
.!?+ пробел).Фолбэк на последний пробел (чтобы не разорвать слово).
Hard cut по лимиту (только если попалась непрерывная строка длиннее 900 символов, например, длинный URL).
4. TTS Worker — фоновая озвучка
Воркер работает в бесконечном цикле while True с интервалом asyncio.sleep(5). Главная задача воркера — приоритизация. Если пользователь начал слушать книгу и находится на 50-м чанке, нет смысла тратить ресурсы на генерацию 1-го чанка другой книги. SQL-запрос сортирует pending чанки на основе current_chunk_index текущего активного пользователя.
Retry-стратегия примитивна, но надёжна: если генерация через Edge TTS падает (обычно из-за сетевого таймаута), статус чанка откатывается обратно в pending, и воркер попытается обработать его на следующем цикле.
5. Telegram Bot (aiogram 3) и обход лимитов
Для интеграции с Telegram используется aiogram 3.x с Router-архитектурой.
Local Bot API Server Стандартный API Telegram не позволяет ботам скачивать файлы размером более 20 МБ. Для профессиональной литературы в PDF это блокер. Решение — запуск собственного Local Bot API Server. В Dockerfile мы поднимаем бинарник telegram-bot-api, который увеличивает лимит до 2 ГБ, а в коде инициализируем сессию через него:
Python
local_server = TelegramAPIServer.from_base(settings.telegram_api_url)
session = AiohttpSession(api=local_server)
bot = Bot(token=settings.bot_token, session=session)6. Web App — Telegram Mini App (TMA)
Фронтенд написан на ванильном HTML/CSS/JS. Причины две: критичность скорости загрузки для TMA и отсутствие необходимости поддерживать сложную инфраструктуру сборки (Webpack/Vite) для утилитарного интерфейса. Весь бандл весит около 95 КБ.
Для создания иллюзии непрерывного воспроизведения (насколько это позволяет стандартный тег <audio>) JS-клиент предзагружает следующий чанк за несколько секунд до окончания текущего. Для реализации полноценного true gapless без микропауз потребовалось бы переписать плеер на Web Audio API, что пока избыточно для пет-проекта.
Позиция прослушивания (индекс чанка и секунда) отправляется на сервер при постановке на паузу или закрытии TMA.
7. Грабли, на которые я наступила
1. Сюрпризы SQLAlchemy при смене владельца записей. При объединении Telegram-аккаунта с Web-аккаунтом необходимо перенести книги и удалить старый профиль. Если у связи User.books стоит cascade="all, delete-orphan", то простое удаление пользователя удалит и все перенесённые книги (ORM помнит их состояние в памяти). Решение: Вместо костылей с raw SQL, правильно очищать связи на уровне объектов перед удалением: old_user.books.clear(), затем session.flush(), и только потом session.delete(old_user).
2. UNIQUE constraint при миграции telegram_id. Нельзя перевесить telegram_id на новый аккаунт одним коммитом, пока он числится за старым. Обязателен строгий порядок: сначала обнулить ID у старого пользователя -> session.flush() -> присвоить ID новому.
3. TelegramConflictError при редеплое на Railway. При деплое Railway на несколько секунд оставляет работать старый контейнер параллельно с новым. Два инстанса aiogram, делающие polling, немедленно роняют друг друга. Решение: При старте приложения делаем await bot.delete_webhook(drop_pending_updates=True) и добавляем искусственную паузу asyncio.sleep(3), давая старому контейнеру время умереть.
Заключение
Что работает отлично:
Архитектура "Всё в одном процессе" экономит ресурсы и упрощает деплой.
DBaaQ на базе PostgreSQL полностью закрывает потребности асинхронной обработки без оверхеда на поддержку брокеров.
Edge TTS выдаёт качество звука, несопоставимое с бесплатными офлайн-аналогами.
Зоны роста:
Переход на HLS-стриминг (HTTP Live Streaming) вместо отдачи сырых MP3-файлов чанками.
Внедрение
asyncio.Eventдля пробуждения воркера: это уберёт 5-секундную задержку поллинга, заставляя воркер начинать генерацию моментально при добавлении книги.
Ссылки
🐙 GitHub: github.com/tomashevanatalia-tech/voicebooks (MIT)
🤖 Бот: @voicemybooks_bot
Буду рада вопросам и пулл-реквестам!