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 python311Python 3.11 on Ubuntu uses the deadsnakes PPA, covered in Multiple Python Versions on Ubuntu.
ffmpeg on Arch:
sudo pacman -S ffmpegffmpeg on Ubuntu:
sudo apt install ffmpegCreate 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 pippip install torch==2.5.1 torchvision==0.20.1 torchaudio==2.5.1 --index-url https://download.pytorch.org/whl/cu121pip install coqui-tts pydub soundfile transformers==4.56.2Project structure
xtts-narration/
script.txt
make_episode.py
chunks/
episode.mp3Add 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.txtVolatility 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.pyimport 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.pyExpected 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.42Notes
- The 250 character limit is per call. XTTS truncates English text above roughly 250 characters, so keep
MAX_CHARSbelow 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.