Skip to content

Local Text to Speech with XTTS v2

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.

  • 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):

Terminal window
yay -S python311

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

ffmpeg on Arch:

Terminal window
sudo pacman -S ffmpeg

ffmpeg on Ubuntu:

Terminal window
sudo apt install ffmpeg

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

Terminal window
python3.11 -m venv venv
source venv/bin/activate
python -m pip install -U pip
Terminal window
pip install torch==2.5.1 torchvision==0.20.1 torchaudio==2.5.1 --index-url https://download.pytorch.org/whl/cu121
Terminal window
pip install coqui-tts pydub soundfile transformers==4.56.2
xtts-narration/
script.txt
make_episode.py
chunks/
episode.mp3

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.

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)

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))

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

Terminal window
python make_episode.py

Expected output:

Terminal window
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
  • 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.