Speech-Language Integration › Code Implementation: Building a Speech-LLM Interface

Michael BrenndoerferFebruary 18, 20266 min read
Language AI HandbookAll chapters
Part 51 of 67Speech and Audio
Chapter 396Speech-Language Integration › Code Implementation: Building a Speech-LLM Interface
Reading section 9 of 13 · Code Implementation: Building a Speech-LLM Interface · 6 minSection 9 of 13 · 6 min

Book contents

Section 9 of 13 · 6 min read

Speech-Language Integration › Code Implementation: Building a Speech-LLM InterfaceOne section · Sections in this chapter9 / 13 ↓
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 us implement a simplified speech-to-text interface using a pre-trained Whisper encoder connected to a small language model. While production systems like Qwen-Audio use proprietary architectures, we can demonstrate the core principles: audio encoding, projection, and text generation.

We will use PyTorch and create a minimal connector between Whisper's encoder and a small GPT-style decoder.

In[11]:
Code
import torch

# Stub encoder and LLM objects to keep this example self-contained.
# In practice, load real models with:
#   from transformers import WhisperModel, GPT2Model, GPT2Tokenizer
#   whisper_encoder = WhisperModel.from_pretrained("openai/whisper-base").encoder
#   gpt2_model = GPT2Model.from_pretrained("gpt2")
#   gpt2_tokenizer = GPT2Tokenizer.from_pretrained("gpt2")

whisper_encoder = type(
    "obj",
    (object,),
    {
        "config": type("cfg", (object,), {"d_model": 512})(),
        "parameters": lambda self: [],
        "__call__": lambda self, x: type(
            "out", (object,), {"last_hidden_state": torch.randn(1, 1500, 512)}
        )(),
    },
)()

gpt2_model = type(
    "obj",
    (object,),
    {
        "config": type("cfg", (object,), {"n_embd": 768})(),
        "parameters": lambda self: [],
        "transformer": type(
            "t",
            (object,),
            {"wte": lambda self, x: torch.randn(1, x.shape[1], 768)},
        )(),
    },
)()

gpt2_tokenizer = type(
    "obj",
    (object,),
    {
        "encode": lambda self, text, return_tensors=None: torch.tensor(
            [[101, 102, 103]]
        ),
    },
)()

# Freeze base models so only the connector trains
for param in whisper_encoder.parameters():
    param.requires_grad = False
for param in gpt2_model.parameters():
    param.requires_grad = False
In[12]:
Code
import torch.nn as nn


class SpeechLLMConnector(nn.Module):
    """
    Projects Whisper encoder outputs into GPT-2's embedding space.
    A linear projection maps audio_dim -> text_dim, and learned
    positional embeddings preserve temporal ordering information.
    """

    def __init__(self, audio_dim=512, text_dim=768, max_audio_len=1500):
        super().__init__()
        self.projection = nn.Linear(audio_dim, text_dim)
        self.layer_norm = nn.LayerNorm(text_dim)
        # Learned positional embeddings for audio tokens
        self.audio_pos_embed = nn.Parameter(
            torch.randn(1, max_audio_len, text_dim) * 0.02
        )

    def forward(self, audio_features):
        """
        Args:
            audio_features: [batch, seq_len, audio_dim] from Whisper encoder
        Returns:
            projected: [batch, seq_len, text_dim] ready for GPT-2
        """
        projected = self.projection(audio_features)
        seq_len = audio_features.size(1)
        projected = projected + self.audio_pos_embed[:, :seq_len, :]
        projected = self.layer_norm(projected)
        return projected


connector = SpeechLLMConnector(
    audio_dim=whisper_encoder.config.d_model,  # 512 for Whisper base
    text_dim=gpt2_model.config.n_embd,  # 768 for GPT-2
)

print(
    f"Connector parameters: {sum(p.numel() for p in connector.parameters()):,}"
)

The connector has far fewer parameters than either foundation model. Whisper base has roughly 74M parameters, GPT-2 has roughly 117M, but the connector shown here has only about 790K. This lightweight design is deliberate: during Stage 1 training, we want the connector to adapt quickly to align the two pre-trained feature spaces without requiring extensive compute.

In[13]:
Code
import torch

# Simulated mel-spectrogram input (batch=1, n_mels=80, seq_len=3000)
# This represents approximately 30 seconds of audio at Whisper's frame rate.
batch_size = 1
n_mels = 80
sequence_length = 3000

dummy_audio_features = torch.randn(batch_size, n_mels, sequence_length)

# Step 1: Encode audio with Whisper
with torch.no_grad():
    encoder_outputs = whisper_encoder(dummy_audio_features)
    audio_hidden_states = encoder_outputs.last_hidden_state  # [1, 1500, 512]

# Step 2: Project to GPT-2 embedding space
audio_embeddings = connector(audio_hidden_states)  # [1, 1500, 768]

# Step 3: Prepare a text prefix to prepend before the audio context
prefix_text = "The user said:"
prefix_tokens = gpt2_tokenizer.encode(prefix_text, return_tensors="pt")
prefix_embeds = gpt2_model.transformer.wte(prefix_tokens)  # [1, N_tokens, 768]

# Step 4: Concatenate audio and text embeddings into a unified sequence
combined_embeds = torch.cat([audio_embeddings, prefix_embeds], dim=1)

encoder_shape = audio_hidden_states.shape
projected_shape = audio_embeddings.shape
prefix_shape = prefix_embeds.shape
combined_length = combined_embeds.shape[1]
Out[14]:
Console
Encoder output shape:      torch.Size([1, 1500, 512])   (batch, frames, audio_dim)
Projected embeddings shape: torch.Size([1, 1500, 768]) (batch, frames, text_dim)
Prefix embeddings shape:   torch.Size([1, 3, 768])      (batch, tokens, text_dim)
Combined sequence length:  1503 tokens total

Whisper CNN downsampled the 3000 mel frames to 1500 audio hidden states.
After projection, all 1500 audio frames share GPT-2's 768-d embedding space.
The combined embedding is now ready for GPT-2 autoregressive generation.
Out[15]:
Visualization
Flow diagram showing four labeled boxes connected by arrows representing tensor shapes at each stage of the speech-LLM pipeline.
Tensor dimension transformations through the speech-LLM connector pipeline. Audio encoder output (1500 x 512) passes through a linear projection to match GPT-2 dimensions (1500 x 768), then optionally compresses via Perceiver resampling to a short fixed-length summary (32 x 768) before concatenation with text token embeddings for LLM input.
In[16]:
Code
class AlignmentLoss(nn.Module):
    """
    Training objective: Given audio features, predict the text transcript.
    This is essentially an ASR objective expressed through the LLM's vocabulary,
    teaching the connector to translate acoustic content into linguistic tokens.
    """

    def __init__(self):
        super().__init__()
        self.ce_loss = nn.CrossEntropyLoss()

    def forward(self, model_outputs, target_tokens):
        """
        model_outputs: [batch, seq_len, vocab_size]
        target_tokens: [batch, target_len]
        """
        # Shift logits and labels for next-token prediction
        shift_logits = model_outputs[..., :-1, :].contiguous()
        shift_labels = target_tokens[..., 1:].contiguous()

        loss = self.ce_loss(
            shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1)
        )
        return loss


alignment_criterion = AlignmentLoss()

# Simulate a target transcript
transcript = "What is the weather like today?"
target_tokens = gpt2_tokenizer.encode(transcript, return_tensors="pt")
target_ids_sample = target_tokens[0][:8]
target_shape = target_tokens.shape
Out[17]:
Console
Target token IDs (first 8): [101, 102, 103]
Target shape: torch.Size([1, 3])

Training strategy:
  Stage 1: Frozen encoder + LLM, train connector only
  Stage 2: Frozen encoder, train connector + LoRA on LLM
  Stage 3: All parameters update on instruction data

This implementation illustrates the core principle of modern speech-LLM integration: use frozen foundation models for their respective modalities, and train lightweight connectors to bridge the semantic gap. The connector learns a translation from "acoustic semantics" to "linguistic semantics" without destroying the pre-trained knowledge in either the speech encoder or the language model.