Ctrl K

Local Text to Speech with XTTS v2

Set up Coqui XTTS v2 locally on GPU and turn a text script into a single-voice narration MP3, with sentence chunking for the 250 character limit.

Coqui XTTS v2 runs locally on an NVIDIA GPU and ships with a set of built-in speakers, so a written script can be turned into a single-voice narration MP3 with no voice cloning and no cloud service. The one constraint that shapes everything is a 250 character limit per synthesis call, so the script is split into sentence-sized chunks, synthesized one at a time, and concatenated.

Prerequisites

  • An NVIDIA GPU with a working driver. The pinned cu121 PyTorch wheels bundle the CUDA runtime, so a full CUDA Toolkit (nvcc) install is not required. To confirm the GPU is visible from Python, see CUDA Setup on Ubuntu 24.04.
  • Python 3.11. XTTS v2 targets 3.11, and newer interpreters can fail to resolve the dependencies.
  • ffmpeg, used by pydub to export MP3.

Python 3.11 on Arch (from the AUR):

yay -S python311

Python 3.11 on Ubuntu uses the deadsnakes PPA, covered in Multiple Python Versions on Ubuntu.

ffmpeg on Arch:

sudo pacman -S ffmpeg

ffmpeg on Ubuntu:

sudo apt install ffmpeg

Create the environment

Create an isolated virtual environment, install the pinned PyTorch CUDA build first, then the TTS stack.

python3.11 -m venv venv
source venv/bin/activate
python -m pip install -U pip
pip install torch==2.5.1 torchvision==0.20.1 torchaudio==2.5.1 --index-url https://download.pytorch.org/whl/cu121
pip install coqui-tts pydub soundfile transformers==4.56.2

Project structure

xtts-narration/
  script.txt
  make_episode.py
  chunks/
  episode.mp3

Add the input script

Write the narration text as plain ASCII prose. Punctuation drives the chunk boundaries, so normal sentences are all that is needed.

script.txt
Volatility is the central quantity in finance. It governs how assets are priced,
how portfolios are allocated, and how risk is managed. For most of the history of
quantitative finance, it was treated as something hidden, inferred indirectly
from the behavior of returns.

Choose a speaker

List the built-in speakers and pick one by its display name. Pass the name with a space, for example "Viktor Menelaos". An underscored form is read as a voice-clone file slug and raises FileNotFoundError: Voice file Viktor_Menelaos.pth ... not found.

from TTS.api import TTS

tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2")
print(len(tts.speakers))
print(tts.speakers)

Build the narration script

Chunk the text under the 250 character limit, falling back to splitting long sentences on commas, synthesize each chunk to a wav, join the parts with a short silence, and export one MP3.

make_episode.py
import os
import re
import time
from pydub import AudioSegment
from TTS.api import TTS

INPUT_TXT = "script.txt"
OUTPUT_MP3 = "episode.mp3"
TMP_DIR = "chunks"
SPEAKER = "Viktor Menelaos"
LANGUAGE = "en"
MAX_CHARS = 230
SILENCE_MS = 180
BITRATE = "192k"


def split_into_chunks(text, max_chars=230):
    text = text.replace("\r\n", "\n")
    text = re.sub(r"[ \t]+", " ", text)
    text = re.sub(r"\n{3,}", "\n\n", text).strip()

    sentences = re.split(r"(?<=[.!?])\s+", text.replace("\n", " "))
    chunks = []
    cur = ""

    for s in sentences:
        s = s.strip()
        if not s:
            continue
        if len(s) > max_chars:
            for p in re.split(r"(?<=[,;:])\s+", s):
                p = p.strip()
                if not p:
                    continue
                if len(cur) + len(p) + 1 <= max_chars:
                    cur = (cur + " " + p).strip()
                else:
                    if cur:
                        chunks.append(cur)
                    cur = p
            continue
        if len(cur) + len(s) + 1 <= max_chars:
            cur = (cur + " " + s).strip()
        else:
            if cur:
                chunks.append(cur)
            cur = s

    if cur:
        chunks.append(cur)
    return chunks


with open(INPUT_TXT, "r", encoding="utf-8") as f:
    text = f.read().strip()
if not text:
    raise RuntimeError("Input text file is empty")

print("Loading XTTS v2...")
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to("cuda")

os.makedirs(TMP_DIR, exist_ok=True)
chunks = split_into_chunks(text, MAX_CHARS)
total = len(chunks)
print(f"Speaker: {SPEAKER}")
print(f"Chunks: {total}")

silence = AudioSegment.silent(duration=SILENCE_MS)
full = AudioSegment.empty()
start = time.time()

for i, chunk in enumerate(chunks):
    wav_path = os.path.join(TMP_DIR, f"chunk_{i:04d}.wav")
    tts.tts_to_file(
        text=chunk,
        file_path=wav_path,
        language=LANGUAGE,
        speaker=SPEAKER,
    )
    full += AudioSegment.from_wav(wav_path) + silence
    elapsed = time.time() - start
    eta = elapsed / (i + 1) * (total - i - 1)
    print(f"[{i + 1}/{total}] {(i + 1) / total * 100:5.1f}% | elapsed {elapsed / 60:4.1f}m | eta {eta / 60:4.1f}m")

print("Exporting MP3...")
full.export(OUTPUT_MP3, format="mp3", bitrate=BITRATE)
print("Done:", OUTPUT_MP3, "| minutes:", round(len(full) / 1000 / 60, 2))

Run

The first run downloads the XTTS v2 weights and caches them locally, so later runs go straight to synthesis.

python make_episode.py

Expected output:

Loading XTTS v2...
Speaker: Viktor Menelaos
Chunks: 4
[1/4]  25.0% | elapsed  0.1m | eta  0.3m
[2/4]  50.0% | elapsed  0.2m | eta  0.2m
[3/4]  75.0% | elapsed  0.3m | eta  0.1m
[4/4] 100.0% | elapsed  0.4m | eta  0.0m
Exporting MP3...
Done: episode.mp3 | minutes: 0.42

Notes

  • The 250 character limit is per call. XTTS truncates English text above roughly 250 characters, so keep MAX_CHARS below it. Raising it risks clipped audio mid-chunk.
  • Speaker names use a space, not an underscore, as covered in Choose a speaker.
  • .to("cuda") requires an NVIDIA GPU. This script has no CPU fallback.
  • XTTS v2 weights ship under the Coqui Public Model License, which is non-commercial. Check it before any commercial use.
  • Running this in Jupyter instead of as a script may print TqdmWarning: IProgress not found. It is harmless and is resolved in Fix tqdm IProgress Warning in VS Code.