Text-to-Speech › Code Implementation: Building a TTS Pipeline

Michael BrenndoerferFebruary 19, 20269 min read
Language AI HandbookAll chapters
Part 51 of 67Speech and Audio
Chapter 397Text-to-Speech › Code Implementation: Building a TTS Pipeline
Reading section 8 of 12 · Code Implementation: Building a TTS Pipeline · 9 minSection 8 of 12 · 9 min

Book contents

Section 8 of 12 · 9 min read

Text-to-Speech › Code Implementation: Building a TTS PipelineOne section · Sections in this chapter8 / 12 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's implement a complete neural TTS pipeline using modern libraries. We will use a pre-trained model from the transformers library to generate speech, then examine the intermediate representations to understand the acoustic modeling and vocoding stages.

In[9]:
Code
# Install required packages
# !uv pip install transformers torch torchaudio soundfile librosa matplotlib numpy textalloc

import warnings

import torch
from transformers import set_seed

warnings.filterwarnings("ignore")

# Set seed for reproducibility
set_seed(42)
device = "cuda" if torch.cuda.is_available() else "cpu"
Out[10]:
Console
Using device: cpu

We use the VITS (Variational Inference with adversarial learning for end-to-end Text-to-Speech) model, which combines acoustic modeling and vocoding into a single end-to-end architecture. Unlike the two-stage pipeline discussed earlier, VITS uses a variational autoencoder with adversarial training to map text directly to waveform, though internally it still operates on latent representations analogous to spectrograms.

In[11]:
Code
## Load pre-trained VITS model and tokenizer
## VITS is an end-to-end model that combines acoustic features and vocoding
from transformers import VitsModel, VitsTokenizer

model_name = "facebook/mms-tts-eng"
tokenizer = VitsTokenizer.from_pretrained(model_name)
model = VitsModel.from_pretrained(model_name).to(device)
model.eval()
Out[12]:
Console
Model loaded: facebook/mms-tts-eng
Vocabulary size: 38

Now we process text through the pipeline. The tokenizer converts text to phoneme IDs using a G2P (grapheme-to-phoneme) model internally, handling normalization and pronunciation automatically.

In[13]:
Code
# Input text for synthesis
text = "Text to speech synthesis bridges the gap between written language and spoken communication."

# Tokenize input
inputs = tokenizer(text, return_tensors="pt")
input_ids = inputs["input_ids"].to(device)
Out[14]:
Console
Input text: 'Text to speech synthesis bridges the gap between written language and spoken communication.'
Token IDs shape: torch.Size([1, 181])
Token IDs: [0, 33, 0, 7, 0, 31, 0, 33, 0, 19, 0, 33, 0, 22, 0, 19, 0, 8, 0, 13]...

With tokenized inputs, we generate speech. The model outputs a waveform directly, but we can also extract intermediate features to visualize the acoustic modeling process.

In[15]:
Code
# Generate speech
with torch.no_grad():
    outputs = model(input_ids)
    waveform = outputs.waveform[0].cpu().numpy()

    # Extract intermediate spectrogram if available (model-dependent)
    # VITS internally uses latent representations, but we can compute mel-spec from output
Out[16]:
Console
Generated waveform shape: (102656,)
Duration: 6.42 seconds
Sampling rate: 16000 Hz

The generated waveform spans the calculated duration of audio at the model's sampling rate. This gives enough temporal resolution for clear speech intelligibility while maintaining reasonable computational efficiency.

Out[17]:
Visualization
Waveform plot showing amplitude variation over time for synthesized speech.
Generated speech waveform showing amplitude over time. The pattern exhibits natural variation in amplitude corresponding to syllabic structure and stress patterns.

The waveform displays the characteristic amplitude modulation of speech, with higher amplitude regions corresponding to voiced segments (vowels) and lower amplitude regions to consonants and pauses. Unlike the square waves of synthetic beeps, natural speech exhibits complex harmonic structures with gradual transitions between sounds.

Now let's compute and visualize the mel-spectrogram to examine the frequency content over time. This is what an acoustic model would output in a two-stage system.

In[18]:
Code
def hz_to_mel(freq_hz):
    return 2595 * np.log10(1 + freq_hz / 700)


def mel_to_hz(mel):
    return 700 * (10 ** (mel / 2595) - 1)


def mel_filter_bank(sr, n_fft, n_mels, fmin=0, fmax=8000):
    """Create triangular mel filters without an external audio package."""
    mel_points = np.linspace(hz_to_mel(fmin), hz_to_mel(fmax), n_mels + 2)
    hz_points = mel_to_hz(mel_points)
    bins = np.floor((n_fft + 1) * hz_points / sr).astype(int)
    filters = np.zeros((n_mels, n_fft // 2 + 1))

    for m in range(1, n_mels + 1):
        left, center, right = bins[m - 1], bins[m], bins[m + 1]
        if center > left:
            filters[m - 1, left:center] = (np.arange(left, center) - left) / (
                center - left
            )
        if right > center:
            filters[m - 1, center:right] = (
                right - np.arange(center, right)
            ) / (right - center)
    return filters


def stft_power(signal, n_fft=1024, hop_length=256):
    """Compute a power spectrogram with NumPy."""
    window = np.hanning(n_fft)
    frames = []
    for start in range(0, max(len(signal) - n_fft + 1, 1), hop_length):
        frame = np.zeros(n_fft)
        chunk = signal[start : start + n_fft]
        frame[: len(chunk)] = chunk
        spectrum = np.fft.rfft(frame * window)
        frames.append(np.abs(spectrum) ** 2)
    return np.array(frames).T


## Compute mel-spectrogram from the generated waveform
## This simulates the intermediate representation in a two-stage TTS system
n_fft = 1024
hop_length = 256
n_mels = 80
power_spec = stft_power(waveform, n_fft=n_fft, hop_length=hop_length)
mel_filters = mel_filter_bank(
    model.config.sampling_rate, n_fft=n_fft, n_mels=n_mels, fmax=8000
)
mel_spec = np.maximum(mel_filters @ power_spec, 1e-10)

## Convert to log scale (dB)
mel_spec_db = 10 * np.log10(mel_spec / np.max(mel_spec))
Out[19]:
Console
Mel-spectrogram shape: (80, 398)
Time frames: 398
Frequency bins: 80

This compression to 80 frequency bins and the computed number of time frames reaches approximately a 200:1 reduction compared to raw audio samples, retaining the needed spectral envelope while discarding phase information that the vocoder will reconstruct.

Out[20]:
Visualization
Mel-spectrogram heatmap showing frequency distribution over time for synthesized speech.
Mel-spectrogram of generated speech showing frequency content (y-axis, mel scale) over time (x-axis). Brighter regions indicate higher energy. Horizontal bands stand for formant frequencies characteristic of different vowel sounds.

The mel-spectrogram reveals the spectral envelope of the speech. The horizontal dark bands stand for formants: resonant frequencies of the vocal tract that distinguish different vowel sounds. The vertical striations indicate pitch periods: the basic frequency of vocal cord vibration. The smoothness and continuity of these patterns indicate high-quality synthesis without the buzziness or metallic artifacts characteristic of older vocoders.

Let's save the audio and examine the attention alignment that the model learned between text and audio (if available in this architecture).

In[21]:
Code
import wave

# Save the generated audio
output_path = "tts_output.wav"
audio_int16 = np.int16(np.clip(waveform, -1.0, 1.0) * 32767)
with wave.open(output_path, "wb") as wav_file:
    wav_file.setnchannels(1)
    wav_file.setsampwidth(2)
    wav_file.setframerate(model.config.sampling_rate)
    wav_file.writeframes(audio_int16.tobytes())
# Note: VITS uses stochastic duration prediction internally rather than explicit attention
# For visualization purposes, we'll simulate an alignment matrix showing how text
# positions map to time steps (conceptually similar to Tacotron attention)
Out[22]:
Console
Audio saved to: tts_output.wav

The synthesized WAV file is now ready for subjective listening evaluation or objective quality assessment using metrics such as MOS or PESQ discussed in the evaluation section.

In[23]:
Code
# Create a simulated alignment matrix to illustrate the concept
# In real Tacotron models, this would be the attention weights
text_length = min(len(text), 50)  # Cap for visualization
time_steps = mel_spec_db.shape[1]

# Create monotonic alignment with varying durations (simulating different phoneme lengths)
alignment = np.zeros((text_length, time_steps))
duration_weights = 1.0 + 0.45 * np.sin(np.arange(text_length) * 0.7) ** 2
frame_edges = np.rint(
    np.concatenate(([0], np.cumsum(duration_weights)))
    / duration_weights.sum()
    * time_steps
).astype(int)

for i, (start, end) in enumerate(zip(frame_edges[:-1], frame_edges[1:])):
    if start < end:
        alignment[i, start:end] = 1.0
        if end < time_steps:
            alignment[i, end : min(end + 2, time_steps)] = np.linspace(
                0.45, 0.0, min(2, time_steps - end)
            )
Out[24]:
Visualization
Alignment heatmap showing text to speech temporal correspondence.
Simulated monotonic alignment between text characters (y-axis) and audio time steps (x-axis). The diagonal pattern indicates left-to-right progression through the text, with varying slopes representing different phoneme durations.

The alignment visualization shows the monotonic left-to-right attention required for TTS. Unlike machine translation where attention might jump between distant words, TTS attention must progress steadily through the text. The varying widths of the attention bands indicate different phoneme durations: consonants typically occupy fewer frames than vowels.

Finally, let's compare the spectral characteristics of our synthesized speech against what would be expected in natural speech by examining the basic frequency (pitch) contour.

In[25]:
Code
# Estimate pitch with frame-wise autocorrelation in the human speech range.
n_fft = 1024
hop_length = 256
sampling_rate = model.config.sampling_rate
frame_starts = range(0, max(len(waveform) - n_fft + 1, 1), hop_length)
frames = []
for start in frame_starts:
    frame = np.zeros(n_fft)
    chunk = waveform[start : start + n_fft]
    frame[: len(chunk)] = chunk
    frames.append(frame)

frame_rms = np.array([np.sqrt(np.mean(frame**2)) for frame in frames])
energy_threshold = 0.08 * frame_rms.max()
min_lag = int(sampling_rate / 300)
max_lag = int(sampling_rate / 75)
pitch_values = np.full(len(frames), np.nan)

for t, frame in enumerate(frames):
    if frame_rms[t] < energy_threshold:
        continue
    centered = (frame - frame.mean()) * np.hanning(n_fft)
    autocorr = np.correlate(centered, centered, mode="full")[n_fft - 1 :]
    if autocorr[0] <= 0:
        continue
    lag = min_lag + np.argmax(autocorr[min_lag : max_lag + 1])
    if autocorr[lag] / autocorr[0] >= 0.25:
        pitch_values[t] = sampling_rate / lag

# Suppress isolated octave errors without filling unvoiced gaps.
smoothed_pitch = pitch_values.copy()
for t in range(len(pitch_values)):
    if np.isfinite(pitch_values[t]):
        neighborhood = pitch_values[max(0, t - 2) : t + 3]
        smoothed_pitch[t] = np.nanmedian(neighborhood)

# A single-speaker utterance has a stable pitch register. Fold estimates that
# land well above that register down one octave, a common autocorrelation error.
pitch_values = smoothed_pitch.copy()
speaker_register = np.nanmedian(pitch_values)
octave_doubled = pitch_values > 1.55 * speaker_register
pitch_values[octave_doubled] /= 2

# Smooth within voiced segments once more, without bridging silent gaps.
smoothed_pitch = pitch_values.copy()
for t in range(len(pitch_values)):
    if np.isfinite(pitch_values[t]):
        neighborhood = pitch_values[max(0, t - 2) : t + 3]
        smoothed_pitch[t] = np.nanmedian(neighborhood)
pitch_values = smoothed_pitch

time_axis = (
    np.arange(len(pitch_values)) * hop_length / model.config.sampling_rate
)
Out[26]:
Visualization
Line plot showing pitch variation over time for synthesized utterance.
Pitch contour (basic frequency) over time for synthesized speech. Variations in pitch indicate intonation patterns, with rises typically signaling questions or continuation and falls showing statement completion.

The pitch contour reveals the prosodic patterns of the synthesized utterance. Natural speech exhibits continuous variation in basic frequency (F0) to convey emphasis and emotion as well as syntactic structure. The smoothness of this contour indicates that the model has learned appropriate prosody rather than generating robotic, monotone speech.