← Назад

Кустарная аудиокнига

Предисловие

Люблю книги. Но в плотном распорядке дня совсем нет времени на чтение. Спасение — аудиокниги. За неполный прошлый год удалось послушать 68 штук. А ещё люблю не просто книги, а серии — с большими мирами и раскрытыми персонажами.

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

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


Что понадобится

  • Электронная книга в формате epub (или txt)
  • Python 3+
  • ffmpeg
  • Calibre CLI
  • npx (устанавливается вместе с Node.js) (если у вас его ещё нет)
  • Учётная запись в Google Cloud с включённым биллингом
  • Включённый Cloud Text-to-Speech API

Cook book

Подготовка текста

Первая мысль — просто скормить epub в какой-нибудь сервис и получить готовое аудио. Но все существующие продукты либо не заточены под большой объём текста, либо стоят дорого.

Ну что, разве я не разработчик? Пойдём разбираться.

Первым делом нужно преобразовать epub в обычный текст. Если уже есть чистый txt — этот шаг можно пропустить.

Для конвертации подойдёт Calibre CLI:

ebook-convert book.epub book.txt

Так мы получаем файл book.txt с полным текстом книги.

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

Нарезка на главы

Дальше я нарезала книгу на главы. Во-первых, я не знала, каким будет результат и было бы обидно озвучить всю книгу, а потом понять, что мне не нравится. Лучше разочароваться пораньше. Во-вторых, я не была уверена, как будет тарифицироваться генерация речи и решила озучивать по одной главе, чтобы следить за затратами и не обалдеть от суммы в конце. Спойлер: вышло бесплатно.

Книгу я резала по главам с небольшими условиями. Всё, что до первой главы — предисловие. В начале каждого файла главы должно быть «Глава №», чтобы голос это проговаривал.

Заодно немного нормализовала текст. Почистила лишние переносы строк, пробелы, чтобы в озвучке не было пауз.

Использовала Python-скрипт:

Скрипт нарезки на главы
#!/usr/bin/env python3
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import json
import re
from typing import List, Optional

INPUT = "book.txt" # входной файл с текстом книги
OUT_DIR = Path("chapters")
OUT_DIR.mkdir(exist_ok=True)

# Ищем явные главы/части. "Пролог" вытащим отдельной логикой.
CHAPTER_RE = re.compile(r"(?im)^\s*(глава|часть)\s+([0-9]+|[ivxlcdm]+)\b.*$")

# Явный заголовок пролога (если есть строкой)
PROLOG_RE = re.compile(r"(?im)^\s*пролог\b.*$")

# Если глав нет — режем на части фикс. размера
FALLBACK_PART_CHARS = 120_000

@dataclass
class Section:
    index: int
    title: str      # для человека
    heading: str    # что будет озвучено в начале файла
    text: str

def safe_slug(s: str, max_len: int = 60) -> str:
    s = s.strip().lower()
    s = re.sub(r"[^\wа-яё]+", "_", s, flags=re.IGNORECASE)
    s = s.strip("_")
    return (s[:max_len] if s else "section")

def normalize_preserve_paragraphs(t: str) -> str:
    t = t.replace("\ufeff", "")
    t = t.replace("\r\n", "\n").replace("\r", "\n")
    # Схлопываем слишком много пустых строк
    t = re.sub(r"\n{3,}", "\n\n", t)
    # Пробелы/табы внутри строк — да, но НЕ переносы строк
    t = re.sub(r"[ \t]+", " ", t)
    return t.strip()

def add_paragraph_pauses(t: str) -> str:
    """
    Делает паузы на абзацах:
    Двойной перенос -> '.\n\n' (если перед ним не было знака конца предложения).
    """
    # сначала нормализуем разные варианты "пустой строки"
    t = re.sub(r"\n\s*\n", "\n\n", t)

    # добавим точку перед абзацем, если её нет
    t = re.sub(r"(?<![.!?…])\n\n", ".\n\n", t)

    return t

def split_by_markers(lines: List[str], markers: List[int]) -> List[tuple[str, str]]:
    # возвращает список (title_line, body_text)
    out = []
    for k, start in enumerate(markers):
        end = markers[k + 1] if k + 1 < len(markers) else len(lines)
        title = lines[start].strip()
        body = "\n".join(lines[start + 1:end]).strip()
        if body:
            out.append((title, body))
    return out

def split_into_sections(text: str) -> List[Section]:
    lines = text.split("\n")

    # 1) находим главы/части
    chapter_markers = [i for i, line in enumerate(lines) if CHAPTER_RE.match(line.strip())]

    # 2) если глав не нашли — fallback
    if not chapter_markers:
        sections: List[Section] = []
        raw = text
        start = 0
        idx = 1
        while start < len(raw):
            part = raw[start:start + FALLBACK_PART_CHARS].strip()
            if part:
                sections.append(Section(index=idx, title=f"Part {idx}", heading=f"Часть {idx}.", text=part))
                idx += 1
            start += FALLBACK_PART_CHARS
        return sections

    sections: List[Section] = []

    # 3) ПРОЛОГ: всё до первой главы
    first_marker = chapter_markers[0]
    before = "\n".join(lines[:first_marker]).strip()

    # Если в этом куске есть явное слово "Пролог" строкой — считаем, что это пролог.
    if before:
        sections.append(
            Section(
                index=1,
                title="Пролог",
                heading="Пролог.",
                text=before
            )
        )

    # 4) ГЛАВЫ
    chapters = split_by_markers(lines, chapter_markers)

    chapter_num = 1
    for title, body in chapters:
        sections.append(
            Section(
                index=len(sections) + 1,
                title=title,
                heading=f"Глава {chapter_num}.",
                text=body
            )
        )
        chapter_num += 1

    return sections

def main():
    raw = Path(INPUT).read_text(encoding="utf-8", errors="ignore")
    raw = normalize_preserve_paragraphs(raw)
    raw = add_paragraph_pauses(raw)

    sections = split_into_sections(raw)
    print(f"Sections: {len(sections)}")

    manifest = {
        "input": INPUT,
        "sections_dir": str(OUT_DIR),
        "sections": [],
        "total_chars": 0,
        "total_utf8_bytes": 0,
        "note": "Per-section counts include spoken heading and inserted paragraph pauses.",
    }

    for s in sections:
        fname = f"{s.index:03d}_{safe_slug(s.title)}.txt"
        path = OUT_DIR / fname

        full_text = f"{s.heading}\n\n{s.text}".strip()
        path.write_text(full_text, encoding="utf-8")

        chars = len(full_text)
        utf8_bytes = len(full_text.encode("utf-8"))

        manifest["sections"].append({
            "index": s.index,
            "title": s.title,
            "heading": s.heading,
            "file": str(path),
            "chars": chars,
            "utf8_bytes": utf8_bytes,
        })
        manifest["total_chars"] += chars
        manifest["total_utf8_bytes"] += utf8_bytes

        print(f"[{s.index:03d}] {s.heading} -> {fname} | chars={chars} | bytes={utf8_bytes}")

    (OUT_DIR / "manifest.json").write_text(
        json.dumps(manifest, ensure_ascii=False, indent=2),
        encoding="utf-8"
    )

    print("\nTotals:")
    print(f"  total chars: {manifest['total_chars']}")
    print(f"  total utf-8 bytes: {manifest['total_utf8_bytes']}")
    print(f"Manifest: {OUT_DIR / 'manifest.json'}")

if __name__ == "__main__":
    main()

В итоге получаем папку chapters/ с файлами глав и манифестом manifest.json, в котором есть информация по всем главам: сколько символов, байт и т.д.

Список файлов с нарезанными главами
Список файлов с нарезанными главами

Добавление Ё

После первых попыток озвучки стало понятно: текст без Ё звучит плохо. Поэтому добавился шаг с автоматической расстановкой Ё через библиотеку eyo-kernel.

npx eyo chapters/002_глава_1.txt > chapters/002_глава_1.yo.txt

Теперь текст готов к озвучке.

Проблемы инструментов

Конечно, хотелось сделать это за 0 денег. Первым делом — поиск опенсорсных TTS-движков.

Попробовала Silero TTS. Спасибо мейнтейнерам проекта! Но качество не подошло. С чтением всё было ок, но на стыках между фразами голос плыл. Кто ещё застал аудиокассеты — точно помнит этот звук, когда в плеере садились батарейки. Примерно так звучали эти стыки.

Вздохнула и пошла искать платные, но недорогие решения.

Google Cloud Text-to-Speech

Сервисов много, но выбор пал на Google Cloud Text-to-Speech — там, как оказалось, висел неиспользованный кредит.

Для работы с сервисом нужно создать аккаунт, привязать биллинг и включить API Cloud Text-to-Speech.

Включение Cloud Text-to-Speech API
Включение Cloud Text-to-Speech API

Обязательно изучите прайс. Есть голоса по $100+ за миллион символов. Студийное качество на этом этапе не нужно, поэтому выбор — недорогие голоса WaveNet. Послушать семплы. Понравился ru-RU-Wavenet-D (~$4 за миллион символов).

Авторизация в gcloud CLI:

gcloud auth application-default login

И поехали озвучивать.

Скрипт озвучки

Что важно в скрипте озвучки:

  1. Разбивка текста на куски по 5000 байт (ограничение API). Важно: именно байт, не символов! Кириллица в UTF-8 занимает 2 байта на символ, реальный лимит ~2400-2500 символов. Для подстраховки — MAX_BYTES_DEFAULT = 4800.
  2. Резать текст по предложениям ([.!?…]), не в случайных местах. Слишком длинные предложения — по пробелам.
  3. Озвученные чанки сохранять во временную папку tmp_audio_run/, чистить её перед озвучкой каждой главы.
  4. Склейка чанков через ffmpeg — быстро и без потери качества.
  5. В конце вывод количества символов и байтов для оценки стоимости.

На выходе — mp3.

Скрипт озвучки через Google TTS
#!/usr/bin/env python3
from __future__ import annotations

from pathlib import Path
import argparse
import subprocess
import re
import shutil
from google.cloud import texttospeech

# ====== Google TTS settings (defaults) ======
LANG_DEFAULT = "ru-RU"
VOICE_DEFAULT = "ru-RU-Wavenet-D"
RATE_DEFAULT = 0.95

# Google limit is 5000 BYTES per request; keep margin.
MAX_BYTES_DEFAULT = 4800

SENT_CUT_RE = re.compile(r"([.!?…]+)\s+")


def split_sentences(t: str):
    parts = SENT_CUT_RE.split(t)
    out = []
    for i in range(0, len(parts), 2):
        chunk = parts[i].strip()
        punct = parts[i + 1] if i + 1 < len(parts) else ""
        s = (chunk + punct).strip()
        if s:
            out.append(s)
    return out


def chunk_by_utf8_bytes(text: str, max_bytes: int):
    sentences = split_sentences(" ".join(text.split()))
    chunks = []
    cur = ""

    def fits(s: str) -> bool:
        return len(s.encode("utf-8")) <= max_bytes

    for s in sentences:
        if not s:
            continue

        candidate = (cur + " " + s).strip() if cur else s

        if fits(candidate):
            cur = candidate
            continue

        if cur:
            chunks.append(cur)
            cur = ""

        # single sentence too long -> split by words
        if not fits(s):
            words = s.split()
            tmp = ""
            for w in words:
                cand = (tmp + " " + w).strip() if tmp else w
                if fits(cand):
                    tmp = cand
                else:
                    if tmp:
                        chunks.append(tmp)
                    tmp = w
            if tmp:
                chunks.append(tmp)
        else:
            cur = s

    if cur:
        chunks.append(cur)

    return chunks

def ffmpeg_concat_mp3(tmp_dir: Path, output_mp3: Path):
    subprocess.run(
        ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", "files.txt", "-c", "copy", "out.mp3"],
        check=True,
        cwd=str(tmp_dir),
    )
    output_mp3.parent.mkdir(parents=True, exist_ok=True)
    (tmp_dir / "out.mp3").replace(output_mp3)


def main():
    ap = argparse.ArgumentParser(
        description="Generate MP3 from a text file using Google Cloud TTS (WaveNet) with safe UTF-8 byte chunking."
    )
    ap.add_argument("input", help="Path to input .txt file")
    ap.add_argument("-o", "--output", help="Output .mp3 path (default: рядом с входным файлом)")
    ap.add_argument("--voice", default=VOICE_DEFAULT, help=f"Voice name (default: {VOICE_DEFAULT})")
    ap.add_argument("--lang", default=LANG_DEFAULT, help=f"Language code (default: {LANG_DEFAULT})")
    ap.add_argument("--rate", type=float, default=RATE_DEFAULT, help=f"Speaking rate (default: {RATE_DEFAULT})")
    ap.add_argument("--max-bytes", type=int, default=MAX_BYTES_DEFAULT, help=f"Max UTF-8 bytes per request (default: {MAX_BYTES_DEFAULT})")
    ap.add_argument("--tmp-dir", default="tmp_audio_run", help="Temporary directory for mp3 chunks (default: tmp_audio_run)")
    ap.add_argument("--clean", action="store_true", help="Delete temp directory before run")

    args = ap.parse_args()

    input_path = Path(args.input)
    if not input_path.exists():
        raise SystemExit(f"Input not found: {input_path}")

    out_path = Path(args.output) if args.output else input_path.with_suffix(".mp3")

    tmp_dir = Path(args.tmp_dir)
    if args.clean and tmp_dir.exists():
        shutil.rmtree(tmp_dir)
    tmp_dir.mkdir(parents=True, exist_ok=True)

    text = input_path.read_text(encoding="utf-8", errors="ignore").strip()
    if not text:
        raise SystemExit("Input file is empty.")

    parts = chunk_by_utf8_bytes(text, args.max_bytes)
    if not parts:
        raise SystemExit("No chunks produced (unexpected).")

    client = texttospeech.TextToSpeechClient()
    voice = texttospeech.VoiceSelectionParams(language_code=args.lang, name=args.voice)
    audio_cfg = texttospeech.AudioConfig(
        audio_encoding=texttospeech.AudioEncoding.MP3,
        speaking_rate=args.rate,
    )

    filelist = []
    total_chars = 0
    total_utf8_bytes = 0

    for i, part in enumerate(parts, 1):
        b = len(part.encode("utf-8"))
        if b > args.max_bytes:
            raise SystemExit(f"Chunk {i} too big: {b} bytes (max {args.max_bytes})")
        total_utf8_bytes += b
        total_chars += len(part)

        resp = client.synthesize_speech(
            input=texttospeech.SynthesisInput(text=part),
            voice=voice,
            audio_config=audio_cfg,
        )

        fname = tmp_dir / f"part_{i:04d}.mp3"
        fname.write_bytes(resp.audio_content)
        filelist.append(fname)

        if i % 25 == 0 or i == len(parts):
            print(f"Parts: {i}/{len(parts)}")

    (tmp_dir / "files.txt").write_text(
        "\n".join(f"file '{f.name}'" for f in filelist),
        encoding="utf-8"
    )

    ffmpeg_concat_mp3(tmp_dir, out_path)

    print(f"Done: {out_path}")
    print(f"Chunks: {len(parts)}")
    print(f"Total chars sent: {total_chars}")
    print(f"Total utf-8 bytes sent: {total_utf8_bytes}")


if __name__ == "__main__":
    main()

Озвучка глав

Запуск скрипта для каждой главы:

python gcloud_tts_file.py chapters/001_prolog.txt -o audio/001_prolog.mp3 --clean

Флаги:

  • --clean — очистка временной папки перед запуском;
  • -o — путь к выходному mp3.

В скрипте есть и другие флаги — настраивайте под себя.

Результат — аудиофайл для каждой главы.

Файлы с озвученными главами
Файлы с озвученными главами

Profit!

Отлично провела время, разбираясь во всём этом. И в итоге послушала книгу =)


Цена

Книга обошлась бесплатно: не превысила 1 миллион символов, уместилась в бесплатный лимит WaveNet.


Зоны развития

Робот-диктор

Стоит быть готовым: это не живой диктор. В озвучке будет много неправильных ударений, произношений, интонаций.

Чтобы оценить уровень — короткий семпл:

Для художественной литературы такой вариант лично мне не подошёл. В итоге перешла на аудиокниги на английском. Тут вспоминается поговорка про два стула: либо робот-диктор на русском, либо великолепный диктор на английском.

А вот для технической литературы, которая не озвучена это отличный подход.

Разметка текста

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

Интерфейс

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

Один файл

В этот раз работала с каждой главой отдельно и это было обосновано. В следующий раз автоматизирую все промежуточные шаги. В конце буду склеивать всё в один m4b файл с главами и обложкой.