Building a Voice Pipeline by Hand
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Written on 2026-10-01 against Spring AI 2.0.1 on Spring Boot 4.1.x (Spring Framework 7) with Java 21, FastAPI 0.142.2 and
LangChain 1.4.3 create_agent on Python 3.12, Silero VAD 6.2.3, faster-whisper 1.2.1, Pipecat 1.12.0 and LiveKit Agents
1.8.3 (see the version baseline). The class and method names of
TranscriptionModel, StreamingTextToSpeechModel, ChatClient, VADIterator, asyncio.Task and the FastAPI WebSocket
class were read from the official documentation and, for the Spring AI interfaces and the Silero iterator, from the
2.0.1 jar and the current Silero source. Nothing on this page was run against live audio: a microphone or a telephone
line, a speech-to-text account and a text-to-speech account are needed, and none is available here. The pipeline logic,
in particular the barge-in sequence, is documented from the official references and from the behaviour of Reactor
cancellation and asyncio cancellation, and it is illustrative code, not a compiled project.
Building the cascaded pipeline from the architecture page by hand is worth doing once, even if you later adopt a framework, because every behaviour that makes a voice agent feel good or bad lives in the glue: when speech starts, when a turn ends, how much text is buffered before the voice starts, and what is cancelled when the caller speaks over the agent. The architecture is the cascaded one of Voice Architectures; turn-taking parameters are the subject of Turn-Taking and Interruptions, speech-to-text choices of Speech to Text and synthesis choices of Text to Speech. This page only wires them together.
The shape of the pipeline
One call is one session. Inside a session, audio flows through five stages, and one rule governs the whole design: everything that produces output for the current answer belongs to one cancellable unit, so that a single cancel stops the language model, the synthesiser and the sending of audio together.
The stages and what each one needs to be true:
-
Transport. Delivers fixed-size PCM frames in, and accepts audio out plus a way to clear audio already queued. The session code must not know whether the bytes arrive on a browser WebSocket, a Twilio Media Streams socket or an Asterisk channel.
-
VAD. Turns frames into two events, speech started and speech ended after a silence (see Turn-Taking and Interruptions for thresholds).
-
STT. Converts the utterance between those two events into text. The simplest correct design transcribes the finished utterance; a provider socket that streams interim results lowers latency further and is described below.
-
LLM. The DocsAssistant core as a token stream:
ChatClient.stream()in Spring AI,astreamon a LangChaincreate_agentagent. -
Chunker and TTS. Buffers tokens up to a sentence boundary (the chunker of Voice Architectures) and synthesises each sentence while the next is still being generated.
The latency of this arrangement is the budget of the architecture page, . The time to stop when the caller interrupts is a separate quantity that you control entirely:
where is the detector’s frame delay, the minimum speech duration you require before believing it is the caller and not an echo, the time for the cancellation to reach the HTTP clients (small, but not zero for a model that is mid-token) and the time for the transport to drop what it had queued. Of the four, and are the ones callers notice.
Spring Boot
The Java version uses Spring MVC’s WebSocket support for the transport and Reactor for the turn. Reactor is a good fit
for the turn because a Flux pipeline from tokens to audio is exactly the "one cancellable unit" the design needs:
disposing the subscription cancels every upstream stage. The reactive background (cold publishers, Disposable,
flatMap concurrency, schedulers) is in Reactive Programming, and
ChatClient.stream() cancellation is treated in Streaming and Reactive.
A note on names: books and blog posts written against Spring AI 1.0 call the speech interface SpeechModel. In Spring AI
2.0.1 the interfaces are TextToSpeechModel and StreamingTextToSpeechModel for synthesis, and TranscriptionModel
(which extends StreamingTranscriptionModel) for recognition; there is no SpeechModel class in the 2.0.1 jars. See
Audio Transcription & Speech for starters and options.
A transport abstraction
The only code that knows about WebSockets, Twilio JSON envelopes or mu-law is a pair of small types. The session receives frames through a method and sends through the transport. This is the seam the Telephony with Twilio page uses to put Media Streams in front of the same session: only the adapter changes.
package com.example.docsassistant.voice;
/** What a voice session needs from the wire. Implementations must be safe to call from any thread. */
public interface AudioTransport {
/** Send synthesised audio to the caller, already in the transport's format (see {@link #outputFormat()}). */
void send(byte[] audio);
/** Drop every audio buffer not yet played (browser: a control message; Twilio: the clear message). */
void clear();
/** Hang up or close the socket. */
void close();
/** Describes the PCM the session should produce; the adapter converts if the wire differs. */
AudioFormat outputFormat();
record AudioFormat(int sampleRateHz, String encoding) { }
}
Source: Spring Framework: WebSocket server (the handler and session types the adapter below wraps). The interface itself is application code, not a Spring API.
The WebSocket adapter
For the browser the adapter is a BinaryWebSocketHandler. Binary messages carry little-endian 16-bit PCM at 16 kHz mono;
text messages carry control JSON. The ConcurrentWebSocketSessionDecorator matters: the turn writes audio from a
Reactor thread while the handler thread may be sending a control message, and a raw WebSocketSession does not allow
concurrent sends.
package com.example.docsassistant.voice;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.web.socket.BinaryMessage;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.BinaryWebSocketHandler;
import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator;
public class WebSocketVoiceHandler extends BinaryWebSocketHandler {
private final VoiceSessionFactory sessions;
private final Map<String, VoiceSession> bySocket = new ConcurrentHashMap<>();
public WebSocketVoiceHandler(VoiceSessionFactory sessions) {
this.sessions = sessions;
}
@Override
public void afterConnectionEstablished(WebSocketSession socket) {
WebSocketSession safe = new ConcurrentWebSocketSessionDecorator(socket, 5_000, 512 * 1024);
this.bySocket.put(socket.getId(), this.sessions.create(socket.getId(), new Transport(safe)));
}
@Override
protected void handleBinaryMessage(WebSocketSession socket, BinaryMessage message) {
byte[] frame = new byte[message.getPayloadLength()];
message.getPayload().get(frame);
this.bySocket.get(socket.getId()).onInboundAudio(frame);
}
@Override
public void afterConnectionClosed(WebSocketSession socket, CloseStatus status) {
VoiceSession session = this.bySocket.remove(socket.getId());
if (session != null) {
session.close(); // cancels the current turn
}
}
/** Adapter: PCM16 at 16 kHz both ways; the browser client plays binary messages and obeys {"type":"clear"}. */
private record Transport(WebSocketSession socket) implements AudioTransport {
@Override
public void send(byte[] audio) {
try {
this.socket.sendMessage(new BinaryMessage(audio));
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
@Override
public void clear() {
try {
this.socket.sendMessage(new TextMessage("{\"type\":\"clear\"}"));
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
@Override
public void close() {
try {
this.socket.close();
} catch (IOException ignored) {
// already closed
}
}
@Override
public AudioFormat outputFormat() {
return new AudioFormat(16_000, "pcm_s16le");
}
}
}
Source: Spring Framework: WebSocket server,
which documents BinaryWebSocketHandler, registration with WebSocketConfigurer and the
ConcurrentWebSocketSessionDecorator for sending from several threads. Register the handler on a path such as /voice,
authenticate the HTTP upgrade request with the same filter chain as the rest of the application, and allow only your own
origins.
The session: VAD, STT, LLM, chunker, TTS
The session is independent of the transport. It receives frames, runs a VoiceActivityDetector (an interface here,
because Spring has no VAD: back it with the Silero ONNX model through ONNX Runtime for Java, as the Silero repository’s
Java example does, or with any detector that reports speech start and end per 32 ms frame), collects the utterance,
transcribes it and starts a turn.
The turn is one Flux pipeline whose Disposable is the only handle the session keeps. Four details carry the design:
-
chatClient.prompt().user(text).stream().content()is cold. Nothing is requested from the model until the pipeline is subscribed, and disposing the subscription cancels the HTTP stream to the model. -
The sentence chunker is stateful, so it is created inside
Flux.defer: every subscription gets a fresh buffer. -
flatMapSequential(…, 2)subscribes to the synthesis of the next sentence while the current one is still being streamed, but emits the audio strictly in sentence order. PlainconcatMapwould wait for sentence one to finish before asking for sentence two, leaving an audible gap between sentences. The cost is at most one extra synthesis request in flight, and cancelling the outer subscription cancels both. -
Blocking calls (the transcription of a finished utterance) run on
Schedulers.boundedElastic()and never on the WebSocket thread.
package com.example.docsassistant.voice;
import java.io.ByteArrayOutputStream;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.ai.audio.transcription.TranscriptionModel;
import org.springframework.ai.audio.tts.StreamingTextToSpeechModel;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.core.io.ByteArrayResource;
import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
public class VoiceSession {
private final String conversationId;
private final AudioTransport transport;
private final VoiceActivityDetector vad;
private final TranscriptionModel stt;
private final StreamingTextToSpeechModel tts;
private final ChatClient chat;
private final ByteArrayOutputStream utterance = new ByteArrayOutputStream();
private final AtomicBoolean speaking = new AtomicBoolean(false);
private volatile Disposable turn = () -> { };
public VoiceSession(String conversationId, AudioTransport transport, VoiceActivityDetector vad,
TranscriptionModel stt, StreamingTextToSpeechModel tts, ChatClient chat) {
this.conversationId = conversationId;
this.transport = transport;
this.vad = vad;
this.stt = stt;
this.tts = tts;
this.chat = chat;
}
/** Called by the transport for every inbound PCM16 16 kHz frame (512 samples). */
public synchronized void onInboundAudio(byte[] frame) {
switch (this.vad.accept(frame)) {
case SPEECH_START -> {
if (this.speaking.get()) {
bargeIn();
}
this.utterance.reset();
this.utterance.writeBytes(frame);
}
case SPEECH_END -> {
byte[] pcm = this.utterance.toByteArray();
this.utterance.reset();
startTurn(pcm);
}
case NONE -> {
if (this.vad.inSpeech()) {
this.utterance.writeBytes(frame);
}
}
}
}
private void startTurn(byte[] pcm) {
this.turn = Mono.fromCallable(() -> this.stt.transcribe(new WavResource(pcm, 16_000))) // blocking call
.subscribeOn(Schedulers.boundedElastic())
.filter(text -> !text.isBlank())
.flatMapMany(this::answer)
.doOnSubscribe(s -> this.speaking.set(true))
.doOnNext(this.transport::send)
.doFinally(signal -> this.speaking.set(false))
.subscribe(audio -> { }, error -> this.transport.clear());
}
private Flux<byte[]> answer(String question) {
Flux<String> tokens = this.chat.prompt()
.user(question)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, this.conversationId))
.stream()
.content();
return Flux.defer(() -> {
SentenceChunker chunker = new SentenceChunker();
return tokens
.concatMapIterable(chunker::accept)
.concatWith(Mono.fromSupplier(chunker::flush).filter(s -> !s.isBlank()))
.flatMapSequential(sentence -> this.tts.stream(sentence), 2);
});
}
/** The caller spoke over the agent: stop everything, then empty the wire. */
private void bargeIn() {
this.turn.dispose(); // cancels the model stream, the synthesis requests and the sending
this.transport.clear(); // audio already queued downstream would otherwise keep playing
this.speaking.set(false);
}
public void close() {
this.turn.dispose();
this.transport.close();
}
}
Source: Spring AI: ChatClient API (stream().content() and
the ChatMemory.CONVERSATION_ID advisor parameter),
Spring AI: Transcription API and
Spring AI: Text-To-Speech API
(StreamingTextToSpeechModel.stream(String) returns a Flux<byte[]>). Disposable.dispose() and flatMapSequential are
documented in the Reactor reference guide.
Three things in this listing are deliberately small and must grow in a product:
-
WavResourceis aByteArrayResourcethat prepends a 44-byte RIFF header to the PCM and returns a file name (utterance.wav), because the OpenAI transcription endpoint infers the format from the file name.SentenceChunkeris the Java twin of the Python chunker on the architecture page: it buffers aStringBuilder, returns the completed sentences fromaccept(String token)and the remainder fromflush(), and must not split "v1.4.3" or "e.g.". -
The transcription is of a finished utterance.
StreamingTranscriptionModel.stream(…)streams the text of a file that already exists in full, so it shortens the wait for a long recording but does not listen to a live microphone. For true streaming recognition, connect the session to a provider’s streaming socket (the skeleton on the speech-to-text page) and feed it the frames as they arrive, which also removes the end-of-utterance wait from the critical path. -
The
speakingflag is set when the pipeline starts, not when the first audio byte leaves. A refinement is to set it indoOnNextand to ignore speech starts during the first moments of an answer, since the caller’s own "uh-huh" would otherwise cancel it. The history-truncation step of the barge-in (the model should remember only what was heard) is a memory operation specific to your repository and is left to the turn-taking page.
Disposing a pipeline does not undo a tool call already in progress inside the model’s stream, so a tool with side effects needs its own idempotency and should not run before the model is sure of the user’s intent. The DocsAssistant’s retrieval tool is read-only, so a cancelled turn costs nothing but the tokens already generated.
Python and FastAPI
The Python version is the same pipeline with asyncio tasks in place of a Reactor subscription. The unit of
cancellation is an asyncio.Task for the whole answer; inside it, an asyncio.TaskGroup runs a producer (LLM tokens to
sentences) and a consumer (sentences to audio) joined by a queue, so cancelling the outer task cancels both children and
unwinds the HTTP streams they hold. The transport is FastAPI’s WebSocket; the equivalent seam to AudioTransport is a
class with send, clear and close coroutines.
Transport, VAD and the receive loop
Silero VAD’s VADIterator takes 512-sample windows at 16 kHz (256 at 8 kHz) and returns {'start': …} when speech
begins, {'end': …} after min_silence_duration_ms of silence, and None otherwise. The blocking model call is a few
hundred microseconds on a CPU, so it runs inline in the receive loop; transcription, which is slower, goes to a thread.
import asyncio
import json
import numpy as np
import torch
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from silero_vad import VADIterator, load_silero_vad
from docsassistant.voice.turn import run_turn # shown below
from docsassistant.voice.stt import transcribe # faster-whisper in a worker thread, see the STT page
app = FastAPI()
vad_model = load_silero_vad() # loaded once, shared weights; each call gets its own VADIterator state
class WebSocketTransport:
"""Python twin of AudioTransport: the session code only calls these three coroutines."""
def __init__(self, websocket: WebSocket) -> None:
self._ws = websocket
async def send(self, audio: bytes) -> None:
await self._ws.send_bytes(audio)
async def clear(self) -> None:
await self._ws.send_text(json.dumps({"type": "clear"}))
async def close(self) -> None:
await self._ws.close()
@app.websocket("/voice")
async def voice(websocket: WebSocket) -> None:
await websocket.accept()
transport = WebSocketTransport(websocket)
vad = VADIterator(vad_model, threshold=0.5, sampling_rate=16000, min_silence_duration_ms=500)
thread_id = websocket.query_params.get("conversation", "anonymous")
utterance = bytearray()
in_speech = False
turn: asyncio.Task | None = None
try:
while True:
frame = await websocket.receive_bytes() # 512 samples of PCM16 = 1024 bytes
samples = torch.from_numpy(np.frombuffer(frame, dtype=np.int16).astype(np.float32) / 32768.0)
event = vad(samples)
if event and "start" in event:
if turn and not turn.done(): # barge-in: the agent is still talking or thinking
turn.cancel()
await asyncio.gather(turn, return_exceptions=True)
await transport.clear()
in_speech = True
utterance.clear()
if in_speech:
utterance.extend(frame)
if event and "end" in event:
in_speech = False
pcm = bytes(utterance)
utterance.clear()
turn = asyncio.create_task(run_turn(pcm, thread_id, transport))
except WebSocketDisconnect:
pass
finally:
if turn and not turn.done():
turn.cancel()
await asyncio.gather(turn, return_exceptions=True)
vad.reset_states()
Source: FastAPI: WebSockets (accept, receive_bytes, send_bytes,
WebSocketDisconnect) and Silero VAD repository (load_silero_vad and
VADIterator; the parameters and the start and end dictionaries are in its utils_vad.py). The 500 ms silence value is an
example, not a recommendation: tune it on your callers' recordings.
One turn as a cancellable task
run_turn transcribes, then streams the LangChain agent, chunks sentences and synthesises. It is the only place that
creates child tasks, which keeps cancellation simple: turn.cancel() raises asyncio.CancelledError at the next await
inside it, the TaskGroup cancels its children and waits for them, and the async with blocks around the HTTP clients
close their connections. The exception must never be swallowed with a bare except.
import asyncio
from collections.abc import AsyncIterator
from docsassistant.agent import agent # create_agent(...) with the retriever tool and checkpointer
from docsassistant.voice.sentences import sentences # the chunker from the architecture page
from docsassistant.voice.stt import transcribe
from docsassistant.voice.tts import synthesize # async generator of audio bytes for one sentence
_END = object()
async def llm_tokens(text: str, thread_id: str) -> AsyncIterator[str]:
config = {"configurable": {"thread_id": thread_id}}
async for part in agent.astream(
{"messages": [{"role": "user", "content": text}]},
config=config,
stream_mode="messages",
version="v2",
):
if part["type"] == "messages":
token, metadata = part["data"]
if metadata.get("langgraph_node") == "model" and isinstance(token.content, str):
yield token.content
async def run_turn(pcm: bytes, thread_id: str, transport) -> None:
text = await asyncio.to_thread(transcribe, pcm) # blocking recogniser off the event loop
if not text.strip():
return
queue: asyncio.Queue = asyncio.Queue(maxsize=2) # at most two sentences ahead of the voice
async def produce() -> None:
async for sentence in sentences(llm_tokens(text, thread_id)):
await queue.put(sentence)
await queue.put(_END)
async def consume() -> None:
while (sentence := await queue.get()) is not _END:
async for audio in synthesize(sentence):
await transport.send(audio)
async with asyncio.TaskGroup() as group: # cancelling run_turn cancels both children
group.create_task(produce())
group.create_task(consume())
Source: Python: Coroutines and Tasks (Task.cancel, TaskGroup,
to_thread) and LangChain: Streaming (astream with
stream_mode="messages" and version="v2"; the stream mode is described on
Streaming and the agent on
Agents with create_agent). synthesize wraps a provider’s streaming
synthesis call; two are shown on the Text to Speech page.
The bounded queue is the back-pressure of this design, as flatMapSequential concurrency is in the Java one: the LLM
cannot run more than two sentences ahead of the voice, which bounds the tokens wasted when the caller interrupts. An
unbounded queue lets a fast model finish a long answer in a second, all of which is discarded at the first "wait".
What happens on a barge-in
The two implementations perform the same sequence. What differs is the cancellation primitive, and the rule that the transport is cleared after the generation is cancelled, so no new audio can be queued behind the clear.
| Step | Spring (Reactor) | Python (asyncio) |
|---|---|---|
Handle to the running turn |
|
|
Cancel |
|
|
What stops with it |
The |
The |
Cleanup hook |
|
|
Race to guard against |
A |
Audio sent between |
Thread-safety of the transport |
|
One event loop, so sends are serialised by |
What the table does not cover is the echo: if the caller uses a speaker, the agent’s own voice returns through the microphone and the VAD reports it as speech, so the agent interrupts itself. The cure is acoustic echo cancellation in the client (a browser does it for you with the right constraints; a telephone network does it for you) or, as a crude substitute, a higher minimum speech duration while the agent is speaking. The options per transport are listed on Turn-Taking and Interruptions.
What the frameworks save you
The page above is about two hundred lines of application code and it is not a product. A framework supplies the rest.
| Concern | Hand-rolled | In Pipecat or LiveKit Agents |
|---|---|---|
Frame and event plumbing |
Your own queues, flags and callbacks between VAD, STT, LLM, chunker and TTS. |
A pipeline of processors (Pipecat) or an |
Turn detection beyond silence |
A silence timer, as above; a semantic model is an extra integration. |
Smart Turn in Pipecat, the turn detector plugin in LiveKit Agents. |
Interruption |
A cancel and a clear per transport, history truncation written by you. |
Interruption frames flow through the pipeline; unspoken text is not kept in the context. |
Provider integrations |
One adapter per STT, LLM and TTS vendor, with reconnect and error handling. |
Maintained service classes for many vendors, swapped by configuration. |
Transports |
One adapter per wire (this page’s |
WebRTC, WebSocket, Twilio, Telnyx and others as transports or serializers; LiveKit also brings its own media server and SIP. |
Scaling and operations |
Your concern: workers, autoscaling, session limits, metrics. |
Worker dispatch and metrics hooks, more complete in LiveKit Agents; still yours to deploy. |
Model providers in Java |
Spring AI directly. |
Both frameworks are Python (and, for LiveKit, also have a Node.js SDK); a Java stack integrates over a service boundary. |
Prefer the hand-rolled route when the pipeline is small, the stack is Java, the call volume is modest, or you need to understand every millisecond before trusting anything else. Move to Pipecat or LiveKit Agents when you start writing a second transport, a semantic turn detector, a provider fail-over or a reconnect loop: each of those is a framework feature that you would be reimplementing.
Reusing the pipeline
Because VoiceSession talks only to AudioTransport, other wires need only an adapter and a format conversion:
-
A browser microphone page, covered in Voice in the Browser, sends PCM frames over this WebSocket (or uses WebRTC to a framework instead).
-
Twilio Media Streams delivers 8 kHz mu-law in JSON envelopes. The adapter on Telephony with Twilio decodes mu-law to PCM16, upsamples to 16 kHz for the VAD, implements
sendas an outbound media message andclearas the Twilioclearmessage. -
The same holds for an Asterisk channel (see Telephony with Asterisk and SIP): the adapter changes, the session does not.
-
Behind a WebSocket on Quarkus the session code is also reusable; the server side is described in WebSockets.
Production concerns that this page leaves out (limits per session, logging that avoids storing audio, consent, recording, failure behaviour when a provider is down) are on Production Voice Agents. Quality measurement of such a system belongs to the LLMOps & Evaluation sub-section, and prompt-injection and privacy questions of spoken input to AI Security & Responsible AI (see Prompt Injection).
Choosing a hand-rolled pipeline
Choose it to learn, to run a small number of calls inside an existing Spring Boot or FastAPI service, or to keep a Java
code base free of a Python media tier. Keep the seams that make leaving it cheap: one AudioTransport per wire, one
cancellable unit per turn, a chunker that is a pure function of the token stream and provider calls behind interfaces.
Choose a framework when turn detection, several transports and provider failover become your daily work, and a realtime
API (Realtime APIs) or a hosted platform
(ElevenLabs Agents) when you would rather not operate the audio path at all.