INTRO
XTTS-v2 is the best open-source voice cloning model available in 2026. It produces cross-lingual output — take 6 seconds of English audio, generate speech in Japanese, Arabic, or Spanish using the same vocal characteristics. It runs entirely locally with no API required.
It is also significantly under-documented for production desktop application deployment.
I built XTTS-v2 into Pocket Core AI — an offline AI desktop app — and hit every sharp edge it has. This post covers what I learned: the pipeline, the quality observations across languages, the chunking strategy for long texts, and the memory leak that will bite you in production if you don't know about it.
If you're evaluating XTTS-v2 for local deployment, this is the post I wish existed when I started.
HOW CROSS-LINGUAL TRANSFER ACTUALLY WORKS
XTTS-v2 separates voice identity from language content using a speaker encoder.
When you provide reference audio, the model extracts a speaker embedding — a high-dimensional vector representing the vocal characteristics of that voice. This captures timbre, rhythm, and prosody independently of the phoneme content.
The synthesis step conditions on both:
The speaker embedding (who is speaking)
The target language phonemes (what they say)
This is why cross-lingual transfer works: the speaker identity is encoded separately from the language. You can apply any speaker embedding to any language's phoneme sequence.
In practice the quality of cross-lingual transfer varies significantly by language family — more on that below.
THE BASIC PIPELINE
python
# core/voice_cloner.py
import os
import io
import base64
import hashlib
import tempfile
from pathlib import Path
from pydub import AudioSegment
import soundfile as sf
import numpy as np
class VoiceCloner:
def __init__(self, model_path: str = None):
self.model = None
self.model_path = model_path
self.generation_count = 0
self.MAX_GENERATIONS_BEFORE_REINIT = 50
self._supported_languages = [
'en', 'es', 'fr', 'de', 'it', 'pt',
'pl', 'tr', 'ru', 'nl', 'cs', 'ar',
'zh-cn', 'ja', 'ko', 'hu', 'hi'
]
def _load_model(self):
"""Load XTTS-v2. Deferred until first use."""
from TTS.api import TTS
import torch
device = "cuda" if torch.cuda.is_available() else \
"mps" if torch.backends.mps.is_available() else \
"cpu"
if self.model_path:
self.model = TTS(
model_path=self.model_path,
config_path=os.path.join(self.model_path, "config.json")
).to(device)
else:
self.model = TTS(
"tts_models/multilingual/multi-dataset/xtts_v2"
).to(device)
self.generation_count = 0
def _ensure_model_ready(self):
"""
Load or reinitialise the model.
Reinitialisation every 50 generations
works around the memory leak.
"""
if self.model is None:
self._load_model()
return
if self.generation_count >= self.MAX_GENERATIONS_BEFORE_REINIT:
self._reinitialise()
def _reinitialise(self):
"""
Reinitialise model to clear leaked memory.
Do NOT skip this in production.
"""
import gc
import torch
del self.model
self.model = None
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
elif hasattr(torch.backends, 'mps') and \
torch.backends.mps.is_available():
# MPS doesn't have explicit cache clearing
# gc.collect() handles most of it
pass
self._load_model()
def preprocess_audio(self, audio_path: str) -> str:
"""
Convert any audio format to 22050Hz mono WAV.
XTTS-v2 requires this exact format.
Returns path to processed WAV file.
"""
audio = AudioSegment.from_file(audio_path)
# Convert to mono
if audio.channels > 1:
audio = audio.set_channels(1)
# Resample to 22050Hz
audio = audio.set_frame_rate(22050)
# Validate minimum duration
duration_seconds = len(audio) / 1000.0
if duration_seconds < 3.0:
raise ValueError(
f"Reference audio too short: {duration_seconds:.1f}s. "
f"Minimum 3 seconds required. "
f"15-20 seconds recommended for best quality."
)
if duration_seconds < 8.0:
# Warn but don't block — short audio works, just worse
print(f"Warning: {duration_seconds:.1f}s reference audio. "
f"Quality improves significantly above 15 seconds.")
# Export to temp WAV
temp_wav = tempfile.mktemp(suffix='.wav')
audio.export(temp_wav, format='wav')
return temp_wav
def chunk_text(self, text: str,
max_chars: int = 250) -> list[str]:
"""
Split text at sentence boundaries.
XTTS-v2 has a ~300 character practical limit.
We use 250 to leave headroom.
"""
import spacy
# Load spacy model — en_core_web_sm is sufficient
try:
nlp = spacy.load("en_core_web_sm")
except OSError:
# Fallback: split on punctuation
return self._simple_chunk(text, max_chars)
doc = nlp(text)
chunks = []
current_chunk = ""
for sent in doc.sents:
sent_text = sent.text.strip()
if len(current_chunk) + len(sent_text) <= max_chars:
current_chunk += (" " if current_chunk else "") + sent_text
else:
if current_chunk:
chunks.append(current_chunk)
# If single sentence exceeds max_chars,
# split it at word boundaries
if len(sent_text) > max_chars:
chunks.extend(
self._simple_chunk(sent_text, max_chars)
)
else:
current_chunk = sent_text
if current_chunk:
chunks.append(current_chunk)
return [c for c in chunks if c.strip()]
def _simple_chunk(self, text: str,
max_chars: int) -> list[str]:
"""Fallback chunker — splits at word boundaries."""
words = text.split()
chunks = []
current = ""
for word in words:
if len(current) + len(word) + 1 <= max_chars:
current += (" " if current else "") + word
else:
if current:
chunks.append(current)
current = word
if current:
chunks.append(current)
return chunks
def join_audio_chunks(self,
chunks: list[np.ndarray],
sample_rate: int = 24000,
silence_ms: int = 150) -> np.ndarray:
"""
Join audio chunks with natural silence between them.
XTTS-v2 output is 24000Hz.
"""
silence_samples = int(sample_rate * silence_ms / 1000)
silence = np.zeros(silence_samples)
parts = []
for i, chunk in enumerate(chunks):
parts.append(chunk)
if i < len(chunks) - 1:
parts.append(silence)
return np.concatenate(parts)
def clone(
self,
text: str,
reference_audio_path: str,
language: str = 'en',
speed: float = 1.0
) -> dict:
"""
Main voice cloning method.
Returns dict with audio_base64 and duration_seconds.
"""
if language not in self._supported_languages:
raise ValueError(
f"Language '{language}' not supported. "
f"Supported: {self._supported_languages}"
)
self._ensure_model_ready()
# Preprocess reference audio
processed_ref = self.preprocess_audio(reference_audio_path)
try:
# Split text into chunks
text_chunks = self.chunk_text(text)
if not text_chunks:
raise ValueError("Text is empty after processing")
# Generate audio for each chunk
audio_chunks = []
for chunk in text_chunks:
wav = self.model.tts(
text=chunk,
speaker_wav=processed_ref,
language=language,
speed=speed
)
audio_chunks.append(np.array(wav))
# Join chunks with silence
if len(audio_chunks) == 1:
final_audio = audio_chunks[0]
else:
final_audio = self.join_audio_chunks(audio_chunks)
# Convert to WAV bytes
buffer = io.BytesIO()
sf.write(buffer, final_audio, 24000, format='WAV')
audio_bytes = buffer.getvalue()
# Base64 encode for API response
audio_b64 = base64.b64encode(audio_bytes).decode('utf-8')
duration = len(final_audio) / 24000.0
self.generation_count += 1
return {
"audio_base64": audio_b64,
"duration_seconds": round(duration, 2),
"chunks_processed": len(text_chunks),
"language": language
}
finally:
# Always clean up temp file
if os.path.exists(processed_ref):
os.remove(processed_ref)
def get_supported_languages(self) -> list[dict]:
"""Return language list with metadata."""
language_info = {
'en': {'name': 'English', 'accent': 'American',
'quality': 'excellent'},
'es': {'name': 'Spanish', 'accent': 'Neutral',
'quality': 'excellent'},
'fr': {'name': 'French', 'accent': 'French',
'quality': 'excellent'},
'de': {'name': 'German', 'accent': 'German',
'quality': 'very good'},
'it': {'name': 'Italian', 'accent': 'Italian',
'quality': 'very good'},
'pt': {'name': 'Portuguese', 'accent': 'Brazilian',
'quality': 'very good'},
'pl': {'name': 'Polish', 'accent': 'Polish',
'quality': 'good'},
'tr': {'name': 'Turkish', 'accent': 'Turkish',
'quality': 'good'},
'ru': {'name': 'Russian', 'accent': 'Russian',
'quality': 'good'},
'nl': {'name': 'Dutch', 'accent': 'Dutch',
'quality': 'good'},
'cs': {'name': 'Czech', 'accent': 'Czech',
'quality': 'good'},
'ar': {'name': 'Arabic', 'accent': 'MSA',
'quality': 'moderate'},
'zh-cn': {'name': 'Chinese', 'accent': 'Mandarin',
'quality': 'moderate'},
'ja': {'name': 'Japanese', 'accent': 'Standard',
'quality': 'moderate'},
'ko': {'name': 'Korean', 'accent': 'Standard',
'quality': 'moderate'},
'hu': {'name': 'Hungarian', 'accent': 'Hungarian',
'quality': 'good'},
'hi': {'name': 'Hindi', 'accent': 'Standard',
'quality': 'moderate'},
}
return [
{'code': code, **info}
for code, info in language_info.items()
if code in self._supported_languages
]
THE MEMORY LEAK — WHAT IT IS AND WHY IT HAPPENS
If you run XTTS-v2 in a long-running process and generate audio hundreds of times, you will eventually see your process consuming multiple gigabytes of RAM that it never releases.
This is a known issue in the XTTS-v2 / Coqui TTS library related to how PyTorch tensors accumulate in the speaker encoder cache across inference calls.
The symptom: RAM usage grows steadily from ~2GB at startup to 6-8GB+ after several hundred generations, then the process crashes or becomes unusably slow.
The fix: Reinitialise the model periodically. Yes, this means recreating the TTS object and reloading weights. It takes 5-10 seconds. It completely clears the accumulated memory.
python
def _reinitialise(self):
import gc
import torch
# Delete the model object
del self.model
self.model = None
# Force garbage collection
gc.collect()
# Clear GPU cache
if torch.cuda.is_available():
torch.cuda.empty_cache()
# Reload
self._load_model()
We trigger this every 50 generations. For our use case (desktop app with human-paced usage), this means a reinitialisation roughly every few hours of heavy use. Users never notice it because the next generation just takes slightly longer than usual.
If you're building a high-throughput voice generation service, you'll want a more sophisticated approach — perhaps a pool of model instances you cycle through. For a desktop application, periodic reinitialisation is the pragmatic solution.
LANGUAGE QUALITY — HONEST ASSESSMENT
From English reference audio, here is the honest quality breakdown across language families:
Excellent (near-native quality):
Spanish, French, Italian, Portuguese
These share significant phoneme overlap with English and have similar prosody patterns. The cross-lingual transfer works cleanly.
Very Good (clearly intelligible, minor accent artifacts):
German, Dutch, Polish, Czech, Hungarian
Germanic and Slavic languages work well despite different phoneme sets. The rhythm and stress patterns transfer reasonably well.
Good (intelligible with noticeable synthetic quality):
Turkish, Russian
The speaker embedding transfers but the output has more obvious synthesis artifacts. Still usable for most content creation purposes.
Moderate (functional but clearly synthetic):
Japanese, Mandarin Chinese, Korean, Arabic, Hindi
These languages have fundamentally different phoneme inventories, tonal or pitch-accent systems, and prosody patterns. The voice identity transfers but the output quality is noticeably lower. For many use cases this is still adequate — particularly for long-form content where listeners adapt to the synthetic quality.
Quality improves with:
Longer reference audio (15-30 seconds vs 6 seconds minimum)
Multiple reference samples averaged together
Clean recording environment (quiet, no reverb)
Consistent speaking pace in the reference
INFERENCE TIMING ON REAL HARDWARE
These are measured times for generating approximately 20 seconds of audio output:
Hardware Time Notes
Apple M2 Pro (16GB) ~8s Via MPS
Apple M1 (8GB) ~14s Via MPS
Intel i7 + RTX 3070 ~2s Via CUDA
Intel i7 + RTX 3080 ~1.2s Via CUDA
Intel i7 (CPU only) ~55s No GPU
AMD Ryzen 7 + RX 6800 ~4s Via ROCm
Raspberry Pi 4 (8GB) ~8min Not viable
Key observations:
CPU-only inference is usable for short clips but becomes frustrating for long content. A dedicated GPU makes the experience dramatically better.
Apple Silicon via MPS is surprisingly good — an M2 Pro produces output faster than real-time, which means no waiting during normal usage.
CUDA inference is fast enough to feel instantaneous for conversational use.
AUDIO PREPROCESSING REQUIREMENTS
XTTS-v2 has specific requirements for reference audio that are not well documented:
python
REQUIRED_FORMAT = {
"sample_rate": 22050, # Hz — must be exact
"channels": 1, # Mono only
"format": "wav", # WAV container
"bit_depth": 16, # 16-bit PCM
"min_duration": 3.0, # seconds
"recommended_duration": 15.0, # seconds
}
The sample rate is particularly important. Submit audio at 44100Hz and you'll get degraded output without any error message. Always resample to 22050Hz before passing to the model.
We use pydub for preprocessing because it handles every input format through ffmpeg and makes sample rate conversion trivial:
python
from pydub import AudioSegment
def prepare_reference_audio(input_path: str) -> str:
audio = AudioSegment.from_file(input_path)
audio = audio.set_channels(1) # Mono
audio = audio.set_frame_rate(22050) # Required sample rate
audio = audio.set_sample_width(2) # 16-bit
output_path = input_path.rsplit('.', 1)[0] + '_processed.wav'
audio.export(output_path, format='wav')
return output_path
THE FASTAPI ENDPOINT
python
# In server.py
from fastapi import FastAPI, UploadFile, File, Form
from fastapi.responses import JSONResponse
import tempfile
import os
voice_cloner = VoiceCloner()
@app.post("/api/clone-voice")
async def clone_voice_endpoint(
text: str = Form(...),
language: str = Form(default='en'),
speed: float = Form(default=1.0),
reference_audio: UploadFile = File(...)
):
# Save uploaded audio to temp file
suffix = os.path.splitext(reference_audio.filename)[1]
with tempfile.NamedTemporaryFile(
suffix=suffix, delete=False
) as tmp:
content = await reference_audio.read()
tmp.write(content)
tmp_path = tmp.name
try:
result = voice_cloner.clone(
text=text,
reference_audio_path=tmp_path,
language=language,
speed=speed
)
return JSONResponse(content=result)
except ValueError as e:
return JSONResponse(
status_code=400,
content={"error": str(e)}
)
except Exception as e:
return JSONResponse(
status_code=500,
content={"error": f"Generation failed: {str(e)}"}
)
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
@app.get("/api/tts/voices")
async def get_voices():
return voice_cloner.get_supported_languages()
@app.post("/api/tts/preview")
async def preview_voice(
reference_audio: UploadFile = File(...),
language: str = Form(default='en')
):
"""Generate a 5-second preview to evaluate voice quality."""
preview_text = {
'en': "Hello, this is a preview of your cloned voice.",
'es': "Hola, esta es una vista previa de tu voz clonada.",
'fr': "Bonjour, ceci est un aperçu de votre voix clonée.",
'de': "Hallo, dies ist eine Vorschau Ihrer geklonten Stimme.",
'ja': "こんにちは、これはクローンされた声のプレビューです。",
}.get(language, "Hello, this is a preview of your cloned voice.")
# Same logic as clone_voice_endpoint
# [abbreviated for clarity]
pass
WHAT I'D DO DIFFERENTLY
- Test with more diverse voice types early
I tested primarily with my own voice during development. In production, I discovered that voices with unusual characteristics (strong accents, high/low pitch extremes) sometimes produce NaN outputs on CPU inference.
Add a retry mechanism:
python
MAX_RETRIES = 3
for attempt in range(MAX_RETRIES):
try:
wav = self.model.tts(
text=chunk,
speaker_wav=processed_ref,
language=language
)
# Check for NaN/Inf
audio_array = np.array(wav)
if np.isnan(audio_array).any() or \
np.isinf(audio_array).any():
raise ValueError("NaN/Inf in output")
audio_chunks.append(audio_array)
break
except Exception as e:
if attempt == MAX_RETRIES - 1:
raise
time.sleep(0.5)
- Implement voice profile caching
Computing the speaker embedding from reference audio takes ~1 second. If a user generates multiple outputs from the same voice profile, cache the speaker embedding rather than recomputing it.
- Add voice similarity scoring earlier
We eventually added cosine similarity between reference and output embeddings as a quality score. I should have added this from day one — it catches generation failures that sound wrong but don't produce errors.
WRAPPING UP
XTTS-v2 is genuinely impressive for a locally-runnable open-source model. The cross-lingual capability is remarkable — 6 seconds of reference audio producing intelligible speech in 17 languages is not something most people expect from a model that runs on their laptop.
The rough edges (memory leak, preprocessing requirements, language quality variance) are manageable once you know about them.
I built all of this into Pocket Core AI — an offline AI desktop app at getpocketcore.com if you want to see it in action without writing any code.
Questions about the implementation — drop them in the comments. I'm reading all of them.














