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.
Prerequisites
Section titled “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
Section titled “Create the environment”Create an isolated virtual environment, install the pinned PyTorch CUDA build first, then the TTS stack.
python3.11 -m venv venvsource venv/bin/activatepython -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
Section titled “Project structure”xtts-narration/ script.txt make_episode.py chunks/ episode.mp3Add the input script
Section titled “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.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 ofquantitative finance, it was treated as something hidden, inferred indirectlyfrom the behavior of returns.Choose a speaker
Section titled “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
Section titled “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 osimport reimport timefrom pydub import AudioSegmentfrom TTS.api import TTS
INPUT_TXT = "script.txt"OUTPUT_MP3 = "episode.mp3"TMP_DIR = "chunks"SPEAKER = "Viktor Menelaos"LANGUAGE = "en"MAX_CHARS = 230SILENCE_MS = 180BITRATE = "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.
python make_episode.pyExpected output:
Loading XTTS v2...Speaker: Viktor MenelaosChunks: 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.0mExporting 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_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.