How to Implement a Custom STT Handler for New Models in HuggingFace Speech‑to‑Speech
Subclass BaseHandler[VADAudio], implement setup() to load your model, and implement process() to yield (transcription, language_code) tuples—then wire it into the S2SPipeline in s2s_pipeline.py.
The HuggingFace speech‑to‑speech repository provides a modular, queue‑based pipeline where each processing stage runs as an independent thread. To add support for a new speech‑to‑text (STT) model, you create a custom STT handler that inherits from BaseHandler and conforms to the library's messaging protocol. This guide walks through the implementation using the actual source code structure, with MoonshineSTTHandler in archive/STT/moonshine_handler.py serving as the reference pattern.
Core Architecture: The BaseHandler Abstraction
Every handler in the speech‑to‑speech pipeline extends BaseHandler from src/speech_to_speech/baseHandler.py. This base class manages:
- Thread lifecycle — automatic start/stop with
stop_eventsignaling - Queue management —
queue_inreceivesVADAudioobjects;queue_outdelivers results downstream - Control messages —
PipelineControlMessageandSESSION_ENDfromsrc/speech_to_speech/pipeline/control.pyallow per‑session state resets - Timing and logging — built‑in performance tracking with threshold warnings
The generic signature BaseHandler[T] lets you specify the input type. For STT handlers, this is always VADAudio, defined in src/speech_to_speech/pipeline/messages.py.
Required Methods for a Custom STT Handler
A minimal custom STT handler must implement three hook methods:
| Method | Purpose | Called When |
|---|---|---|
setup(self, **kwargs) |
Load model, tokenizer, device; configure generation parameters | Once, before the thread starts processing |
warmup(self) |
Run dummy inference to prime caches (optional but recommended) | From setup() or explicitly after initialization |
process(self, vad_audio: VADAudio) |
Execute inference on audio chunk and yield results | For every item in queue_in |
The process() method must yield a tuple. The convention is (transcription: str, language_code: str), though you can adapt this to your pipeline's needs.
Complete Implementation Walkthrough
Step 1: Create the Handler File
Create a new file in archive/STT/—for example, my_model_handler.py. The library organizes legacy and reference handlers in this directory.
Step 2: ImportBase Classes and Dependencies
import logging
import torch
from speech_to_speech.baseHandler import BaseHandler
from speech_to_speech.pipeline.messages import VADAudio
logger = logging.getLogger(__name__)
Step 3: Define the Handler Class with Type Annotation
class MyModelSTTHandler(BaseHandler[VADAudio]):
"""Custom STT handler wrapping a HuggingFace Transformers speech-to-text model."""
def setup(self, model_path: str, device: str = "cpu", **gen_kwargs):
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor
self.device = device
self.gen_kwargs = gen_kwargs
# Load processor and model
self.processor = AutoProcessor.from_pretrained(model_path)
self.model = AutoModelForSpeechSeq2Seq.from_pretrained(model_path).to(device)
# Prime the model to avoid first-call latency
self.warmup()
Step 4: Implement Warm‑Up to Eliminate Cold Start
def warmup(self):
"""Execute dummy inference to initialize CUDA kernels and allocate buffers."""
logger.info(f"Warming up {self.__class__.__name__} …")
dummy_audio = torch.randn(1, 16000).to(self.device) # 1 second of fake audio
with torch.no_grad():
_ = self.model.generate(dummy_audio, **self.gen_kwargs)
Step 5: Implement the Core process() Method
def process(self, vad_audio: VADAudio):
"""
Receive VADAudio, run STT inference, yield (transcription, language).
VADAudio attributes:
- audio: numpy array of float32 waveform samples
- sample_rate: integer sampling frequency (typically 16000)
"""
# Prepare inputs for the model
inputs = self.processor(
vad_audio.audio,
sampling_rate=vad_audio.sample_rate,
return_tensors="pt"
)
inputs = {k: v.to(self.device) for k, v in inputs.items()}
# Generate transcription
with torch.no_grad():
generated_ids = self.model.generate(**inputs, **self.gen_kwargs)
# Decode to text
transcription = self.processor.batch_decode(
generated_ids,
skip_special_tokens=True
)[0]
# Yield result tuple; language detection can be added here
yield (transcription, "en")
Step 6: Expose the Handler for Import
Add to archive/STT/__init__.py:
from .my_model_handler import MyModelSTTHandler
__all__ = ["MyModelSTTHandler"]
Step 7: Integrate into the Pipeline
In your application code or a custom pipeline script, instantiate and inject your handler:
from threading import Event
from speech_to_speech.s2s_pipeline import S2SPipeline
from speech_to_speech.STT.my_model_handler import MyModelSTTHandler
stop_event = Event()
# Instantiate with configuration
stt_handler = MyModelSTTHandler(
stop_event=stop_event,
queue_in=vad_queue, # queue receiving VADAudio from voice activity detection
queue_out=text_queue, # queue delivering results to LLM handler
model_path="openai/whisper-small",
device="cuda",
max_new_tokens=256
)
# Or use with S2SPipeline factory
pipeline = S2SPipeline(
stt_handler=MyModelSTTHandler,
stt_kwargs=dict(
model_path="openai/whisper-small",
device="cuda",
max_new_tokens=256,
do_sample=False
),
# ... other handler configurations
)
pipeline.start()
# Audio flows: microphone → VAD → MyModelSTTHandler → LLM → TTS → speaker
Reference Implementation: MoonshineSTTHandler
The archive/STT/moonshine_handler.py file demonstrates this pattern with the Moonshine model family. Key excerpts showing the same structural elements:
class MoonshineSTTHandler(BaseHandler[VADAudio]):
def setup(self, model_name="moonshine/base", torch_dtype="float16", **gen_kwargs):
self.torch_dtype = getattr(torch, torch_dtype)
self.gen_kwargs = gen_kwargs
# Moonshine-specific loading
self.tokenizer = moonshine.load_tokenizer()
self.model = moonshine.load_model(model_name)
self.warmup()
def warmup(self):
# Model-specific dummy generation
dummy = torch.zeros(1, 16000, dtype=self.torch_dtype)
_ = self.model.generate(dummy, **self.gen_kwargs)
def process(self, vad_audio: VADAudio):
# Moonshine expects batch dimension
pred_ids = self.model.generate(vad_audio.audio[None, :], **self.gen_kwargs)
pred_text = self.tokenizer.decode_batch(pred_ids)[0]
yield (pred_text, "en")
Advanced: Hooks for Input/Output Filtering
BaseHandler provides optional hooks for fine‑grained control:
should_process_input(item)— returnFalseto skip stale or cancelled audio chunksshould_emit_output(result)— returnFalseto suppress unwanted outputs
Override these in your handler if your pipeline requires filtering based on timestamps, confidence scores, or session state.
Testing Your Custom STT Handler
Verify implementation with a minimal test script:
import wave
import numpy as np
import queue
from threading import Event
from speech_to_speech.pipeline.messages import VADAudio
from speech_to_speech.STT.my_model_handler import MyModelSTTHandler
# Load test audio
with wave.open("tests/audio/sample.wav") as wf:
frames = wf.readframes(wf.getnframes())
audio = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
vad_audio = VADAudio(
audio=audio,
sample_rate=wf.getframerate()
)
# Set up queues and run handler directly
stop_event = Event()
q_in = queue.Queue()
q_out = queue.Queue()
handler = MyModelSTTHandler(
stop_event=stop_event,
queue_in=q_in,
queue_out=q_out,
model_path="openai/whisper-tiny",
device="cpu"
)
# Start handler thread
handler.start()
q_in.put(vad_audio)
# Retrieve result
result = q_out.get(timeout=30)
print(f"Transcription: {result[0]}")
print(f"Language: {result[1]}")
# Signal shutdown
stop_event.set()
handler.join()
Run the full test suite to ensure integration compatibility:
pytest -q
Summary
- Inherit from
BaseHandler[VADAudio]— located insrc/speech_to_speech/baseHandler.py - Implement
setup()— load model, tokenizer, configure device and generation parameters - Call
warmup()— eliminate first‑inference latency with dummy forward passes - Yield from
process()— return(transcription, language_code)tuples for eachVADAudioinput - Register in
archive/STT/— follow theMoonshineSTTHandlerpattern fromarchive/STT/moonshine_handler.py - Wire into
S2SPipeline— pass handler class and kwargs tostt_handlerinsrc/speech_to_speech/s2s_pipeline.py
Frequently Asked Questions
What input format does process() receive?
The process() method receives a VADAudio object from src/speech_to_speech/pipeline/messages.py. This dataclass contains audio (numpy array of float32 samples) and sample_rate (integer, typically 16000 Hz). The voice activity detection (VAD) stage upstream segments continuous audio into these chunks.
Can I change the output format from (transcription, language)?
Yes. While the convention is a two‑element tuple, downstream handlers in your pipeline must expect the structure you yield. If you need additional metadata—confidence scores, word‑level timestamps, or alternative hypotheses—yield a dictionary or custom dataclass instead, and update consuming handlers accordingly.
How do I handle different sampling rates?
Resampling is typically handled upstream in the VAD or audio capture stage. If your model requires a specific rate, check vad_audio.sample_rate in process() and resample using librosa or torchaudio before feeding to the model. The Whisper processor in the example above accepts variable rates and handles resampling internally.
Why is warmup() important for real‑time pipelines?
The first CUDA kernel launch, memory allocation, and model graph compilation introduce significant latency—often hundreds of milliseconds. warmup() executes dummy inference during initialization so that real user audio experiences consistent, low‑latency processing. Without it, the first utterance in a session stalls noticeably.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →